Also document that Sec-Fetch-Site is only sent over HTTPS, so plain-HTTP LAN access relies on SameSite=Lax alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
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<string> | 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<void> {
|
|
// 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<string> {
|
|
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login");
|
|
return this.dummyHash;
|
|
}
|
|
}
|