feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)

This commit is contained in:
MPM Dev
2026-10-06 14:22:11 +02:00
commit a7e1c421f2
85 changed files with 17701 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
...tseslint.configs.recommended,
{
languageOptions: {
globals: { ...globals.node, ...globals.jest },
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
);

View File

@@ -0,0 +1,10 @@
/** Jest-Konfiguration (CommonJS, ts-jest). */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testRegex: '.*\\.spec\\.ts$',
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: ['src/**/*.ts', '!src/main.ts', '!src/**/*.module.ts'],
coverageDirectory: './coverage',
};

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"tsConfigPath": "tsconfig.build.json"
}
}

9522
apps/platform-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"name": "@mpm/platform-backend",
"version": "0.1.0",
"private": true,
"description": "MPM – Management-Backend (Auth, RBAC, Health, Audit)",
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"lint": "eslint .",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"@node-rs/argon2": "^2.0.0",
"cookie-parser": "^1.4.7",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^24.0.0",
"@types/pg": "^8.11.0",
"@types/supertest": "^6.0.2",
"eslint": "^9.0.0",
"globals": "^15.14.0",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0"
}
}

View 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 {}

View 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 {}

View 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();
});
});

View 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),
);
}
}
}

View 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) };
}
}

View 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 {}

View 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);
});
});
});

View 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,
});
}
}

View 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;
}

View 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);
});
});

View 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;
}
}

View 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' });
});
});

View 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;
}
}

View 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);
});
});

View 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);
}
}

View 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,
};
}
}

View File

@@ -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;
},
);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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',
});
}
}

View 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);
}
}

View 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;
}
}

View 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 {}

View 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 };

View 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,
},
};
}

View 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 {}

View 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;
}
}

View 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;
}
}
}

View 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>;
}

View File

@@ -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)`);
},
};

View File

@@ -0,0 +1,4 @@
import { migration001CoreSchema } from './001-core-schema';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [migration001CoreSchema];

View 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 },
},
};
}
}

View 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 {}

View 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();

View 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);
});
});

View 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);
}
}

View 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`);
}
});
}
}

View 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,
};
}
}

View 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>;

View 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 {}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["node", "jest"]
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}