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