Login System + API
This commit is contained in:
@@ -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"
|
||||
outDir: "./dist",
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone"
|
||||
})
|
||||
});
|
||||
15
package.json
15
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",
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
71
src/components/LoginView.svelte
Normal file
71
src/components/LoginView.svelte
Normal 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>
|
||||
74
src/components/RegisterView.svelte
Normal file
74
src/components/RegisterView.svelte
Normal 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>
|
||||
@@ -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
38
src/lib/APIResponse.ts
Normal 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
9
src/lib/Ausweis/index.ts
Normal 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
0
src/lib/Ausweis/type.ts
Normal file
10
src/lib/JsonWebToken.ts
Normal file
10
src/lib/JsonWebToken.ts
Normal 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
22
src/lib/Password.ts
Normal 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
88
src/lib/User/index.ts
Normal 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
17
src/lib/User/type.ts
Normal 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
19
src/lib/shared.ts
Normal 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
34
src/pages/api/login.ts
Normal 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
43
src/pages/api/user.ts
Normal 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
17
src/pages/login.astro
Normal 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
17
src/pages/signup.astro
Normal 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>
|
||||
63
src/pages/user/index.astro
Normal file
63
src/pages/user/index.astro
Normal 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>
|
||||
Reference in New Issue
Block a user