import type { UserRepository } from "../../db/repositories/user.repository.js"; import { hashPassword, verifyPassword } from "../../server/auth/password.js"; import type { UserRole } from "../../shared/constants/user-roles.js"; import type { AuthConfig } from "../../server/auth/auth-config.js"; import { createLogger } from "../../shared/logging/logger.js"; const logger = createLogger("user-service"); export class InvalidCredentialsError extends Error { constructor() { super("Anmeldung fehlgeschlagen."); } } export class UsernameTakenError extends Error { constructor() { super("Dieser Anmeldename ist bereits vergeben."); } } export class WrongPasswordError extends Error { constructor() { super("Das aktuelle Passwort ist falsch."); } } export class PasswordUnchangedError extends Error { constructor() { super("Das neue Passwort muss sich vom bisherigen unterscheiden."); } } export class LastAdminError extends Error { constructor() { super("Es muss mindestens ein aktiver Administrator bleiben."); } } function isUniqueConstraintError(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE" ); } export class UserService { private dummyHash: Promise | null = null; constructor( private readonly repository: UserRepository, private readonly authConfig: AuthConfig ) {} /** * Creates the bootstrap admin from env vars, but only while the users * table is still completely empty. After the first account exists, every * further account is created through Config -> Benutzer, and the bootstrap * env vars are ignored - that's the whole point of them being a one-time * escape hatch instead of a standing backdoor. */ async ensureBootstrapAdmin(): Promise { // Warm the dummy hash at startup so even the first unknown-user login // costs one scrypt run, not two. await this.getDummyHash(); const existingCount = await this.repository.count(); if (existingCount > 0) return; if (!this.authConfig.bootstrap) { logger.warn( "no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in" ); return; } const passwordHash = await hashPassword(this.authConfig.bootstrap.password); await this.repository.create({ username: this.authConfig.bootstrap.username, name: "Administrator", role: "admin", passwordHash, }); logger.info("bootstrap admin created", { username: this.authConfig.bootstrap.username, }); } async authenticate(username: string, password: string) { const user = await this.repository.findByUsername(username); // Unknown users are verified against a fixed dummy hash: exactly one // scrypt run either way, so response time doesn't reveal which usernames // exist. const passwordHash = user?.passwordHash ?? (await this.getDummyHash()); const passwordMatches = await verifyPassword(password, passwordHash); if (!user || !user.active || !passwordMatches) { throw new InvalidCredentialsError(); } return user; } async createUser(input: { username: string; name: string; role: UserRole; initialPassword: string; }) { const existing = await this.repository.findByUsername(input.username); if (existing) throw new UsernameTakenError(); const passwordHash = await hashPassword(input.initialPassword); try { return await this.repository.create({ username: input.username, name: input.name, role: input.role, passwordHash, }); } catch (error) { // Two admins creating the same name at once: the unique index decides. if (isUniqueConstraintError(error)) throw new UsernameTakenError(); throw error; } } async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { const removesAdmin = patch.role === "user" || patch.active === false; if (removesAdmin) { const target = await this.repository.findById(userId); const targetIsActiveAdmin = target?.role === "admin" && target.active; if (targetIsActiveAdmin) { const activeAdmins = (await this.repository.list()).filter( (user) => user.role === "admin" && user.active ); if (activeAdmins.length <= 1) throw new LastAdminError(); } } return this.repository.updateRoleAndActive(userId, patch); } /** Admin-triggered reset with a new start password; forces a change on next login. */ async resetPassword(userId: string, newPassword: string) { const passwordHash = await hashPassword(newPassword); return this.repository.setPassword(userId, passwordHash, true); } /** Self-service change by the signed-in user; clears mustChangePassword. */ async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) { const user = await this.repository.findById(userId); if (!user) throw new WrongPasswordError(); const currentMatches = await verifyPassword(currentPassword, user.passwordHash); if (!currentMatches) throw new WrongPasswordError(); if (newPassword === currentPassword) throw new PasswordUnchangedError(); const passwordHash = await hashPassword(newPassword); return this.repository.setPassword(userId, passwordHash, false); } private getDummyHash(): Promise { this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login"); return this.dummyHash; } }