Compare commits

...

10 Commits

17 changed files with 529 additions and 6 deletions

9
src/app.d.ts vendored
View File

@ -2,8 +2,13 @@
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Error {
message: string
cause?: number,
}
interface Locals {
user?: User
}
// interface PageData {}
// interface PageState {}
// interface Platform {}

View File

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="stylesheet" href="%sveltekit.assets%/global.css" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

77
src/hooks.server.ts Normal file
View File

@ -0,0 +1,77 @@
import type { Handle } from "@sveltejs/kit"
import { error, redirect, json, fail } from "@sveltejs/kit"
import { Error401Cause } from "$lib/errors"
import Config from "$lib/server/config"
import UserMgmt from "$lib/server/usermgmt"
function action_fail<T = undefined>(status: number, data: T) {
return json(fail(status, data))
}
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get("session")
let session = await UserMgmt.session_login(token ?? "")
if (session && event.route.id !== "/api/refresh") {
if (session.expires.getTime() < Date.now() + Config.session_refresh_grace) {
const response = await event.fetch("/api/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
token: token
})
})
if (response.ok) {
const new_session = await response.json()
if (new_session && new_session.token !== null && new_session.expires !== null) {
event.cookies.set("session", new_session.token, {
expires: new Date(new_session.expires),
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/"
})
// For mobile
/*event.setHeaders({
"X-Authorization-Refresh": new_session.token
})*/
session = await UserMgmt.session_login(new_session.token)
}
}
}
}
event.locals.user = session?.user
if (!event.route.id) {
return error(404, { message: "Diese Seite existiert nicht" })
}
if (event.route.id.startsWith("/api")) {
if (event.route.id.startsWith("/api/users") && !event.locals.user) {
return error(401, { cause: Error401Cause.NotLoggedIn, message: "Please log in" })
}
} else {
if (!event.locals.user && !event.route.id.startsWith("/login")) {
if (event.request.method === "POST") {
if (event.request.headers.get("x-sveltekit-action")) {
return action_fail(401, { cause: Error401Cause.NotLoggedIn, message: "Bitte melden Sie sich an." })
}
return error(401, { cause: Error401Cause.NotLoggedIn, message: "Bitte melden Sie sich an." })
}
return redirect(307, "/login")
}
}
return await resolve(event)
}

45
src/lib/errors.ts Normal file
View File

@ -0,0 +1,45 @@
export class ArgumentError extends Error {
constructor(message: string) {
super(message)
}
}
export class DuplicateError extends Error {
fields: Array<string>
constructor(fields: Array<string>, message: string) {
super(message)
this.fields = fields
}
}
export const enum Error401Cause {
NotLoggedIn
}
export const enum RegisterResponseCause {
Server = 1,
MalformedRequest,
EmailLength,
EmailFormat,
EmailDuplicate,
PasswordLength,
DisplayNameLength
}
export const enum LoginResponseCause {
Server = 1,
MalformedRequest,
EmailLength,
PasswordLength,
NotFound,
Timeout
}
export const enum RefreshResponseCause {
Server = 1,
MalformedRequest,
InvalidToken,
InvalidSession
}

View File

@ -37,6 +37,9 @@ class Config {
readonly is_debug: boolean = process.env.NODE_ENV != "production"
readonly is_production: boolean = process.env.NODE_ENV == "production"
private _session_timeout: number = 15 * 60 * 1000
private _session_refresh_grace: number = 5 * 60 * 1000 // time until expiration
get log_dir(): string {
return this._log_dir
}
@ -44,6 +47,9 @@ class Config {
return this._log_to_file_when_debug
}
get session_timeout(): number { return this._session_timeout }
get session_refresh_grace(): number { return this._session_refresh_grace }
constructor() {
this._log_dir = resolve_env_to_path(process.env.APP_LOG_DIR, "./data/logs")
this._log_to_file_when_debug = resolve_env_to_boolean(process.env.LOG_TO_FILE_WHEN_DEBUG, false)

View File

@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export default prisma

View File

@ -10,7 +10,8 @@ export enum LogSeverity {
export enum LogModule {
PROCESS,
USER
USER,
DATABASE
}
abstract class Logger {

163
src/lib/server/usermgmt.ts Normal file
View File

@ -0,0 +1,163 @@
import Bun from "bun"
import Crypto from "node:crypto"
import { Prisma } from "@prisma/client"
import { ArgumentError, DuplicateError } from "$lib/errors"
import Config from "$lib/server/config"
import Log from "$lib/server/log"
import db from "$lib/server/database"
export type User = Prisma.UserGetPayload<Prisma.UserDefaultArgs>
export interface SessionData {
user: User
token: string
issued: Date
expires: Date
}
interface CacheUserInfo {
user: User
last_update: Date
}
interface CacheSessionInfo {
user_id: number
issued: Date
expires: Date
}
class Cache {
private _id_map: Map<number, CacheUserInfo> = new Map()
private _session_map: Map<string, CacheSessionInfo> = new Map()
add(user: User, token: string, issued: Date, expires: Date): SessionData {
this._id_map.set(user.id, {
user: user,
last_update: new Date()
})
this._session_map.set(token, {
user_id: user.id,
issued: issued,
expires: expires
})
return {
user: user,
token: token,
issued: issued,
expires: expires
}
}
invalidate_session(session: SessionData) {
this._session_map.delete(session.token)
}
get_session(token: string): SessionData|null {
const session_info = this._session_map.get(token)
if (!session_info) return null
const user_info = this._id_map.get(session_info.user_id)
if (!user_info) return null
return {
user: user_info.user,
token: token,
issued: session_info.issued,
expires: session_info.expires
}
}
}
class UserMgmt {
_cache: Cache = new Cache()
async register(email: string, password: string, display_name: string): Promise<User|null> {
if (email.length == 0 || password.length == 0 || display_name.length == 0) {
throw new ArgumentError("No field may be empty")
}
try {
const user = await db.user.create({
data: {
email: email,
password_hash: await Bun.password.hash(password, "argon2id"),
display_name: display_name
}
})
Log.info(Log.module.USER, `Created user with id ${user.id}`)
return user;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code == "P2002") {
const duplicates = Array.isArray(e.meta?.target) ? e.meta.target as Array<string> : ["unknown"]
throw new DuplicateError(duplicates, "Already exists")
}
Log.error(Log.module.DATABASE, `Failed to create User in database! Error: ${JSON.stringify(e)}`)
return null
}
}
async login(email: string, password: string): Promise<SessionData|null> {
const user = await db.user.findUnique({
where: {
email: email
}
})
if (!user) {
// throw of timing attacks
await Bun.password.verify("a", "$argon2id$v=19$m=16,t=2,p=1$ZHB6Zjd4NXV6RXZBZk9wRg$QaYjeAGLon+x3c5I1KB7UQ")
return null
}
if (!await Bun.password.verify(password, user.password_hash)) {
return null
}
const session_info = this._generate_session_for_user(user)
return session_info
}
async session_login(token: string): Promise<SessionData|null> {
const session = this._cache.get_session(token)
if (!session) {
return null
}
if (session?.expires.getTime() < Date.now()) {
return null
}
return session
}
async refresh_session(token: string): Promise<SessionData|null> {
const session = await this.session_login(token)
if (!session) {
return null
}
const new_session = this._generate_session_for_user(session.user)
this._cache.invalidate_session(session)
return new_session
}
private _generate_session_for_user(user: User): SessionData {
const token = Crypto.randomBytes(32).toBase64()
const session_info = this._cache.add(user, token, new Date(), new Date(Date.now() + Config.session_timeout))
return session_info
}
}
const _manager = new UserMgmt()
export default _manager

13
src/lib/util.ts Normal file
View File

@ -0,0 +1,13 @@
function check_email_format(email: string) {
const trimmed = email.trim()
if (trimmed.length > 254) return false
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$/;
return emailRegex.test(trimmed)
}
export default {
check_email_format,
}

View File

@ -0,0 +1,33 @@
import type { RequestHandler } from "./$types"
import { error, json, text } from "@sveltejs/kit"
import UserMgmt from "$lib/server/usermgmt"
import { LoginResponseCause } from "$lib/errors"
export const POST: RequestHandler = async ({ request, cookies }) => {
const data = await request.formData()
const email = data.get("email")
const password = data.get("password")
if (data.keys.length > 2 || !(typeof email === "string" && typeof password === "string")) {
return error(400, { cause: LoginResponseCause.MalformedRequest, message: "Invalid request" })
}
if (email.length == 0) {
return error(400, { cause: LoginResponseCause.EmailLength, message: "Email must be provided" })
}
if (password.length == 0) {
return error(400, { cause: LoginResponseCause.PasswordLength, message: "Password must be provided" })
}
const session = await UserMgmt.login(email, password)
if (!session) {
return error(401, { message: "Invalid username or password" })
}
return json({ token: session.token, expires: session.expires })
}

View File

@ -0,0 +1,27 @@
import type { RequestHandler } from "./$types"
import { error, json } from "@sveltejs/kit"
import UserMgmt from "$lib/server/usermgmt"
import { RefreshResponseCause } from "$lib/errors"
export const POST: RequestHandler = async ({ request }) => {
const data = await request.json()
const token = data["token"]
if (!token || typeof token !== "string") {
return error(400, { cause: RefreshResponseCause.MalformedRequest, message: "token must be provided as string." })
}
const new_session = await UserMgmt.refresh_session(token)
if (!new_session) {
return error(401, { cause: RefreshResponseCause.InvalidSession, message: "No session for token" })
}
return json({
token: new_session.token,
expires: new_session.expires
})
}

View File

@ -0,0 +1,48 @@
import type { RequestHandler } from "./$types"
import { json, error } from "@sveltejs/kit"
import UserMgmt from "$lib/server/usermgmt"
import Util from "$lib/util"
import { DuplicateError, RegisterResponseCause } from "$lib/errors"
export const POST: RequestHandler = async ({ request }) => {
const data = await request.formData()
const email = data.get("email")
const password = data.get("password")
const display_name = data.get("display_name")
if (data.keys.length > 3 || !(typeof email === "string" && typeof password === "string" && typeof display_name === "string")) {
return error(400, { cause: RegisterResponseCause.MalformedRequest, message: "Invalid field arguments" })
}
if (email.length == 0) {
return error(400, { cause: RegisterResponseCause.EmailLength, message: "email must not be empty" })
}
if (password.length == 0) {
return error(400, { cause: RegisterResponseCause.PasswordLength, message: "password must not be empty" })
}
if (display_name.length == 0) {
return error(400, { cause: RegisterResponseCause.DisplayNameLength, message: "Display name must not be empty" })
}
if (!Util.check_email_format(email)) {
return error(400, { cause: RegisterResponseCause.EmailFormat, message: "invalid email format" })
}
try {
const user = await UserMgmt.register(email, password, display_name)
} catch (e) {
if (e instanceof DuplicateError) {
if (e.fields.includes("email")) {
return error(409, { cause: RegisterResponseCause.EmailDuplicate, message: "email already in use" })
}
}
return error(500, { cause: RegisterResponseCause.Server, message: "Server failed to create user"})
}
return json({})
}

View File

@ -1,10 +1,46 @@
import type { Actions } from "./$types"
import { fail } from "@sveltejs/kit"
import Log from "$lib/server/log"
export const actions = {
login: ({ }) => {
login: async ({ request, fetch, cookies }) => {
console.log("login")
return {}
const formData = await request.formData()
const result = await fetch("/api/login", {
method: "POST",
body: formData
})
if (!result.ok) {
return fail(401, { email: formData.get("email") ?? "", message: "Benutzername oder Passwort ist falsch." })
}
const body = await (async () => {
try {
return await result.json()
} catch (e) {
Log.error(Log.module.PROCESS, `Failed to parse body of login response from endpoint`)
return null
}
})()
if (!body || !body.token || !body.expires) {
return fail(502, "Invalid response from login endpoint")
}
cookies.set("session", body.token, {
expires: new Date(body.expires),
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/"
})
return { message: "Erfolg" }
}
} satisfies Actions;

View File

@ -1,5 +1,9 @@
<script lang="ts">
import type { PageProps } from "./$types"
const { form }: PageProps = $props()
</script>
<form action="?/login" method="POST" id="form_login"></form>
@ -8,6 +12,7 @@
<div class="grid">
<h1>Login</h1>
<p class="error_msg">{form?.message}</p>
<label for="username">E-Mail: </label><input type="text" id="username" form="form_login" name="email" />
<label for="password">Passwort: </label><input type="password" id="password" form="form_login" name="password" />
<button type="submit" form="form_login">-></button>
@ -24,6 +29,8 @@
width: 100%;
height: 100%;
padding-top: 100px;
flex-direction: column;
justify-content: center;
align-items: center;
@ -42,6 +49,11 @@
grid-column: 1 / span 2;
}
.grid > p {
width: 100%;
grid-column: 1 / span 2;
}
.grid > button {
width: 50%;
margin-top: 10px;
@ -49,4 +61,8 @@
grid-column: 2;
}
.error_msg {
color: red;
}
</style>

View File

@ -1,10 +1,38 @@
import { RegisterResponseCause } from "$lib/errors";
import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit"
export const actions = {
register: async ({ request }) => {
register: async ({ request, fetch }) => {
console.log("register")
const result = await fetch("/api/register", {
method: "POST",
body: await request.formData()
})
const result_body = await result.json()
if (!result.ok) {
if (!result_body.cause) {
return fail(500, { message: "Interner Fehler, versuche es erneut." })
}
switch (result_body.cause) {
case RegisterResponseCause.MalformedRequest: return fail(400, { message: "Bitte versuche es erneut." })
case RegisterResponseCause.EmailLength: return fail(400, { message: "Bitte gib eine Email ein." })
case RegisterResponseCause.EmailFormat: return fail(400, { message: "Das Format der Email ist nicht gültig." })
case RegisterResponseCause.EmailDuplicate: return fail(409, { message: "Email wird bereits verwendet." })
case RegisterResponseCause.DisplayNameLength: return fail(400, { message: "Bitt gib einen Display Namen ein" })
case RegisterResponseCause.PasswordLength: return fail(400, { message: "Bitte gib ein Passwort ein." })
default:
return fail(500, { message: "Ein interner Fehler ist aufgetreten. Bitte versuche es erneut."})
}
}
return { message: "Erfolg" }
}
} satisfies Actions

View File

@ -1,4 +1,8 @@
<script lang="ts">
import type { PageProps } from "./$types"
const { form }: PageProps = $props()
</script>
@ -8,6 +12,7 @@
<div class="grid">
<h1>Login</h1>
<p class="error_msg">{form?.message}</p>
<label for="username">E-Mail: </label><input type="text" id="username" form="form_register" name="email" />
<label for="password">Passwort: </label><input type="password" id="password" form="form_register" name="password" />
<label for="display_name">Anzeigename: </label><input type="text" id="display_name" form="form_register" name="display_name" />
@ -25,6 +30,8 @@
width: 100%;
height: 100%;
padding-top: 100px;
flex-direction: column;
justify-content: center;
align-items: center;
@ -42,6 +49,11 @@
text-align: center;
grid-column: 1 / span 2;
}
.grid > p {
width: 100%;
grid-column: 1 / span 2;
}
.grid > button {
width: 50%;
@ -50,4 +62,8 @@
grid-column: 2;
}
.error_msg {
color: red;
}
</style>

3
static/global.css Normal file
View File

@ -0,0 +1,3 @@
body {
width: 100%;
}