feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)
This commit is contained in:
27
apps/platform-backend/src/app.module.ts
Normal file
27
apps/platform-backend/src/app.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { MigrationRunner } from './database/migration.runner';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { SessionGuard } from './auth/guards/session.guard';
|
||||
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
|
||||
/**
|
||||
* Wurzelmodul der Management-Plattform.
|
||||
* Globale Guards: SessionGuard (Authentifizierung) → CsrfGuard → RolesGuard.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule, AuthModule, UsersModule, HealthModule],
|
||||
providers: [
|
||||
MigrationRunner,
|
||||
{ provide: APP_GUARD, useClass: SessionGuard },
|
||||
{ provide: APP_GUARD, useClass: CsrfGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
12
apps/platform-backend/src/audit/audit.module.ts
Normal file
12
apps/platform-backend/src/audit/audit.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
/** Global verfügbares Audit-Logging. */
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
74
apps/platform-backend/src/audit/audit.service.spec.ts
Normal file
74
apps/platform-backend/src/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { AUDIT_ACTIONS, AuditService } from './audit.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/** Mock-Datenbank für Audit-Tests. */
|
||||
class MockDatabaseService {
|
||||
public readonly queries: Array<{ sql: string; params: unknown[] }> = [];
|
||||
|
||||
async query(sql: string, params: readonly unknown[] = []): Promise<{ rows: unknown[]; rowCount: number }> {
|
||||
this.queries.push({ sql, params: [...params] });
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuditService', () => {
|
||||
let auditService: AuditService;
|
||||
let database: MockDatabaseService;
|
||||
|
||||
beforeEach(() => {
|
||||
database = new MockDatabaseService();
|
||||
auditService = new AuditService(database as unknown as DatabaseService);
|
||||
});
|
||||
|
||||
it('schreibt einen Audit-Eintrag mit allen Feldern', async () => {
|
||||
await auditService.record({
|
||||
userId: 'user-1',
|
||||
username: 'max',
|
||||
action: AUDIT_ACTIONS.LOGIN_SUCCESS,
|
||||
details: { reason: 'OK' },
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(database.queries).toHaveLength(1);
|
||||
expect(database.queries[0].params).toEqual([
|
||||
'user-1',
|
||||
'max',
|
||||
'LOGIN_SUCCESS',
|
||||
'{"reason":"OK"}',
|
||||
'127.0.0.1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('verwendet leere Details und null-IP als Default', async () => {
|
||||
await auditService.record({
|
||||
userId: null,
|
||||
username: 'unknown',
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||
});
|
||||
|
||||
expect(database.queries[0].params).toEqual([
|
||||
null,
|
||||
'unknown',
|
||||
'LOGIN_FAILED',
|
||||
'{}',
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('wirft keinen Fehler, wenn die Datenbank nicht erreichbar ist', async () => {
|
||||
const failingDatabase = {
|
||||
query: () => {
|
||||
throw new Error('connection refused');
|
||||
},
|
||||
};
|
||||
const service = new AuditService(failingDatabase as unknown as DatabaseService);
|
||||
|
||||
await expect(
|
||||
service.record({
|
||||
userId: null,
|
||||
username: 'max',
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
52
apps/platform-backend/src/audit/audit.service.ts
Normal file
52
apps/platform-backend/src/audit/audit.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/** Definierte Audit-Aktionen der Plattform. */
|
||||
export const AUDIT_ACTIONS = {
|
||||
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
|
||||
LOGOUT: 'LOGOUT',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
/**
|
||||
* Zentrales Audit-Logging: Sicherheitsrelevante Ereignisse werden
|
||||
* nachvollziehbar aufgezeichnet. Es werden niemals Passwörter,
|
||||
* Tokens oder personenbezogene Daten geloggt.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger('Audit');
|
||||
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async record(input: {
|
||||
userId: string | null;
|
||||
username: string;
|
||||
action: AuditAction;
|
||||
details?: Record<string, unknown>;
|
||||
ipAddress?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.database.query(
|
||||
`INSERT INTO audit_logs (user_id, username, action, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)`,
|
||||
[
|
||||
input.userId,
|
||||
input.username,
|
||||
input.action,
|
||||
JSON.stringify(input.details ?? {}),
|
||||
input.ipAddress ?? null,
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
// Audit-Fehler dürfen den eigentlichen Request niemals blockieren.
|
||||
this.logger.error(
|
||||
`Audit-Log fehlgeschlagen (${input.action})`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
apps/platform-backend/src/auth/auth.controller.ts
Normal file
98
apps/platform-backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import type { AuthenticatedRequest } from './authenticated-request';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CsrfGuard } from './guards/csrf.guard';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { loginSchema, type LoginDto } from '../users/user.types';
|
||||
import type { AuthUser } from '../users/user.types';
|
||||
|
||||
/** Öffentliche Benutzerdaten in API-Antworten. */
|
||||
interface AuthUserResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'ADMIN' | 'USER';
|
||||
}
|
||||
|
||||
function toAuthUserResponse(user: AuthUser): AuthUserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifizierungs-Endpunkte: Login, Logout, aktueller Benutzer.
|
||||
* Alle Endpunkte sind versioniert unter /api/v1/auth.
|
||||
*/
|
||||
@Controller({ path: 'api/v1/auth' })
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(
|
||||
@Body(new ZodValidationPipe(loginSchema)) body: LoginDto,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<{ user: AuthUserResponse }> {
|
||||
const result = await this.authService.login({
|
||||
username: body.username,
|
||||
password: body.password,
|
||||
ipAddress: request.ip ?? null,
|
||||
});
|
||||
|
||||
const cookieMaxAgeSeconds = this.config.security.sessionTtlMinutes * 60;
|
||||
response.cookie('mpm_session', result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: this.config.security.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: cookieMaxAgeSeconds,
|
||||
});
|
||||
response.cookie('mpm_csrf', result.session.csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: this.config.security.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: cookieMaxAgeSeconds,
|
||||
});
|
||||
|
||||
return { user: toAuthUserResponse(result.user) };
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard, CsrfGuard)
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
async logout(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<{ success: true }> {
|
||||
if (request.session) {
|
||||
await this.authService.logout(request.session.id, user, request.ip ?? null);
|
||||
}
|
||||
response.clearCookie('mpm_session', { path: '/' });
|
||||
response.clearCookie('mpm_csrf', { path: '/' });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Get('me')
|
||||
async me(@CurrentUser() user: AuthUser): Promise<{ user: AuthUserResponse }> {
|
||||
return { user: toAuthUserResponse(user) };
|
||||
}
|
||||
}
|
||||
29
apps/platform-backend/src/auth/auth.module.ts
Normal file
29
apps/platform-backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PasswordHasher } from '../users/password-hasher';
|
||||
import { UserRepository } from '../users/user.repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { RateLimiterService } from './rate-limiter.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { CsrfGuard } from './guards/csrf.guard';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
|
||||
/** Authentifizierung: Login, Logout, Sessions, Guards. */
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
SessionService,
|
||||
RateLimiterService,
|
||||
AuthService,
|
||||
SessionGuard,
|
||||
CsrfGuard,
|
||||
UserRepository,
|
||||
PasswordHasher,
|
||||
],
|
||||
exports: [SessionService, SessionGuard, CsrfGuard, UserRepository, PasswordHasher],
|
||||
})
|
||||
export class AuthModule {}
|
||||
270
apps/platform-backend/src/auth/auth.service.spec.ts
Normal file
270
apps/platform-backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { AppConfig } from '../config/config.tokens';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { PasswordHasher } from '../users/password-hasher';
|
||||
import { UserRepository } from '../users/user.repository';
|
||||
import type { AuthUser, UserRecord } from '../users/user.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimiterService } from './rate-limiter.service';
|
||||
import { SessionService, type SessionData } from './session.service';
|
||||
|
||||
/** Erzeugt eine Test-Konfiguration mit überschreibbaren Werten. */
|
||||
function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig {
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
port: 3000,
|
||||
database: { url: 'postgresql://test' },
|
||||
security: {
|
||||
sessionTtlMinutes: 120,
|
||||
cookieSecure: false,
|
||||
behindProxy: false,
|
||||
loginMaxAttempts: 3,
|
||||
loginLockoutMinutes: 15,
|
||||
loginRateLimitAttempts: 10,
|
||||
loginRateLimitWindowMinutes: 5,
|
||||
...overrides,
|
||||
},
|
||||
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 findByUsernameResult: UserRecord | null = null;
|
||||
public updateLoginSuccessCalls: string[] = [];
|
||||
public updateLoginFailureCalls: Array<{ userId: string; attempts: number; shouldLock: boolean; lockoutMinutes: number }> = [];
|
||||
|
||||
async findByUsername(): Promise<UserRecord | null> {
|
||||
return this.findByUsernameResult;
|
||||
}
|
||||
|
||||
async updateLoginSuccess(userId: string): Promise<void> {
|
||||
this.updateLoginSuccessCalls.push(userId);
|
||||
}
|
||||
|
||||
async updateLoginFailure(userId: string, attempts: number, shouldLock: boolean, lockoutMinutes: number): Promise<void> {
|
||||
this.updateLoginFailureCalls.push({ userId, attempts, shouldLock, lockoutMinutes });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des SessionService. */
|
||||
class MockSessionService {
|
||||
public createResult: { token: string; data: SessionData } = {
|
||||
token: 'session-token',
|
||||
data: {
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
csrfToken: 'csrf-token',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
};
|
||||
|
||||
async create(): Promise<{ token: string; data: SessionData }> {
|
||||
return this.createResult;
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }> = [];
|
||||
|
||||
async record(entry: { userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
let userRepository: MockUserRepository;
|
||||
let passwordHasher: PasswordHasher;
|
||||
let sessionService: MockSessionService;
|
||||
let auditService: MockAuditService;
|
||||
let rateLimiter: RateLimiterService;
|
||||
let authService: AuthService;
|
||||
let config: AppConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = new MockUserRepository();
|
||||
passwordHasher = new PasswordHasher();
|
||||
sessionService = new MockSessionService();
|
||||
auditService = new MockAuditService();
|
||||
rateLimiter = new RateLimiterService();
|
||||
config = createConfig();
|
||||
authService = new AuthService(
|
||||
userRepository as unknown as UserRepository,
|
||||
passwordHasher,
|
||||
sessionService as unknown as SessionService,
|
||||
rateLimiter,
|
||||
auditService as unknown as AuditService,
|
||||
config,
|
||||
);
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('meldet einen Benutzer mit korrekten Zugangsdaten an', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
||||
|
||||
const result = await authService.login({
|
||||
username: 'max',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(result.user.username).toBe('max');
|
||||
expect(result.sessionToken).toBe('session-token');
|
||||
expect(userRepository.updateLoginSuccessCalls).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_SUCCESS);
|
||||
});
|
||||
|
||||
it('lehnt unbekannte Benutzer mit generischer Meldung ab', async () => {
|
||||
userRepository.findByUsernameResult = null;
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'ghost', password: 'wrong', ipAddress: '127.0.0.1' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'UNKNOWN_USER' });
|
||||
});
|
||||
|
||||
it('lehnt falsche Passwörter ab und zählt Fehlversuche', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
expect(userRepository.updateLoginFailureCalls).toEqual([
|
||||
{ userId: 'user-1', attempts: 1, shouldLock: false, lockoutMinutes: 15 },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
||||
});
|
||||
|
||||
it('sperrt das Konto nach Erreichen der maximalen Fehlversuche', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({
|
||||
passwordHash,
|
||||
failedLoginAttempts: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
expect(userRepository.updateLoginFailureCalls).toEqual([
|
||||
{ userId: 'user-1', attempts: 3, shouldLock: true, lockoutMinutes: 15 },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_LOCKED);
|
||||
});
|
||||
|
||||
it('lehnt gesperrte Benutzer ab', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({
|
||||
passwordHash,
|
||||
lockedUntil: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' },
|
||||
)).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_LOCKED' });
|
||||
});
|
||||
|
||||
it('lehnt deaktivierte Benutzer ab', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({
|
||||
passwordHash,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
|
||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_INACTIVE' });
|
||||
});
|
||||
|
||||
it('blockiert Requests nach Überschreitung des Rate Limits', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
||||
|
||||
// Limit: 10 Versuche / 5 Minuten (Default-Konfiguration)
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await authService
|
||||
.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }),
|
||||
).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
||||
|
||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' });
|
||||
});
|
||||
|
||||
it('setzt das Rate-Limit-Fenster nach erfolgreichem Login zurück', async () => {
|
||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
||||
|
||||
for (let attempt = 0; attempt < 9; attempt += 1) {
|
||||
await authService
|
||||
.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
const result = await authService.login({
|
||||
username: 'max',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
expect(result.user.username).toBe('max');
|
||||
|
||||
// Nach Reset ist ein neuer Login sofort wieder möglich.
|
||||
const secondResult = await authService.login({
|
||||
username: 'max',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
expect(secondResult.user.username).toBe('max');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('löscht die Session und schreibt ein Audit-Log', async () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
await authService.logout('session-1', user, '127.0.0.1');
|
||||
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGOUT);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
apps/platform-backend/src/auth/auth.service.ts
Normal file
154
apps/platform-backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PasswordHasher } from '../users/password-hasher';
|
||||
import { UserRepository } from '../users/user.repository';
|
||||
import type { AuthUser } from '../users/user.types';
|
||||
import { RateLimiterService } from './rate-limiter.service';
|
||||
import { SessionService, type SessionData } from './session.service';
|
||||
|
||||
/** Ergebnis eines erfolgreichen Logins. */
|
||||
export interface LoginResult {
|
||||
readonly user: AuthUser;
|
||||
readonly sessionToken: string;
|
||||
readonly session: SessionData;
|
||||
}
|
||||
|
||||
/** Generische Meldung – verhindert User-Enumeration. */
|
||||
const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch';
|
||||
|
||||
/**
|
||||
* Authentifizierungs-Logik (Domain/Application):
|
||||
* Login mit Rate Limiting, Account Lockout, Argon2id-Verifikation,
|
||||
* Session-Erstellung und Audit-Logging.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly rateLimiter: RateLimiterService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async login(input: {
|
||||
username: string;
|
||||
password: string;
|
||||
ipAddress: string | null;
|
||||
}): Promise<LoginResult> {
|
||||
const { security } = this.config;
|
||||
const rateLimitKey = `login:${input.ipAddress ?? 'unknown'}`;
|
||||
|
||||
if (!this.rateLimiter.isAllowed(
|
||||
rateLimitKey,
|
||||
security.loginRateLimitAttempts,
|
||||
security.loginRateLimitWindowMinutes,
|
||||
)) {
|
||||
await this.auditService.record({
|
||||
userId: null,
|
||||
username: input.username,
|
||||
action: 'LOGIN_FAILED',
|
||||
details: { reason: 'RATE_LIMITED' },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByUsername(input.username);
|
||||
|
||||
// Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort"
|
||||
// (keine User-Enumeration).
|
||||
if (!user) {
|
||||
await this.auditService.record({
|
||||
userId: null,
|
||||
username: input.username,
|
||||
action: 'LOGIN_FAILED',
|
||||
details: { reason: 'UNKNOWN_USER' },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
await this.auditService.record({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'LOGIN_FAILED',
|
||||
details: { reason: 'ACCOUNT_LOCKED' },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||
}
|
||||
|
||||
const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password);
|
||||
if (!passwordValid) {
|
||||
const attempts = user.failedLoginAttempts + 1;
|
||||
const shouldLock = attempts >= security.loginMaxAttempts;
|
||||
await this.userRepository.updateLoginFailure(
|
||||
user.id,
|
||||
attempts,
|
||||
shouldLock,
|
||||
security.loginLockoutMinutes,
|
||||
);
|
||||
await this.auditService.record({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: shouldLock ? 'LOGIN_FAILED_LOCKED' : 'LOGIN_FAILED',
|
||||
details: { reason: 'INVALID_PASSWORD', attempts },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
await this.auditService.record({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'LOGIN_FAILED',
|
||||
details: { reason: 'ACCOUNT_INACTIVE' },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||
}
|
||||
|
||||
await this.userRepository.updateLoginSuccess(user.id);
|
||||
this.rateLimiter.reset(rateLimitKey);
|
||||
|
||||
const { token, data } = await this.sessionService.create(
|
||||
user.id,
|
||||
security.sessionTtlMinutes,
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'LOGIN_SUCCESS',
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
},
|
||||
sessionToken: token,
|
||||
session: data,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(sessionId: string, user: AuthUser, ipAddress: string | null): Promise<void> {
|
||||
await this.sessionService.delete(sessionId);
|
||||
await this.auditService.record({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'LOGOUT',
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
14
apps/platform-backend/src/auth/authenticated-request.ts
Normal file
14
apps/platform-backend/src/auth/authenticated-request.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request } from 'express';
|
||||
import type { AuthUser } from '../users/user.types';
|
||||
|
||||
/** Erweitert Express-Request um die authentifizierten Daten. */
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthUser;
|
||||
session?: SessionInfo;
|
||||
}
|
||||
|
||||
/** Informationen zur aktiven Session (nach SessionGuard). */
|
||||
export interface SessionInfo {
|
||||
readonly id: string;
|
||||
readonly csrfToken: string;
|
||||
}
|
||||
56
apps/platform-backend/src/auth/guards/csrf.guard.spec.ts
Normal file
56
apps/platform-backend/src/auth/guards/csrf.guard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { CsrfGuard } from './csrf.guard';
|
||||
|
||||
/** Erzeugt einen Test-ExecutionContext mit Request-Mock. */
|
||||
function createContext(request: Record<string, unknown>): {
|
||||
switchToHttp: () => { getRequest: () => Record<string, unknown> };
|
||||
} {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('CsrfGuard', () => {
|
||||
const guard = new CsrfGuard();
|
||||
|
||||
it('lässt GET-Requests ohne Token durch', () => {
|
||||
const context = createContext({ method: 'GET' });
|
||||
expect(guard.canActivate(context as never)).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt POST-Requests ohne CSRF-Token ab', () => {
|
||||
const context = createContext({
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
session: { id: 'session-1', csrfToken: 'csrf-token' },
|
||||
});
|
||||
expect(() => guard.canActivate(context as never)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lehnt POST-Requests mit falschem CSRF-Token ab', () => {
|
||||
const context = createContext({
|
||||
method: 'POST',
|
||||
headers: { 'x-csrf-token': 'falsches-token' },
|
||||
session: { id: 'session-1', csrfToken: 'csrf-token' },
|
||||
});
|
||||
expect(() => guard.canActivate(context as never)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lässt POST-Requests mit korrektem CSRF-Token durch', () => {
|
||||
const context = createContext({
|
||||
method: 'POST',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
session: { id: 'session-1', csrfToken: 'csrf-token' },
|
||||
});
|
||||
expect(guard.canActivate(context as never)).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt POST-Requests ohne Session nicht ab (Login-Schutz über Rate Limiting)', () => {
|
||||
const context = createContext({
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
session: undefined,
|
||||
});
|
||||
expect(guard.canActivate(context as never)).toBe(true);
|
||||
});
|
||||
});
|
||||
46
apps/platform-backend/src/auth/guards/csrf.guard.ts
Normal file
46
apps/platform-backend/src/auth/guards/csrf.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../../auth/authenticated-request';
|
||||
|
||||
/** Zustandsändernde HTTP-Methoden, die CSRF-Schutz benötigen. */
|
||||
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/**
|
||||
* CSRF-Schutz (Doppel-Submit): Bei zustandsändernden Requests mit
|
||||
* bestehender Session muss der Header X-CSRF-Token mit dem CSRF-Token
|
||||
* der Session übereinstimmen (konstanter Zeitvergleich).
|
||||
*
|
||||
* Requests ohne Session (z. B. Login) sind ausgenommen: Sie besitzen
|
||||
* kein Session-CSRF-Token. Das Login ist stattdessen durch Rate
|
||||
* Limiting, Account Lockout und SameSite=Lax-Cookies geschützt.
|
||||
* Ungültige Sessions werden bereits vom SessionGuard mit 401 abgewiesen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CsrfGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest & Request>();
|
||||
|
||||
if (!STATE_CHANGING_METHODS.has(request.method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionCsrfToken = request.session?.csrfToken;
|
||||
if (!sessionCsrfToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerToken = request.headers['x-csrf-token'];
|
||||
if (typeof headerToken !== 'string' || headerToken.length === 0) {
|
||||
throw new ForbiddenException('CSRF-Token fehlt oder ist ungültig');
|
||||
}
|
||||
|
||||
const expected = Buffer.from(sessionCsrfToken);
|
||||
const provided = Buffer.from(headerToken);
|
||||
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
||||
throw new ForbiddenException('CSRF-Token fehlt oder ist ungültig');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
140
apps/platform-backend/src/auth/guards/session.guard.spec.ts
Normal file
140
apps/platform-backend/src/auth/guards/session.guard.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRepository } from '../../users/user.repository';
|
||||
import type { UserRecord } from '../../users/user.types';
|
||||
import { SessionService } from '../session.service';
|
||||
import { SessionGuard } from './session.guard';
|
||||
|
||||
/** 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 SessionService. */
|
||||
class MockSessionService {
|
||||
public findValidResult: { id: string; userId: string; csrfToken: string; expiresAt: Date } | null = null;
|
||||
public deletedSessions: string[] = [];
|
||||
|
||||
async findValid(): Promise<MockSessionService['findValidResult']> {
|
||||
return this.findValidResult;
|
||||
}
|
||||
|
||||
async delete(sessionId: string): Promise<void> {
|
||||
this.deletedSessions.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des UserRepository. */
|
||||
class MockUserRepository {
|
||||
public findByIdResult: UserRecord | null = null;
|
||||
|
||||
async findById(): Promise<UserRecord | null> {
|
||||
return this.findByIdResult;
|
||||
}
|
||||
}
|
||||
|
||||
/** Erzeugt einen Test-ExecutionContext mit Request-Mock. */
|
||||
function createContext(request: Record<string, unknown>): {
|
||||
switchToHttp: () => { getRequest: () => Record<string, unknown> };
|
||||
getHandler: () => () => undefined;
|
||||
getClass: () => () => undefined;
|
||||
} {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
getHandler: () => () => undefined,
|
||||
getClass: () => () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionGuard', () => {
|
||||
let sessionService: MockSessionService;
|
||||
let userRepository: MockUserRepository;
|
||||
let guard: SessionGuard;
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionService = new MockSessionService();
|
||||
userRepository = new MockUserRepository();
|
||||
reflector = new Reflector();
|
||||
guard = new SessionGuard(
|
||||
sessionService as unknown as SessionService,
|
||||
userRepository as unknown as UserRepository,
|
||||
reflector,
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt öffentliche Endpunkte ohne Session durch', async () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
|
||||
|
||||
const context = createContext({});
|
||||
await expect(guard.canActivate(context as never)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt Requests ohne Session-Cookie ab', async () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||
|
||||
const context = createContext({ headers: {} });
|
||||
await expect(guard.canActivate(context as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('lehnt ungültige Sessions ab', async () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||
sessionService.findValidResult = null;
|
||||
|
||||
const context = createContext({ headers: { cookie: 'mpm_session=invalid-token' } });
|
||||
await expect(guard.canActivate(context as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('lehnt deaktivierte Benutzer ab und löscht deren Session', async () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||
sessionService.findValidResult = {
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
csrfToken: 'csrf-token',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
};
|
||||
userRepository.findByIdResult = createUserRecord({ isActive: false });
|
||||
|
||||
const context = createContext({ headers: { cookie: 'mpm_session=valid-token' } });
|
||||
await expect(guard.canActivate(context as never)).rejects.toThrow(UnauthorizedException);
|
||||
expect(sessionService.deletedSessions).toEqual(['session-1']);
|
||||
});
|
||||
|
||||
it('setzt Benutzer und Session bei gültiger Session', async () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
|
||||
sessionService.findValidResult = {
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
csrfToken: 'csrf-token',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
};
|
||||
userRepository.findByIdResult = createUserRecord();
|
||||
|
||||
const request: Record<string, unknown> = { headers: { cookie: 'mpm_session=valid-token' } };
|
||||
const context = createContext(request as Partial<Request>);
|
||||
await expect(guard.canActivate(context as never)).resolves.toBe(true);
|
||||
|
||||
expect(request.user).toEqual({
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
});
|
||||
expect(request.session).toEqual({ id: 'session-1', csrfToken: 'csrf-token' });
|
||||
});
|
||||
});
|
||||
82
apps/platform-backend/src/auth/guards/session.guard.ts
Normal file
82
apps/platform-backend/src/auth/guards/session.guard.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
|
||||
import { UserRepository } from '../../users/user.repository';
|
||||
import type { AuthenticatedRequest } from '../authenticated-request';
|
||||
import { SessionService } from '../session.service';
|
||||
|
||||
/** Minimaler Request-Typ für die Cookie-Extraktion. */
|
||||
interface RequestWithCookieHeader {
|
||||
readonly headers: { readonly cookie?: string };
|
||||
}
|
||||
|
||||
/** Extrahiert das Session-Cookie aus einem Request. */
|
||||
export function extractSessionToken(request: RequestWithCookieHeader): string | null {
|
||||
const cookieHeader = request.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === 'mpm_session') {
|
||||
return decodeURIComponent(value.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifiziert jeden Request über die serverseitige Session und lädt
|
||||
* den zugehörigen Benutzer. Deaktivierte Benutzer werden sofort
|
||||
* abgewiesen (auch mit gültiger Session). Endpunkte mit @Public()
|
||||
* sind ausgenommen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = extractSessionToken(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.findValid(token);
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findById(session.userId);
|
||||
if (!user || !user.isActive) {
|
||||
// Session ungültig machen, damit sie nicht weiter verwendet wird.
|
||||
await this.sessionService.delete(session.id);
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
|
||||
request.session = {
|
||||
id: session.id,
|
||||
csrfToken: session.csrfToken,
|
||||
};
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
44
apps/platform-backend/src/auth/rate-limiter.service.spec.ts
Normal file
44
apps/platform-backend/src/auth/rate-limiter.service.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { RateLimiterService } from './rate-limiter.service';
|
||||
|
||||
describe('RateLimiterService', () => {
|
||||
it('erlaubt Requests innerhalb des Limits', () => {
|
||||
const limiter = new RateLimiterService();
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
expect(limiter.isAllowed('key', 5, 5)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('blockiert Requests nach Überschreiten des Limits', () => {
|
||||
const limiter = new RateLimiterService();
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
limiter.isAllowed('key', 5, 5);
|
||||
}
|
||||
|
||||
expect(limiter.isAllowed('key', 5, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('verwaltet Schlüssel unabhängig voneinander', () => {
|
||||
const limiter = new RateLimiterService();
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
limiter.isAllowed('key-a', 5, 5);
|
||||
}
|
||||
|
||||
expect(limiter.isAllowed('key-a', 5, 5)).toBe(false);
|
||||
expect(limiter.isAllowed('key-b', 5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('setzt das Fenster nach reset zurück', () => {
|
||||
const limiter = new RateLimiterService();
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
limiter.isAllowed('key', 5, 5);
|
||||
}
|
||||
expect(limiter.isAllowed('key', 5, 5)).toBe(false);
|
||||
|
||||
limiter.reset('key');
|
||||
expect(limiter.isAllowed('key', 5, 5)).toBe(true);
|
||||
});
|
||||
});
|
||||
41
apps/platform-backend/src/auth/rate-limiter.service.ts
Normal file
41
apps/platform-backend/src/auth/rate-limiter.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/** Ein Eintrag im Rate-Limit-Fenster. */
|
||||
interface RateLimitEntry {
|
||||
readonly timestamps: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfacher In-Memory-Rate-Limiter (Fenster pro Schlüssel).
|
||||
* Für Phase 1 ausreichend (einzelner Prozess); ab Phase 3 kann auf
|
||||
* Redis umgestellt werden, sobald mehrere Instanzen entstehen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private readonly entries = new Map<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Request innerhalb des Limits liegt.
|
||||
* @returns true, wenn erlaubt; false, wenn das Limit überschritten ist.
|
||||
*/
|
||||
isAllowed(key: string, limit: number, windowMinutes: number): boolean {
|
||||
const now = Date.now();
|
||||
const windowMs = windowMinutes * 60_000;
|
||||
const entry = this.entries.get(key) ?? { timestamps: [] };
|
||||
const recent = entry.timestamps.filter((timestamp) => now - timestamp < windowMs);
|
||||
|
||||
if (recent.length >= limit) {
|
||||
this.entries.set(key, { timestamps: recent });
|
||||
return false;
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
this.entries.set(key, { timestamps: recent });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Setzt das Fenster eines Schlüssels zurück (z. B. nach erfolgreichem Login). */
|
||||
reset(key: string): void {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
103
apps/platform-backend/src/auth/session.service.ts
Normal file
103
apps/platform-backend/src/auth/session.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/** Öffentliche Session-Daten (ohne Hashes). */
|
||||
export interface SessionData {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly csrfToken: string;
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
csrf_token: string;
|
||||
expires_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serverseitige Session-Verwaltung (Infrastructure):
|
||||
* - 256-Bit-Zufalls-Token, in der DB wird nur der SHA-256-Hash gespeichert
|
||||
* - Gleitende Verlängerung (sliding expiration)
|
||||
* - CSRF-Token pro Session (Doppel-Submit-Prüfung)
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/** Legt eine neue Session an und gibt Klartext-Token + Daten zurück. */
|
||||
async create(userId: string, ttlMinutes: number): Promise<{ token: string; data: SessionData }> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const csrfToken = randomBytes(32).toString('base64url');
|
||||
const tokenHash = this.hashToken(token);
|
||||
|
||||
const result = await this.database.query<SessionRow>(
|
||||
`INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at)
|
||||
VALUES ($1, $2, $3, now() + make_interval(mins => $4::int))
|
||||
RETURNING id, user_id, csrf_token, expires_at`,
|
||||
[userId, tokenHash, csrfToken, ttlMinutes],
|
||||
);
|
||||
|
||||
return { token, data: this.mapRow(result.rows[0]) };
|
||||
}
|
||||
|
||||
/** Findet eine gültige Session anhand des Klartext-Tokens. */
|
||||
async findValid(token: string): Promise<SessionData | null> {
|
||||
const result = await this.database.query<SessionRow>(
|
||||
`SELECT id, user_id, csrf_token, expires_at
|
||||
FROM sessions
|
||||
WHERE token_hash = $1 AND expires_at > now()`,
|
||||
[this.hashToken(token)],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Verlängert die Session, wenn mehr als die Hälfte der Laufzeit vergangen ist. */
|
||||
async touch(sessionId: string, ttlMinutes: number): Promise<void> {
|
||||
await this.database.query(
|
||||
`UPDATE sessions
|
||||
SET last_seen_at = now(),
|
||||
expires_at = CASE
|
||||
WHEN expires_at < now() + make_interval(mins => $2::int) / 2
|
||||
THEN now() + make_interval(mins => $2::int)
|
||||
ELSE expires_at END
|
||||
WHERE id = $1`,
|
||||
[sessionId, ttlMinutes],
|
||||
);
|
||||
}
|
||||
|
||||
/** Löscht eine Session (Logout). */
|
||||
async delete(sessionId: string): Promise<void> {
|
||||
await this.database.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
|
||||
/** 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()');
|
||||
}
|
||||
|
||||
/** Konstanter Zeitvergleich für CSRF-Token. */
|
||||
verifyCsrfToken(expected: string, provided: string): boolean {
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private mapRow(row: SessionRow): SessionData {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
csrfToken: row.csrf_token,
|
||||
expiresAt: row.expires_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createParamDecorator, UnauthorizedException, type ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../../auth/authenticated-request';
|
||||
import type { AuthUser } from '../../users/user.types';
|
||||
|
||||
/**
|
||||
* Injiziert den authentifizierten Benutzer in einen Controller-Parameter.
|
||||
* Nur nach erfolgreichem SessionGuard verfügbar.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthUser => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
if (!request.user) {
|
||||
// SessionGuard stellt sicher, dass request.user gesetzt ist.
|
||||
throw new UnauthorizedException('Kein authentifizierter Benutzer');
|
||||
}
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/** Metadaten-Schlüssel für öffentliche (nicht authentifizierte) Endpunkte. */
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** Markiert einen Endpunkt als öffentlich (keine Session erforderlich). */
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { RoleName } from '../../users/user.types';
|
||||
|
||||
/** Metadaten-Schlüssel für erforderliche Rollen. */
|
||||
export const ROLES_KEY = 'requiredRoles';
|
||||
|
||||
/** Legt die Rollen fest, die einen Endpunkt aufrufen dürfen (RBAC). */
|
||||
export const Roles = (...roles: RoleName[]): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Zentraler Exception-Filter: Wandelt alle Fehler in strukturierte
|
||||
* JSON-Antworten um. Stack-Traces und interne Details verlassen niemals
|
||||
* das Backend.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger('Exceptions');
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
|
||||
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
this.logger.error(
|
||||
`Serverfehler bei ${request.method} ${request.url}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json(
|
||||
typeof body === 'string'
|
||||
? { statusCode: status, message: body }
|
||||
: body,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Unbehandelter Fehler bei ${request.method} ${request.url}`,
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: 'Interner Serverfehler',
|
||||
});
|
||||
}
|
||||
}
|
||||
29
apps/platform-backend/src/common/guards/roles.guard.ts
Normal file
29
apps/platform-backend/src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type CanActivate, type ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedRequest } from '../../auth/authenticated-request';
|
||||
import type { RoleName } from '../../users/user.types';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
/**
|
||||
* Rollenbasierter Zugriffsschutz (RBAC, Ebene 1 – Plattform-Rechte).
|
||||
* Prüft die über @Roles(...) geforderten Rollen gegen die Rolle des
|
||||
* authentifizierten Benutzers. Läuft nach dem SessionGuard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<RoleName[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.user !== undefined && requiredRoles.includes(request.user.role);
|
||||
}
|
||||
}
|
||||
29
apps/platform-backend/src/common/zod-validation.pipe.ts
Normal file
29
apps/platform-backend/src/common/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
type ArgumentMetadata,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
type PipeTransform,
|
||||
} from '@nestjs/common';
|
||||
import type { ZodSchema } from 'zod';
|
||||
|
||||
/**
|
||||
* Validiert beliebige Eingaben gegen ein Zod-Schema.
|
||||
* Serverseitige Validierung ist verpflichtend – Client-Validierung ist nur UX.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private readonly schema: ZodSchema) {}
|
||||
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: 'Validierung fehlgeschlagen',
|
||||
error: 'Bad Request',
|
||||
details: result.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
12
apps/platform-backend/src/config/config.module.ts
Normal file
12
apps/platform-backend/src/config/config.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_CONFIG, loadConfiguration } from './config.tokens';
|
||||
|
||||
/**
|
||||
* Stellt die validierte Anwendungskonfiguration bereit.
|
||||
* In Tests kann der Provider mit einem useValue-Objekt überschrieben werden.
|
||||
*/
|
||||
@Module({
|
||||
providers: [{ provide: APP_CONFIG, useFactory: loadConfiguration }],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
7
apps/platform-backend/src/config/config.tokens.ts
Normal file
7
apps/platform-backend/src/config/config.tokens.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { loadConfiguration, type AppConfig } from './configuration';
|
||||
|
||||
/** Injection-Token für die validierte Anwendungskonfiguration. */
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
export { loadConfiguration };
|
||||
export type { AppConfig };
|
||||
83
apps/platform-backend/src/config/configuration.ts
Normal file
83
apps/platform-backend/src/config/configuration.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zentrale, typsichere Konfiguration der Management-Plattform.
|
||||
* Alle Werte stammen aus Umgebungsvariablen und werden beim Start
|
||||
* einmalig validiert (fail-fast bei fehlerhafter Konfiguration).
|
||||
*/
|
||||
|
||||
export type NodeEnvironment = 'development' | 'test' | 'production';
|
||||
|
||||
export interface DatabaseConfig {
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
readonly sessionTtlMinutes: number;
|
||||
readonly cookieSecure: boolean;
|
||||
readonly behindProxy: boolean;
|
||||
readonly loginMaxAttempts: number;
|
||||
readonly loginLockoutMinutes: number;
|
||||
readonly loginRateLimitAttempts: number;
|
||||
readonly loginRateLimitWindowMinutes: number;
|
||||
}
|
||||
|
||||
export interface AdminSeedConfig {
|
||||
readonly username: string;
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
readonly nodeEnv: NodeEnvironment;
|
||||
readonly port: number;
|
||||
readonly database: DatabaseConfig;
|
||||
readonly security: SecurityConfig;
|
||||
readonly adminSeed: AdminSeedConfig;
|
||||
}
|
||||
|
||||
const booleanFromString = z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true');
|
||||
|
||||
const environmentSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('production'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL ist erforderlich'),
|
||||
SESSION_TTL_MINUTES: z.coerce.number().int().positive().default(120),
|
||||
COOKIE_SECURE: booleanFromString,
|
||||
BEHIND_PROXY: booleanFromString,
|
||||
LOGIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
||||
LOGIN_LOCKOUT_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
LOGIN_RATE_LIMIT_ATTEMPTS: z.coerce.number().int().positive().default(10),
|
||||
LOGIN_RATE_LIMIT_WINDOW_MINUTES: z.coerce.number().int().positive().default(5),
|
||||
ADMIN_USERNAME: z.string().trim().min(3).max(100),
|
||||
ADMIN_EMAIL: z.string().trim().email(),
|
||||
ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200),
|
||||
});
|
||||
|
||||
/** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */
|
||||
export function loadConfiguration(): AppConfig {
|
||||
const environment = environmentSchema.parse(process.env);
|
||||
|
||||
return {
|
||||
nodeEnv: environment.NODE_ENV,
|
||||
port: environment.PORT,
|
||||
database: { url: environment.DATABASE_URL },
|
||||
security: {
|
||||
sessionTtlMinutes: environment.SESSION_TTL_MINUTES,
|
||||
cookieSecure: environment.COOKIE_SECURE,
|
||||
behindProxy: environment.BEHIND_PROXY,
|
||||
loginMaxAttempts: environment.LOGIN_MAX_ATTEMPTS,
|
||||
loginLockoutMinutes: environment.LOGIN_LOCKOUT_MINUTES,
|
||||
loginRateLimitAttempts: environment.LOGIN_RATE_LIMIT_ATTEMPTS,
|
||||
loginRateLimitWindowMinutes: environment.LOGIN_RATE_LIMIT_WINDOW_MINUTES,
|
||||
},
|
||||
adminSeed: {
|
||||
username: environment.ADMIN_USERNAME,
|
||||
email: environment.ADMIN_EMAIL,
|
||||
password: environment.ADMIN_PASSWORD,
|
||||
},
|
||||
};
|
||||
}
|
||||
12
apps/platform-backend/src/database/database.module.ts
Normal file
12
apps/platform-backend/src/database/database.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
/** Global verfügbarer Datenbank-Pool. */
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
73
apps/platform-backend/src/database/database.service.ts
Normal file
73
apps/platform-backend/src/database/database.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from 'pg';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
|
||||
/**
|
||||
* Zentrale Datenbankzugriffsschicht (Infrastructure).
|
||||
* Alle SQL-Zugriffe laufen parametrisiert über diesen Pool –
|
||||
* keine String-Konkatenation, keine SQL-Injection.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||
this.pool = new Pool({
|
||||
connectionString: this.config.database.url,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.pool.query('SELECT 1');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
/** Führt eine parametrisierte Abfrage aus. */
|
||||
async query<Row extends QueryResultRow = QueryResultRow>(
|
||||
sql: string,
|
||||
params: readonly unknown[] = [],
|
||||
): Promise<QueryResult<Row>> {
|
||||
return this.pool.query<Row>(sql, [...params]);
|
||||
}
|
||||
|
||||
/** Führt mehrere Abfragen in einer Transaktion aus. */
|
||||
async transaction<TResult>(
|
||||
work: (client: PoolClient) => Promise<TResult>,
|
||||
): Promise<TResult> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stellt einen exklusiven Client bereit (z. B. für Advisory-Locks). */
|
||||
async withClient(work: (client: PoolClient) => Promise<void>): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await work(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft die Erreichbarkeit der Datenbank (für Health-Checks). */
|
||||
async ping(): Promise<number> {
|
||||
const start = process.hrtime.bigint();
|
||||
await this.pool.query('SELECT 1');
|
||||
return Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
}
|
||||
}
|
||||
75
apps/platform-backend/src/database/migration.runner.ts
Normal file
75
apps/platform-backend/src/database/migration.runner.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { MIGRATIONS } from './migrations';
|
||||
import type { Migration } from './migration.types';
|
||||
|
||||
/**
|
||||
* Eigener, schlanker Migrations-Runner:
|
||||
* - Serialisiert parallele Starts über einen Advisory-Lock
|
||||
* - Führt jede Migration in einer Transaktion aus
|
||||
* - Zeichnet angewandte Migrationen in schema_migrations auf
|
||||
*
|
||||
* Läuft in onModuleInit, damit Migrationen garantiert vor allen
|
||||
* onApplicationBootstrap-Hooks (z. B. SeedService) abgeschlossen sind.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MigrationRunner implements OnModuleInit {
|
||||
private readonly logger = new Logger('Migrations');
|
||||
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.runMigrations();
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
await this.database.withClient(async (client) => {
|
||||
await client.query('SELECT pg_advisory_lock(727272)');
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
(await client.query<{ id: string }>('SELECT id FROM schema_migrations')).rows.map(
|
||||
(row) => row.id,
|
||||
),
|
||||
);
|
||||
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (!applied.has(migration.id)) {
|
||||
await this.applyMigration(client, migration);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock(727272)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async applyMigration(client: PoolClient, migration: Migration): Promise<void> {
|
||||
this.logger.log(`Wende Migration an: ${migration.id} – ${migration.description}`);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await migration.up(client);
|
||||
await client.query('INSERT INTO schema_migrations (id, description) VALUES ($1, $2)', [
|
||||
migration.id,
|
||||
migration.description,
|
||||
]);
|
||||
await client.query('COMMIT');
|
||||
this.logger.log(`Migration ${migration.id} erfolgreich`);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
this.logger.error(
|
||||
`Migration ${migration.id} fehlgeschlagen`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/platform-backend/src/database/migration.types.ts
Normal file
12
apps/platform-backend/src/database/migration.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
/**
|
||||
* Repräsentiert eine einzelne Datenbank-Migration.
|
||||
* Migrations laufen transaktionssicher und werden über einen
|
||||
* PostgreSQL-Advisory-Lock serialisiert (parallele Starts sind sicher).
|
||||
*/
|
||||
export interface Migration {
|
||||
readonly id: string;
|
||||
readonly description: string;
|
||||
readonly up: (client: PoolClient) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Migration } from '../migration.types';
|
||||
|
||||
/** Phase 1: Rollen, Benutzer, Sessions, Audit-Log. */
|
||||
export const migration001CoreSchema: Migration = {
|
||||
id: '001-core-schema',
|
||||
description: 'Rollen, Benutzer, Sessions und Audit-Log anlegen',
|
||||
up: async (client) => {
|
||||
await client.query(`
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query(`CREATE INDEX idx_sessions_user_id ON sessions(user_id)`);
|
||||
await client.query(`CREATE INDEX idx_sessions_expires_at ON sessions(expires_at)`);
|
||||
await client.query(`CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC)`);
|
||||
await client.query(`CREATE INDEX idx_audit_logs_action ON audit_logs(action)`);
|
||||
},
|
||||
};
|
||||
4
apps/platform-backend/src/database/migrations/index.ts
Normal file
4
apps/platform-backend/src/database/migrations/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { migration001CoreSchema } from './001-core-schema';
|
||||
|
||||
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
|
||||
export const MIGRATIONS = [migration001CoreSchema];
|
||||
48
apps/platform-backend/src/health/health.controller.ts
Normal file
48
apps/platform-backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/** Status des Gesamtsystems (Phase 1: Backend + Datenbank). */
|
||||
export interface HealthResponse {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
version: string;
|
||||
uptimeSeconds: number;
|
||||
components: {
|
||||
backend: 'healthy';
|
||||
database: { status: 'healthy' | 'unhealthy'; latencyMs: number };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Health-Endpoint für Monitoring und Container-Healthcheck.
|
||||
* Öffentlich – liefert keine sensiblen Daten.
|
||||
*/
|
||||
@ApiTags('System')
|
||||
@Controller('api/v1/health')
|
||||
export class HealthController {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async check(): Promise<HealthResponse> {
|
||||
let databaseStatus: 'healthy' | 'unhealthy' = 'unhealthy';
|
||||
let latencyMs = -1;
|
||||
try {
|
||||
latencyMs = Math.round(await this.database.ping());
|
||||
databaseStatus = 'healthy';
|
||||
} catch {
|
||||
databaseStatus = 'unhealthy';
|
||||
}
|
||||
|
||||
return {
|
||||
status: databaseStatus === 'healthy' ? 'healthy' : 'unhealthy',
|
||||
version: '0.1.0',
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
components: {
|
||||
backend: 'healthy',
|
||||
database: { status: databaseStatus, latencyMs },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
10
apps/platform-backend/src/health/health.module.ts
Normal file
10
apps/platform-backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
/** Health-Endpoints (Monitoring). */
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
49
apps/platform-backend/src/main.ts
Normal file
49
apps/platform-backend/src/main.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { loadConfiguration } from './config/config.tokens';
|
||||
|
||||
/**
|
||||
* Bootstrap der Management-API:
|
||||
* - Security-Header (Helmet), Trust-Proxy für korrekte IPs
|
||||
* - OpenAPI/Swagger unter /api/docs
|
||||
* - Strukturierte Fehlerbehandlung
|
||||
* Hinweis: Validierung erfolgt über Zod (ZodValidationPipe),
|
||||
* nicht über den NestJS-ValidationPipe (class-validator).
|
||||
*/
|
||||
async function bootstrap(): Promise<void> {
|
||||
const config = loadConfiguration();
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: config.nodeEnv === 'production' ? ['log', 'warn', 'error'] : ['log', 'warn', 'error', 'debug'],
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
|
||||
if (config.security.behindProxy) {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('MPM Management API')
|
||||
.setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)')
|
||||
.setVersion('0.1.0')
|
||||
.addCookieAuth('mpm_session')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
await app.listen(config.port, '0.0.0.0');
|
||||
logger.log(`Management-Backend läuft auf Port ${config.port}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
26
apps/platform-backend/src/users/password-hasher.spec.ts
Normal file
26
apps/platform-backend/src/users/password-hasher.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
|
||||
describe('PasswordHasher', () => {
|
||||
const passwordHasher = new PasswordHasher();
|
||||
|
||||
it('erzeugt einen Argon2id-Hash', async () => {
|
||||
const hash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
expect(hash).toMatch(/^\$argon2id\$/);
|
||||
});
|
||||
|
||||
it('verifiziert das korrekte Passwort', async () => {
|
||||
const hash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
await expect(passwordHasher.verify(hash, 'Sicheres-Passwort-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt ein falsches Passwort ab', async () => {
|
||||
const hash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
await expect(passwordHasher.verify(hash, 'falsch')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('erzeugt für dasselbe Passwort unterschiedliche Hashes (Salt)', async () => {
|
||||
const first = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
const second = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
25
apps/platform-backend/src/users/password-hasher.ts
Normal file
25
apps/platform-backend/src/users/password-hasher.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
/**
|
||||
* OWASP-Empfehlung für Argon2id (Stand 2024/2025):
|
||||
* m=19456 KiB (19 MiB), t=2 Iterationen, p=1 Parallelität.
|
||||
* Klartextpasswörter werden niemals gespeichert oder geloggt.
|
||||
*/
|
||||
export const ARGON2_OPTIONS = {
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
/** Zentrale Passwort-Hash-Funktion (Argon2id). */
|
||||
@Injectable()
|
||||
export class PasswordHasher {
|
||||
hash(plainPassword: string): Promise<string> {
|
||||
return hash(plainPassword, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
verify(passwordHash: string, plainPassword: string): Promise<boolean> {
|
||||
return verify(passwordHash, plainPassword);
|
||||
}
|
||||
}
|
||||
51
apps/platform-backend/src/users/seed.service.ts
Normal file
51
apps/platform-backend/src/users/seed.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ARGON2_OPTIONS } from './password-hasher';
|
||||
|
||||
/**
|
||||
* Legt beim ersten Start die Systemrollen und den initialen Admin an.
|
||||
* Idempotent: Existierende Datensätze werden nicht verändert.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeedService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger('Seed');
|
||||
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.seed();
|
||||
}
|
||||
|
||||
async seed(): Promise<void> {
|
||||
await this.database.transaction(async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO roles (name, description) VALUES ('ADMIN', 'Plattform-Administrator')
|
||||
ON CONFLICT (name) DO NOTHING`,
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO roles (name, description) VALUES ('USER', 'Standardbenutzer')
|
||||
ON CONFLICT (name) DO NOTHING`,
|
||||
);
|
||||
|
||||
const admin = this.config.adminSeed;
|
||||
const passwordHash = await hash(admin.password, ARGON2_OPTIONS);
|
||||
|
||||
const result = await client.query<{ id: string }>(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, role_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = 'ADMIN'))
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
RETURNING id`,
|
||||
[admin.username, admin.email, passwordHash, 'Administrator'],
|
||||
);
|
||||
|
||||
if (result.rowCount === 1) {
|
||||
this.logger.log(`Initialer Admin "${admin.username}" angelegt`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
133
apps/platform-backend/src/users/user.repository.ts
Normal file
133
apps/platform-backend/src/users/user.repository.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
display_name: string;
|
||||
role_name: RoleName;
|
||||
is_active: boolean;
|
||||
failed_login_attempts: number;
|
||||
locked_until: Date | null;
|
||||
last_login_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const USER_COLUMNS = `u.id, u.username, u.email, u.password_hash, u.display_name, r.name AS role_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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
|
||||
* Enthält keine Business-Logik – nur Datenzugriff.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
) {}
|
||||
|
||||
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`,
|
||||
[username],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
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`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateLoginSuccess(userId: string): Promise<void> {
|
||||
await this.database.query(
|
||||
`UPDATE users
|
||||
SET last_login_at = now(),
|
||||
failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
async updateLoginFailure(
|
||||
userId: string,
|
||||
attempts: number,
|
||||
shouldLock: boolean,
|
||||
lockoutMinutes: number,
|
||||
): Promise<void> {
|
||||
await this.database.query(
|
||||
`UPDATE users
|
||||
SET failed_login_attempts = $2,
|
||||
locked_until = CASE WHEN $3::boolean
|
||||
THEN now() + make_interval(mins => $4::int)
|
||||
ELSE locked_until END,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[userId, attempts, shouldLock, lockoutMinutes],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
passwordHash: row.password_hash,
|
||||
displayName: row.display_name,
|
||||
role: row.role_name,
|
||||
isActive: row.is_active,
|
||||
failedLoginAttempts: row.failed_login_attempts,
|
||||
lockedUntil: row.locked_until,
|
||||
lastLoginAt: row.last_login_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
apps/platform-backend/src/users/user.types.ts
Normal file
32
apps/platform-backend/src/users/user.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Globale Plattform-Rollen (Ebene 1 – Plattform-Rechte). */
|
||||
export const ROLE_NAMES = ['ADMIN', 'USER'] as const;
|
||||
export type RoleName = (typeof ROLE_NAMES)[number];
|
||||
|
||||
/** Öffentliche Benutzerdaten (ohne Passwort-Hash). */
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
readonly role: RoleName;
|
||||
}
|
||||
|
||||
/** Vollständiger Benutzer-Datensatz aus der Datenbank. */
|
||||
export interface UserRecord extends AuthUser {
|
||||
readonly passwordHash: string;
|
||||
readonly isActive: boolean;
|
||||
readonly failedLoginAttempts: number;
|
||||
readonly lockedUntil: Date | null;
|
||||
readonly lastLoginAt: Date | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Login-Anfrage (Zod-Schema, serverseitig verpflichtend). */
|
||||
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>;
|
||||
14
apps/platform-backend/src/users/users.module.ts
Normal file
14
apps/platform-backend/src/users/users.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { SeedService } from './seed.service';
|
||||
import { UserRepository } from './user.repository';
|
||||
|
||||
/** Benutzerverwaltung (Phase 1: Modell, Rollen, Seed). */
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule],
|
||||
providers: [UserRepository, PasswordHasher, SeedService],
|
||||
exports: [UserRepository, PasswordHasher],
|
||||
})
|
||||
export class UsersModule {}
|
||||
Reference in New Issue
Block a user