From 5285f832bf37a1bc5bdf4fa28fad3f5ad0e65f23 Mon Sep 17 00:00:00 2001 From: Moritz Utcke <62291876+Letsmoe@users.noreply.github.com> Date: Sat, 25 Mar 2023 19:51:35 +0400 Subject: [PATCH] Login System + API --- astro.config.mjs | 14 +++-- package.json | 15 ++++- src/components/Header.astro | 2 +- src/components/LoginView.svelte | 71 ++++++++++++++++++++++++ src/components/RegisterView.svelte | 74 +++++++++++++++++++++++++ src/layouts/Layout.astro | 5 ++ src/lib/APIResponse.ts | 38 +++++++++++++ src/lib/Ausweis/index.ts | 9 +++ src/lib/Ausweis/type.ts | 0 src/lib/JsonWebToken.ts | 10 ++++ src/lib/Password.ts | 22 ++++++++ src/lib/User/index.ts | 88 ++++++++++++++++++++++++++++++ src/lib/User/type.ts | 17 ++++++ src/lib/shared.ts | 19 +++++++ src/pages/api/login.ts | 34 ++++++++++++ src/pages/api/user.ts | 43 +++++++++++++++ src/pages/login.astro | 17 ++++++ src/pages/signup.astro | 17 ++++++ src/pages/user/index.astro | 63 +++++++++++++++++++++ 19 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 src/components/LoginView.svelte create mode 100644 src/components/RegisterView.svelte create mode 100644 src/lib/APIResponse.ts create mode 100644 src/lib/Ausweis/index.ts create mode 100644 src/lib/Ausweis/type.ts create mode 100644 src/lib/JsonWebToken.ts create mode 100644 src/lib/Password.ts create mode 100644 src/lib/User/index.ts create mode 100644 src/lib/User/type.ts create mode 100644 src/lib/shared.ts create mode 100644 src/pages/api/login.ts create mode 100644 src/pages/api/user.ts create mode 100644 src/pages/login.astro create mode 100644 src/pages/signup.astro create mode 100644 src/pages/user/index.astro diff --git a/astro.config.mjs b/astro.config.mjs index 7f03590e..08071ca4 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,13 +1,19 @@ import { defineConfig } from "astro/config"; -// https://astro.build/config import svelte from "@astrojs/svelte"; // https://astro.build/config import tailwind from "@astrojs/tailwind"; +// https://astro.build/config +import node from "@astrojs/node"; + // https://astro.build/config export default defineConfig({ - integrations: [svelte(), tailwind()], - outDir: "./dist" -}); + integrations: [svelte(), tailwind()], + outDir: "./dist", + output: "server", + adapter: node({ + mode: "standalone" + }) +}); \ No newline at end of file diff --git a/package.json b/package.json index 6e669050..ee9cc903 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,25 @@ }, "private": true, "dependencies": { + "@astrojs/node": "^5.1.0", "@astrojs/svelte": "^2.0.2", "@astrojs/tailwind": "^3.1.0", - "astro": "^2.0.16", + "astro": "^2.1.7", + "bcrypt": "^5.1.0", + "cookiejs": "^2.1.2", + "jwt-simple": "^0.5.6", + "knex": "^2.4.2", + "moment": "^2.29.4", + "pg": "^8.10.0", "svelte": "^3.54.0", "tailwindcss": "^3.0.24", - "vitest": "^0.29.2" + "uuid": "^9.0.0", + "vitest": "^0.29.2", + "zod": "^3.21.4" }, "devDependencies": { + "@types/bcrypt": "^5.0.0", + "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.36.1", "@typescript-eslint/parser": "^5.36.1", "astro": "^2.1.2", diff --git a/src/components/Header.astro b/src/components/Header.astro index eb3e631a..0bf1b2bf 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -28,7 +28,7 @@ > Kontakt AGB - Login + Login hamburger diff --git a/src/components/LoginView.svelte b/src/components/LoginView.svelte new file mode 100644 index 00000000..ec53f1f2 --- /dev/null +++ b/src/components/LoginView.svelte @@ -0,0 +1,71 @@ + + +
+

Login:

+
+ {#if hasError} +

Das hat leider nicht geklappt, haben sie ihr Passwort und den Nutzernamen richtig eingegeben?

+ {/if} +
+

Benutzername

+ +
+
+

Passwort

+ +
+
+ + Registrieren +
+ +
+
\ No newline at end of file diff --git a/src/components/RegisterView.svelte b/src/components/RegisterView.svelte new file mode 100644 index 00000000..47d450dd --- /dev/null +++ b/src/components/RegisterView.svelte @@ -0,0 +1,74 @@ + + +
+

Registrieren:

+
+ {#if hasError} +

Das hat leider nicht geklappt, haben sie ihr Passwort und den Nutzernamen richtig eingegeben?

+ {/if} +
+

Benutzername

+ +
+
+

Email

+ +
+
+

Passwort

+ +
+
+ + Einloggen +
+ +
+
\ No newline at end of file diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index fbcf8a1e..f23d7d79 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -101,4 +101,9 @@ const schema = JSON.stringify({ body { min-height: 100vh; } + + button, .button { + @apply px-8 py-2 bg-secondary rounded-lg text-white font-medium hover:shadow-lg transition-all hover:underline active:bg-blue-900; + color: #fff !important; + } diff --git a/src/lib/APIResponse.ts b/src/lib/APIResponse.ts new file mode 100644 index 00000000..221cc051 --- /dev/null +++ b/src/lib/APIResponse.ts @@ -0,0 +1,38 @@ +import type { ZodError } from "zod"; + +export function success(data: any = {}) { + return new Response(JSON.stringify({ + success: true, + data + })) +} + +export function error(errors: any[]) { + return new Response(JSON.stringify({ + success: false, + errors + })) +} + +export function MissingPropertyError(properties: string[]) { + return error(properties.map(property => { + return `Missing property '${property}' in request body.` + })); +} + +/** + * Signalisiert ein fehlendes Objekt und gibt den Fehler als HTTP Response zurück. + * @param name Der Name der Entität, die nicht gefunden werden konnte. + * @returns {Response} HTTP Response Objekt + */ +export function MissingEntityError(name: string): Response { + return error([`Missing entity, ${name} does not exist.`]) +} + +export function ActionFailedError(): Response { + return error(["Failed executing action, error encountered."]); +} + +export function InvalidDataError(err: ZodError): Response { + return error(err.issues); +} \ No newline at end of file diff --git a/src/lib/Ausweis/index.ts b/src/lib/Ausweis/index.ts new file mode 100644 index 00000000..dfd9f2c4 --- /dev/null +++ b/src/lib/Ausweis/index.ts @@ -0,0 +1,9 @@ +export class Ausweis { + public static fromPublicId(public_id: string) { + + } + + public static fromPrivateId(id: number) { + + } +} \ No newline at end of file diff --git a/src/lib/Ausweis/type.ts b/src/lib/Ausweis/type.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/JsonWebToken.ts b/src/lib/JsonWebToken.ts new file mode 100644 index 00000000..d2e2e6b8 --- /dev/null +++ b/src/lib/JsonWebToken.ts @@ -0,0 +1,10 @@ +import * as jwt from "jwt-simple"; + +export function encodeToken(data: Record) { + const token = jwt.encode(data, "yIvbgS$k7Bfc+mpV%TWDZAhje9#uJad4", "HS256"); + return token; +} + +export function decodeToken(token: string) { + return jwt.decode(token, "yIvbgS$k7Bfc+mpV%TWDZAhje9#uJad4"); +} \ No newline at end of file diff --git a/src/lib/Password.ts b/src/lib/Password.ts new file mode 100644 index 00000000..0a310c10 --- /dev/null +++ b/src/lib/Password.ts @@ -0,0 +1,22 @@ +import * as bcrypt from "bcrypt"; + +export async function hashPassword(password: string): Promise { + const saltRounds = 4; + + try { + const salt = await bcrypt.genSalt(saltRounds); + const hash = await bcrypt.hash(password, salt); + return hash; + } catch(e) { + return null; + } +} + +export async function validatePassword(known: string, unknown: string): Promise { + try { + const compare = await bcrypt.compare(unknown, known) + return compare + } catch(e) { + return false; + } +} \ No newline at end of file diff --git a/src/lib/User/index.ts b/src/lib/User/index.ts new file mode 100644 index 00000000..c9975aec --- /dev/null +++ b/src/lib/User/index.ts @@ -0,0 +1,88 @@ +import { db } from "../shared"; +import { UserRegisterValidator, UserType, UserTypeValidator } from "./type"; +import { v4 as uuid } from "uuid"; +import { hashPassword } from "../Password"; + + +export class User { + /** + * Sucht einen Nutzer in der Datenbank anhand seiner Public/Unique id und gibt ihn zurück. + * @param uid Die unique/public id des gesuchten Benutzers. + * @returns {UserType | null} Die Daten des Nutzers oder null falls dieser nicht gefunden werden kann. + */ + public static async fromPublicId(uid: string): Promise { + if (!uid || typeof uid !== "string") { + return null; + } + + const user = await db("users").select("*").where("uid", uid).first(); + + if (!user) { + return null; + } + + return user; + } + + public static async fromUsername(username: string): Promise { + if (!username || typeof username !== "string") { + return null; + } + + const user = await db("users").select("*").where("username", username).first(); + + if (!user) { + return null; + } + + return user; + } + + /** + * Sucht einen Nutzer in der Datenbank anhand seiner Private id und gibt ihn zurück. + * @param uid Die private id des gesuchten Benutzers. + * @returns {UserType | null} Die Daten des Nutzers oder null falls dieser nicht gefunden werden kann. + */ + public static async fromPrivateId(id: number): Promise { + if (!id || typeof id !== "number") { + return null; + } + + const user = await db("users").select("*").where("id", id).first(); + + if (!user) { + return null; + } + + return user; + } + + public static async create(user: UserType): Promise<{uid: string, id: number} | null> { + if (!user || UserRegisterValidator.safeParse(user).success == false) { + return null; + } + + const uid = uuid(); + const hashedPassword = await hashPassword(user.password); + + if (!hashedPassword) { + return null; + } + + const result = await db("users").insert({ + username: user.username, + email: user.email, + password: hashedPassword, + uid: uid + }, ["id"]) + + if (!result) { + return null; + } + + return { + uid, + id: result[0].id + } + } +} \ No newline at end of file diff --git a/src/lib/User/type.ts b/src/lib/User/type.ts new file mode 100644 index 00000000..ec515d00 --- /dev/null +++ b/src/lib/User/type.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export const UserTypeValidator = z.object({ + username: z.string().min(4).max(64), + id: z.number(), + uid: z.string().length(36), + email: z.string().max(255), + password: z.string().min(6), +}) + +export const UserRegisterValidator = z.object({ + username: z.string().min(4).max(64), + email: z.string().max(255), + password: z.string().min(6), +}) + +export type UserType = z.infer \ No newline at end of file diff --git a/src/lib/shared.ts b/src/lib/shared.ts new file mode 100644 index 00000000..d3c21152 --- /dev/null +++ b/src/lib/shared.ts @@ -0,0 +1,19 @@ +import knex, { Knex } from "knex"; + +export function dbOpen(): Knex { + const db = knex({ + client: 'pg', + connection: { + host : '127.0.0.1', + port : 5436, + user : 'main', + password : 'hHMP8cd^N3SnzGRR', + database : 'main' + } + }) + + return db; +} + + +export const db = dbOpen(); \ No newline at end of file diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts new file mode 100644 index 00000000..4835db80 --- /dev/null +++ b/src/pages/api/login.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from "astro"; +import { success, MissingPropertyError, MissingEntityError, ActionFailedError, InvalidDataError, error } from "../../lib/APIResponse"; +import { validatePassword } from "../../lib/Password"; +import { User } from "../../lib/User"; +import moment from "moment"; +import { encodeToken } from "../../lib/JsonWebToken"; + +/** + * Ruft einen Nutzer anhand seiner uid aus der Datenbank ab. + * @param param0 Die Request mit dem request body. Dieser enthält entweder eine uid mit der der Benutzer identifiziert werden kann. + */ +export const post: APIRoute = async ({ request }) => { + const body = await request.json(); + + if (!body.hasOwnProperty("username") || !body.hasOwnProperty("password")) { + return MissingPropertyError(["username", "password"]); + } + + const user = await User.fromUsername(body.username); + + if (!user) { + return error(["Invalid username or password."]); + } + + // Validate Password + if (!validatePassword(user.password, body.password)) { + return error(["Invalid username or password."]); + } + + const expiry = moment().add(2, "days").unix(); + const token = encodeToken({ id: user.id, uid: user.uid, exp: expiry }) + + return success({ token, expires: expiry }); +} \ No newline at end of file diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts new file mode 100644 index 00000000..fc4d0398 --- /dev/null +++ b/src/pages/api/user.ts @@ -0,0 +1,43 @@ +import type { APIRoute } from "astro"; +import { success, MissingPropertyError, MissingEntityError, ActionFailedError, InvalidDataError } from "../../lib/APIResponse"; +import { User } from "../../lib/User"; +import { UserRegisterValidator, UserType, UserTypeValidator } from "../../lib/User/type"; + +/** + * Ruft einen Nutzer anhand seiner uid aus der Datenbank ab. + * @param param0 Die Request mit dem request body. Dieser enthält entweder eine uid mit der der Benutzer identifiziert werden kann. + */ +export const get: APIRoute = async ({ request }) => { + const body = await request.json(); + + if (!body.hasOwnProperty("uid")) { + return MissingPropertyError(["uid"]); + } + + const user = User.fromPublicId(body.uid); + + if (!user) { + return MissingEntityError("user"); + } + + return success(user); +} + +export const put: APIRoute = async ({ request }) => { + const body = await request.json(); + + const validate = UserRegisterValidator.safeParse(body); + + if (validate.success == false) { + return InvalidDataError(validate.error); + } + + const result = await User.create(body as UserType); + + if (!result) { + return ActionFailedError(); + } + + return success({ uid: result.uid, id: result.id }); +} + diff --git a/src/pages/login.astro b/src/pages/login.astro new file mode 100644 index 00000000..cf7205de --- /dev/null +++ b/src/pages/login.astro @@ -0,0 +1,17 @@ +--- +import moment from "moment"; +import LoginView from "../components/LoginView.svelte"; +import Layout from "../layouts/Layout.astro"; + +const token = Astro.cookies.get("token").value; +const expires = Astro.cookies.get("expires").number(); + +const now = moment().unix(); +if (token && now < expires) { + return Astro.redirect("/user"); +} +--- + + + + diff --git a/src/pages/signup.astro b/src/pages/signup.astro new file mode 100644 index 00000000..3059a5af --- /dev/null +++ b/src/pages/signup.astro @@ -0,0 +1,17 @@ +--- +import moment from "moment"; +import RegisterView from "../components/RegisterView.svelte"; +import Layout from "../layouts/Layout.astro"; + +const token = Astro.cookies.get("token").value; +const expires = Astro.cookies.get("expires").number(); + +const now = moment().unix(); +if (token && now < expires) { + return Astro.redirect("/user"); +} +--- + + + + diff --git a/src/pages/user/index.astro b/src/pages/user/index.astro new file mode 100644 index 00000000..9a35e8c5 --- /dev/null +++ b/src/pages/user/index.astro @@ -0,0 +1,63 @@ +--- +import moment from "moment"; +import Layout from "../../layouts/Layout.astro" +import { decodeToken } from "../../lib/JsonWebToken"; +import { User } from "../../lib/User"; + +const token = Astro.cookies.get("token").value; +const expires = Astro.cookies.get("expires").number(); + +const now = moment().unix(); +if (!token || now > expires) { + Astro.cookies.delete("token"); + Astro.cookies.delete("expires"); + return Astro.redirect("/login"); +} + +const parsed = decodeToken(token); +const user = await User.fromPublicId(parsed.uid); + +if (!user) { + return Astro.redirect("/login"); +} + +--- + + +
+

Willkommen zurück {user.username}

+
+
+ +
+ +
+ + +
+
+ +
+
\ No newline at end of file