import { hash, verify } from '@node-rs/argon2'; import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { error } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit'; import { getAuthConfig } from './config.js'; import { getDatabase } from './database.js'; import { sendMail } from './mail.js'; export const SESSION_COOKIE = 'sendewerk_session'; export const INVITATION_COOKIE = 'sendewerk_invitation'; export const RESET_COOKIE = 'sendewerk_password_reset'; const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; const LINK_DURATION_MS = 24 * 60 * 60 * 1000; const LOGIN_WINDOW_MS = 15 * 60 * 1000; const EMAIL_ATTEMPT_LIMIT = 5; const ADDRESS_ATTEMPT_LIMIT = 20; const RESET_WINDOW_MS = 60 * 60 * 1000; const RESET_EMAIL_LIMIT = 3; const RESET_ADDRESS_LIMIT = 10; const PASSWORD_OPTIONS = { algorithm: 2, memoryCost: 19_456, timeCost: 2, parallelism: 1, outputLen: 32 } as const; export type Role = 'barkeeper' | 'thekencrew'; export type AuthUser = { id: string; email: string; displayName: string; role: Role; lockedAtUtc: string | null; }; type UserRow = { id: string; email: string; display_name: string; password_hash: string; role: Role; locked_at_utc: string | null; }; type InvitationRow = { id: string; email: string; role: Role; expires_at_utc: string; accepted_at_utc: string | null; revoked_at_utc: string | null; }; function toUser(row: UserRow): AuthUser { return { id: row.id, email: row.email, displayName: row.display_name, role: row.role, lockedAtUtc: row.locked_at_utc }; } export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } export function validEmail(email: string): boolean { return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } export function validDisplayName(name: string): boolean { return name.trim().length >= 2 && name.trim().length <= 80; } export function validPassword(password: string): boolean { return password.length >= 12 && Buffer.byteLength(password, 'utf8') <= 256; } export function isRole(value: string): value is Role { return value === 'barkeeper' || value === 'thekencrew'; } export async function hashPassword(password: string): Promise { return hash(password, PASSWORD_OPTIONS); } export async function verifyPassword(passwordHash: string, password: string): Promise { return verify(passwordHash, password, PASSWORD_OPTIONS); } const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex')); function createToken(): string { return randomBytes(32).toString('base64url'); } function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } function nowUtc(): string { return new Date().toISOString(); } function futureUtc(durationMs: number): string { return new Date(Date.now() + durationMs).toISOString(); } export function hasUsers(): boolean { const row = getDatabase().prepare('SELECT count(*) AS count FROM users').get() as { count: number; }; return row.count > 0; } export function setupIsConfigured(): boolean { return getAuthConfig().setupToken.length >= 32; } function setupTokenMatches(candidate: string): boolean { const configured = getAuthConfig().setupToken; if (configured.length < 32 || candidate.length !== configured.length) return false; return timingSafeEqual(Buffer.from(candidate), Buffer.from(configured)); } export async function createFirstAdmin(input: { setupToken: string; email: string; displayName: string; password: string; }): Promise { if (!setupTokenMatches(input.setupToken)) throw new Error('SETUP_TOKEN_INVALID'); const email = input.email.trim(); const emailNormalized = normalizeEmail(email); const displayName = input.displayName.trim(); if (!validEmail(email) || !validDisplayName(displayName) || !validPassword(input.password)) { throw new Error('VALIDATION'); } const passwordHash = await hashPassword(input.password); const database = getDatabase(); const create = database.transaction(() => { const count = database.prepare('SELECT count(*) AS count FROM users').get() as { count: number; }; if (count.count !== 0) throw new Error('SETUP_CLOSED'); const id = randomUUID(); const timestamp = nowUtc(); database .prepare( `INSERT INTO users ( id, email, email_normalized, display_name, password_hash, role, password_changed_at_utc, created_at_utc, updated_at_utc ) VALUES (?, ?, ?, ?, ?, 'barkeeper', ?, ?, ?)` ) .run(id, email, emailNormalized, displayName, passwordHash, timestamp, timestamp, timestamp); return { id, email, displayName, role: 'barkeeper', lockedAtUtc: null } satisfies AuthUser; }); return create(); } function sessionCookieOptions(expires?: Date) { return { path: '/', httpOnly: true, sameSite: 'lax' as const, secure: getAuthConfig().secureCookies, expires }; } function linkCookieOptions() { return { path: '/', httpOnly: true, sameSite: 'strict' as const, secure: getAuthConfig().secureCookies, maxAge: 10 * 60 }; } export function storeLinkToken(cookies: Cookies, name: string, token: string): void { cookies.set(name, token, linkCookieOptions()); } export function readLinkToken(cookies: Cookies, name: string): string { return cookies.get(name) || ''; } export function deleteLinkToken(cookies: Cookies, name: string): void { cookies.delete(name, { ...linkCookieOptions(), maxAge: 0 }); } export function createSession(userId: string, cookies: Cookies): void { const token = createToken(); const expires = new Date(Date.now() + SESSION_DURATION_MS); getDatabase() .prepare( 'INSERT INTO sessions (token_hash, user_id, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?)' ) .run(hashToken(token), userId, nowUtc(), expires.toISOString()); cookies.set(SESSION_COOKIE, token, sessionCookieOptions(expires)); } export function deleteSession(cookies: Cookies): void { const token = cookies.get(SESSION_COOKIE); if (token) getDatabase().prepare('DELETE FROM sessions WHERE token_hash = ?').run(hashToken(token)); cookies.delete(SESSION_COOKIE, sessionCookieOptions()); } export function getSessionUser(token: string | undefined): AuthUser | null { if (!token) return null; const database = getDatabase(); const row = database .prepare( `SELECT u.id, u.email, u.display_name, u.password_hash, u.role, u.locked_at_utc FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at_utc > ? AND u.locked_at_utc IS NULL` ) .get(hashToken(token), nowUtc()) as UserRow | undefined; if (!row) return null; return toUser(row); } function rateKey(value: string): string { return hashToken(value || 'unknown'); } export function loginIsRateLimited(email: string, address: string): boolean { const database = getDatabase(); const since = new Date(Date.now() - LOGIN_WINDOW_MS).toISOString(); database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since); const emailCount = database .prepare( 'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?' ) .get(rateKey(normalizeEmail(email)), since) as { count: number }; const addressCount = database .prepare( 'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?' ) .get(rateKey(address), since) as { count: number }; return emailCount.count >= EMAIL_ATTEMPT_LIMIT || addressCount.count >= ADDRESS_ATTEMPT_LIMIT; } export function reserveLoginAttempt(email: string, address: string): boolean { const database = getDatabase(); const reserve = database.transaction(() => { if (loginIsRateLimited(email, address)) return false; database .prepare( 'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)' ) .run(randomUUID(), rateKey(normalizeEmail(email)), rateKey(address), nowUtc()); return true; }); return reserve(); } export async function authenticate(email: string, password: string): Promise { const database = getDatabase(); const normalized = normalizeEmail(email); const row = database .prepare( `SELECT id, email, display_name, password_hash, role, locked_at_utc FROM users WHERE email_normalized = ?` ) .get(normalized) as UserRow | undefined; const matches = row ? await verifyPassword(row.password_hash, password) : await verifyPassword(await dummyPasswordHash, password); if (!row || !matches || row.locked_at_utc) { return null; } database.prepare('DELETE FROM login_attempts WHERE email_key = ?').run(rateKey(normalized)); return toUser(row); } export function requireUser(user: AuthUser | null): AuthUser { if (!user) error(401, 'Anmeldung erforderlich'); return user; } export function requireBarkeeper(user: AuthUser | null): AuthUser { const authenticated = requireUser(user); if (authenticated.role !== 'barkeeper') error(403, 'Diese Aktion erfordert Barkeeper-Rechte.'); return authenticated; } function requireCurrentBarkeeper(actor: AuthUser): AuthUser { const row = getDatabase() .prepare( `SELECT id, email, display_name, password_hash, role, locked_at_utc FROM users WHERE id = ? AND role = 'barkeeper' AND locked_at_utc IS NULL` ) .get(actor.id) as UserRow | undefined; if (!row) error(403, 'Diese Aktion erfordert aktive Barkeeper-Rechte.'); return toUser(row); } export function listUsers(): AuthUser[] { const rows = getDatabase() .prepare( `SELECT id, email, display_name, password_hash, role, locked_at_utc FROM users ORDER BY display_name COLLATE NOCASE` ) .all() as UserRow[]; return rows.map(toUser); } export function listOpenInvitations() { return getDatabase() .prepare( `SELECT id, email, role, expires_at_utc FROM invitations WHERE accepted_at_utc IS NULL AND revoked_at_utc IS NULL AND expires_at_utc > ? ORDER BY created_at_utc DESC` ) .all(nowUtc()) as Array<{ id: string; email: string; role: Role; expires_at_utc: string }>; } export async function inviteUser( actor: AuthUser, emailValue: string, role: Role ): Promise<'local' | 'smtp'> { actor = requireCurrentBarkeeper(actor); const email = emailValue.trim(); const normalized = normalizeEmail(email); if (!validEmail(email) || !isRole(role)) throw new Error('VALIDATION'); const database = getDatabase(); if (database.prepare('SELECT 1 FROM users WHERE email_normalized = ?').get(normalized)) { throw new Error('USER_EXISTS'); } const token = createToken(); const id = randomUUID(); const timestamp = nowUtc(); const expiresAt = futureUtc(LINK_DURATION_MS); const store = database.transaction(() => { database .prepare( `UPDATE invitations SET revoked_at_utc = ? WHERE email_normalized = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL` ) .run(timestamp, normalized); database .prepare( `INSERT INTO invitations ( id, email, email_normalized, role, token_hash, created_by_user_id, created_at_utc, expires_at_utc ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) .run(id, email, normalized, role, hashToken(token), actor.id, timestamp, expiresAt); }); store(); const link = new URL( `/einladung?token=${encodeURIComponent(token)}`, getAuthConfig().origin ).toString(); try { return await sendMail({ to: email, subject: 'Einladung zu Sendewerk', text: `Du wurdest zu Sendewerk eingeladen. Der Link ist 24 Stunden gültig und einmalig nutzbar:\n\n${link}` }); } catch (mailError) { database.prepare('UPDATE invitations SET revoked_at_utc = ? WHERE id = ?').run(nowUtc(), id); throw mailError; } } export function getInvitation(token: string): InvitationRow | null { if (!token || token.length > 100) return null; return ( (getDatabase() .prepare( `SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc FROM invitations WHERE token_hash = ?` ) .get(hashToken(token)) as InvitationRow | undefined) ?? null ); } export async function acceptInvitation(input: { token: string; displayName: string; password: string; }): Promise { const invitation = getInvitation(input.token); const displayName = input.displayName.trim(); if ( !invitation || invitation.accepted_at_utc || invitation.revoked_at_utc || invitation.expires_at_utc <= nowUtc() ) { throw new Error('TOKEN_INVALID'); } if (!validDisplayName(displayName) || !validPassword(input.password)) throw new Error('VALIDATION'); const passwordHash = await hashPassword(input.password); const database = getDatabase(); const accept = database.transaction(() => { const current = database .prepare( `SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc FROM invitations WHERE id = ?` ) .get(invitation.id) as InvitationRow; if (current.accepted_at_utc || current.revoked_at_utc || current.expires_at_utc <= nowUtc()) { throw new Error('TOKEN_INVALID'); } const id = randomUUID(); const timestamp = nowUtc(); database .prepare( `INSERT INTO users ( id, email, email_normalized, display_name, password_hash, role, password_changed_at_utc, created_at_utc, updated_at_utc ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, current.email, normalizeEmail(current.email), displayName, passwordHash, current.role, timestamp, timestamp, timestamp ); database .prepare('UPDATE invitations SET accepted_at_utc = ? WHERE id = ?') .run(timestamp, current.id); return { id, email: current.email, displayName, role: current.role, lockedAtUtc: null } satisfies AuthUser; }); return accept(); } export function reservePasswordResetRequest(email: string, address: string): boolean { const database = getDatabase(); const since = new Date(Date.now() - RESET_WINDOW_MS).toISOString(); const emailKey = rateKey(`reset-email:${normalizeEmail(email)}`); const addressKey = rateKey(`reset-address:${address}`); const reserve = database.transaction(() => { database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since); const emailCount = database .prepare( 'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?' ) .get(emailKey, since) as { count: number }; const addressCount = database .prepare( 'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?' ) .get(addressKey, since) as { count: number }; if (emailCount.count >= RESET_EMAIL_LIMIT || addressCount.count >= RESET_ADDRESS_LIMIT) return false; database .prepare( 'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)' ) .run(randomUUID(), emailKey, addressKey, nowUtc()); return true; }); return reserve(); } export async function requestPasswordReset(emailValue: string): Promise { const normalized = normalizeEmail(emailValue); const database = getDatabase(); const row = database .prepare('SELECT id, email FROM users WHERE email_normalized = ?') .get(normalized) as { id: string; email: string } | undefined; if (!row) return; const token = createToken(); const id = randomUUID(); const timestamp = nowUtc(); database .prepare( `INSERT INTO password_reset_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?, ?)` ) .run(id, row.id, hashToken(token), timestamp, futureUtc(LINK_DURATION_MS)); const link = new URL( `/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`, getAuthConfig().origin ).toString(); try { await sendMail({ to: row.email, subject: 'Sendewerk-Passwort zurücksetzen', text: `Mit diesem 24 Stunden gültigen, einmaligen Link kannst du dein Passwort zurücksetzen:\n\n${link}` }); } catch (mailError) { database.prepare('DELETE FROM password_reset_tokens WHERE id = ?').run(id); throw mailError; } } export function passwordResetIsValid(token: string): boolean { if (!token || token.length > 100) return false; return Boolean( getDatabase() .prepare( `SELECT 1 FROM password_reset_tokens WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?` ) .get(hashToken(token), nowUtc()) ); } export async function resetPassword(token: string, password: string): Promise { if (!passwordResetIsValid(token)) throw new Error('TOKEN_INVALID'); if (!validPassword(password)) throw new Error('VALIDATION'); const passwordHash = await hashPassword(password); const database = getDatabase(); const reset = database.transaction(() => { const row = database .prepare( `SELECT id, user_id FROM password_reset_tokens WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?` ) .get(hashToken(token), nowUtc()) as { id: string; user_id: string } | undefined; if (!row) throw new Error('TOKEN_INVALID'); const timestamp = nowUtc(); database .prepare( `UPDATE users SET password_hash = ?, password_changed_at_utc = ?, updated_at_utc = ? WHERE id = ?` ) .run(passwordHash, timestamp, timestamp, row.user_id); database .prepare( 'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL' ) .run(timestamp, row.user_id); database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id); }); reset(); } function activeBarkeeperCount(): number { const row = getDatabase() .prepare( "SELECT count(*) AS count FROM users WHERE role = 'barkeeper' AND locked_at_utc IS NULL" ) .get() as { count: number }; return row.count; } export function setUserLocked(actor: AuthUser, userId: string, locked: boolean): void { actor = requireCurrentBarkeeper(actor); const database = getDatabase(); const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as { role: Role; locked_at_utc: string | null } | undefined; if (!row) throw new Error('NOT_FOUND'); if (locked && actor.id === userId) throw new Error('SELF_LOCK'); if (locked && row.role === 'barkeeper' && !row.locked_at_utc && activeBarkeeperCount() <= 1) { throw new Error('LAST_BARKEEPER'); } const update = database.transaction(() => { database .prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?') .run(locked ? nowUtc() : null, nowUtc(), userId); if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); }); update(); } export function setUserRole(actor: AuthUser, userId: string, role: Role): void { requireCurrentBarkeeper(actor); if (!isRole(role)) throw new Error('VALIDATION'); const database = getDatabase(); const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as { role: Role; locked_at_utc: string | null } | undefined; if (!row) throw new Error('NOT_FOUND'); if ( row.role === 'barkeeper' && role === 'thekencrew' && !row.locked_at_utc && activeBarkeeperCount() <= 1 ) { throw new Error('LAST_BARKEEPER'); } database .prepare('UPDATE users SET role = ?, updated_at_utc = ? WHERE id = ?') .run(role, nowUtc(), userId); } export function revokeInvitation(actor: AuthUser, invitationId: string): void { requireCurrentBarkeeper(actor); getDatabase() .prepare( `UPDATE invitations SET revoked_at_utc = ? WHERE id = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL` ) .run(nowUtc(), invitationId); }