Login System + API

This commit is contained in:
Moritz Utcke
2023-03-25 19:51:35 +04:00
parent dcbdf0e8d0
commit 5285f832bf
19 changed files with 551 additions and 7 deletions

View File

@@ -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"
})
});

View File

@@ -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",

View File

@@ -28,7 +28,7 @@
>
<a class="headerButton" href="/energieausweis-kontakt.php">Kontakt</a>
<a class="headerButton" href="/agb.php">AGB</a>
<a class="headerButton" href="/user/energieausweis-login">Login</a>
<a class="headerButton" href="/login">Login</a>
<a class="hamburger_menu"
><img src="/images/hamburger.png" alt="hamburger" /></a
>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import cookie from "cookiejs"
let username: string;
let password: string;
let hasError: boolean;
async function login() {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({
username, password
})
})
const json = await response.json()
if (json.success == true) {
cookie.set("token", json.data.token);
cookie.set("expires", json.data.expires);
localStorage.setItem("token", json.data.token);
localStorage.setItem("expires", json.data.expires);
window.location.href = "/user";
} else {
hasError = true;
}
}
</script>
<div style="width:50%;margin: 0 auto">
<h1>Login:</h1>
<div class="login_page">
{#if hasError}
<p>Das hat leider nicht geklappt, haben sie ihr Passwort und den Nutzernamen richtig eingegeben?</p>
{/if}
<div class="block_4" style="margin-top: 25px;">
<h4 class="heading_3">Benutzername</h4>
<input
type="text"
placeholder="Benutzername"
name="username"
class="formInput"
bind:value={username}
required
/>
</div>
<div class="block_4">
<h4 class="heading_3">Passwort</h4>
<input
type="password"
placeholder="********"
name="password"
class="formInput"
bind:value={password}
required
/>
</div>
<div class="mt-2 flex flex-row justify-between">
<button on:click={login}
>Einloggen</button
>
<a class="button"
href="/signup">Registrieren</a
>
</div>
<div class="flex-row justify-between" style="margin-top: 10px">
<a href="/">Home</a>
<a href="/user/passwort_vergessen">Passwort Vergessen?</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
let username: string;
let password: string;
let email: string;
let hasError: boolean;
async function login() {
const response = await fetch("/api/user", {
method: "PUT",
body: JSON.stringify({
username, password, email
})
})
const json = await response.json()
if (json.success == true) {
window.location.href = "/login";
} else {
hasError = true;
}
}
</script>
<div style="width:50%;margin: 0 auto">
<h1>Registrieren:</h1>
<div class="login_page">
{#if hasError}
<p>Das hat leider nicht geklappt, haben sie ihr Passwort und den Nutzernamen richtig eingegeben?</p>
{/if}
<div class="block_4" style="margin-top: 25px;">
<h4 class="heading_3">Benutzername</h4>
<input
type="text"
placeholder="Benutzername"
class="formInput"
bind:value={username}
required
/>
</div>
<div class="block_4" style="margin-top: 25px;">
<h4 class="heading_3">Email</h4>
<input
type="text"
placeholder="Email"
class="formInput"
bind:value={email}
required
/>
</div>
<div class="block_4">
<h4 class="heading_3">Passwort</h4>
<input
type="password"
placeholder="********"
class="formInput"
bind:value={password}
required
/>
</div>
<div class="mt-2 flex flex-row justify-between">
<button on:click={login}
>Registrieren</button
>
<a class="button"
href="/login">Einloggen</a
>
</div>
<div class="flex-row justify-between" style="margin-top: 10px">
<a href="/">Home</a>
<a href="/user/passwort_vergessen">Passwort Vergessen?</a>
</div>
</div>
</div>

View File

@@ -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;
}
</style>

38
src/lib/APIResponse.ts Normal file
View File

@@ -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);
}

9
src/lib/Ausweis/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export class Ausweis {
public static fromPublicId(public_id: string) {
}
public static fromPrivateId(id: number) {
}
}

0
src/lib/Ausweis/type.ts Normal file
View File

10
src/lib/JsonWebToken.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as jwt from "jwt-simple";
export function encodeToken(data: Record<string, any>) {
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");
}

22
src/lib/Password.ts Normal file
View File

@@ -0,0 +1,22 @@
import * as bcrypt from "bcrypt";
export async function hashPassword(password: string): Promise<string | null> {
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<boolean> {
try {
const compare = await bcrypt.compare(unknown, known)
return compare
} catch(e) {
return false;
}
}

88
src/lib/User/index.ts Normal file
View File

@@ -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<UserType | null> {
if (!uid || typeof uid !== "string") {
return null;
}
const user = await db<UserType>("users").select("*").where("uid", uid).first();
if (!user) {
return null;
}
return user;
}
public static async fromUsername(username: string): Promise<UserType | null> {
if (!username || typeof username !== "string") {
return null;
}
const user = await db<UserType>("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<UserType | null> {
if (!id || typeof id !== "number") {
return null;
}
const user = await db<UserType>("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<UserType>("users").insert({
username: user.username,
email: user.email,
password: hashedPassword,
uid: uid
}, ["id"])
if (!result) {
return null;
}
return {
uid,
id: result[0].id
}
}
}

17
src/lib/User/type.ts Normal file
View File

@@ -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<typeof UserTypeValidator>

19
src/lib/shared.ts Normal file
View File

@@ -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();

34
src/pages/api/login.ts Normal file
View File

@@ -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 });
}

43
src/pages/api/user.ts Normal file
View File

@@ -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 });
}

17
src/pages/login.astro Normal file
View File

@@ -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");
}
---
<Layout title="Login">
<LoginView client:only></LoginView>
</Layout>

17
src/pages/signup.astro Normal file
View File

@@ -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");
}
---
<Layout title="Login">
<RegisterView client:only></RegisterView>
</Layout>

View File

@@ -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");
}
---
<Layout title="Dashboard">
<div class="profile_container">
<div><h1>Willkommen zurück <b>{user.username}</b></h1>
<div id="searchBoxContainer" class="flex-row" style="gap: 5px; margin: 10px 0;"></div>
</div>
<div class="flex-column" style="gap: 10px;">
<a href="/user/settings" class="weiterbutton">Account Details Ändern</a>
<a href="/logout" class="weiterbutton">Ausloggen</a>
</div>
<div class="flex-row filter-bar" style="grid-area: 2/1/2/3; min-height: 0; gap: 20px;">
<div class="flex-row" style="width: 100%;">
<a onclick="addSort()" class='weiterbutton'>Filter Hinzufügen</a>
<a onclick="window.Offset > 0 ? (() => {
GetData(window.Offset - 1)
})() : null" class='weiterbutton'>Vorherige Seite</a>
<a onclick="window.Offset >= 0 ? (() => {
GetData(window.Offset + 1)
})() : null" class='weiterbutton'>Nächste Seite</a>
<a onclick="window.Offset > 0 ? (() => {
GetData(0)
})() : null" class='weiterbutton'>Erste Seite</a>
</div>
<div class="flex-row" style="width: 100%;">
<select onchange="window.GetLength = parseInt(this.value); GetData(0);">
<option value="25">25</option>
<option value="50">50</option>
<option value="75">75</option>
<option value="100">100</option>
</select>
<select onchange="window.orderDirection = this.value; GetData(0);">
<option value="DESC">Absteigend</option>
<option value="ASC">Aufsteigend</option>
</select>
</div>
</div>
<div class='login-container' id="items-container"></div>
</div>
</Layout>