feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)

This commit is contained in:
MPM Dev
2026-10-06 14:58:36 +02:00
parent a7e1c421f2
commit e87dc1f836
25 changed files with 2056 additions and 75 deletions

View File

@@ -7,6 +7,13 @@ export const AUDIT_ACTIONS = {
LOGIN_FAILED: 'LOGIN_FAILED',
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
LOGOUT: 'LOGOUT',
USER_CREATED: 'USER_CREATED',
USER_UPDATED: 'USER_UPDATED',
USER_ENABLED: 'USER_ENABLED',
USER_DISABLED: 'USER_DISABLED',
USER_DELETED: 'USER_DELETED',
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];

View File

@@ -73,6 +73,18 @@ export class SessionService {
await this.database.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
}
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
if (exceptSessionId) {
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
userId,
exceptSessionId,
]);
return;
}
await this.database.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
}
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
async deleteExpired(): Promise<void> {
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');

View File

@@ -17,11 +17,21 @@ export class ZodValidationPipe implements PipeTransform {
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
const flattened = result.error.flatten();
const details: Record<string, string[]> = {};
for (const [key, messages] of Object.entries(flattened.fieldErrors)) {
if (messages) {
details[key] = messages;
}
}
if (flattened.formErrors.length > 0) {
details.form = flattened.formErrors;
}
throw new BadRequestException({
statusCode: 400,
message: 'Validierung fehlgeschlagen',
error: 'Bad Request',
details: result.error.flatten().fieldErrors,
details,
});
}
return result.data;

View File

@@ -0,0 +1,62 @@
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
import type { Request } from 'express';
import { ApiTags } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import type { AuthUser, RoleName, UserRecord } from './user.types';
import { changePasswordSchema, type ChangePasswordDto } from './user.types';
import { ProfileService } from './profile.service';
/** Profil-Daten in API-Antworten. */
interface ProfileResponse {
id: string;
username: string;
email: string;
displayName: string;
role: RoleName;
lastLoginAt: string | null;
createdAt: string;
}
function toProfileResponse(user: UserRecord): ProfileResponse {
return {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
role: user.role,
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
createdAt: user.createdAt.toISOString(),
};
}
/**
* Profil des angemeldeten Benutzers (jede Rolle).
* Passwort-Änderung erfordert das aktuelle Passwort.
*/
@ApiTags('Profile')
@Controller({ path: 'api/v1/profile' })
export class ProfileController {
constructor(private readonly profileService: ProfileService) {}
@Get()
async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> {
const profile = await this.profileService.getProfile(user.id);
return { profile: toProfileResponse(profile) };
}
@Patch('password')
async changePassword(
@CurrentUser() user: AuthUser,
@Req() request: AuthenticatedRequest & Request,
@Body(new ZodValidationPipe(changePasswordSchema)) body: ChangePasswordDto,
): Promise<{ success: true }> {
const sessionId = request.session?.id;
if (!sessionId) {
throw new Error('Session-Kontext fehlt');
}
await this.profileService.changePassword(user.id, sessionId, body, request.ip ?? null);
return { success: true };
}
}

View File

@@ -0,0 +1,121 @@
import { UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { UserRecord } from './user.types';
import { ProfileService } from './profile.service';
/** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
return {
id: 'user-1',
username: 'max',
email: 'max@example.com',
passwordHash: 'not-a-real-hash',
displayName: 'Max Mustermann',
role: 'USER',
isActive: true,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/** Mock des UserRepository. */
class MockUserRepository {
public user: UserRecord | null = createUserRecord();
public updatedPasswords: Array<{ id: string; hash: string }> = [];
async findById(id: string): Promise<UserRecord | null> {
return this.user && this.user.id === id ? this.user : null;
}
async updatePassword(id: string, hash: string): Promise<void> {
this.updatedPasswords.push({ id, hash });
}
}
/** Mock des SessionService. */
class MockSessionService {
public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = [];
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
this.deletedForUser.push({ userId, exceptSessionId });
}
}
/** Mock des AuditService. */
class MockAuditService {
public records: Array<{ action: string }> = [];
async record(entry: { action: string }): Promise<void> {
this.records.push(entry);
}
}
describe('ProfileService', () => {
let userRepository: MockUserRepository;
let sessionService: MockSessionService;
let auditService: MockAuditService;
let profileService: ProfileService;
let passwordHasher: PasswordHasher;
beforeEach(async () => {
userRepository = new MockUserRepository();
sessionService = new MockSessionService();
auditService = new MockAuditService();
passwordHasher = new PasswordHasher();
profileService = new ProfileService(
userRepository as unknown as UserRepository,
passwordHasher,
sessionService as unknown as SessionService,
auditService as unknown as AuditService,
);
userRepository.user = createUserRecord({
passwordHash: await passwordHasher.hash('Altes-Passwort-1'),
});
});
it('gibt das Profil des angemeldeten Benutzers zurück', async () => {
const profile = await profileService.getProfile('user-1');
expect(profile.username).toBe('max');
});
it('wirft UnauthorizedException bei unbekanntem Benutzer', async () => {
await expect(profileService.getProfile('unbekannt')).rejects.toThrow(
UnauthorizedException,
);
});
it('ändert das Passwort mit korrektem aktuellen Passwort', async () => {
await profileService.changePassword(
'user-1',
'session-1',
{ currentPassword: 'Altes-Passwort-1', newPassword: 'Neues-Passwort-123' },
'127.0.0.1',
);
expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedForUser).toEqual([
{ userId: 'user-1', exceptSessionId: 'session-1' },
]);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
});
it('lehnt falsches aktuelles Passwort ab', async () => {
await expect(
profileService.changePassword(
'user-1',
'session-1',
{ currentPassword: 'falsch', newPassword: 'Neues-Passwort-123' },
null,
),
).rejects.toThrow(UnauthorizedException);
expect(userRepository.updatedPasswords).toHaveLength(0);
});
});

View File

@@ -0,0 +1,61 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { ChangePasswordDto, UserRecord } from './user.types';
/**
* Profil-Verwaltung des angemeldeten Benutzers:
* Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
* Nach der Änderung werden alle übrigen Sessions des Benutzers
* ungültig gemacht – die aktuelle Session bleibt aktiv.
*/
@Injectable()
export class ProfileService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
) {}
async getProfile(userId: string): Promise<UserRecord> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('Nicht authentifiziert');
}
return user;
}
async changePassword(
userId: string,
currentSessionId: string,
input: ChangePasswordDto,
ipAddress: string | null,
): Promise<void> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('Nicht authentifiziert');
}
const currentPasswordValid = await this.passwordHasher.verify(
user.passwordHash,
input.currentPassword,
);
if (!currentPasswordValid) {
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
}
const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(userId, newPasswordHash);
await this.sessionService.deleteAllForUser(userId, currentSessionId);
await this.auditService.record({
userId,
username: user.username,
action: AUDIT_ACTIONS.USER_PASSWORD_CHANGED,
ipAddress,
});
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { PasswordHasher } from './password-hasher';
import type { AuthUser, RoleName, UserRecord } from './user.types';
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
interface UserRow {
id: string;
@@ -22,20 +22,11 @@ const USER_COLUMNS = `u.id, u.username, u.email, u.password_hash, u.display_name
u.is_active, u.failed_login_attempts, u.locked_until,
u.last_login_at, u.created_at, u.updated_at`;
/** Wandelt einen Datenbank-Datensatz in die öffentliche Benutzer-Repräsentation um. */
function toAuthUser(record: UserRecord): AuthUser {
return {
id: record.id,
username: record.username,
email: record.email,
displayName: record.displayName,
role: record.role,
};
}
const USER_FROM = `FROM users u JOIN roles r ON r.id = u.role_id`;
/**
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
* Enthält keine Business-Logik – nur Datenzugriff.
* Enthält keine Business-Logik – nur parametrisierten Datenzugriff.
*/
@Injectable()
export class UserRepository {
@@ -44,11 +35,17 @@ export class UserRepository {
private readonly passwordHasher: PasswordHasher,
) {}
/** Alle Benutzer, neueste zuerst. */
async list(): Promise<UserRecord[]> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS} ${USER_FROM} ORDER BY u.created_at DESC`,
);
return result.rows.map((row) => this.mapRow(row));
}
async findByUsername(username: string): Promise<UserRecord | null> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS}
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.username = $1`,
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.username = $1`,
[username],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
@@ -56,14 +53,104 @@ export class UserRepository {
async findById(id: string): Promise<UserRecord | null> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS}
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.id = $1`,
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.id = $1`,
[id],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
}
async findByEmail(email: string): Promise<UserRecord | null> {
const result = await this.database.query<UserRow>(
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.email = $1`,
[email],
);
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
}
/** Legt einen Benutzer an (Passwort wird gehasht). */
async create(input: CreateUserDto): Promise<UserRecord> {
const passwordHash = await this.passwordHasher.hash(input.password);
const result = await this.database.query<UserRow>(
`INSERT INTO users (username, email, password_hash, display_name, role_id)
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
RETURNING id, username, email, password_hash, display_name,
(SELECT name FROM roles WHERE id = role_id) AS role_name,
true AS is_active, 0 AS failed_login_attempts,
NULL::timestamptz AS locked_until, NULL::timestamptz AS last_login_at,
now() AS created_at, now() AS updated_at`,
[input.username, input.email, passwordHash, input.displayName, input.role],
);
return this.mapRow(result.rows[0]);
}
/** Aktualisiert nur die übergebenen Felder (dynamisch, parametrisiert). */
async update(id: string, changes: UpdateUserDto): Promise<UserRecord> {
const setClauses: string[] = [];
const params: unknown[] = [];
let parameterIndex = 1;
if (changes.email !== undefined) {
setClauses.push(`email = $${parameterIndex++}`);
params.push(changes.email);
}
if (changes.displayName !== undefined) {
setClauses.push(`display_name = $${parameterIndex++}`);
params.push(changes.displayName);
}
if (changes.role !== undefined) {
setClauses.push(`role_id = (SELECT id FROM roles WHERE name = $${parameterIndex++})`);
params.push(changes.role);
}
if (changes.isActive !== undefined) {
setClauses.push(`is_active = $${parameterIndex++}`);
params.push(changes.isActive);
}
setClauses.push('updated_at = now()');
params.push(id);
const result = await this.database.query<UserRow>(
`UPDATE users
SET ${setClauses.join(', ')}
WHERE id = $${parameterIndex}
RETURNING id, username, email, password_hash, display_name,
(SELECT name FROM roles WHERE id = role_id) AS role_name,
is_active, failed_login_attempts, locked_until,
last_login_at, created_at, updated_at`,
params,
);
return this.mapRow(result.rows[0]);
}
/** Setzt einen neuen Passwort-Hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> {
await this.database.query(
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1',
[id, passwordHash],
);
}
/** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */
async resetLoginLockout(id: string): Promise<void> {
await this.database.query(
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = now() WHERE id = $1',
[id],
);
}
async delete(id: string): Promise<void> {
await this.database.query('DELETE FROM users WHERE id = $1', [id]);
}
/** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */
async countActiveAdmins(): Promise<number> {
const result = await this.database.query<{ count: number }>(
`SELECT count(*)::int AS count
FROM users u JOIN roles r ON r.id = u.role_id
WHERE r.name = 'ADMIN' AND u.is_active`,
);
return result.rows[0]?.count ?? 0;
}
async updateLoginSuccess(userId: string): Promise<void> {
await this.database.query(
`UPDATE users
@@ -94,26 +181,6 @@ export class UserRepository {
);
}
async create(input: {
username: string;
email: string;
password: string;
displayName: string;
role: RoleName;
}): Promise<AuthUser> {
const passwordHash = await this.passwordHasher.hash(input.password);
const result = await this.database.query<UserRow>(
`INSERT INTO users (username, email, password_hash, display_name, role_id)
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
RETURNING id, username, email, display_name,
(SELECT name FROM roles WHERE id = role_id) AS role_name,
true AS is_active, 0 AS failed_login_attempts, NULL::timestamptz AS locked_until,
NULL::timestamptz AS last_login_at, now() AS created_at, now() AS updated_at`,
[input.username, input.email, passwordHash, input.displayName, input.role],
);
return toAuthUser(this.mapRow(result.rows[0]));
}
private mapRow(row: UserRow): UserRecord {
return {
id: row.id,

View File

@@ -29,4 +29,48 @@ export const loginSchema = z.object({
username: z.string().trim().min(1).max(100),
password: z.string().min(1).max(200),
});
export type LoginDto = z.infer<typeof loginSchema>;
export type LoginDto = z.infer<typeof loginSchema>;
/** Erlaubte Zeichen für Benutzernamen (kein Whitespace, keine Sonderzeichen). */
const USERNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Benutzer anlegen (Admin). */
export const createUserSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
.max(100)
.regex(USERNAME_PATTERN, 'Benutzername darf nur Buchstaben, Zahlen sowie . _ - enthalten'),
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200),
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
role: z.enum(ROLE_NAMES),
});
export type CreateUserDto = z.infer<typeof createUserSchema>;
/** Benutzer bearbeiten (Admin) – mindestens ein Feld muss gesetzt sein. */
export const updateUserSchema = z
.object({
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255).optional(),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200).optional(),
role: z.enum(ROLE_NAMES).optional(),
isActive: z.boolean().optional(),
})
.refine((data) => Object.values(data).some((value) => value !== undefined), {
message: 'Mindestens ein Feld ist erforderlich',
});
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
/** Passwort durch einen Administrator zurücksetzen. */
export const resetPasswordSchema = z.object({
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
});
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
/** Eigenes Passwort ändern (angemeldeter Benutzer). */
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich').max(200),
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;

View File

@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { Request } from 'express';
import { ApiTags } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { Roles } from '../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import type { AuthUser, RoleName, UserRecord } from './user.types';
import {
createUserSchema,
resetPasswordSchema,
updateUserSchema,
type CreateUserDto,
type ResetPasswordDto,
type UpdateUserDto,
} from './user.types';
import { UsersService } from './users.service';
/** Benutzerdaten in API-Antworten (ohne interne Felder). */
interface UserResponse {
id: string;
username: string;
email: string;
displayName: string;
role: RoleName;
isActive: boolean;
lastLoginAt: string | null;
createdAt: string;
}
function toUserResponse(user: UserRecord): UserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
role: user.role,
isActive: user.isActive,
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
createdAt: user.createdAt.toISOString(),
};
}
/**
* Benutzerverwaltung (nur Administratoren).
* Guards (Session, CSRF, Rollen) sind global registriert;
* @Roles('ADMIN') erzwingt die Administrator-Rolle auf Klassenebene.
*/
@ApiTags('Users')
@Roles('ADMIN')
@Controller({ path: 'api/v1/users' })
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async list(): Promise<{ users: UserResponse[] }> {
const users = await this.usersService.list();
return { users: users.map(toUserResponse) };
}
@Post()
async create(
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserDto,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ user: UserResponse }> {
const user = await this.usersService.create(body, actor, request.ip ?? null);
return { user: toUserResponse(user) };
}
@Get(':id')
async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ user: UserResponse }> {
const user = await this.usersService.findById(id);
return { user: toUserResponse(user) };
}
@Patch(':id/password')
async resetPassword(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(resetPasswordSchema)) body: ResetPasswordDto,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ success: true }> {
await this.usersService.resetPassword(id, body, actor, request.ip ?? null);
return { success: true };
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserDto,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ user: UserResponse }> {
const user = await this.usersService.update(id, body, actor, request.ip ?? null);
return { user: toUserResponse(user) };
}
@Delete(':id')
async remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ success: true }> {
await this.usersService.remove(id, actor, request.ip ?? null);
return { success: true };
}
}

View File

@@ -1,14 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '../config/config.module';
import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
import { SeedService } from './seed.service';
import { UserRepository } from './user.repository';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
/** Benutzerverwaltung (Phase 1: Modell, Rollen, Seed). */
/** Benutzerverwaltung: Admin-API, Profil, Rollen, Seed. */
@Module({
imports: [ConfigModule, DatabaseModule],
providers: [UserRepository, PasswordHasher, SeedService],
imports: [ConfigModule, DatabaseModule, AuditModule],
controllers: [UsersController, ProfileController],
providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService],
exports: [UserRepository, PasswordHasher],
})
export class UsersModule {}

View File

@@ -0,0 +1,253 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { CreateUserDto, UserRecord } from './user.types';
import { UsersService, type ActingUser } from './users.service';
/** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
return {
id: 'user-1',
username: 'max',
email: 'max@example.com',
passwordHash: 'not-a-real-hash',
displayName: 'Max Mustermann',
role: 'USER',
isActive: true,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' };
/** Mock des UserRepository. */
class MockUserRepository {
public users: UserRecord[] = [createUserRecord()];
public deletedIds: string[] = [];
public updatedPasswords: Array<{ id: string; hash: string }> = [];
public lockoutResets: string[] = [];
async list(): Promise<UserRecord[]> {
return this.users;
}
async findById(id: string): Promise<UserRecord | null> {
return this.users.find((user) => user.id === id) ?? null;
}
async findByUsername(username: string): Promise<UserRecord | null> {
return this.users.find((user) => user.username === username) ?? null;
}
async findByEmail(email: string): Promise<UserRecord | null> {
return this.users.find((user) => user.email === email) ?? null;
}
async create(input: CreateUserDto): Promise<UserRecord> {
const user = createUserRecord({
id: 'new-user',
username: input.username,
email: input.email,
displayName: input.displayName,
role: input.role,
});
this.users.push(user);
return user;
}
async update(id: string, changes: Partial<UserRecord>): Promise<UserRecord> {
const index = this.users.findIndex((user) => user.id === id);
if (index === -1) {
throw new Error('nicht gefunden');
}
this.users[index] = { ...this.users[index], ...changes };
return this.users[index];
}
async updatePassword(id: string, hash: string): Promise<void> {
this.updatedPasswords.push({ id, hash });
}
async resetLoginLockout(id: string): Promise<void> {
this.lockoutResets.push(id);
}
async delete(id: string): Promise<void> {
this.deletedIds.push(id);
this.users = this.users.filter((user) => user.id !== id);
}
async countActiveAdmins(): Promise<number> {
return this.users.filter((user) => user.role === 'ADMIN' && user.isActive).length;
}
}
/** Mock des SessionService. */
class MockSessionService {
public deletedSessionsForUser: string[] = [];
async deleteAllForUser(userId: string): Promise<void> {
this.deletedSessionsForUser.push(userId);
}
}
/** Mock des AuditService. */
class MockAuditService {
public records: Array<{ action: string; userId: string | null }> = [];
async record(entry: { action: string; userId: string | null }): Promise<void> {
this.records.push(entry);
}
}
describe('UsersService', () => {
let userRepository: MockUserRepository;
let sessionService: MockSessionService;
let auditService: MockAuditService;
let usersService: UsersService;
let passwordHasher: PasswordHasher;
const createUserInput: CreateUserDto = {
username: 'neu',
email: 'neu@example.com',
displayName: 'Neuer Benutzer',
password: 'Sicheres-Passwort-1',
role: 'USER',
};
beforeEach(() => {
userRepository = new MockUserRepository();
sessionService = new MockSessionService();
auditService = new MockAuditService();
passwordHasher = new PasswordHasher();
usersService = new UsersService(
userRepository as unknown as UserRepository,
passwordHasher,
sessionService as unknown as SessionService,
auditService as unknown as AuditService,
);
});
describe('list', () => {
it('gibt alle Benutzer zurück', async () => {
const users = await usersService.list();
expect(users).toHaveLength(1);
expect(users[0].username).toBe('max');
});
});
describe('findById', () => {
it('wirft NotFoundException bei unbekannter ID', async () => {
await expect(usersService.findById('unbekannt')).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('legt einen neuen Benutzer an und schreibt Audit', async () => {
const user = await usersService.create(createUserInput, ACTOR, '127.0.0.1');
expect(user.username).toBe('neu');
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_CREATED);
});
it('lehnt doppelte Benutzernamen ab', async () => {
await expect(
usersService.create({ ...createUserInput, username: 'max' }, ACTOR, null),
).rejects.toThrow(ConflictException);
});
it('lehnt doppelte E-Mail-Adressen ab', async () => {
await expect(
usersService.create({ ...createUserInput, email: 'max@example.com' }, ACTOR, null),
).rejects.toThrow(ConflictException);
});
});
describe('update', () => {
it('aktualisiert den Anzeigenamen und schreibt Audit', async () => {
const updated = await usersService.update(
'user-1',
{ displayName: 'Max M.' },
ACTOR,
null,
);
expect(updated.displayName).toBe('Max M.');
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_UPDATED);
});
it('verhindert Selbst-Deaktivierung', async () => {
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
await expect(
usersService.update('admin-1', { isActive: false }, ACTOR, null),
).rejects.toThrow(BadRequestException);
});
it('meldet deaktivierte Benutzer von allen Sessions ab', async () => {
await usersService.update('user-1', { isActive: false }, ACTOR, null);
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DISABLED);
});
it('setzt Login-Sperre bei Reaktivierung zurück', async () => {
userRepository.users[0] = createUserRecord({ isActive: false });
await usersService.update('user-1', { isActive: true }, ACTOR, null);
expect(userRepository.lockoutResets).toEqual(['user-1']);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_ENABLED);
});
it('verhindert die Deaktivierung des letzten aktiven Admins', async () => {
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
await expect(
usersService.update('admin-1', { isActive: false }, { id: 'anderer', username: 'x' }, null),
).rejects.toThrow(BadRequestException);
});
it('verhindert die Herabstufung des letzten aktiven Admins', async () => {
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
await expect(
usersService.update('admin-1', { role: 'USER' }, { id: 'anderer', username: 'x' }, null),
).rejects.toThrow(BadRequestException);
});
});
describe('resetPassword', () => {
it('setzt das Passwort, meldet Sessions ab und schreibt Audit', async () => {
await usersService.resetPassword(
'user-1',
{ newPassword: 'Neues-Passwort-123' },
ACTOR,
null,
);
expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
});
});
describe('remove', () => {
it('löscht einen Benutzer und schreibt Audit', async () => {
await usersService.remove('user-1', ACTOR, null);
expect(userRepository.deletedIds).toEqual(['user-1']);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DELETED);
});
it('verhindert Selbst-Löschung', async () => {
await expect(usersService.remove('admin-1', ACTOR, null)).rejects.toThrow(
BadRequestException,
);
});
it('verhindert die Löschung des letzten aktiven Admins', async () => {
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
await expect(
usersService.remove('admin-1', { id: 'anderer', username: 'x' }, null),
).rejects.toThrow(BadRequestException);
});
});
});

View File

@@ -0,0 +1,174 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { CreateUserDto, ResetPasswordDto, UpdateUserDto, UserRecord } from './user.types';
/** Der handelnde Benutzer (für Audit-Einträge). */
export interface ActingUser {
readonly id: string;
readonly username: string;
}
/**
* Benutzerverwaltung (Application-Layer): Business-Regeln für Anlegen,
* Bearbeiten, Aktivieren/Deaktivieren und Löschen von Benutzern.
*
* Schutzregeln:
* - Keine Selbst-Deaktivierung und keine Selbst-Löschung
* - Der letzte aktive Administrator kann nicht herabgestuft,
* deaktiviert oder gelöscht werden
* - Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
*/
@Injectable()
export class UsersService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
) {}
async list(): Promise<UserRecord[]> {
return this.userRepository.list();
}
async findById(id: string): Promise<UserRecord> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
return user;
}
async create(
input: CreateUserDto,
actor: ActingUser,
ipAddress: string | null,
): Promise<UserRecord> {
const [existingUsername, existingEmail] = await Promise.all([
this.userRepository.findByUsername(input.username),
this.userRepository.findByEmail(input.email),
]);
if (existingUsername) {
throw new ConflictException('Benutzername ist bereits vergeben');
}
if (existingEmail) {
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
}
const user = await this.userRepository.create(input);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.USER_CREATED,
details: { targetUserId: user.id, targetUsername: user.username, role: user.role },
ipAddress,
});
return user;
}
async update(
id: string,
input: UpdateUserDto,
actor: ActingUser,
ipAddress: string | null,
): Promise<UserRecord> {
const user = await this.findById(id);
if (input.email !== undefined && input.email !== user.email) {
const existingEmail = await this.userRepository.findByEmail(input.email);
if (existingEmail && existingEmail.id !== id) {
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
}
}
const demotesFromAdmin = user.role === 'ADMIN' && input.role === 'USER';
const deactivates = input.isActive === false && user.isActive;
const activates = input.isActive === true && !user.isActive;
if (deactivates && id === actor.id) {
throw new BadRequestException('Sie können Ihr eigenes Konto nicht deaktivieren');
}
if ((demotesFromAdmin || deactivates) && (await this.isLastActiveAdmin(user))) {
throw new BadRequestException(
'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden',
);
}
const updated = await this.userRepository.update(id, input);
if (deactivates) {
await this.sessionService.deleteAllForUser(id);
}
if (activates) {
await this.userRepository.resetLoginLockout(id);
}
const action: AuditAction = deactivates
? AUDIT_ACTIONS.USER_DISABLED
: activates
? AUDIT_ACTIONS.USER_ENABLED
: AUDIT_ACTIONS.USER_UPDATED;
await this.auditService.record({
userId: actor.id,
username: actor.username,
action,
details: { targetUserId: id, targetUsername: user.username },
ipAddress,
});
return updated;
}
async resetPassword(
id: string,
input: ResetPasswordDto,
actor: ActingUser,
ipAddress: string | null,
): Promise<void> {
const user = await this.findById(id);
const passwordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(id, passwordHash);
await this.sessionService.deleteAllForUser(id);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.USER_PASSWORD_RESET,
details: { targetUserId: id, targetUsername: user.username },
ipAddress,
});
}
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
if (id === actor.id) {
throw new BadRequestException('Sie können Ihr eigenes Konto nicht löschen');
}
const user = await this.findById(id);
if (await this.isLastActiveAdmin(user)) {
throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden');
}
await this.userRepository.delete(id);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.USER_DELETED,
details: { targetUserId: id, targetUsername: user.username },
ipAddress,
});
}
/** Prüft, ob der gegebene Benutzer der einzige aktive Administrator ist. */
private async isLastActiveAdmin(user: UserRecord): Promise<boolean> {
if (user.role !== 'ADMIN' || !user.isActive) {
return false;
}
const activeAdminCount = await this.userRepository.countActiveAdmins();
return activeAdminCount <= 1;
}
}