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 @@
>
-
+
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
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
\ 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}
+
+
+
+
+
+
+
+ 25
+ 50
+ 75
+ 100
+
+
+ Absteigend
+ Aufsteigend
+
+
+
+
+
+
\ No newline at end of file