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 UserService { 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 { if (!this.authConfig.bootstrap) return; const existingCount = await this.repository.count(); if (existingCount > 0) 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); // Always hash something, even for an unknown user: otherwise the // response time would leak which usernames exist. const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder")); 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); return this.repository.create({ username: input.username, name: input.name, role: input.role, passwordHash, }); } async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { 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(); const passwordHash = await hashPassword(newPassword); return this.repository.setPassword(userId, passwordHash, false); } }