This commit is contained in:
Bastian Wagner
2026-07-16 09:49:22 +02:00
commit 543e8273a7
157 changed files with 22761 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
node_modules
**/node_modules
dist
**/dist
apps/backend/public
coverage
**/coverage
.angular
**/.angular
.env
.env.*
!.env.example
npm-debug.log*

12
.editorconfig Normal file
View File

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

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
NODE_ENV=development
PORT=3000
APP_BASE_URL=http://localhost:3000
FRONTEND_BASE_URL=http://localhost:4200
TRUST_PROXY=false
DATABASE_HOST=mysql.example.internal
DATABASE_PORT=3306
DATABASE_NAME=business_app
DATABASE_USER=business_app
DATABASE_PASSWORD=change-me
DATABASE_SSL=false
OIDC_ISSUER=https://idp.example.com/realms/internal
OIDC_CLIENT_ID=business-app
OIDC_CLIENT_SECRET=change-me
OIDC_SCOPES=openid profile email
OIDC_ALLOWED_ALGORITHMS=RS256
OIDC_HTTP_TIMEOUT_MS=5000
SESSION_COOKIE_NAME=app_session
SESSION_IDLE_TIMEOUT_SECONDS=28800
SESSION_ABSOLUTE_TIMEOUT_SECONDS=604800
SESSION_SECRET=replace-with-at-least-32-random-bytes
SESSION_ENCRYPTION_KEY=replace-with-32-byte-base64url-key
CORS_ORIGINS=http://localhost:4200,http://localhost:3000
CSRF_HEADER_NAME=X-CSRF-Token
LOG_LEVEL=info
SWAGGER_ENABLED=true
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX_REQUESTS=300
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60
RATE_LIMIT_SENSITIVE_MAX_REQUESTS=10

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
apps/backend/public/
coverage/
.angular/
.env
.env.*
!.env.example
npm-debug.log*
*.local

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.18.0

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist
coverage
node_modules
package-lock.json
apps/frontend/.angular

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all"
}

16
AGENTS.md Normal file
View File

@@ -0,0 +1,16 @@
# Arbeitsregeln fuer Codex
- Das Repository ist ein npm-Workspace-Monorepo mit `apps/frontend`, `apps/backend` und `packages/api-client`; keine Nx-Einfuehrung.
- TypeScript bleibt strikt. Kein `any`, keine unnoetigen Non-Null-Assertions.
- Backend-Controller verwenden niemals TypeORM-Repositories direkt, sondern Services.
- Services kapseln Fachlogik; Datenbankzugriffe laufen ueber Repository-Klassen oder klar benannte Persistence-Services.
- Keine UI-Library einsetzen. Angular bleibt mobile-first, mit eigenem HTML und SCSS.
- OIDC-Tokens bleiben ausschliesslich im Backend. Der Browser erhaelt nur Session-Cookie und CSRF-Token.
- Sessions liegen serverseitig in MySQL. Session-Cookies enthalten keine Tokens oder sensiblen Daten.
- Permissions sind im Code definiert. Benutzer haben keine direkten Permissions, sondern Rollen.
- Das Backend ist fuer Authentifizierung, Autorisierung und CSRF verbindlich; Angular nutzt Permissions nur zur Darstellung.
- Migrationen werden niemals automatisch beim normalen App-Start ausgefuehrt. Der Start prueft nur auf fehlende Migrationen.
- Secrets, Session-IDs und Tokens duerfen nicht geloggt oder ins Repository aufgenommen werden.
- Tests muessen Verhalten pruefen; keine trivialen Tests, die nur Existenz testen.
- Keine Auth- oder CSRF-Umgehung fuer Entwicklung oder Tests in Production-Code einbauen.
- Vor Abschluss muessen `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm test`, `npm run build` und `docker build .` bestehen.

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:24.18.0-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/frontend/package.json apps/frontend/package.json
COPY apps/backend/package.json apps/backend/package.json
COPY packages/api-client/package.json packages/api-client/package.json
RUN npm ci
COPY . .
RUN npm run build
RUN mkdir -p apps/backend/dist/public \
&& cp -R apps/frontend/dist/frontend/browser/* apps/backend/dist/public/ \
&& npm prune --omit=dev --workspaces --include-workspace-root
FROM node:24.18.0-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/package.json /app/package-lock.json ./
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/apps/backend/package.json ./apps/backend/package.json
COPY --from=build --chown=app:app /app/apps/backend/dist ./apps/backend/dist
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "apps/backend/dist/main.js"]

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Angular/NestJS Business Boilerplate
Production-ready npm-Workspace-Monorepo fuer interne Business-Anwendungen mit
Angular 22, NestJS 11, Node.js 24.18 LTS, TypeORM, MySQL 8, OIDC, serverseitigen
Sessions und Vitest.
Dieses Repository ist als Startpunkt fuer eigene Projekte gedacht. Es bringt die
technischen Grundentscheidungen mit, die in internen Business-Anwendungen haeufig
spaet und teuer nachgezogen werden: Backend-for-Frontend Authentifizierung,
rollenbasierte Autorisierung, CSRF-Schutz, serverseitige Sessions, Migrationen,
Docker-Auslieferung, Healthchecks und eine einfache Angular-Verwaltungsoberflaeche.
## Was enthalten ist
- npm Workspaces ohne Nx: `apps/frontend`, `apps/backend`, `packages/api-client`
- Angular Standalone Components, mobile-first SCSS und keine UI-Library
- NestJS API mit modularen Features, Services, Repositories, Guards und DTOs
- OIDC Authorization Code Flow mit PKCE; Tokens bleiben ausschliesslich im Backend
- HttpOnly Session-Cookie plus CSRF-Cookie/Header fuer schreibende Requests
- konfigurierbares In-Memory Rate Limiting pro IP mit strengeren sensiblen Endpunkten
- MySQL-8-Persistenz mit TypeORM, Migrationen und Startpruefung auf fehlende Migrationen
- Code-definierte Permissions, Rollenverwaltung, Benutzerverwaltung, Audit-Log und Sessions
- Generierter API-Client fuer das Angular-Frontend
- Dockerfile und Compose-Beispiel fuer eine einzelne auslieferbare App
## Schnellstart
```bash
npm ci
cp .env.example .env
npm run dev
```
Angular laeuft lokal auf `http://localhost:4200` und proxyt `/api` an NestJS auf
`http://localhost:3000`. Eine MySQL-8-Datenbank und ein OIDC Provider muessen
konfiguriert sein; Compose startet bewusst keine lokale Datenbank.
## Wichtige Befehle
```bash
npm run dev # Frontend und Backend parallel starten
npm run lint # ESLint fuer alle Workspaces
npm run format:check # Prettier-Pruefung
npm run typecheck # TypeScript-Pruefung aller Workspaces
npm test # Backend- und Frontend-Tests
npm run build # API-Client, Backend, Frontend und Bundle bauen
npm run migration:status # offene TypeORM-Migrationen anzeigen
npm run migration:run # Migrationen ausfuehren
npm run docker:build # Docker-Image bauen
```
Vor einem Merge oder Release muessen `npm run lint`, `npm run format:check`,
`npm run typecheck`, `npm test`, `npm run build` und `docker build .` erfolgreich
laufen.
## Dokumentation
- [Getting Started](docs/getting-started.md): lokaler Start, Konfiguration und erster Login
- [Als Projektvorlage verwenden](docs/using-as-template.md): Umbenennen, Entfernen von Demo-Code und erste fachliche Erweiterung
- [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen
- [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client
- [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging
- [Deployment und Betrieb](docs/deployment.md): Build, Docker, Migrationen, Runtime-Konfiguration und Healthchecks
- [Konfiguration](docs/configuration.md): Umgebungsvariablen und Produktionshinweise
- [Nginx-Beispiel](docs/nginx-example.conf): Reverse Proxy mit HTTPS-Terminierung
## Repository-Struktur
```text
apps/
backend/ NestJS API, Auth, Sessions, Rollen, Fachmodule, Migrationen
frontend/ Angular SPA, Shell, Feature Pages, Guards, Interceptor
packages/
api-client/ generierter Angular API-Client
scripts/ Build- und Generator-Hilfsskripte
docs/ Betriebs-, Architektur- und Starter-Dokumentation
```
## Auslieferungsmodell
Der Produktionsbuild erzeugt eine einzelne NestJS-Anwendung. Das Angular-Frontend
wird nach `apps/backend/dist/public` kopiert und vom Backend unter `/`
ausgeliefert. Die API liegt unter `/api`, Swagger optional unter `/api/docs` und
Healthchecks unter `/health/live` sowie `/health/ready`.
Ein typisches Release:
```bash
npm ci
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
docker build -t registry.example.com/business-app:<tag> .
docker push registry.example.com/business-app:<tag>
```
Auf der Zielumgebung werden Migrationen einmalig mit demselben Image ausgefuehrt,
danach wird der App-Container gestartet. Details stehen in
[Deployment und Betrieb](docs/deployment.md).
## Sicherheitsgrundsaetze
- OIDC-Tokens werden nur serverseitig gespeichert und verschluesselt.
- Der Browser erhaelt keine Access-, Refresh- oder ID-Tokens.
- Session-Cookies enthalten nur eine signierte Session-ID.
- Schreibende Requests brauchen ein gueltiges CSRF-Token.
- Permissions sind im Code definiert; Benutzer erhalten Rechte nur ueber Rollen.
- Migrationen laufen nie automatisch beim normalen App-Start.
- Secrets, Tokens, Cookies und Session-IDs duerfen nicht geloggt werden.
## Lizenzierung fuer eigene Projekte
Das Root-`package.json` ist aktuell `private` und `UNLICENSED`. Wenn dieses
Boilerplate als Open-Source-Startpunkt veroeffentlicht werden soll, muessen vor
der Veroeffentlichung eine passende Lizenzdatei, Paketnamen, Repository-Links und
Projektmetadaten bewusst gesetzt werden.

4
apps/backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

40
apps/backend/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Backend Workspace
NestJS Backend fuer das Boilerplate. Der Workspace wird normalerweise ueber die
Root-Scripts gesteuert.
## Befehle
```bash
npm --workspace apps/backend run start:dev
npm --workspace apps/backend run build
npm --workspace apps/backend run typecheck
npm --workspace apps/backend run test
npm --workspace apps/backend run migration:status
npm --workspace apps/backend run migration:run
npm --workspace apps/backend run migration:generate
```
Root-Aliase fuer die wichtigsten Befehle stehen in `../../package.json`.
## Struktur
- `src/main.ts`: Bootstrap, Security Header, CORS, Swagger, Static Assets
- `src/app.module.ts`: globale Guards, Filter, Rate Limiting und Feature-Module
- `src/auth`: OIDC Login, Callback, Public Decorator und Guards
- `src/sessions`: serverseitige Sessions und CSRF
- `src/roles`: Rollen und Code-definierte Permissions
- `src/users`: lokale Benutzer und Einstellungen
- `src/items`: Beispiel-Fachmodul
- `src/database`: TypeORM-Konfiguration, Entities, Migrationen und Healthcheck
- `src/common`: Fehlerformat, Validierung, Request ID und Hilfsdienste
## Entwicklungsregeln
Controller verwenden keine TypeORM-Repositories direkt. Fachlogik gehoert in
Services, Datenbankzugriff in Repository-Klassen oder klar benannte
Persistence-Services. Neue Endpunkte muessen mit Permissions geschuetzt werden,
sofern sie nicht explizit public sind.
Migrationen werden nicht automatisch beim normalen App-Start ausgefuehrt. Nutze
`npm run migration:run` aus dem Root oder den Workspace-Befehl oben.

View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
);

View File

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

43
apps/backend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "@boilerplate/backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"build": "nest build",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --config vitest.config.ts",
"migration:status": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:show",
"migration:run": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:run",
"migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration"
},
"dependencies": {
"@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.28",
"@nestjs/platform-express": "11.1.28",
"@nestjs/swagger": "11.2.3",
"@nestjs/throttler": "6.4.0",
"@nestjs/typeorm": "11.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"cookie-parser": "1.4.7",
"dotenv": "17.4.2",
"helmet": "8.1.0",
"jose": "6.1.2",
"mysql2": "3.15.3",
"openid-client": "6.8.1",
"pino": "10.1.0",
"pino-http": "11.0.0",
"pino-pretty": "13.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2",
"typeorm": "0.3.27",
"zod": "4.1.13"
},
"devDependencies": {
"typeorm-ts-node-commonjs": "0.3.20"
}
}

View File

@@ -0,0 +1,99 @@
import { createHash } from 'node:crypto';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
import {
ThrottlerGuard,
ThrottlerModule,
type ThrottlerGenerateKeyFunction,
type ThrottlerGetTrackerFunction,
} from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { CsrfGuard } from './auth/guards/csrf.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { AppConfigModule } from './config/config.module';
import { AppConfigService } from './config/config.service';
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
import { RequestIdMiddleware } from './common/request-context/request-id.middleware';
import { SENSITIVE_RATE_LIMIT_KEY } from './common/rate-limit/sensitive-rate-limit.decorator';
import { typeOrmOptionsFactory } from './database/typeorm-options';
import { DatabaseModule } from './database/database.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { HealthModule } from './health/health.module';
import { ItemsModule } from './items/items.module';
import { RolesModule } from './roles/roles.module';
import { SessionsModule } from './sessions/sessions.module';
import { UsersModule } from './users/users.module';
const getIpTracker: ThrottlerGetTrackerFunction = (req) => {
const ip = typeof req['ip'] === 'string' ? req['ip'] : undefined;
const socket = req['socket'] as { remoteAddress?: unknown } | undefined;
const remoteAddress =
typeof socket?.remoteAddress === 'string'
? socket.remoteAddress
: undefined;
return ip ?? remoteAddress ?? 'unknown';
};
const generateGlobalKey: ThrottlerGenerateKeyFunction = (
_context,
tracker,
throttlerName,
) => createHash('sha256').update(`${throttlerName}:${tracker}`).digest('hex');
@Module({
imports: [
AppConfigModule,
TypeOrmModule.forRootAsync({
imports: [AppConfigModule],
inject: [AppConfigService],
useFactory: typeOrmOptionsFactory,
}),
DatabaseModule,
DashboardModule,
ThrottlerModule.forRootAsync({
imports: [AppConfigModule],
inject: [AppConfigService, Reflector],
useFactory: (config: AppConfigService, reflector: Reflector) => ({
getTracker: getIpTracker,
generateKey: generateGlobalKey,
throttlers: [
{
name: 'default',
ttl: config.rateLimit.global.windowSeconds * 1000,
limit: config.rateLimit.global.maxRequests,
},
{
name: 'sensitive',
ttl: config.rateLimit.sensitive.windowSeconds * 1000,
limit: config.rateLimit.sensitive.maxRequests,
skipIf: (context) =>
!reflector.getAllAndOverride<boolean>(SENSITIVE_RATE_LIMIT_KEY, [
context.getHandler(),
context.getClass(),
]),
},
],
}),
}),
AuthModule,
UsersModule,
RolesModule,
SessionsModule,
AuditModule,
ItemsModule,
HealthModule,
],
providers: [
{ provide: APP_FILTER, useClass: ApiExceptionFilter },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Query } from '@nestjs/common';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { Permission } from '../roles/permissions';
import { AuditService } from './audit.service';
@Controller('audit-log')
export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
@RequirePermissions(Permission.AuditRead)
list(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.audit.list(Number(page ?? 1), Number(pageSize ?? 20));
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLogEntity } from './entities/audit-log.entity';
import { AuditController } from './audit.controller';
import { AuditRepository } from './repositories/audit.repository';
import { AuditService } from './audit.service';
@Module({
imports: [TypeOrmModule.forFeature([AuditLogEntity])],
controllers: [AuditController],
providers: [AuditRepository, AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { getRequestId } from '../common/request-context/request-context';
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
import { AuditRepository } from './repositories/audit.repository';
@Injectable()
export class AuditService {
constructor(private readonly audit: AuditRepository) {}
async record(
actorUserId: string | null,
action: AuditAction,
targetType: string,
targetId: string,
metadata: Record<string, string | number | boolean | null> | null = null,
): Promise<void> {
const entry = new AuditLogEntity();
entry.actorUserId = actorUserId;
entry.action = action;
entry.targetType = targetType;
entry.targetId = targetId;
entry.metadata = metadata;
entry.requestId = getRequestId();
await this.audit.save(entry);
}
async list(page = 1, pageSize = 20) {
const [items, total] = await this.audit.list(page, pageSize);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
export enum AuditAction {
UserActivated = 'USER_ACTIVATED',
UserDeactivated = 'USER_DEACTIVATED',
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
UserRoleRemoved = 'USER_ROLE_REMOVED',
RoleCreated = 'ROLE_CREATED',
RoleUpdated = 'ROLE_UPDATED',
RoleDeleted = 'ROLE_DELETED',
RolePermissionsUpdated = 'ROLE_PERMISSIONS_UPDATED',
SessionRevoked = 'SESSION_REVOKED',
AllUserSessionsRevoked = 'ALL_USER_SESSIONS_REVOKED',
}
@Entity('audit_logs')
export class AuditLogEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('idx_audit_created_at')
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@Column({ name: 'actor_user_id', type: 'char', length: 36, nullable: true })
actorUserId!: string | null;
@Column({ type: 'varchar', length: 80 })
action!: AuditAction;
@Column({ name: 'target_type', type: 'varchar', length: 80 })
targetType!: string;
@Column({ name: 'target_id', type: 'varchar', length: 120 })
targetId!: string;
@Column({ type: 'json', nullable: true })
metadata!: Record<string, string | number | boolean | null> | null;
@Column({ name: 'request_id', type: 'varchar', length: 128 })
requestId!: string;
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLogEntity } from '../entities/audit-log.entity';
@Injectable()
export class AuditRepository {
constructor(
@InjectRepository(AuditLogEntity)
private readonly repo: Repository<AuditLogEntity>,
) {}
save(entry: AuditLogEntity): Promise<AuditLogEntity> {
return this.repo.save(entry);
}
list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> {
return this.repo.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
}
}

View File

@@ -0,0 +1,69 @@
import { Controller, Get, Query, Redirect, Req, Res } from '@nestjs/common';
import type { Response } from 'express';
import type { AuthenticatedRequest } from './authenticated-request';
import { Public } from './guards/public.decorator';
import { AppConfigService } from '../config/config.service';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: AppConfigService,
) {}
@Get('login')
@Public()
@SensitiveRateLimit()
@Redirect()
async login() {
return { url: await this.auth.createLoginUrl() };
}
@Get('callback')
@Public()
@SensitiveRateLimit()
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const { session, csrfToken } = await this.auth.completeLogin(
code,
state,
req.get('user-agent'),
req.ip,
);
res.cookie(this.config.session.cookieName, session.id, {
httpOnly: true,
signed: true,
secure: this.config.isProduction,
sameSite: 'lax',
path: '/',
expires: session.absoluteExpiresAt,
});
res.cookie('csrf_token', csrfToken, {
httpOnly: false,
secure: this.config.isProduction,
sameSite: 'lax',
path: '/',
expires: session.absoluteExpiresAt,
});
res.redirect(this.config.frontendBaseUrl);
}
@Get('logout')
@Public()
@SensitiveRateLimit()
async logout(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.signedCookies?.[this.config.session.cookieName] as
| string
| undefined;
await this.auth.logout(sessionId);
res.clearCookie(this.config.session.cookieName, { path: '/' });
res.clearCookie('csrf_token', { path: '/' });
res.redirect(this.config.frontendBaseUrl);
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExternalHttpClient } from '../common/http/external-http-client';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
TypeOrmModule.forFeature([
OidcLoginStateEntity,
UserEntity,
UserSettingsEntity,
]),
RolesModule,
SessionsModule,
],
controllers: [AuthController],
providers: [AuthService, ExternalHttpClient, UsersRepository],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,286 @@
import { createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, LessThan, Repository } from 'typeorm';
import { ExternalHttpClient } from '../common/http/external-http-client';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { AppConfigService } from '../config/config.service';
import { RolesService } from '../roles/roles.service';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import { SessionsService } from '../sessions/sessions.service';
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import type {
OidcDiscovery,
OidcTokenResponse,
OidcUserInfo,
} from './oidc.types';
@Injectable()
export class AuthService {
constructor(
private readonly config: AppConfigService,
private readonly http: ExternalHttpClient,
private readonly roles: RolesService,
private readonly users: UsersRepository,
private readonly sessions: SessionsService,
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(OidcLoginStateEntity)
private readonly loginStates: Repository<OidcLoginStateEntity>,
) {}
async createLoginUrl(): Promise<string> {
const discovery = await this.discovery();
const state = randomBytes(32).toString('hex');
const nonce = randomBytes(32).toString('base64url');
const codeVerifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
await this.loginStates.delete({ expiresAt: LessThan(new Date()) });
const loginState = new OidcLoginStateEntity();
loginState.state = state;
loginState.codeVerifier = codeVerifier;
loginState.nonce = nonce;
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.loginStates.save(loginState);
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.config.oidc.clientId);
url.searchParams.set('redirect_uri', this.callbackUrl);
url.searchParams.set('scope', this.config.oidc.scopes);
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
async completeLogin(
code: string,
state: string,
userAgent: string | undefined,
ip: string | undefined,
) {
const loginState = await this.loginStates.findOneBy({ state });
if (!loginState || loginState.expiresAt <= new Date()) {
throw new ApiError(
ErrorCode.Unauthorized,
'Die Anmeldung ist abgelaufen.',
401,
);
}
await this.loginStates.delete({ state });
const discovery = await this.discovery();
const tokens = await this.exchangeCode(
discovery,
code,
loginState.codeVerifier,
);
const profile = await this.verifyAndLoadProfile(
discovery,
tokens,
loginState.nonce,
);
const user = await this.upsertLocalUser(discovery.issuer, profile);
if (!user.active) {
throw new ApiError(
ErrorCode.UserDisabled,
'Dieser Benutzer ist deaktiviert.',
403,
);
}
return this.sessions.createSession(
user,
{
accessToken: tokens.access_token,
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
idToken: tokens.id_token,
accessTokenExpiresAt: new Date(
Date.now() + (tokens.expires_in ?? 3600) * 1000,
),
},
userAgent,
ip,
);
}
async logout(sessionId: string | undefined): Promise<void> {
if (sessionId) {
await this.sessions.revoke(sessionId);
}
}
private async upsertLocalUser(
issuer: string,
profile: OidcUserInfo,
): Promise<UserEntity> {
return this.dataSource.transaction(async (manager) => {
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
try {
const { admin, user: userRole } =
await this.roles.ensureSystemRoles(manager);
let user = await manager.getRepository(UserEntity).findOne({
where: { issuer, subject: profile.sub },
relations: { roles: true, settings: true },
});
if (!user) {
user = new UserEntity();
user.issuer = issuer;
user.subject = profile.sub;
user.active = true;
user.roles = [userRole];
const userCount = await manager.getRepository(UserEntity).count();
if (userCount === 0) {
user.roles = [userRole, admin];
}
}
user.name = profile.name ?? profile.email ?? profile.sub;
user.email = profile.email ?? null;
user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user);
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
return savedUser;
} finally {
await manager.query("SELECT RELEASE_LOCK('business_app_first_admin')");
}
});
}
private async ensureUserSettings(
manager: EntityManager,
user: UserEntity,
): Promise<UserSettingsEntity> {
const settingsRepository = manager.getRepository(UserSettingsEntity);
const existingSettings = await settingsRepository.findOne({
where: { user: { id: user.id } },
});
if (existingSettings) {
return existingSettings;
}
const settings = new UserSettingsEntity();
settings.user = user;
return settingsRepository.save(settings);
}
private async discovery(): Promise<OidcDiscovery> {
const discoveryUrl = new URL(
'/.well-known/openid-configuration',
this.config.oidc.issuer,
);
const discovery = await this.http.requestJson<OidcDiscovery>(
discoveryUrl.toString(),
);
if (discovery.issuer !== this.config.oidc.issuer) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Issuer ist ungueltig.',
401,
);
}
return discovery;
}
private async exchangeCode(
discovery: OidcDiscovery,
code: string,
codeVerifier: string,
): Promise<OidcTokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.callbackUrl,
client_id: this.config.oidc.clientId,
code_verifier: codeVerifier,
});
const basic = Buffer.from(
`${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`,
'utf8',
).toString('base64');
return this.http.requestJson<OidcTokenResponse>(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
}
private async verifyAndLoadProfile(
discovery: OidcDiscovery,
tokens: OidcTokenResponse,
nonce: string,
): Promise<OidcUserInfo> {
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
await import('jose');
const protectedHeader = decodeProtectedHeader(tokens.id_token);
if (!this.isAllowedOidcAlgorithm(protectedHeader.alg)) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Signaturalgorithmus ist ungueltig.',
401,
);
}
const { payload } = await jwtVerify(
tokens.id_token,
createRemoteJWKSet(new URL(discovery.jwks_uri)),
{
issuer: discovery.issuer,
audience: this.config.oidc.clientId,
algorithms: this.config.oidc.allowedAlgorithms,
},
);
if (payload['nonce'] !== nonce || typeof payload.sub !== 'string') {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Token ist ungueltig.',
401,
);
}
if (!discovery.userinfo_endpoint) {
const fallback: OidcUserInfo = { sub: payload.sub };
if (typeof payload['name'] === 'string') {
fallback.name = payload['name'];
}
if (typeof payload['email'] === 'string') {
fallback.email = payload['email'];
}
return fallback;
}
const userInfo = await this.http.requestJson<OidcUserInfo>(
discovery.userinfo_endpoint,
{
headers: { Authorization: `Bearer ${tokens.access_token}` },
},
);
if (userInfo.sub !== payload.sub) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-UserInfo ist ungueltig.',
401,
);
}
return userInfo;
}
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
return (
typeof algorithm === 'string' &&
algorithm.toLowerCase() !== 'none' &&
this.config.oidc.allowedAlgorithms.includes(algorithm)
);
}
private get callbackUrl(): string {
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
}
}

View File

@@ -0,0 +1,12 @@
import type { Request } from 'express';
import type { Permission } from '../roles/permissions';
export interface AuthenticatedUser {
id: string;
sessionId: string;
permissions: Permission[];
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}

View File

@@ -0,0 +1,19 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('oidc_login_states')
export class OidcLoginStateEntity {
@PrimaryColumn({ type: 'char', length: 64 })
state!: string;
@Column({ name: 'code_verifier', type: 'varchar', length: 160 })
codeVerifier!: string;
@Column({ type: 'varchar', length: 160 })
nonce!: string;
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
}

View File

@@ -0,0 +1,49 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiError } from '../../common/errors/api-error';
import { ErrorCode } from '../../common/errors/error-codes';
import { AppConfigService } from '../../config/config.service';
import { SessionsService } from '../../sessions/sessions.service';
import { IS_PUBLIC_KEY } from './public.decorator';
import type { AuthenticatedRequest } from '../authenticated-request';
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@Injectable()
export class CsrfGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionsService,
private readonly config: AppConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!unsafeMethods.has(request.method)) {
return true;
}
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const sessionId = request.signedCookies?.[
this.config.session.cookieName
] as string | undefined;
const csrfToken = request.header(this.config.csrfHeaderName);
if (
!sessionId ||
!csrfToken ||
!(await this.sessions.verifyCsrfToken(sessionId, csrfToken))
) {
throw new ApiError(
ErrorCode.CsrfInvalid,
'Das Sicherheits-Token ist ungueltig.',
403,
);
}
return true;
}
}

View File

@@ -0,0 +1,68 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiError } from '../../common/errors/api-error';
import { ErrorCode } from '../../common/errors/error-codes';
import { AppConfigService } from '../../config/config.service';
import { SessionsService } from '../../sessions/sessions.service';
import type { Permission } from '../../roles/permissions';
import { IS_PUBLIC_KEY } from './public.decorator';
import { REQUIRED_PERMISSIONS_KEY } from './require-permissions.decorator';
import type { AuthenticatedRequest } from '../authenticated-request';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionsService,
private readonly config: AppConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
const required = this.reflector.getAllAndOverride<Permission[]>(
REQUIRED_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic || !required?.length) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const sessionId = request.signedCookies?.[
this.config.session.cookieName
] as string | undefined;
if (!sessionId) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
const session = await this.sessions.resolveSession(
sessionId,
request.ip,
request.get('user-agent'),
);
request.user = {
id: session.user.id,
sessionId,
permissions: session.permissions,
};
const allowed = required.every((permission) =>
session.permissions.includes(permission),
);
if (!allowed) {
throw new ApiError(
ErrorCode.PermissionDenied,
'Keine Berechtigung fuer diese Aktion.',
403,
);
}
return true;
}
}

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import type { Permission } from '../../roles/permissions';
export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,23 @@
export interface OidcDiscovery {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
}
export interface OidcTokenResponse {
access_token: string;
refresh_token?: string;
id_token: string;
expires_in?: number;
token_type: string;
}
export interface OidcUserInfo {
sub: string;
name?: string;
email?: string;
}

View File

@@ -0,0 +1,28 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@IsOptional()
@IsString()
search?: string;
}
export interface PageDto<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}

View File

@@ -0,0 +1,27 @@
import { HttpException } from '@nestjs/common';
import type { HttpStatus } from '@nestjs/common';
import type { ErrorCode } from './error-codes';
export interface ValidationErrorDetail {
field: string;
messages: string[];
}
export interface ApiErrorBody {
status: number;
code: ErrorCode;
message: string;
requestId: string;
validation?: ValidationErrorDetail[];
}
export class ApiError extends HttpException {
constructor(
public readonly code: ErrorCode,
message: string,
status: HttpStatus,
public readonly validation?: ValidationErrorDetail[],
) {
super(message, status);
}
}

View File

@@ -0,0 +1,66 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Response } from 'express';
import { QueryFailedError } from 'typeorm';
import { getRequestId } from '../request-context/request-context';
import { ApiError, type ApiErrorBody } from './api-error';
import { ErrorCode } from './error-codes';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const requestId = getRequestId();
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
let code = ErrorCode.InternalError;
let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let validation: ApiErrorBody['validation'];
if (exception instanceof ApiError) {
status = exception.getStatus();
code = exception.code;
message = exception.message;
validation = exception.validation;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
code =
status === 401
? ErrorCode.Unauthorized
: status === 429
? ErrorCode.RateLimitExceeded
: ErrorCode.InternalError;
message =
status === 429
? 'Zu viele Anfragen. Bitte versuchen Sie es spaeter erneut.'
: status >= 500
? message
: 'Die Anfrage konnte nicht verarbeitet werden.';
} else if (exception instanceof QueryFailedError) {
status = HttpStatus.CONFLICT;
code = ErrorCode.Conflict;
message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.';
}
if (status >= 500) {
this.logger.error({ requestId, exception }, 'Unhandled API error');
}
response.status(status).json({
status,
code,
message,
requestId,
...(validation ? { validation } : {}),
} satisfies ApiErrorBody);
}
}

View File

@@ -0,0 +1,13 @@
export enum ErrorCode {
PermissionDenied = 'PERMISSION_DENIED',
Unauthorized = 'UNAUTHORIZED',
ValidationFailed = 'VALIDATION_FAILED',
NotFound = 'NOT_FOUND',
Conflict = 'CONFLICT',
CsrfInvalid = 'CSRF_INVALID',
UserDisabled = 'USER_DISABLED',
LastAdminRequired = 'LAST_ADMIN_REQUIRED',
MigrationMissing = 'MIGRATION_MISSING',
RateLimitExceeded = 'RATE_LIMIT_EXCEEDED',
InternalError = 'INTERNAL_ERROR',
}

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../../config/config.service';
import { getRequestId } from '../request-context/request-context';
export interface ExternalHttpOptions {
method?: 'GET' | 'POST';
headers?: Record<string, string>;
body?: URLSearchParams | string;
timeoutMs?: number;
}
@Injectable()
export class ExternalHttpClient {
private readonly logger = new Logger(ExternalHttpClient.name);
constructor(private readonly config: AppConfigService) {}
async requestJson<T>(
url: string,
options: ExternalHttpOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? this.config.oidc.httpTimeoutMs,
);
try {
const init: RequestInit = {
method: options.method ?? 'GET',
headers: {
Accept: 'application/json',
'X-Request-ID': getRequestId(),
...(options.headers ?? {}),
},
signal: controller.signal,
};
if (options.body !== undefined) {
init.body = options.body;
}
const response = await fetch(url, init);
if (!response.ok) {
this.logger.warn(
{ url, status: response.status },
'External HTTP request failed',
);
throw new Error(`External HTTP request failed with ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
}

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const SENSITIVE_RATE_LIMIT_KEY = Symbol('SENSITIVE_RATE_LIMIT');
export const SensitiveRateLimit = () =>
SetMetadata(SENSITIVE_RATE_LIMIT_KEY, true);

View File

@@ -0,0 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
requestId: string;
userId?: string;
}
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();
export function getRequestId(): string {
return requestContextStorage.getStore()?.requestId ?? 'unknown';
}

View File

@@ -0,0 +1,15 @@
import { randomUUID } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
import { requestContextStorage } from './request-context';
export const requestIdHeader = 'x-request-id';
export class RequestIdMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(requestIdHeader);
const requestId =
incoming && incoming.length <= 128 ? incoming : randomUUID();
res.setHeader('X-Request-ID', requestId);
requestContextStorage.run({ requestId }, () => next());
}
}

View File

@@ -0,0 +1,42 @@
import { ValidationPipe } from '@nestjs/common';
import type { ValidationError } from '@nestjs/common';
import { ErrorCode } from '../errors/error-codes';
import { ApiError, type ValidationErrorDetail } from '../errors/api-error';
function flattenValidation(
errors: ValidationError[],
parent = '',
): ValidationErrorDetail[] {
return errors.flatMap((error) => {
const field = parent ? `${parent}.${error.property}` : error.property;
const own = error.constraints
? [
{
field,
messages: Object.values(error.constraints).map(
() => 'Ungueltiger Wert.',
),
},
]
: [];
return [...own, ...flattenValidation(error.children ?? [], field)];
});
}
export function createValidationPipe(): ValidationPipe {
return new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: false },
exceptionFactory: (errors) => {
const validation = flattenValidation(errors);
return new ApiError(
ErrorCode.ValidationFailed,
'Bitte pruefen Sie die markierten Felder.',
400,
validation,
);
},
});
}

View File

@@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { envFilePaths, loadConfigFromEnv } from './env';
import { AppConfigService } from './config.service';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
cache: true,
envFilePath: envFilePaths,
expandVariables: false,
isGlobal: true,
validate: loadConfigFromEnv,
}),
],
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { loadConfigFromEnv } from './env';
const validEnv = {
NODE_ENV: 'test',
PORT: '3000',
APP_BASE_URL: 'http://localhost:3000',
DATABASE_HOST: 'localhost',
DATABASE_PORT: '3306',
DATABASE_NAME: 'business_app_test',
DATABASE_USER: 'test',
DATABASE_PASSWORD: 'test',
OIDC_ISSUER: 'https://idp.example.test',
OIDC_CLIENT_ID: 'client',
OIDC_CLIENT_SECRET: '12345678901234567890123456789012',
OIDC_ALLOWED_ALGORITHMS: 'RS256',
SESSION_SECRET: '12345678901234567890123456789012',
SESSION_ENCRYPTION_KEY: 'abcdefghijklmnopqrstuvwxyz123456',
CORS_ORIGINS: 'http://localhost:4200',
};
describe('loadConfigFromEnv', () => {
it('validates required configuration centrally', () => {
const config = loadConfigFromEnv(validEnv);
expect(config.database.name).toBe('business_app_test');
expect(config.frontendBaseUrl).toBe('http://localhost:3000');
expect(config.oidc.allowedAlgorithms).toEqual(['RS256']);
expect(config.rateLimit.global).toEqual({
windowSeconds: 60,
maxRequests: 300,
});
expect(config.rateLimit.sensitive).toEqual({
windowSeconds: 60,
maxRequests: 10,
});
});
it('allows all rate limit buckets to be configured independently', () => {
const config = loadConfigFromEnv({
...validEnv,
RATE_LIMIT_WINDOW_SECONDS: '30',
RATE_LIMIT_MAX_REQUESTS: '200',
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: '90',
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: '5',
});
expect(config.rateLimit).toEqual({
global: { windowSeconds: 30, maxRequests: 200 },
sensitive: { windowSeconds: 90, maxRequests: 5 },
});
});
it('uses a separate frontend base URL when configured', () => {
const config = loadConfigFromEnv({
...validEnv,
FRONTEND_BASE_URL: 'http://localhost:4200',
});
expect(config.frontendBaseUrl).toBe('http://localhost:4200');
});
it('rejects unsafe production secret placeholders', () => {
expect(() =>
loadConfigFromEnv({
...validEnv,
NODE_ENV: 'production',
OIDC_CLIENT_SECRET: 'change-me-change-me-change-me-change-me',
}),
).toThrow(/Production-Secrets/);
});
it('accepts comma or whitespace separated OIDC signing algorithms', () => {
const config = loadConfigFromEnv({
...validEnv,
OIDC_ALLOWED_ALGORITHMS: 'RS256 PS256,ES256',
});
expect(config.oidc.allowedAlgorithms).toEqual(['RS256', 'PS256', 'ES256']);
});
it('rejects alg none in OIDC signing algorithms', () => {
expect(() =>
loadConfigFromEnv({
...validEnv,
OIDC_ALLOWED_ALGORITHMS: 'RS256,none',
}),
).toThrow(/OIDC_ALLOWED_ALGORITHMS/);
});
});

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
import type { AppConfig, NodeEnv } from './config.types';
@Injectable()
export class AppConfigService {
private readonly config: AppConfig;
constructor(private readonly nestConfig: NestConfigService<AppConfig, true>) {
this.config = {
nodeEnv: this.nestConfig.getOrThrow('nodeEnv', { infer: true }),
port: this.nestConfig.getOrThrow('port', { infer: true }),
appBaseUrl: this.nestConfig.getOrThrow('appBaseUrl', { infer: true }),
frontendBaseUrl: this.nestConfig.getOrThrow('frontendBaseUrl', {
infer: true,
}),
trustProxy: this.nestConfig.getOrThrow('trustProxy', { infer: true }),
database: this.nestConfig.getOrThrow('database', { infer: true }),
oidc: this.nestConfig.getOrThrow('oidc', { infer: true }),
session: this.nestConfig.getOrThrow('session', { infer: true }),
corsOrigins: this.nestConfig.getOrThrow('corsOrigins', { infer: true }),
csrfHeaderName: this.nestConfig.getOrThrow('csrfHeaderName', {
infer: true,
}),
logLevel: this.nestConfig.getOrThrow('logLevel', { infer: true }),
swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', {
infer: true,
}),
rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }),
};
}
get nodeEnv(): NodeEnv {
return this.config.nodeEnv;
}
get isProduction(): boolean {
return this.config.nodeEnv === 'production';
}
get port(): number {
return this.config.port;
}
get appBaseUrl(): string {
return this.config.appBaseUrl;
}
get frontendBaseUrl(): string {
return this.config.frontendBaseUrl;
}
get trustProxy(): boolean {
return this.config.trustProxy;
}
get database(): AppConfig['database'] {
return this.config.database;
}
get oidc(): AppConfig['oidc'] {
return this.config.oidc;
}
get session(): AppConfig['session'] {
return this.config.session;
}
get corsOrigins(): string[] {
return this.config.corsOrigins;
}
get csrfHeaderName(): string {
return this.config.csrfHeaderName;
}
get logLevel(): string {
return this.config.logLevel;
}
get swaggerEnabled(): boolean {
return this.config.swaggerEnabled;
}
get rateLimit(): AppConfig['rateLimit'] {
return this.config.rateLimit;
}
/**
* @deprecated Use rateLimit.global.windowSeconds.
*/
get rateLimitWindowSeconds(): number {
return this.config.rateLimit.global.windowSeconds;
}
/**
* @deprecated Use rateLimit.global.maxRequests.
*/
get rateLimitMaxRequests(): number {
return this.config.rateLimit.global.maxRequests;
}
}

View File

@@ -0,0 +1,45 @@
export type NodeEnv = 'development' | 'test' | 'production';
export interface RateLimitRuleConfig {
windowSeconds: number;
maxRequests: number;
}
export interface AppConfig {
nodeEnv: NodeEnv;
port: number;
appBaseUrl: string;
frontendBaseUrl: string;
trustProxy: boolean;
database: {
host: string;
port: number;
name: string;
user: string;
password: string;
ssl: boolean;
};
oidc: {
issuer: string;
clientId: string;
clientSecret: string;
scopes: string;
allowedAlgorithms: string[];
httpTimeoutMs: number;
};
session: {
cookieName: string;
idleTimeoutSeconds: number;
absoluteTimeoutSeconds: number;
secret: string;
encryptionKey: string;
};
corsOrigins: string[];
csrfHeaderName: string;
logLevel: string;
swaggerEnabled: boolean;
rateLimit: {
global: RateLimitRuleConfig;
sensitive: RateLimitRuleConfig;
};
}

View File

@@ -0,0 +1,152 @@
import { config as loadDotenv } from 'dotenv';
import { z } from 'zod';
import type { AppConfig } from './config.types';
export const envFilePaths = ['.env', '../../.env'];
const booleanFromString = z
.string()
.transform((value) => value.toLowerCase())
.pipe(z.enum(['true', 'false']))
.transform((value) => value === 'true');
const envSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
APP_BASE_URL: z.url(),
FRONTEND_BASE_URL: z.url().optional(),
TRUST_PROXY: booleanFromString.default(false),
DATABASE_HOST: z.string().min(1),
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
DATABASE_NAME: z.string().min(1),
DATABASE_USER: z.string().min(1),
DATABASE_PASSWORD: z.string().min(1),
DATABASE_SSL: booleanFromString.default(false),
OIDC_ISSUER: z.url(),
OIDC_CLIENT_ID: z.string().min(1),
OIDC_CLIENT_SECRET: z.string().min(1),
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
OIDC_ALLOWED_ALGORITHMS: z
.string()
.min(1)
.transform((value) =>
value
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean),
)
.refine(
(algorithms) =>
algorithms.length > 0 &&
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
),
OIDC_HTTP_TIMEOUT_MS: z.coerce
.number()
.int()
.min(1000)
.max(30000)
.default(5000),
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800),
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
.number()
.int()
.min(3600)
.default(604800),
SESSION_SECRET: z.string().min(32),
SESSION_ENCRYPTION_KEY: z.string().min(32),
CORS_ORIGINS: z.string().transform((value) =>
value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
),
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
LOG_LEVEL: z.string().min(1).default('info'),
SWAGGER_ENABLED: booleanFromString.default(false),
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
.number()
.int()
.min(1)
.default(60),
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10),
});
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
const parsed = envSchema.safeParse(env);
if (!parsed.success) {
const issues = parsed.error.issues.map(
(issue) => `${issue.path.join('.')}: ${issue.message}`,
);
throw new Error(`Ungueltige Konfiguration:\n${issues.join('\n')}`);
}
const value = parsed.data;
if (value.NODE_ENV === 'production') {
const insecure = [
value.SESSION_SECRET.includes('change'),
value.SESSION_ENCRYPTION_KEY.includes('change'),
value.OIDC_CLIENT_SECRET.includes('change'),
];
if (insecure.some(Boolean)) {
throw new Error(
'Production-Secrets muessen explizit und sicher gesetzt werden.',
);
}
}
return {
nodeEnv: value.NODE_ENV,
port: value.PORT,
appBaseUrl: value.APP_BASE_URL,
frontendBaseUrl: value.FRONTEND_BASE_URL ?? value.APP_BASE_URL,
trustProxy: value.TRUST_PROXY,
database: {
host: value.DATABASE_HOST,
port: value.DATABASE_PORT,
name: value.DATABASE_NAME,
user: value.DATABASE_USER,
password: value.DATABASE_PASSWORD,
ssl: value.DATABASE_SSL,
},
oidc: {
issuer: value.OIDC_ISSUER,
clientId: value.OIDC_CLIENT_ID,
clientSecret: value.OIDC_CLIENT_SECRET,
scopes: value.OIDC_SCOPES,
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
},
session: {
cookieName: value.SESSION_COOKIE_NAME,
idleTimeoutSeconds: value.SESSION_IDLE_TIMEOUT_SECONDS,
absoluteTimeoutSeconds: value.SESSION_ABSOLUTE_TIMEOUT_SECONDS,
secret: value.SESSION_SECRET,
encryptionKey: value.SESSION_ENCRYPTION_KEY,
},
corsOrigins: value.CORS_ORIGINS,
csrfHeaderName: value.CSRF_HEADER_NAME,
logLevel: value.LOG_LEVEL,
swaggerEnabled: value.SWAGGER_ENABLED,
rateLimit: {
global: {
windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS,
maxRequests: value.RATE_LIMIT_MAX_REQUESTS,
},
sensitive: {
windowSeconds: value.RATE_LIMIT_SENSITIVE_WINDOW_SECONDS,
maxRequests: value.RATE_LIMIT_SENSITIVE_MAX_REQUESTS,
},
},
};
}
export function loadConfigForCli(): AppConfig {
loadDotenv({ path: envFilePaths, override: false, quiet: true });
return loadConfigFromEnv(process.env);
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { Permission } from '../roles/permissions';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboard: DashboardService) {}
@Get()
@RequirePermissions(Permission.ItemsRead)
summary() {
return this.dashboard.summary();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { IsNull } from 'typeorm';
import { ItemEntity } from '../items/entities/item.entity';
import { RoleEntity } from '../roles/entities/role.entity';
import { SessionEntity } from '../sessions/entities/session.entity';
import { UserEntity } from '../users/entities/user.entity';
@Injectable()
export class DashboardService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async summary(): Promise<{
userCount: number;
activeSessions: number;
roleCount: number;
itemCount: number;
}> {
const [userCount, activeSessions, roleCount, itemCount] = await Promise.all(
[
this.dataSource.getRepository(UserEntity).count(),
this.dataSource
.getRepository(SessionEntity)
.count({ where: { revokedAt: IsNull() } }),
this.dataSource.getRepository(RoleEntity).count(),
this.dataSource.getRepository(ItemEntity).count(),
],
);
return { userCount, activeSessions, roleCount, itemCount };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MigrationHealthService } from './migration-health.service';
@Module({
providers: [MigrationHealthService],
exports: [MigrationHealthService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,19 @@
import { AuditLogEntity } from '../audit/entities/audit-log.entity';
import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
import { ItemEntity } from '../items/entities/item.entity';
import { RoleEntity } from '../roles/entities/role.entity';
import { PermissionEntity } from '../roles/entities/permission.entity';
import { SessionEntity } from '../sessions/entities/session.entity';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
export const entities = [
AuditLogEntity,
OidcLoginStateEntity,
ItemEntity,
RoleEntity,
PermissionEntity,
SessionEntity,
UserSettingsEntity,
UserEntity,
];

View File

@@ -0,0 +1,41 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { ErrorCode } from '../common/errors/error-codes';
import { ApiError } from '../common/errors/api-error';
@Injectable()
export class MigrationHealthService {
private initialized = false;
private missingMigrations = false;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async assertNoPendingMigrations(): Promise<void> {
const pending = await this.dataSource.showMigrations();
this.initialized = true;
this.missingMigrations = pending;
if (pending) {
throw new ApiError(
ErrorCode.MigrationMissing,
'Es fehlen Datenbankmigrationen. Fuehren Sie npm run migration:run aus.',
503,
);
}
}
isReady(): boolean {
return (
this.initialized &&
!this.missingMigrations &&
this.dataSource.isInitialized
);
}
async ping(): Promise<void> {
if (!this.isReady()) {
throw new ServiceUnavailableException();
}
await this.dataSource.query('SELECT 1');
}
}

View File

@@ -0,0 +1,144 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1720000000000 implements MigrationInterface {
name = 'InitialSchema1720000000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE permissions (
id varchar(80) NOT NULL,
description varchar(160) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE roles (
id char(36) NOT NULL,
name varchar(80) NOT NULL,
protected tinyint NOT NULL DEFAULT 0,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_roles_name (name),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE role_permissions (
role_id char(36) NOT NULL,
permission_id varchar(80) NOT NULL,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE users (
id char(36) NOT NULL,
issuer varchar(255) NOT NULL,
subject varchar(255) NOT NULL,
name varchar(255) NOT NULL,
email varchar(320) NULL,
active tinyint NOT NULL DEFAULT 1,
last_login_at datetime(3) NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_users_issuer_subject (issuer, subject),
KEY idx_users_issuer (issuer),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE user_roles (
user_id char(36) NOT NULL,
role_id char(36) NOT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE user_settings (
id char(36) NOT NULL,
user_id char(36) NOT NULL,
table_page_size int NOT NULL DEFAULT 20,
sidebar_expanded tinyint NOT NULL DEFAULT 1,
UNIQUE KEY uq_user_settings_user (user_id),
CONSTRAINT fk_user_settings_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE sessions (
id char(64) NOT NULL,
user_id char(36) NOT NULL,
access_token_encrypted text NOT NULL,
refresh_token_encrypted text NULL,
id_token_encrypted text NULL,
csrf_token_hash char(64) NOT NULL,
access_token_expires_at datetime(3) NOT NULL,
expires_at datetime(3) NOT NULL,
absolute_expires_at datetime(3) NOT NULL,
last_activity_at datetime(3) NOT NULL,
user_agent varchar(512) NULL,
last_ip varchar(80) NULL,
revoked_at datetime(3) NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_sessions_user_id (user_id),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE oidc_login_states (
state char(64) NOT NULL,
code_verifier varchar(160) NOT NULL,
nonce varchar(160) NOT NULL,
expires_at datetime(3) NOT NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE audit_logs (
id char(36) NOT NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
actor_user_id char(36) NULL,
action varchar(80) NOT NULL,
target_type varchar(80) NOT NULL,
target_id varchar(120) NOT NULL,
metadata json NULL,
request_id varchar(128) NOT NULL,
KEY idx_audit_created_at (created_at),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await queryRunner.query(`
CREATE TABLE items (
id char(36) NOT NULL,
name varchar(160) NOT NULL,
description text NULL,
status varchar(30) NOT NULL DEFAULT 'draft',
version int NOT NULL DEFAULT 1,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at datetime(3) NULL,
KEY idx_items_name (name),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE items');
await queryRunner.query('DROP TABLE audit_logs');
await queryRunner.query('DROP TABLE oidc_login_states');
await queryRunner.query('DROP TABLE sessions');
await queryRunner.query('DROP TABLE user_settings');
await queryRunner.query('DROP TABLE user_roles');
await queryRunner.query('DROP TABLE users');
await queryRunner.query('DROP TABLE role_permissions');
await queryRunner.query('DROP TABLE roles');
await queryRunner.query('DROP TABLE permissions');
}
}

View File

@@ -0,0 +1,13 @@
import 'reflect-metadata';
import dataSource from './typeorm-cli.datasource';
async function run(): Promise<void> {
await dataSource.initialize();
try {
await dataSource.runMigrations({ transaction: 'all' });
} finally {
await dataSource.destroy();
}
}
void run();

View File

@@ -0,0 +1,23 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { loadConfigForCli } from '../config/env';
import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
const config = loadConfigForCli();
export default new DataSource({
type: 'mysql',
host: config.database.host,
port: config.database.port,
username: config.database.user,
password: config.database.password,
database: config.database.name,
charset: 'utf8mb4_unicode_ci',
timezone: 'Z',
ssl: config.database.ssl ? { rejectUnauthorized: true } : undefined,
synchronize: false,
migrationsRun: false,
entities,
migrations: [InitialSchema1720000000000],
});

View File

@@ -0,0 +1,24 @@
import type { TypeOrmModuleOptions } from '@nestjs/typeorm';
import type { AppConfigService } from '../config/config.service';
import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
export function typeOrmOptionsFactory(
config: AppConfigService,
): TypeOrmModuleOptions {
return {
type: 'mysql',
host: config.database.host,
port: config.database.port,
username: config.database.user,
password: config.database.password,
database: config.database.name,
charset: 'utf8mb4_unicode_ci',
timezone: 'Z',
ssl: config.database.ssl ? { rejectUnauthorized: true } : undefined,
synchronize: false,
migrationsRun: false,
entities,
migrations: [InitialSchema1720000000000],
};
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/guards/public.decorator';
import { MigrationHealthService } from '../database/migration-health.service';
@Controller('health')
export class HealthController {
constructor(private readonly migrations: MigrationHealthService) {}
@Get('live')
@Public()
live() {
return { status: 'ok' };
}
@Get('ready')
@Public()
async ready() {
await this.migrations.ping();
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './health.controller';
@Module({
imports: [DatabaseModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,53 @@
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Length,
Min,
} from 'class-validator';
import { ItemStatus } from '../entities/item.entity';
export class ItemListQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
sort?: 'name' | 'status' | 'createdAt' | 'updatedAt';
@IsOptional()
@IsString()
direction?: 'ASC' | 'DESC';
@IsOptional()
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@IsInt()
@Min(1)
pageSize = 20;
}
export class CreateItemDto {
@IsString()
@Length(1, 160)
name!: string;
@IsOptional()
@IsString()
@Length(0, 4000)
description?: string;
@IsEnum(ItemStatus)
status!: ItemStatus;
}
export class UpdateItemDto extends CreateItemDto {
@IsInt()
@Min(1)
version!: number;
}

View File

@@ -0,0 +1,49 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
VersionColumn,
} from 'typeorm';
export enum ItemStatus {
Draft = 'draft',
Active = 'active',
Archived = 'archived',
}
@Entity('items')
export class ItemEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('idx_items_name')
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 30, default: ItemStatus.Draft })
status!: ItemStatus;
@VersionColumn({ type: 'int' })
version!: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
@DeleteDateColumn({
name: 'deleted_at',
type: 'datetime',
precision: 3,
nullable: true,
})
deletedAt!: Date | null;
}

View File

@@ -0,0 +1,51 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { Permission } from '../roles/permissions';
import { CreateItemDto, ItemListQueryDto, UpdateItemDto } from './dto/item.dto';
import { ItemsService } from './items.service';
@ApiTags('items')
@Controller('items')
export class ItemsController {
constructor(private readonly items: ItemsService) {}
@Get()
@RequirePermissions(Permission.ItemsRead)
list(@Query() query: ItemListQueryDto) {
return this.items.list(query);
}
@Get(':id')
@RequirePermissions(Permission.ItemsRead)
get(@Param('id') id: string) {
return this.items.get(id);
}
@Post()
@RequirePermissions(Permission.ItemsCreate)
create(@Body() dto: CreateItemDto) {
return this.items.create(dto);
}
@Put(':id')
@RequirePermissions(Permission.ItemsUpdate)
update(@Param('id') id: string, @Body() dto: UpdateItemDto) {
return this.items.update(id, dto);
}
@Delete(':id')
@RequirePermissions(Permission.ItemsDelete)
delete(@Param('id') id: string, @Query('version') version: number) {
return this.items.delete(id, Number(version));
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ItemEntity } from './entities/item.entity';
import { ItemsController } from './items.controller';
import { ItemsService } from './items.service';
import { ItemsRepository } from './repositories/items.repository';
@Module({
imports: [TypeOrmModule.forFeature([ItemEntity])],
controllers: [ItemsController],
providers: [ItemsService, ItemsRepository],
})
export class ItemsModule {}

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import type { PageDto } from '../common/dto/pagination.dto';
import { ItemEntity } from './entities/item.entity';
import type {
CreateItemDto,
ItemListQueryDto,
UpdateItemDto,
} from './dto/item.dto';
import {
ItemsRepository,
type ItemSortField,
} from './repositories/items.repository';
@Injectable()
export class ItemsService {
constructor(private readonly items: ItemsRepository) {}
async list(query: ItemListQueryDto): Promise<PageDto<ItemEntity>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const sort: ItemSortField = query.sort ?? 'updatedAt';
const direction = query.direction ?? 'DESC';
const [items, total] = await this.items.list(
query.search,
page,
pageSize,
sort,
direction,
);
return { items, total, page, pageSize };
}
async get(id: string): Promise<ItemEntity> {
const item = await this.items.findById(id);
if (!item) {
throw new ApiError(
ErrorCode.NotFound,
'Der Eintrag wurde nicht gefunden.',
404,
);
}
return item;
}
async create(dto: CreateItemDto): Promise<ItemEntity> {
const item = new ItemEntity();
item.name = dto.name;
item.description = dto.description ?? null;
item.status = dto.status;
return this.items.save(item);
}
async update(id: string, dto: UpdateItemDto): Promise<ItemEntity> {
const item = await this.get(id);
if (item.version !== dto.version) {
throw new ApiError(
ErrorCode.Conflict,
'Der Eintrag wurde zwischenzeitlich geaendert. Bitte laden Sie ihn neu.',
409,
);
}
item.name = dto.name;
item.description = dto.description ?? null;
item.status = dto.status;
return this.items.save(item);
}
async delete(id: string, version: number): Promise<void> {
const item = await this.get(id);
if (item.version !== version) {
throw new ApiError(
ErrorCode.Conflict,
'Der Eintrag wurde zwischenzeitlich geaendert. Bitte laden Sie ihn neu.',
409,
);
}
await this.items.softDelete(item);
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ItemEntity } from '../entities/item.entity';
export type ItemSortField = 'name' | 'status' | 'createdAt' | 'updatedAt';
@Injectable()
export class ItemsRepository {
constructor(
@InjectRepository(ItemEntity) private readonly repo: Repository<ItemEntity>,
) {}
list(
search: string | undefined,
page: number,
pageSize: number,
sort: ItemSortField,
direction: 'ASC' | 'DESC',
): Promise<[ItemEntity[], number]> {
const qb = this.repo.createQueryBuilder('item');
if (search) {
qb.where('item.name LIKE :search OR item.description LIKE :search', {
search: `%${search}%`,
});
}
return qb
.orderBy(`item.${sort}`, direction)
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
}
findById(id: string): Promise<ItemEntity | null> {
return this.repo.findOne({ where: { id } });
}
save(item: ItemEntity): Promise<ItemEntity> {
return this.repo.save(item);
}
async softDelete(item: ItemEntity): Promise<void> {
await this.repo.softRemove(item);
}
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes';
import { ItemEntity, ItemStatus } from '../entities/item.entity';
import { ItemsService } from '../items.service';
import type { ItemsRepository } from '../repositories/items.repository';
function item(version = 2): ItemEntity {
const entity = new ItemEntity();
entity.id = 'item-1';
entity.name = 'Alt';
entity.description = null;
entity.status = ItemStatus.Draft;
entity.version = version;
entity.createdAt = new Date();
entity.updatedAt = new Date();
entity.deletedAt = null;
return entity;
}
describe('ItemsService', () => {
it('answers stale updates with HTTP 409 conflict semantics', async () => {
const repo: Pick<ItemsRepository, 'findById' | 'save'> = {
findById: () => Promise.resolve(item(3)),
save: (entity) => Promise.resolve(entity),
};
const service = new ItemsService(repo as ItemsRepository);
await expect(
service.update('item-1', {
name: 'Neu',
description: 'Beschreibung',
status: ItemStatus.Active,
version: 2,
}),
).rejects.toMatchObject({ code: ErrorCode.Conflict, status: 409 });
});
it('soft deletes only when the submitted version is current', async () => {
let deleted = false;
const repo: Pick<ItemsRepository, 'findById' | 'softDelete'> = {
findById: () => Promise.resolve(item(4)),
softDelete: () => {
deleted = true;
return Promise.resolve();
},
};
const service = new ItemsService(repo as ItemsRepository);
await service.delete('item-1', 4);
expect(deleted).toBe(true);
});
});

99
apps/backend/src/main.ts Normal file
View File

@@ -0,0 +1,99 @@
import { join } from 'node:path';
import cookieParser from 'cookie-parser';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import pinoHttp from 'pino-http';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { createValidationPipe } from './common/security/validation.pipe';
import { AppConfigService } from './config/config.service';
import { MigrationHealthService } from './database/migration-health.service';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true,
});
const config = app.get(AppConfigService);
app.use(
pinoHttp({
level: config.logLevel,
...(config.isProduction ? {} : { transport: { target: 'pino-pretty' } }),
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["x-csrf-token"]',
'req.headers["x-session-id"]',
'res.headers["set-cookie"]',
'*.access_token',
'*.refresh_token',
'*.id_token',
],
censor: '[redacted]',
},
customProps: (req) => ({
requestId: req.headers['x-request-id'],
}),
}),
);
app.use(cookieParser(config.session.secret));
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
},
},
}),
);
app.set('trust proxy', config.trustProxy);
app.enableCors({
origin: config.corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', config.csrfHeaderName, 'X-Request-ID'],
});
app.setGlobalPrefix('api', {
exclude: ['health/live', 'health/ready'],
});
app.useGlobalPipes(createValidationPipe());
app.enableShutdownHooks();
if (config.swaggerEnabled) {
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('Business App API')
.setDescription('Interne Business-Anwendung')
.setVersion('1.0.0')
.build(),
);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: '/api/docs-json',
});
}
app.useStaticAssets(join(__dirname, '..', 'public'), {
index: false,
fallthrough: true,
});
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.url.startsWith('/api') || req.url.startsWith('/health')) {
next();
return;
}
res.sendFile(join(__dirname, '..', 'public', 'index.html'));
});
await app.get(MigrationHealthService).assertNoPendingMigrations();
await app.listen(config.port);
}
void bootstrap();

View File

@@ -0,0 +1,14 @@
import { IsArray, IsEnum, IsString, Length } from 'class-validator';
import { Permission } from '../permissions';
export class CreateRoleDto {
@IsString()
@Length(2, 80)
name!: string;
@IsArray()
@IsEnum(Permission, { each: true })
permissions!: Permission[];
}
export class UpdateRoleDto extends CreateRoleDto {}

View File

@@ -0,0 +1,11 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { Permission } from '../permissions';
@Entity('permissions')
export class PermissionEntity {
@PrimaryColumn({ type: 'varchar', length: 80 })
id!: Permission;
@Column({ type: 'varchar', length: 160 })
description!: string;
}

View File

@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { PermissionEntity } from './permission.entity';
import { UserEntity } from '../../users/entities/user.entity';
@Entity('roles')
@Unique('uq_roles_name', ['name'])
export class RoleEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
name!: string;
@Column({ type: 'boolean', default: false })
protected!: boolean;
@ManyToMany(() => PermissionEntity, { eager: true })
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions!: PermissionEntity[];
@ManyToMany(() => UserEntity, (user) => user.roles)
users!: UserEntity[];
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
}

View File

@@ -0,0 +1,25 @@
export enum Permission {
ItemsRead = 'items.read',
ItemsCreate = 'items.create',
ItemsUpdate = 'items.update',
ItemsDelete = 'items.delete',
UsersRead = 'users.read',
UsersManage = 'users.manage',
RolesRead = 'roles.read',
RolesManage = 'roles.manage',
AuditRead = 'audit.read',
SessionsReadOwn = 'sessions.readOwn',
SessionsRevokeOwn = 'sessions.revokeOwn',
SessionsManage = 'sessions.manage',
}
export const allPermissions = Object.values(Permission);
export const administrativePermissions = [
Permission.UsersRead,
Permission.UsersManage,
Permission.RolesRead,
Permission.RolesManage,
Permission.AuditRead,
Permission.SessionsManage,
] as const;

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { RoleEntity } from '../entities/role.entity';
@Injectable()
export class RolesRepository {
constructor(
@InjectRepository(RoleEntity) private readonly repo: Repository<RoleEntity>,
) {}
findByName(
name: string,
manager?: EntityManager,
): Promise<RoleEntity | null> {
return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({
where: { name },
});
}
findById(id: string): Promise<RoleEntity | null> {
return this.repo.findOne({ where: { id }, relations: { users: true } });
}
list(): Promise<RoleEntity[]> {
return this.repo.find({
order: { name: 'ASC' },
relations: { users: true },
});
}
save(role: RoleEntity, manager?: EntityManager): Promise<RoleEntity> {
return (manager?.getRepository(RoleEntity) ?? this.repo).save(role);
}
remove(role: RoleEntity): Promise<RoleEntity> {
return this.repo.remove(role);
}
}

View File

@@ -0,0 +1,46 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from './permissions';
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
import { RolesService } from './roles.service';
@Controller('roles')
export class RolesController {
constructor(private readonly roles: RolesService) {}
@Get()
@RequirePermissions(Permission.RolesRead)
list() {
return this.roles.list();
}
@Post()
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
create(@Body() dto: CreateRoleDto) {
return this.roles.create(dto);
}
@Put(':id')
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
return this.roles.update(id, dto);
}
@Delete(':id')
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
delete(@Param('id') id: string) {
return this.roles.delete(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity';
import { RolesController } from './roles.controller';
import { RolesRepository } from './repositories/roles.repository';
import { RolesService } from './roles.service';
@Module({
imports: [TypeOrmModule.forFeature([RoleEntity, PermissionEntity])],
controllers: [RolesController],
providers: [RolesRepository, RolesService],
exports: [RolesRepository, RolesService],
})
export class RolesModule {}

View File

@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } from 'typeorm';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity';
import { allPermissions, Permission } from './permissions';
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
import { RolesRepository } from './repositories/roles.repository';
const adminRoleName = 'admin';
const userRoleName = 'user';
@Injectable()
export class RolesService {
constructor(
private readonly roles: RolesRepository,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
list(): Promise<RoleEntity[]> {
return this.roles.list();
}
async create(dto: CreateRoleDto): Promise<RoleEntity> {
const role = new RoleEntity();
role.name = dto.name;
role.protected = false;
role.permissions = await this.loadPermissionEntities(dto.permissions);
return this.roles.save(role);
}
async update(id: string, dto: UpdateRoleDto): Promise<RoleEntity> {
const role = await this.getRole(id);
if (
role.name === adminRoleName &&
dto.permissions.length !== allPermissions.length
) {
throw new ApiError(
ErrorCode.PermissionDenied,
'Die Adminrolle muss alle Rechte behalten.',
403,
);
}
role.name = role.protected ? role.name : dto.name;
role.permissions = await this.loadPermissionEntities(
role.name === adminRoleName ? allPermissions : dto.permissions,
);
return this.roles.save(role);
}
async delete(id: string): Promise<void> {
const role = await this.getRole(id);
if (role.protected) {
throw new ApiError(
ErrorCode.Conflict,
'Systemrollen koennen nicht geloescht werden.',
409,
);
}
if (role.users.length > 0) {
throw new ApiError(
ErrorCode.Conflict,
'Die Rolle ist noch Benutzern zugewiesen.',
409,
);
}
await this.roles.remove(role);
}
async ensureSystemRoles(
manager?: EntityManager,
): Promise<{ admin: RoleEntity; user: RoleEntity }> {
await this.syncPermissions(manager);
const admin = await this.ensureRole(
adminRoleName,
allPermissions,
true,
manager,
);
const user = await this.ensureRole(
userRoleName,
[Permission.ItemsRead, Permission.SessionsReadOwn],
true,
manager,
);
return { admin, user };
}
async syncPermissions(manager?: EntityManager): Promise<void> {
const repo = (manager ?? this.dataSource.manager).getRepository(
PermissionEntity,
);
await repo.save(
allPermissions.map((permission) => ({
id: permission,
description: permission,
})),
);
}
async getRole(id: string): Promise<RoleEntity> {
const role = await this.roles.findById(id);
if (!role) {
throw new ApiError(
ErrorCode.NotFound,
'Die Rolle wurde nicht gefunden.',
404,
);
}
return role;
}
private async ensureRole(
name: string,
permissions: Permission[],
protectedRole: boolean,
manager?: EntityManager,
): Promise<RoleEntity> {
const repo = (manager ?? this.dataSource.manager).getRepository(RoleEntity);
let role = await repo.findOne({
where: { name },
relations: { permissions: true },
});
if (!role) {
role = new RoleEntity();
role.name = name;
role.protected = protectedRole;
}
role.permissions = await this.loadPermissionEntities(permissions, manager);
role.protected = protectedRole;
return repo.save(role);
}
private loadPermissionEntities(
permissions: Permission[],
manager?: EntityManager,
): Promise<PermissionEntity[]> {
return Promise.all(
permissions.map(async (permission) => {
const entity = await (manager ?? this.dataSource.manager)
.getRepository(PermissionEntity)
.findOneBy({ id: permission });
if (!entity) {
throw new ApiError(
ErrorCode.ValidationFailed,
'Unbekannte Permission.',
400,
);
}
return entity;
}),
);
}
}

View File

@@ -0,0 +1,9 @@
export interface SessionListItemDto {
id: string;
createdAt: string;
lastActivityAt: string;
userAgent: string | null;
approximateIp: string | null;
current: boolean;
revokedAt: string | null;
}

View File

@@ -0,0 +1,69 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from '../../users/entities/user.entity';
@Entity('sessions')
export class SessionEntity {
@PrimaryColumn({ type: 'char', length: 64 })
id!: string;
@ManyToOne(() => UserEntity, (user) => user.sessions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: UserEntity;
@Index('idx_sessions_user_id')
@Column({ name: 'user_id', type: 'char', length: 36 })
userId!: string;
@Column({ name: 'access_token_encrypted', type: 'text' })
accessTokenEncrypted!: string;
@Column({ name: 'refresh_token_encrypted', type: 'text', nullable: true })
refreshTokenEncrypted!: string | null;
@Column({ name: 'id_token_encrypted', type: 'text', nullable: true })
idTokenEncrypted!: string | null;
@Column({ name: 'csrf_token_hash', type: 'char', length: 64 })
csrfTokenHash!: string;
@Column({ name: 'access_token_expires_at', type: 'datetime', precision: 3 })
accessTokenExpiresAt!: Date;
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
expiresAt!: Date;
@Column({ name: 'absolute_expires_at', type: 'datetime', precision: 3 })
absoluteExpiresAt!: Date;
@Column({ name: 'last_activity_at', type: 'datetime', precision: 3 })
lastActivityAt!: Date;
@Column({ name: 'user_agent', type: 'varchar', length: 512, nullable: true })
userAgent!: string | null;
@Column({ name: 'last_ip', type: 'varchar', length: 80, nullable: true })
lastIp!: string | null;
@Column({
name: 'revoked_at',
type: 'datetime',
precision: 3,
nullable: true,
})
revokedAt!: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
}

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThan, Repository } from 'typeorm';
import { SessionEntity } from '../entities/session.entity';
@Injectable()
export class SessionsRepository {
constructor(
@InjectRepository(SessionEntity)
private readonly repo: Repository<SessionEntity>,
) {}
findActiveById(id: string): Promise<SessionEntity | null> {
return this.repo.findOne({
where: { id, revokedAt: IsNull() },
relations: { user: { roles: { permissions: true }, settings: true } },
});
}
listForUser(userId: string): Promise<SessionEntity[]> {
return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
save(session: SessionEntity): Promise<SessionEntity> {
return this.repo.save(session);
}
async revoke(sessionId: string): Promise<void> {
await this.repo.update({ id: sessionId }, { revokedAt: new Date() });
}
async revokeAllForUser(
userId: string,
exceptSessionId?: string,
): Promise<void> {
const sessions = await this.repo.find({
where: { userId, revokedAt: IsNull() },
});
const now = new Date();
await this.repo.save(
sessions
.filter((session) => session.id !== exceptSessionId)
.map((session) => ({ ...session, revokedAt: now })),
);
}
async cleanupExpired(now = new Date()): Promise<void> {
await this.repo.delete([
{ expiresAt: LessThan(now) },
{ absoluteExpiresAt: LessThan(now) },
]);
}
}

View File

@@ -0,0 +1,54 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
} from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { AppConfigService } from '../config/config.service';
@Injectable()
export class SessionCryptoService {
private readonly encryptionKey: Buffer;
private readonly hmacKey: Buffer;
constructor(config: AppConfigService) {
this.encryptionKey = createHash('sha256')
.update(config.session.encryptionKey)
.digest();
this.hmacKey = createHash('sha256').update(config.session.secret).digest();
}
encrypt(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.encryptionKey, iv);
const encrypted = Buffer.concat([
cipher.update(value, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
}
decrypt(value: string): string {
const [iv, tag, encrypted] = value.split('.');
if (!iv || !tag || !encrypted) {
throw new Error('Invalid encrypted payload');
}
const decipher = createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
Buffer.from(iv, 'base64url'),
);
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'base64url')),
decipher.final(),
]).toString('utf8');
}
hashToken(token: string): string {
return createHmac('sha256', this.hmacKey).update(token).digest('hex');
}
}

View File

@@ -0,0 +1,61 @@
import { Controller, Delete, Get, Param, Req } from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions';
import { SessionsService } from './sessions.service';
@Controller('sessions')
export class SessionsController {
constructor(private readonly sessions: SessionsService) {}
@Get('own')
@RequirePermissions(Permission.SessionsReadOwn)
listOwn(@Req() req: AuthenticatedRequest) {
const user = this.requireUser(req);
return this.sessions.listOwn(user.id, user.sessionId);
}
@Delete('own/:id')
@RequirePermissions(Permission.SessionsRevokeOwn)
@SensitiveRateLimit()
revokeOwn(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const user = this.requireUser(req);
if (user.sessionId === id) {
throw new ApiError(
ErrorCode.Conflict,
'Die aktuelle Session bitte per Logout beenden.',
409,
);
}
return this.sessions.revoke(id);
}
@Delete('own')
@RequirePermissions(Permission.SessionsRevokeOwn)
@SensitiveRateLimit()
revokeOthers(@Req() req: AuthenticatedRequest) {
const user = this.requireUser(req);
return this.sessions.revokeAllForUser(user.id, user.sessionId);
}
@Delete('users/:userId')
@RequirePermissions(Permission.SessionsManage)
@SensitiveRateLimit()
revokeAllForUser(@Param('userId') userId: string) {
return this.sessions.revokeAllForUser(userId);
}
private requireUser(req: AuthenticatedRequest) {
if (!req.user) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
return req.user;
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../users/entities/user.entity';
import { SessionEntity } from './entities/session.entity';
import { SessionsController } from './sessions.controller';
import { SessionsRepository } from './repositories/sessions.repository';
import { SessionCryptoService } from './session-crypto.service';
import { SessionsService } from './sessions.service';
@Module({
imports: [TypeOrmModule.forFeature([SessionEntity, UserEntity])],
controllers: [SessionsController],
providers: [SessionsRepository, SessionCryptoService, SessionsService],
exports: [SessionsService, SessionsRepository, SessionCryptoService],
})
export class SessionsModule {}

View File

@@ -0,0 +1,154 @@
import { randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { AppConfigService } from '../config/config.service';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { Permission } from '../roles/permissions';
import { UserEntity } from '../users/entities/user.entity';
import { SessionEntity } from './entities/session.entity';
import { SessionsRepository } from './repositories/sessions.repository';
import { SessionCryptoService } from './session-crypto.service';
import type { SessionListItemDto } from './dto/session.dto';
export interface ResolvedSession {
user: UserEntity;
permissions: Permission[];
}
@Injectable()
export class SessionsService {
constructor(
private readonly sessions: SessionsRepository,
private readonly crypto: SessionCryptoService,
private readonly config: AppConfigService,
) {}
async createSession(
user: UserEntity,
tokens: {
accessToken: string;
refreshToken?: string;
idToken?: string;
accessTokenExpiresAt: Date;
},
userAgent: string | undefined,
ip: string | undefined,
): Promise<{ session: SessionEntity; csrfToken: string }> {
await this.sessions.cleanupExpired();
const now = new Date();
const csrfToken = randomBytes(32).toString('base64url');
const session = new SessionEntity();
session.id = randomBytes(32).toString('hex');
session.user = user;
session.userId = user.id;
session.accessTokenEncrypted = this.crypto.encrypt(tokens.accessToken);
session.refreshTokenEncrypted = tokens.refreshToken
? this.crypto.encrypt(tokens.refreshToken)
: null;
session.idTokenEncrypted = tokens.idToken
? this.crypto.encrypt(tokens.idToken)
: null;
session.csrfTokenHash = this.crypto.hashToken(csrfToken);
session.accessTokenExpiresAt = tokens.accessTokenExpiresAt;
session.expiresAt = new Date(
now.getTime() + this.config.session.idleTimeoutSeconds * 1000,
);
session.absoluteExpiresAt = new Date(
now.getTime() + this.config.session.absoluteTimeoutSeconds * 1000,
);
session.lastActivityAt = now;
session.userAgent = userAgent ?? null;
session.lastIp = ip ?? null;
session.revokedAt = null;
return { session: await this.sessions.save(session), csrfToken };
}
async resolveSession(
sessionId: string,
ip: string | undefined,
userAgent: string | undefined,
): Promise<ResolvedSession> {
const session = await this.sessions.findActiveById(sessionId);
const now = new Date();
if (
!session ||
session.expiresAt <= now ||
session.absoluteExpiresAt <= now ||
session.user.active === false
) {
if (session) {
await this.sessions.revoke(session.id);
}
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich erneut an.',
401,
);
}
session.expiresAt = new Date(
now.getTime() + this.config.session.idleTimeoutSeconds * 1000,
);
session.lastActivityAt = now;
session.lastIp = ip ?? session.lastIp;
session.userAgent = userAgent ?? session.userAgent;
await this.sessions.save(session);
return {
user: session.user,
permissions: this.permissionsFor(session.user),
};
}
async verifyCsrfToken(sessionId: string, token: string): Promise<boolean> {
const session = await this.sessions.findActiveById(sessionId);
if (!session) {
return false;
}
return session.csrfTokenHash === this.crypto.hashToken(token);
}
async listOwn(
userId: string,
currentSessionId: string,
): Promise<SessionListItemDto[]> {
const sessions = await this.sessions.listForUser(userId);
return sessions.map((session) => ({
id: session.id,
createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(),
userAgent: session.userAgent,
approximateIp: this.maskIp(session.lastIp),
current: session.id === currentSessionId,
revokedAt: session.revokedAt?.toISOString() ?? null,
}));
}
revoke(sessionId: string): Promise<void> {
return this.sessions.revoke(sessionId);
}
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
return this.sessions.revokeAllForUser(userId, exceptSessionId);
}
private permissionsFor(user: UserEntity): Permission[] {
return Array.from(
new Set(
user.roles.flatMap((role) =>
role.permissions.map((permission) => permission.id),
),
),
);
}
private maskIp(ip: string | null): string | null {
if (!ip) {
return null;
}
if (ip.includes(':')) {
return `${ip.split(':').slice(0, 3).join(':')}:...`;
}
return ip.replace(/\.\d+$/, '.0');
}
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { SessionCryptoService } from '../session-crypto.service';
import type { AppConfigService } from '../../config/config.service';
describe('SessionCryptoService', () => {
const config = {
session: {
encryptionKey: '12345678901234567890123456789012',
secret: 'abcdefghijklmnopqrstuvwxyz123456',
},
} as AppConfigService;
it('encrypts tokens without storing plaintext', () => {
const crypto = new SessionCryptoService(config);
const encrypted = crypto.encrypt('access-token');
expect(encrypted).not.toContain('access-token');
expect(crypto.decrypt(encrypted)).toBe('access-token');
});
it('creates stable CSRF token hashes', () => {
const crypto = new SessionCryptoService(config);
expect(crypto.hashToken('csrf')).toBe(crypto.hashToken('csrf'));
expect(crypto.hashToken('csrf')).not.toBe(crypto.hashToken('other'));
});
});

View File

@@ -0,0 +1,20 @@
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateUserRolesDto {
@IsArray()
@IsString({ each: true })
roleIds!: string[];
}
export class UpdateUserActiveDto {
@IsBoolean()
active!: boolean;
}
export class UpdateSettingsDto {
@IsOptional()
tablePageSize?: number;
@IsOptional()
sidebarExpanded?: boolean;
}

View File

@@ -0,0 +1,24 @@
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('user_settings')
export class UserSettingsEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@OneToOne(() => UserEntity, (user) => user.settings, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: UserEntity;
@Column({ name: 'table_page_size', type: 'int', default: 20 })
tablePageSize!: number;
@Column({ name: 'sidebar_expanded', type: 'boolean', default: true })
sidebarExpanded!: boolean;
}

View File

@@ -0,0 +1,70 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { RoleEntity } from '../../roles/entities/role.entity';
import { SessionEntity } from '../../sessions/entities/session.entity';
import { UserSettingsEntity } from './user-settings.entity';
@Entity('users')
@Unique('uq_users_issuer_subject', ['issuer', 'subject'])
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('idx_users_issuer')
@Column({ type: 'varchar', length: 255 })
issuer!: string;
@Column({ type: 'varchar', length: 255 })
subject!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'boolean', default: true })
active!: boolean;
@Column({
name: 'last_login_at',
type: 'datetime',
precision: 3,
nullable: true,
})
lastLoginAt!: Date | null;
@ManyToMany(() => RoleEntity, (role) => role.users, { eager: true })
@JoinTable({
name: 'user_roles',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles!: RoleEntity[];
@OneToMany(() => SessionEntity, (session) => session.user)
sessions!: SessionEntity[];
@OneToOne(() => UserSettingsEntity, (settings) => settings.user, {
cascade: true,
eager: true,
})
settings!: UserSettingsEntity;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity } from '../entities/user.entity';
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
) {}
findById(id: string): Promise<UserEntity | null> {
return this.repo.findOne({ where: { id } });
}
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
return this.repo.findOne({ where: { issuer, subject } });
}
async search(
query: string | undefined,
page: number,
pageSize: number,
): Promise<[UserEntity[], number]> {
const qb = this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role');
if (query) {
qb.where('user.name LIKE :query OR user.email LIKE :query', {
query: `%${query}%`,
});
}
return qb
.orderBy('user.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
}
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes';
import { UsersService } from '../users.service';
import type { AuditService } from '../../audit/audit.service';
import type { RolesService } from '../../roles/roles.service';
import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../repositories/users.repository';
import type { DataSource } from 'typeorm';
describe('UsersService', () => {
it('blocks changes that would remove the last active admin', async () => {
const dataSource = {
getRepository: () => ({
createQueryBuilder: () => ({
innerJoin: () => ({
where: () => ({
andWhere: () => ({
andWhere: () => ({
getCount: () => Promise.resolve(0),
}),
}),
}),
}),
}),
}),
} as unknown as DataSource;
const service = new UsersService(
{} as UsersRepository,
{} as RolesService,
{} as SessionsService,
{} as AuditService,
dataSource,
);
await expect(
service.assertAnotherActiveAdminRemains('user-1'),
).rejects.toMatchObject({
code: ErrorCode.LastAdminRequired,
});
});
});

View File

@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions';
import {
UpdateSettingsDto,
UpdateUserActiveDto,
UpdateUserRolesDto,
} from './dto/user.dto';
import { UsersService } from './users.service';
@Controller()
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get('me')
@RequirePermissions(Permission.SessionsReadOwn)
me(@Req() req: AuthenticatedRequest) {
return this.users.get(req.user?.id ?? '');
}
@Patch('me/settings')
@RequirePermissions(Permission.SessionsReadOwn)
updateSettings(
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateSettingsDto,
) {
return this.users.updateSettings(req.user?.id ?? '', dto);
}
@Get('users')
@RequirePermissions(Permission.UsersRead)
list(
@Query('search') search?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20));
}
@Patch('users/:id/active')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
setActive(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateUserActiveDto,
) {
return this.users.setActive(this.requireUser(req), id, dto.active);
}
@Patch('users/:id/roles')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
setRoles(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateUserRolesDto,
) {
return this.users.setRoles(this.requireUser(req), id, dto.roleIds);
}
private requireUser(req: AuthenticatedRequest) {
if (!req.user) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
return req.user;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
RolesModule,
AuditModule,
SessionsModule,
],
controllers: [UsersController],
providers: [UsersRepository, UsersService],
exports: [UsersRepository, UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
import type { AuthenticatedUser } from '../auth/authenticated-request';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { RolesService } from '../roles/roles.service';
import { SessionsService } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly users: UsersRepository,
private readonly roles: RolesService,
private readonly sessions: SessionsService,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async list(search: string | undefined, page = 1, pageSize = 20) {
const [items, total] = await this.users.search(search, page, pageSize);
return { items, total, page, pageSize };
}
async get(id: string): Promise<UserEntity> {
const user = await this.users.findById(id);
if (!user) {
throw new ApiError(
ErrorCode.NotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
}
return user;
}
async setActive(
actor: AuthenticatedUser,
userId: string,
active: boolean,
): Promise<UserEntity> {
const user = await this.get(userId);
if (!active) {
await this.assertAnotherActiveAdminRemains(userId);
}
user.active = active;
const saved = await this.users.save(user);
if (!active) {
await this.sessions.revokeAllForUser(userId);
}
await this.audit.record(
actor.id,
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
'user',
userId,
);
return saved;
}
async setRoles(
actor: AuthenticatedUser,
userId: string,
roleIds: string[],
): Promise<UserEntity> {
const user = await this.get(userId);
const previousAdmin = this.hasRole(user, 'admin');
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id)),
);
user.roles = roles;
if (previousAdmin && !this.hasRole(user, 'admin')) {
await this.assertAnotherActiveAdminRemains(userId);
}
const saved = await this.users.save(user);
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
{
roleIds: roleIds.join(','),
},
);
return saved;
}
async updateSettings(
userId: string,
settings: Partial<
Pick<UserSettingsEntity, 'tablePageSize' | 'sidebarExpanded'>
>,
): Promise<UserEntity> {
const user = await this.get(userId);
user.settings.tablePageSize =
settings.tablePageSize ?? user.settings.tablePageSize;
user.settings.sidebarExpanded =
settings.sidebarExpanded ?? user.settings.sidebarExpanded;
return this.users.save(user);
}
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> {
const result = await this.dataSource
.getRepository(UserEntity)
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('user.id <> :excludedUserId', { excludedUserId })
.andWhere('role.name = :role', { role: 'admin' })
.getCount();
if (result < 1) {
throw new ApiError(
ErrorCode.LastAdminRequired,
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.',
409,
);
}
}
private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName);
}
}

View File

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

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2023",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node", "vitest"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"isolatedModules": false,
"strictPropertyInitialization": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
globals: true,
coverage: {
reporter: ['text', 'html'],
},
},
});

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
apps/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
apps/frontend/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
apps/frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
apps/frontend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
apps/frontend/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

35
apps/frontend/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Frontend Workspace
Angular Frontend fuer das Boilerplate. Der Workspace wird normalerweise ueber die
Root-Scripts gesteuert.
## Befehle
```bash
npm --workspace apps/frontend run start
npm --workspace apps/frontend run build
npm --workspace apps/frontend run typecheck
npm --workspace apps/frontend run test
```
Im lokalen Start laeuft Angular auf `http://localhost:4200` und nutzt
`proxy.conf.json`, um `/api` an das Backend auf `http://localhost:3000` zu
proxyn.
## Struktur
- `src/app/layout/app-shell.ts`: Hauptlayout, Navigation und Login-Zustand
- `src/app/app.routes.ts`: Routen und Permission-Daten
- `src/app/core/auth.service.ts`: aktueller Benutzer und Permissions
- `src/app/core/csrf.interceptor.ts`: CSRF-Header fuer schreibende Requests
- `src/app/core/permission.guard.ts`: UI-seitige Routensperre
- `src/app/features`: fachliche Seiten
## Entwicklungsregeln
Das Frontend nutzt keine UI-Library. Komponenten bleiben mobile-first und
verwenden eigenes HTML und SCSS. Permissions im Frontend dienen nur Darstellung
und Navigation; die verbindliche Autorisierung findet im Backend statt.
API-Zugriffe laufen ueber `@boilerplate/api-client`. Der Client ist generiert
und wird aus dem Root mit `npm run api:generate` aktualisiert.

View File

@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.scss"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More