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

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Versionierung
.git
.gitignore
# Abhängigkeiten & Build-Artefakte
**/node_modules
**/dist
**/coverage
**/*.tsbuildinfo
# Umgebungsvariablen & Secrets
.env
.env.*
# Dokumentation (nicht für Laufzeit-Image benötigt)
docs
*.md
# Editor & Betriebssystem
.vscode
.idea
.DS_Store
Thumbs.db

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

38
.env.example Normal file
View File

@@ -0,0 +1,38 @@
# =============================================================
# MPM – Beispiel-Umgebung
# Kopie als ".env" anlegen und Werte anpassen.
# HINWEIS: Niemals echte Secrets in das Git-Repository committen.
# =============================================================
# --- PostgreSQL ------------------------------------------------
POSTGRES_USER=mpm
POSTGRES_PASSWORD=<sicheres-passwort>
POSTGRES_DB=mpm
# --- Plattform-Container ---------------------------------------
NODE_ENV=production
PORT=3000
# Host-Port, unter dem die Plattform erreichbar ist
APP_PORT=8080
# Innerhalb des Compose-Netzes ist der Hostname "postgres".
# Für lokale Entwicklung (Backend außerhalb Docker): 127.0.0.1
DATABASE_URL=postgresql://mpm:<sicheres-passwort>@postgres:5432/mpm
# --- Sicherheit ------------------------------------------------
# Session-Gültigkeit in Minuten (kurz halten)
SESSION_TTL_MINUTES=120
# "true" sobald die Plattform hinter HTTPS/TLS betrieben wird
COOKIE_SECURE=false
# "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist
BEHIND_PROXY=true
# --- Initialer Admin (nur beim ersten Start angelegt) -----------
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=<mindestens-10-zeichen>
# --- Login-Schutz (optional, mit Defaults) ----------------------
# LOGIN_MAX_ATTEMPTS=5
# LOGIN_LOCKOUT_MINUTES=15
# LOGIN_RATE_LIMIT_ATTEMPTS=10
# LOGIN_RATE_LIMIT_WINDOW_MINUTES=5

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Abhängigkeiten
node_modules/
# Build-Artefakte
dist/
build/
*.tsbuildinfo
# Umgebungsvariablen & Secrets
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
# Testabdeckung
coverage/
# Editor & Betriebssystem
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Docker-Volumes
postgres-data/

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
# =============================================================
# MPM – Management-Container
# Ein einzelner Container, der als unprivilegierter Benutzer
# folgende Prozesse verwaltet (via Supervisor):
# - Nginx (Reverse Proxy, Port 8080)
# - NestJS Management-Backend (127.0.0.1:3000)
# Ab Phase 3 laufen hier zusätzlich die Modul-Prozesse.
# Kein Docker-in-Docker, kein Docker-Socket.
# =============================================================
# ---------- Frontend-Build ----------
FROM node:24-slim AS frontend-build
WORKDIR /build
COPY apps/platform-frontend/package.json apps/platform-frontend/package-lock.json ./
RUN npm ci
COPY apps/platform-frontend/ ./
RUN npm run build
# ---------- Backend-Build ----------
FROM node:24-slim AS backend-build
WORKDIR /build
COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./
RUN npm ci
COPY apps/platform-backend/ ./
RUN npm run build
RUN npm prune --omit=dev
# ---------- Laufzeit-Image ----------
FROM node:24-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx supervisor \
&& rm -rf /var/lib/apt/lists/*
# Unprivilegierter Benutzer für alle Prozesse im Container
RUN groupadd --gid 1001 app \
&& useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor \
&& chown -R app:app /tmp/nginx /var/log/supervisor
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
COPY --from=backend-build --chown=app:app /build/node_modules /app/platform-backend/node_modules
COPY --from=backend-build --chown=app:app /build/package.json /app/platform-backend/package.json
COPY --from=frontend-build --chown=app:app /build/dist /app/public
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/api/v1/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# MPM – Modulare Web-Management-Plattform
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können.
**Status: Phase 1 – Grundgerüst (abgeschlossen)**
## Architektur-Überblick
```
Internet
│
▼
Docker Container (mpm-platform, unprivilegierter Benutzer "app")
┌─────────────────────────────────────────────┐
│ Supervisor (Prozessmanager) │
│ ├── Nginx (Reverse Proxy, :8080) │
│ │ ├── / -> Management-Frontend │
│ │ └── /api/ -> Management-Backend │
│ └── NestJS Management-Backend (127.0.0.1:3000)│
│ (ab Phase 3: + Modul-Prozesse) │
└─────────────────────────────────────────────┘
│
▼
PostgreSQL (eigener Container, persistentes Volume)
```
- **Ein** Applikationscontainer, **kein** Docker-in-Docker, **kein** Docker-Socket.
- Module laufen ab Phase 3 als interne Prozesse im selben Container (eigene Ports, nur über den Reverse Proxy erreichbar).
- PostgreSQL liegt außerhalb des Applikationscontainers in einem persistenten Volume.
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES.md`](docs/PHASES.md)
## Schnellstart (Docker)
```bash
# 1. Umgebung prüfen (.env liegt mit Entwicklungs-Defaults bei)
# Für Produktion: Werte ändern und COOKIE_SECURE=true setzen.
# 2. Bauen und starten
docker compose up --build -d
# 3. Öffnen
# http://localhost:8080
# Anmeldung: ADMIN_USERNAME / ADMIN_PASSWORD aus .env
# API-Dokumentation (Swagger): http://localhost:8080/api/docs
```
Definition of Done Phase 1: Webseite erreichbar ✓ Login möglich ✓ Admin-Dashboard sichtbar ✓
## Lokale Entwicklung
```bash
# PostgreSQL starten
docker compose up -d postgres
# Backend (http://127.0.0.1:3000, Swagger: /api/docs)
cd apps/platform-backend
npm install
npm run start:dev
# Hinweis: dafür in .env DATABASE_URL auf 127.0.0.1 umstellen
# Frontend (http://localhost:5173, /api wird an das Backend proxied)
cd apps/platform-frontend
npm install
npm run dev
```
## Projektstruktur
```
MPM/
├── apps/
│ ├── platform-backend/ # NestJS – Management-API (Auth, RBAC, Health, Audit)
│ └── platform-frontend/ # React/Vite/Tailwind – Management-UI
├── docker/ # Nginx- & Supervisor-Konfiguration
├── docs/ # Architektur- & Phasen-Dokumentation
├── modules/ # Installierbare Module (ab Phase 3)
├── Dockerfile # Multi-Stage-Build des Management-Containers
└── docker-compose.yml # PostgreSQL + Management-Container
```
## Tech-Stack
| Bereich | Technologie |
|---|---|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS, React Router, TanStack Query, Zod |
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer |
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet |
## Annahme
„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`).

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"]
}

View File

@@ -0,0 +1,26 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': 'off',
'@typescript-eslint/no-explicit-any': 'error',
},
},
);

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MPM – Modulare Management-Plattform" />
<title>MPM – Management-Plattform</title>
</head>
<body class="h-full bg-slate-50 text-slate-900 antialiased">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4035
apps/platform-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "@mpm/platform-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,118 @@
import { type ReactNode, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../../features/auth/auth-context';
import { Badge } from '../ui/badge';
/** Ein Navigationspunkt der Sidebar. */
interface NavItem {
to: string;
label: string;
icon: string;
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
export function AppLayout(): ReactNode {
const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || user?.role === 'ADMIN',
);
return (
<div className="flex h-full">
{/* Mobile: Overlay-Hintergrund */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-slate-200 bg-white transition-transform lg:static lg:translate-x-0
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
aria-label="Hauptnavigation"
>
<div className="flex h-16 items-center gap-2 border-b border-slate-200 px-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-bold text-white">
M
</span>
<span className="text-base font-semibold text-slate-900">MPM</span>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
${
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`
}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-200 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">
{user?.displayName}
</p>
<p className="truncate text-xs text-slate-500">@{user?.username}</p>
</div>
{user?.role === 'ADMIN' && <Badge variant="info">Admin</Badge>}
</div>
<button
onClick={() => void logout()}
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Abmelden
</button>
</div>
</aside>
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Topbar (mobil: Menü-Button) */}
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 lg:px-6">
<button
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100 lg:hidden"
onClick={() => setMobileMenuOpen((open) => !open)}
aria-label="Menü öffnen"
aria-expanded={mobileMenuOpen}
>
<span aria-hidden="true" className="text-xl">☰</span>
</button>
<span className="hidden text-sm text-slate-500 lg:block">
Management-Plattform
</span>
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
</header>
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../features/auth/auth-context';
import { Spinner } from './ui/states';
/** Schützt Routen: nur für authentifizierte Benutzer. */
export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
const { status } = useAuth();
const location = useLocation();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (status === 'unauthenticated') {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
/** Schützt Admin-Routen: nur für Benutzer mit Rolle ADMIN. */
export function RequireAdmin({ children }: { children: ReactNode }): ReactNode {
const { user, status } = useAuth();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (!user || user.role !== 'ADMIN') {
return <Navigate to="/" replace />;
}
return children;
}

View File

@@ -0,0 +1,29 @@
import { type ReactNode } from 'react';
/** Farbschemata für Badges (Statusanzeigen). */
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info';
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
};
export interface BadgeProps {
variant?: BadgeVariant;
children: ReactNode;
}
/** Kleines Status-Label (Design-System). */
export function Badge({ variant = 'neutral', children }: BadgeProps): ReactNode {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,58 @@
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
/** Varianten des Buttons (Design-System). */
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary:
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400',
danger:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost:
'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400',
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-5 text-base',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
}
/** Standard-Button des Design-Systems. */
export function Button({
variant = 'primary',
size = 'md',
loading = false,
className = '',
disabled,
children,
...rest
}: ButtonProps): ReactNode {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors
focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
disabled={disabled || loading}
{...rest}
>
{loading && (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,46 @@
import { type ReactNode } from 'react';
export interface CardProps {
children: ReactNode;
className?: string;
}
/** Karten-Container des Design-Systems. */
export function Card({ children, className = '' }: CardProps): ReactNode {
return (
<div
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
>
{children}
</div>
);
}
export interface CardHeaderProps {
title: string;
description?: string;
children?: ReactNode;
}
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
<div>
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
{children}
</div>
);
}
export interface CardBodyProps {
children: ReactNode;
className?: string;
}
/** Karten-Inhalt. */
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,51 @@
import { type InputHTMLAttributes, type ReactNode } from 'react';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
hint?: string;
}
/** Text-Input mit Label, Fehler- und Hinweistext (Design-System). */
export function Input({
label,
error,
hint,
className = '',
id,
...rest
}: InputProps): ReactNode {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-slate-700">
{label}
</label>
<input
id={inputId}
className={`h-10 rounded-lg border px-3 text-sm transition-colors
${
error
? 'border-red-400 focus:border-red-500'
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...rest}
/>
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
export interface SpinnerProps {
label?: string;
className?: string;
}
/** Ladeindikator (Design-System). */
export function Spinner({ label = 'Wird geladen…', className = '' }: SpinnerProps): ReactNode {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className}`} role="status">
<span
aria-hidden="true"
className="h-8 w-8 animate-spin rounded-full border-4 border-brand-200 border-t-brand-600"
/>
<span className="text-sm text-slate-500">{label}</span>
</div>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
icon?: ReactNode;
}
/** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
</div>
);
}
export interface ErrorStateProps {
title?: string;
message?: string;
}
/** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({
title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.',
}: ErrorStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-50 text-red-600">
!
</div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client';
import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states';
/** Admin-Seite: detaillierter Systemstatus der Plattform. */
export function SystemStatusPage(): ReactNode {
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> =>
healthSchema.parse(await apiRequest('/api/v1/health')),
refetchInterval: 30_000,
});
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1>
<p className="mt-1 text-sm text-slate-500">
Zustand aller Plattform-Komponenten (aktualisiert alle 30 Sekunden).
</p>
</div>
<Card>
<CardHeader title="Komponenten" />
<CardBody>
{healthQuery.isLoading && <Spinner />}
{healthQuery.isError && (
<ErrorState message="Der Health-Endpoint ist nicht erreichbar." />
)}
{healthQuery.data && (
<div className="space-y-3">
<StatusRow
label="Management-Backend"
variant={healthQuery.data.components.backend === 'healthy' ? 'success' : 'danger'}
status={healthQuery.data.components.backend === 'healthy' ? 'Healthy' : 'Unhealthy'}
/>
<StatusRow
label="PostgreSQL"
variant={
healthQuery.data.components.database.status === 'healthy'
? 'success'
: 'danger'
}
status={
healthQuery.data.components.database.status === 'healthy'
? `Healthy · ${healthQuery.data.components.database.latencyMs} ms`
: 'Unhealthy'
}
/>
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">Plattform-Version</span>
<span className="text-sm text-slate-500">{healthQuery.data.version}</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">Uptime</span>
<span className="text-sm text-slate-500">
{Math.floor(healthQuery.data.uptimeSeconds / 60)} Minuten
</span>
</div>
</div>
)}
</CardBody>
</Card>
</div>
);
}
/** Zeile im Systemstatus. */
function StatusRow({
label,
status,
variant,
}: {
label: string;
status: string;
variant: 'success' | 'danger';
}): ReactNode {
return (
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">{label}</span>
<Badge variant={variant}>{status}</Badge>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { type ReactNode } from 'react';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { EmptyState } from '../../components/ui/states';
/** Platzhalter für die Benutzerverwaltung (Phase 2). */
export function UsersPage(): ReactNode {
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<p className="mt-1 text-sm text-slate-500">
Benutzer anlegen, bearbeiten und verwalten.
</p>
</div>
<Card>
<CardHeader title="Benutzer" description="Alle Benutzer der Plattform" />
<CardBody>
<EmptyState
title="Benutzerverwaltung folgt in Phase 2"
description="Die vollständige Benutzerverwaltung (Liste, Anlegen, Bearbeiten, Deaktivieren) wird in Phase 2 implementiert."
icon={<span className="text-3xl" aria-hidden="true">👥</span>}
/>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import {
type ReactNode,
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
import { authUserSchema, type AuthUser } from '../../lib/schemas';
/** Zustand des Auth-Contexts. */
interface AuthContextValue {
user: AuthUser | null;
status: 'loading' | 'authenticated' | 'unauthenticated';
login: (input: { username: string; password: string }) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/** Lädt den aktuellen Benutzer vom Backend. */
async function fetchCurrentUser(): Promise<AuthUser | null> {
try {
const response = await apiRequest<{ user: unknown }>('/api/v1/auth/me');
return authUserSchema.parse(response.user);
} catch {
return null;
}
}
/**
* Zentraler Authentifizierungs-Zustand der Anwendung.
* Beim Start wird die Session geprüft; bei 401 wird der Zustand
* automatisch auf "nicht authentifiziert" gesetzt.
*/
export function AuthProvider({ children }: { children: ReactNode }): ReactNode {
const [user, setUser] = useState<AuthUser | null>(null);
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
useEffect(() => {
void (async () => {
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
})();
}, []);
useEffect(() => {
setUnauthorizedHandler(() => {
setUser(null);
setStatus('unauthenticated');
});
return () => setUnauthorizedHandler(() => undefined);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
status,
login: async (input) => {
await apiRequest('/api/v1/auth/login', { method: 'POST', body: input });
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
},
logout: async () => {
await apiRequest('/api/v1/auth/logout', { method: 'POST' }).catch(() => undefined);
setUser(null);
setStatus('unauthenticated');
},
refresh: async () => {
const currentUser = await fetchCurrentUser();
setUser(currentUser);
setStatus(currentUser ? 'authenticated' : 'unauthenticated');
},
}),
[user, status],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/** Zugriff auf den Auth-Zustand (wirft bei Verwendung außerhalb des Providers). */
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden');
}
return context;
}

View File

@@ -0,0 +1,104 @@
import { type FormEvent, type ReactNode, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth-context';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { ApiError } from '../../lib/api-client';
import { loginSchema } from '../../lib/schemas';
/** Zentrale Anmeldeseite der Plattform. */
export function LoginPage(): ReactNode {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: string } | null)?.from ?? '/';
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setError(null);
setFieldErrors({});
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0]);
if (!errors[key]) errors[key] = issue.message;
}
setFieldErrors(errors);
return;
}
setSubmitting(true);
try {
await login(parsed.data);
navigate(from, { replace: true });
} catch (caughtError) {
if (caughtError instanceof ApiError) {
setError(caughtError.message);
} else {
setError('Anmeldung fehlgeschlagen. Bitte später erneut versuchen.');
}
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-full items-center justify-center bg-slate-50 px-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<span className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-brand-600 text-lg font-bold text-white">
M
</span>
<h1 className="mt-4 text-2xl font-bold text-slate-900">MPM</h1>
<p className="mt-1 text-sm text-slate-500">
Modulare Management-Plattform
</p>
</div>
<form
onSubmit={(event) => void handleSubmit(event)}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
noValidate
>
<div className="space-y-4">
<Input
label="Benutzername"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
error={fieldErrors.username}
autoFocus
/>
<Input
label="Passwort"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
error={fieldErrors.password}
/>
</div>
{error && (
<p role="alert" className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
<Button type="submit" loading={submitting} className="mt-6 w-full">
Anmelden
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client';
import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Dashboard: Begrüßung, eigene Anwendungen (ab Phase 3) und Systemstatus. */
export function DashboardPage(): ReactNode {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> => healthSchema.parse(
await apiRequest('/api/v1/health'),
),
enabled: isAdmin,
refetchInterval: 30_000,
});
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Willkommen, {user?.displayName} 👋
</h1>
<p className="mt-1 text-sm text-slate-500">
Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.
</p>
</div>
{/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */}
<Card>
<CardHeader
title="Meine Anwendungen"
description="Freigegebene Module der Plattform"
/>
<CardBody>
<EmptyState
title="Noch keine Module installiert"
description="Sobald der Administrator Module installiert und Ihnen zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
</CardBody>
</Card>
{/* Systemstatus (nur für Admins) */}
{isAdmin && (
<Card>
<CardHeader
title="Systemstatus"
description="Live-Status der Plattform-Komponenten"
/>
<CardBody>
{healthQuery.isLoading && <Spinner label="Status wird geladen…" />}
{healthQuery.isError && (
<ErrorState
title="Status nicht verfügbar"
message="Der Health-Endpoint konnte nicht erreicht werden."
/>
)}
{healthQuery.data && (
<div className="grid gap-3 sm:grid-cols-2">
<StatusRow
label="Management"
variant={healthQuery.data.components.backend === 'healthy' ? 'success' : 'danger'}
status={healthQuery.data.components.backend === 'healthy' ? 'Healthy' : 'Unhealthy'}
/>
<StatusRow
label="Datenbank"
variant={
healthQuery.data.components.database.status === 'healthy'
? 'success'
: 'danger'
}
status={
healthQuery.data.components.database.status === 'healthy'
? `Healthy (${healthQuery.data.components.database.latencyMs} ms)`
: 'Unhealthy'
}
/>
</div>
)}
</CardBody>
</Card>
)}
</div>
);
}
/** Zeile im Systemstatus-Dashboard. */
function StatusRow({
label,
status,
variant,
}: {
label: string;
status: string;
variant: 'success' | 'danger';
}): ReactNode {
return (
<div className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-slate-700">{label}</span>
<Badge variant={variant}>{status}</Badge>
</div>
);
}

View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
/* =============================================================
MPM Design-System – zentrale Design-Tokens (Tailwind v4)
============================================================= */
@theme {
/* Markenfarben: professionelles Indigo als Primärfarbe */
--color-brand-50: oklch(0.97 0.014 265);
--color-brand-100: oklch(0.94 0.03 265);
--color-brand-200: oklch(0.89 0.055 265);
--color-brand-300: oklch(0.81 0.1 265);
--color-brand-400: oklch(0.7 0.16 265);
--color-brand-500: oklch(0.6 0.2 265);
--color-brand-600: oklch(0.54 0.22 265);
--color-brand-700: oklch(0.47 0.2 265);
--color-brand-800: oklch(0.4 0.17 265);
--color-brand-900: oklch(0.34 0.13 265);
--color-brand-950: oklch(0.24 0.08 265);
}
/* Fokus-Stil für Tastaturnavigation (Barrierefreiheit) */
@layer base {
:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,95 @@
/**
* Zentraler API-Client des Frontends.
* - Sendet automatisch das CSRF-Token bei zustandsändernden Requests
* - Behandelt 401 (nicht authentifiziert) einheitlich
* - Wirft strukturierte ApiError-Objekte statt roher Response-Objekte
*/
/** Strukturierter API-Fehler mit Statuscode und Meldungen. */
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: Record<string, string[]>,
) {
super(message);
this.name = 'ApiError';
}
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === 'mpm_csrf') {
return decodeURIComponent(value.join('='));
}
}
return null;
}
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
let unauthorizedHandler: (() => void) | null = null;
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
}
/** Führt einen API-Request aus und parst die JSON-Antwort. */
export async function apiRequest<TResponse>(
path: string,
options: RequestOptions = {},
): Promise<TResponse> {
const { method = 'GET', body, signal } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (method !== 'GET') {
const csrfToken = readCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (response.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
if (!response.ok) {
let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let details: Record<string, string[]> | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string[]>;
};
if (typeof errorBody.message === 'string') {
message = errorBody.message;
} else if (Array.isArray(errorBody.message)) {
message = errorBody.message.join(', ');
}
details = errorBody.details;
} catch {
// Antwort enthält kein JSON – Standardmeldung verwenden.
}
throw new ApiError(response.status, message, details);
}
return (await response.json()) as TResponse;
}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
/** Globale Plattform-Rollen (muss zum Backend passen). */
export const ROLE_NAMES = ['ADMIN', 'USER'] as const;
export type RoleName = (typeof ROLE_NAMES)[number];
/** Öffentliche Benutzerdaten (API-Vertrag /api/v1/auth/me). */
export const authUserSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
});
export type AuthUser = z.infer<typeof authUserSchema>;
/** Login-Anfrage (Client-Validierung ist UX; Server validiert erneut). */
export const loginSchema = z.object({
username: z.string().trim().min(1, 'Benutzername ist erforderlich'),
password: z.string().min(1, 'Passwort ist erforderlich'),
});
export type LoginDto = z.infer<typeof loginSchema>;
/** Health-Antwort (API-Vertrag /api/v1/health). */
export const healthSchema = z.object({
status: z.enum(['healthy', 'unhealthy']),
version: z.string(),
uptimeSeconds: z.number(),
components: z.object({
backend: z.enum(['healthy']),
database: z.object({
status: z.enum(['healthy', 'unhealthy']),
latencyMs: z.number(),
}),
}),
});
export type Health = z.infer<typeof healthSchema>;

View File

@@ -0,0 +1,66 @@
import { StrictMode, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppLayout } from './components/layout/app-layout';
import { RequireAdmin, RequireAuth } from './components/route-guards';
import { AuthProvider } from './features/auth/auth-context';
import { LoginPage } from './features/auth/login-page';
import { DashboardPage } from './features/dashboard/dashboard-page';
import { SystemStatusPage } from './features/admin/system-status-page';
import { UsersPage } from './features/admin/users-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css';
/** TanStack Query: keine automatischen Refetches bei Fensterfokus. */
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
/** Routenbaum der Management-Plattform. */
function AppRoutes(): ReactNode {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<DashboardPage />} />
<Route
path="/admin/users"
element={<RequireAdmin><UsersPage /></RequireAdmin>}
/>
<Route
path="/admin/system"
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}
/>
<Route path="/403" element={<ForbiddenPage />} />
</Route>
<Route path="*" element={<Navigate to="/404" replace />} />
<Route path="/404" element={<NotFoundPage />} />
</Routes>
);
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root-Element nicht gefunden');
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,27 @@
import { type ReactNode } from 'react';
/** 403-Seite: Zugriff verweigert. */
export function ForbiddenPage(): ReactNode {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3 text-center">
<span className="text-5xl" aria-hidden="true">🔒</span>
<h1 className="text-2xl font-bold text-slate-900">Zugriff verweigert</h1>
<p className="max-w-sm text-sm text-slate-500">
Sie besitzen keine Berechtigung für diesen Bereich.
</p>
</div>
);
}
/** 404-Seite: Seite nicht gefunden. */
export function NotFoundPage(): ReactNode {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3 text-center">
<span className="text-5xl" aria-hidden="true">🧭</span>
<h1 className="text-2xl font-bold text-slate-900">Seite nicht gefunden</h1>
<p className="max-w-sm text-sm text-slate-500">
Die angeforderte Seite existiert nicht.
</p>
</div>
);
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
// Lokale Entwicklung: API-Requests ans Backend weiterleiten
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
name: mpm
services:
postgres:
image: postgres:18-alpine
container_name: mpm-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?Bitte POSTGRES_USER in .env setzen}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Bitte POSTGRES_PASSWORD in .env setzen}
POSTGRES_DB: ${POSTGRES_DB:?Bitte POSTGRES_DB in .env setzen}
# Nur für lokale Entwicklung auf dem Host erreichbar (Loopback).
ports:
- "127.0.0.1:5432:5432"
volumes:
# postgres:18+ empfiehlt den Mount auf /var/lib/postgresql
# (Daten liegen dann im Unterverzeichnis), siehe docker-library/postgres#37
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
platform:
build:
context: .
dockerfile: Dockerfile
container_name: mpm-platform
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: ${NODE_ENV:-production}
PORT: ${PORT:-3000}
DATABASE_URL: ${DATABASE_URL:?Bitte DATABASE_URL in .env setzen}
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-120}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
BEHIND_PROXY: "true"
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
ports:
- "${APP_PORT:-8080}:8080"
volumes:
postgres-data:

79
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,79 @@
# =============================================================
# MPM – Reverse Proxy (Nginx)
# Läuft als unprivilegierter Benutzer "app" auf Port 8080.
# - / -> Management-Frontend (SPA, statische Dateien)
# - /api/ -> Management-Backend (127.0.0.1:3000)
# Ab Phase 4 werden hier dynamisch Modul-Routen (/slug) ergänzt.
# =============================================================
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
server_tokens off;
sendfile on;
tcp_nopush on;
# Upload-Grenze (z. B. für Modul-ZIP-Pakete ab Phase 3)
client_max_body_size 10m;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
client_body_temp_path /tmp/nginx/client_body;
proxy_temp_path /tmp/nginx/proxy;
fastcgi_temp_path /tmp/nginx/fastcgi;
uwsgi_temp_path /tmp/nginx/uwsgi;
scgi_temp_path /tmp/nginx/scgi;
upstream platform_backend {
server 127.0.0.1:3000;
}
server {
listen 8080;
server_name _;
root /app/public;
index index.html;
# Sicherheits-Header
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Gehashte Frontend-Assets: lange cachen
location /assets/ {
expires 1y;
try_files $uri =404;
}
# Management-API ans Backend proxien
location /api/ {
proxy_pass http://platform_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# SPA-Fallback für React Router
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -0,0 +1,39 @@
; =============================================================
; MPM – Prozessmanager (Supervisor)
; Verwaltet alle Prozesse innerhalb des Management-Containers:
; - platform-backend : NestJS Management-API (127.0.0.1:3000)
; - nginx : Reverse Proxy (0.0.0.0:8080)
; Ab Phase 3 werden hier die Modul-Prozesse registriert.
; Läuft als unprivilegierter Benutzer "app".
; =============================================================
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
childlogdir=/tmp
[program:platform-backend]
command=node dist/main.js
directory=/app/platform-backend
autorestart=true
startretries=10
startsecs=5
stopsignal=TERM
stopwaitsecs=20
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
autorestart=true
startretries=5
stopsignal=QUIT
stopwaitsecs=10
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

179
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,179 @@
# MPM – Architekturdokumentation
## 1. Leitentscheidung
Die Management-Plattform ist ein **Application Host und Gateway für Module** – nicht selbst die Fachanwendung.
```
Management-Plattform Module (ab Phase 3)
├── Identität & Login ├── eigene Oberfläche
├── Benutzer & Rollen ├── eigene Business-Logik
├── Rechte (plattformweit) ├── eigene API
├── Modul-Verwaltung ├── eigene Daten (eigenes Schema)
├── Routing / Gateway ├── eigene Migrationen
├── API & Administration └── optional eigene Modulrechte
└── Audit & Monitoring
```
Die Plattform darf **niemals** von einem einzelnen Modul abhängig sein. Wird ein Modul entfernt, bleiben Login, Benutzerverwaltung, Administration, Rechteverwaltung und alle anderen Module voll funktionsfähig.
## 2. Komponenten & Container-Layout
```
Internet
│
▼ HTTPS (Produktion; lokal :8080)
Docker Container "mpm-platform" (Benutzer: app, kein Root)
┌──────────────────────────────────────────────┐
│ Supervisor (Prozessmanager, Crash-Recovery) │
│ │ │
│ ├── nginx Reverse Proxy :8080 │
│ ├── platform-backend NestJS API 127.0.0.1:3000│
│ └── [ab Phase 3: Modul-Prozesse, │
│ z. B. Kalender 127.0.0.1:41001] │
└──────────────────────────────────────────────┘
│
▼
PostgreSQL "mpm-postgres" (eigener Container,
persistentes Volume "postgres-data")
```
Grundsätze:
- **Ein** Applikationscontainer; Module laufen als interne Prozesse darin (kein Docker-in-Docker, kein Docker-Socket).
- Interne Ports (z. B. 3000, 41001+) werden **nie** nach außen veröffentlicht; nur Nginx lauscht auf 8080.
- PostgreSQL liegt außerhalb des Applikationscontainers → Container-Neustarts/Updates zerstören keine Nutzdaten.
- Alle Prozesse laufen als unprivilegierter Benutzer `app`.
## 3. Prozessmodell
Supervisor verwaltet Start/Stop/Restart, Crash-Recovery und Logging aller Prozesse:
| Prozess | Adresse | Aufgabe |
|---|---|---|
| nginx | 0.0.0.0:8080 | Reverse Proxy, statisches Frontend, Security-Header |
| platform-backend | 127.0.0.1:3000 | Management-API `/api/v1` |
| Modul-Prozesse (ab Phase 3) | 127.0.0.1:41001+ | Fachanwendungen |
Stürzt ein Modulprozess ab, startet Supervisor ihn automatisch neu – die Management-Plattform läuft weiter.
## 4. Request-Flow
### Login
```
Browser ──POST /api/v1/auth/login──▶ Nginx ──▶ NestJS
│ Validierung (Zod) + Rate Limit + Lockout + Argon2id
│ Session in PostgreSQL anlegen (Token nur gehasht gespeichert)
◀── Set-Cookie: mpm_session (HttpOnly, SameSite=Lax)
Set-Cookie: mpm_csrf (lesbar für CSRF-Doppel-Submit)
```
### Authentifizierter Request
```
Browser ──▶ Nginx ──▶ SessionGuard (Session gültig? User aktiv?)
├── CsrfGuard (bei POST/PATCH/DELETE: X-CSRF-Token)
├── RolesGuard (RBAC: ADMIN/USER)
└── Controller/Service (Business-Logik)
```
### Modul-Routing (ab Phase 4, geplant)
```
Browser ──▶ Nginx (/slug) ──▶ Management-Gateway
├── User identifizieren (Session)
├── Permission Check (user_module_permissions)
├── DENIED → 403
└── ALLOWED → Modul-Gateway → Modulprozess
```
Ein Modul vertraut **niemals** allein auf die URL; die Plattform übergibt die Identität sicher an das Modul (Modul-API-Vertrag, Phase 6).
## 5. Datenmodell (Phase 1)
Alle Plattform-Tabellen liegen im Standard-Schema (ab Phase 3 Auslagerung in ein `management`-Schema und pro Modul ein eigenes Schema).
| Tabelle | Zweck |
|---|---|
| `roles` | Globale Rollen: `ADMIN`, `USER` |
| `users` | Benutzer mit Argon2id-Hash, Rollen-FK, Lockout-Feldern |
| `sessions` | Serverseitige Sessions (Token SHA-256-gehasht, CSRF-Token, Gleitende Verlängerung) |
| `audit_logs` | Zentrales Audit (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, …) |
| `schema_migrations` | Angewandte Migrationen (eigener Runner mit Advisory-Lock) |
Geplant (Phase 3+): `modules`, `user_module_permissions`, `module_settings`, `system_settings`, `api_clients`.
## 6. Authentifizierung & Sessions
- **Argon2id** (19 MiB, t=2, p=1 – OWASP-Empfehlung) für Passwort-Hashing; niemals Klartextpasswörter.
- **Serverseitige Sessions**: 256-Bit-Zufalls-Token im `HttpOnly`-Cookie; in der DB wird nur der SHA-256-Hash gespeichert. `Secure` (produktiv), `SameSite=Lax`, kurze Laufzeit (`SESSION_TTL_MINUTES`), gleitende Verlängerung.
- **CSRF-Schutz**: Doppel-Submit – Server speichert pro Session ein CSRF-Token; bei jedem zustandsändernden Request muss der Header `X-CSRF-Token` (konstantzeitvergleich) übereinstimmen.
- **Rate Limiting**: In-Memory-Fenster pro IP für Login (Standard: 10 Versuche / 5 Minuten).
- **Account Lockout**: Nach `LOGIN_MAX_ATTEMPTS` Fehlversuchen wird das Konto für `LOGIN_LOCKOUT_MINUTES` gesperrt.
- **Keine User-Enumeration**: Unbekannter Benutzer, inaktiver Benutzer und falsches Passwort liefern dieselbe generische 401-Meldung.
- **Audit-Log**: Jeder Login-Versuch (Erfolg/Misserfolg mit Grund), Logout.
## 7. Sicherheitsmaßnahmen (Phase 1)
- Security-Header via Helmet (Backend) und Nginx (Frontend): CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options`, `Referrer-Policy`.
- Zod-Validierung **aller** Eingaben (serverseitig, clientseitige Validierung ist nur UX).
- SQL nur parametrisiert (`pg`), keine String-Konkatenation.
- Strukturierte Fehlermeldungen ohne Stack-Traces nach außen.
- Secrets ausschließlich über Umgebungsvariablen (`.env` ist gitignored).
- Interne Ports nicht veröffentlicht; PostgreSQL nur auf Loopback des Hosts gemappt.
- Unprivilegierter Container-Benutzer; kein Root in der Laufzeit.
Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/Restore) ist gemäß Arbeitsplan Phase 10.
## 8. API-Übersicht (Phase 1)
| Methode | Pfad | Auth | Beschreibung |
|---|---|---|---|
| POST | `/api/v1/auth/login` | Public | Anmeldung, setzt Session-Cookies |
| POST | `/api/v1/auth/logout` | Session | Beendet die aktuelle Session |
| GET | `/api/v1/auth/me` | Session | Angemeldeter Benutzer (Id, Rolle, …) |
| GET | `/api/v1/health` | Public | Systemstatus (Backend, DB-Latenz, Version, Uptime) |
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 2: `/api/v1/users/*`, `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
## 9. Modul-Vertrag (Ausblick Phase 3+)
Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag:
```json
{
"id": "calendar",
"name": "Kalender",
"version": "1.0.0",
"slug": "kalender-tool",
"description": "Kalenderverwaltung",
"runtime": "node",
"entrypoint": "server.js",
"port": 41001,
"healthcheck": "/health",
"apiVersion": "v1"
}
```
Minimal-API je Modul: `GET /health`, `GET /api/manifest`, `GET /api/me`. Installation als ZIP-Paket mit Validierung, Dependency-Installation, Migrationen und Healthcheck. Details: [`modules/README.md`](../modules/README.md).
## 10. Architektur-Entscheidungen (ADR-Kurzform)
| # | Entscheidung | Begründung |
|---|---|---|
| 1 | Serverseitige Sessions statt reiner JWTs | Widerrufbar (Logout, Deaktivierung), kein Token-Diebstahl-Risiko, einfache Lockout-Logik; JWT/Access-Tokens für die Modul-Kommunikation ab Phase 6 |
| 2 | Opaque 256-Bit-Token, DB speichert nur SHA-256-Hash | DB-Leck kompromittiert keine Sessions |
| 3 | PostgreSQL außerhalb des Applikationscontainers | Persistenz bei Container-Updates, klare Trennung von Zustand und Compute |
| 4 | Supervisor statt systemd im Container | Container-Standard, verwaltet mehrere Prozesse ohne Root, Crash-Recovery |
| 5 | Nginx im Container als einziger öffentlicher Endpunkt | Zentrales Routing, Security-Header, interne Ports bleiben verborgen |
| 6 | `pg` + eigener Migration-Runner statt ORM | Wenig Abhängigkeiten, volle SQL-Kontrolle, Migrationen transaktionssicher mit Advisory-Lock |
| 7 | Zod statt class-validator | Ein Validierungs-Framework für Frontend und Backend, TypeScript-Inferenz |
| 8 | Monorepo mit unabhängigen Apps (kein Workspace-Tooling) | Einfache, cache-freundliche Docker-Builds, keine Tool-Lock-in |
## 11. Frontend-Architektur
- Schichten: `components/ui` (Design-System) → `components/layout` (App-Shell) → `features/*` (Fachseiten) → `pages` (Fehlerseiten).
- Datenzugriff ausschließlich über `lib/api-client` (CSRF-Header, 401-Behandlung) und TanStack Query.
- Routing: öffentliche Login-Seite, geschützter Bereich (`RequireAuth`), Admin-Bereich (`RequireAdmin`, RBAC im Frontend nur UX – erzwungen wird serverseitig).
- Responsiv: Sidebar wird auf Mobilgeräten zur Overlay-Navigation.

41
docs/PHASES.md Normal file
View File

@@ -0,0 +1,41 @@
# MPM – Phasen-Übersicht
Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das System startbar und testbar sein.
| Phase | Bereich | Status |
|---|---|---|
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
| 2 | Benutzerverwaltung | ⏳ Geplant |
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
| 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant |
| 7 | Referenzmodul Kalender | ⏳ Geplant |
| 8 | Modul-SDK (`platform-module-sdk`) | ⏳ Geplant |
| 9 | Administration (Übersichten, Audit-UI, Einstellungen) | ⏳ Geplant |
| 10 | Security Hardening & Produktivbetrieb | ⏳ Geplant |
## Phase 1 – Grundgerüst (abgeschlossen)
Definition of Done:
- [x] Repository-Struktur
- [x] Docker-Setup (`docker compose up` → Plattform erreichbar)
- [x] Frontend (React, Vite, Tailwind, Design-System-Komponenten)
- [x] Backend (NestJS, REST `/api/v1`, OpenAPI/Swagger)
- [x] PostgreSQL mit persistentem Volume
- [x] Migrationen (eigener Runner mit Advisory-Lock) + Seed (Rollen, Admin)
- [x] Login / Logout (Argon2id, serverseitige Sessions, HttpOnly-Cookies)
- [x] CSRF-Schutz, Rate Limiting, Account Lockout
- [x] User-Modell & Rollen (ADMIN/USER, RBAC-Guards)
- [x] Audit-Log (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT)
- [x] Health-Endpoint (`/api/v1/health`)
- [x] Admin-Dashboard sichtbar (inkl. Systemstatus)
- [x] Backend-Unit-Tests (Auth, Session, Passwort, Validierung)
## Nächste Schritte (Phase 2 – Benutzerverwaltung)
- Benutzerliste, Benutzer erstellen/bearbeiten/deaktivieren
- Passwortänderung und Reset durch Admin
- `GET/POST/PATCH/DELETE /api/v1/users/*` mit `@Roles('ADMIN')`
- Frontend-Seite `/admin/users` mit Tabelle, Formular und Bestätigungsdialogen

56
modules/README.md Normal file
View File

@@ -0,0 +1,56 @@
# MPM – Module
Dieses Verzeichnis enthält ab **Phase 3** die installierbaren Module der Plattform (z. B. das Referenzmodul Kalender).
## Modul-Vertrag (Ausblick)
Jedes Modul ist eine eigenständige Applikation mit:
- eigenständiger Oberfläche, Business-Logik, API und Datenbank-Schema
- einem Manifest `module.json`
- einem minimalen API-Vertrag
### Manifest
```json
{
"id": "calendar",
"name": "Kalender",
"version": "1.0.0",
"slug": "kalender-tool",
"description": "Kalenderverwaltung",
"author": "…",
"runtime": "node",
"entrypoint": "server.js",
"port": 41001,
"healthcheck": "/health",
"apiVersion": "v1"
}
```
### Minimal-API je Modul
| Methode | Pfad | Beschreibung |
|---|---|---|
| GET | `/health` | Liveness/Readiness |
| GET | `/api/manifest` | Manifest zur Laufzeit |
| GET | `/api/me` | Aktueller Benutzer (von der Plattform übergeben) |
### Paketstruktur (ZIP-Installation)
```
calendar-module/
├── module.json
├── backend/
├── frontend/
├── migrations/
├── assets/
└── README.md
```
### Grundsätze
- Module laufen als **interne Prozesse** im Management-Container (kein Docker-in-Docker) auf eigenen Ports (41001+), die nie nach außen veröffentlicht werden.
- Module sind **nicht vertrauenswürdig** und erhalten nur minimal benötigte Rechte.
- Ein Modul darf niemals in fremde Schemas schreiben; jedes Modul erhält ein eigenes PostgreSQL-Schema.
- Entfernt ein Admin ein Modul, müssen alle übrigen Plattformfunktionen unverändert weiterlaufen.