Add login-required user management (admin/user roles, Config->Benutzer)
No self-signup: admins create every account in Config -> Benutzer with a typed start password; a forced password change gates first use. Session- cookie auth protects all /api/* routes; /api/users additionally requires the admin role. See docs/authentication.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b50c00c0ac
commit
c56cfb30ad
36 changed files with 4928 additions and 38 deletions
104
src/domain/services/user.service.ts
Normal file
104
src/domain/services/user.service.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue