Compare commits

6 Commits

Author SHA1 Message Date
Bastian Wagner
90d1561bbc logging 2026-07-17 15:54:26 +02:00
Bastian Wagner
5973658582 logging 2026-07-17 15:34:42 +02:00
Bastian Wagner
e89ac1481e fix 2026-07-17 14:52:20 +02:00
Bastian Wagner
d30d9cfdde angular 2026-07-17 14:34:44 +02:00
357642ad22 Merge pull request 'features' (#1) from Branch_master into master
Reviewed-on: https://gitea.forgecore.work/bastian/ldap-identidy/pulls/1
2026-07-17 14:07:48 +02:00
28d0cdf019 Update README.md 2026-07-17 13:23:29 +02:00
37 changed files with 1747 additions and 164 deletions

View File

@@ -5,6 +5,19 @@ WEB_PORT=4200
PUBLIC_WEB_URL=http://localhost:4200 PUBLIC_WEB_URL=http://localhost:4200
API_BASE_URL=/api API_BASE_URL=/api
APP_PRODUCT_NAME=LDAP Portal
APP_COMPANY_NAME=LDAP Portal
APP_PRIMARY_COLOR=#0f6b6e
APP_SUPPORT_EMAIL=support@example.com
APP_LOGO_URL=
APP_IMPRINT_URL=
APP_PRIVACY_URL=
APPLICATION_LOG_RETENTION_ENABLED=true
APPLICATION_LOG_RETENTION_DAYS=180
APPLICATION_LOG_CLEANUP_BATCH_SIZE=1000
APPLICATION_LOG_CLEANUP_MAX_BATCHES=20
DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal
DATABASE_SSL=false DATABASE_SSL=false
JWT_SECRET=change-me-long-random-jwt-secret JWT_SECRET=change-me-long-random-jwt-secret

View File

@@ -4,6 +4,7 @@ Self-Service-Portal fuer LLDAP mit NestJS API, Angular Frontend und eigenem Open
Die Anwendung ist als Identity-Portal vor einem bestehenden LLDAP gedacht: Nutzer koennen sich registrieren, E-Mail und Passwort verwalten, Administratoren koennen Registrierungen, Nutzer, Gruppen und OIDC-Clients pflegen. Die Anwendung ist als Identity-Portal vor einem bestehenden LLDAP gedacht: Nutzer koennen sich registrieren, E-Mail und Passwort verwalten, Administratoren koennen Registrierungen, Nutzer, Gruppen und OIDC-Clients pflegen.
## Funktionen ## Funktionen
- Registrierung mit E-Mail-Verifikation und anschliessender Admin-Freigabe - Registrierung mit E-Mail-Verifikation und anschliessender Admin-Freigabe
@@ -129,6 +130,13 @@ Die wichtigsten Variablen aus `.env.example`:
| `API_PORT` | Interner Port der NestJS API, im Container standardmaessig `3000`. | | `API_PORT` | Interner Port der NestJS API, im Container standardmaessig `3000`. |
| `PUBLIC_WEB_URL` | Externe Web-URL fuer CORS, Mail-Links und Registrierung aus dem SSO-Login. | | `PUBLIC_WEB_URL` | Externe Web-URL fuer CORS, Mail-Links und Registrierung aus dem SSO-Login. |
| `API_BASE_URL` | API-Basis aus Sicht des Browsers, im Single Container `/api`. | | `API_BASE_URL` | API-Basis aus Sicht des Browsers, im Single Container `/api`. |
| `APP_PRODUCT_NAME`, `APP_COMPANY_NAME` | Zentrale Branding-Namen fuer OIDC-Seiten und Mail-Templates. |
| `APP_PRIMARY_COLOR` | Zentrale Primaerfarbe als sechsstelliger Hex-Wert. |
| `APP_SUPPORT_EMAIL`, `APP_LOGO_URL`, `APP_IMPRINT_URL`, `APP_PRIVACY_URL` | Zentrale Branding-Werte; Bild- und Link-URLs muessen HTTPS verwenden. |
| `APPLICATION_LOG_RETENTION_ENABLED` | Aktiviert den taeglichen Cleanup von Application Info- und Error-Logs; Standard `true`. |
| `APPLICATION_LOG_RETENTION_DAYS` | Aufbewahrungsdauer beider Logtabellen in Tagen; Standard `180`. |
| `APPLICATION_LOG_CLEANUP_BATCH_SIZE` | Datensaetze pro Loeschbatch; Standard `1000`. |
| `APPLICATION_LOG_CLEANUP_MAX_BATCHES` | Maximale Batches pro Tabelle und Lauf; Standard `20`. |
| `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. | | `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. |
| `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. | | `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. |
| `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. | | `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. |
@@ -155,6 +163,8 @@ Die wichtigsten Variablen aus `.env.example`:
Hinweis: `REGISTRATION_MANAGER_GROUP`, `GROUP_MANAGER_GROUP` und `AUDIT_VIEWER_GROUP` stehen aktuell in `.env.example`, werden im Code aber nicht ausgewertet. Die Admin-Gruppennamen sind derzeit fest verdrahtet, siehe "Admin-Rollen". Hinweis: `REGISTRATION_MANAGER_GROUP`, `GROUP_MANAGER_GROUP` und `AUDIT_VIEWER_GROUP` stehen aktuell in `.env.example`, werden im Code aber nicht ausgewertet. Die Admin-Gruppennamen sind derzeit fest verdrahtet, siehe "Admin-Rollen".
Die `APP_*`-Werte haben Vorrang. Solange sie nicht gesetzt sind, verwendet die Anwendung fuer die Rueckwaertskompatibilitaet die entsprechenden `MAIL_*`-Werte. Die OIDC-Seiten liegen unter `apps/api/src/oidc/templates` und werden beim API-Build nach `dist/oidc/templates` kopiert. Sie werden ohne clientseitiges JavaScript direkt durch NestJS gerendert.
## Datenbank ## Datenbank
Die App nutzt TypeORM mit MySQL. In `NODE_ENV=production` ist `synchronize` deaktiviert. Fuer produktive Deployments muss das Schema vorab vorhanden sein oder es muessen Migrationen ergaenzt und ausgefuehrt werden. Die App nutzt TypeORM mit MySQL. In `NODE_ENV=production` ist `synchronize` deaktiviert. Fuer produktive Deployments muss das Schema vorab vorhanden sein oder es muessen Migrationen ergaenzt und ausgefuehrt werden.
@@ -239,10 +249,21 @@ Der LLDAP Admin-User muss ausreichende Rechte fuer diese Operationen haben.
Falls sich GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden. Falls sich GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden.
## Application Info Log
Fachliche, sicherheitsrelevante und technische Aktivitaeten werden dauerhaft in `application_info_logs` gespeichert. Das bestehende `audit_events` bleibt unveraendert bestehen; jeder Aufruf von `AuditService.record(...)` erzeugt zusaetzlich einen strukturierten Info-Logeintrag. Ein globaler Interceptor erfasst abgeschlossene und fehlgeschlagene NestJS-HTTP-Requests ohne Request-Body und ohne Query-Parameter.
Wichtige Felder sind `action`, `category`, `outcome`, `actorType`, `actorId`, `userId`, `clientId`, `correlationId`, HTTP-Metadaten und bereinigter JSON-Kontext. Passwoerter, Tokens, Secrets, Cookies und Authorization-Daten werden entfernt; E-Mail-Adressen werden maskiert. Fehler beim Schreiben des Info-Logs werden auf dem Standardlogger ausgegeben und beeinflussen die protokollierte Aktion nicht.
Erfasst werden unter anderem Portal-Login und -Logout, fehlgeschlagene Logins, OIDC-Benutzerlogin, erfolgreiche Client-Autorisierung, Token-Grants, OIDC-Logout, Consent, Konto-, Passwort-, Registrierungs- und administrative Aenderungen. Die Migration `1721300000000-CreateApplicationInfoLogs.ts` muss in Produktion vor dem Deployment angewendet werden.
Der NestJS-Scheduler entfernt taeglich um 03:15 Serverzeit Info- und Error-Logs, die aelter als `APPLICATION_LOG_RETENTION_DAYS` sind. Die Loeschung erfolgt in begrenzten Batches und wird ueber einen MySQL Advisory Lock zwischen mehreren Instanzen koordiniert. Ein erfolgreicher Lauf erzeugt `maintenance.application_log_cleanup_completed`; Fehler werden nur im Standardlogger ausgegeben und stoppen die Anwendung nicht.
## Produktions-Checkliste ## Produktions-Checkliste
- Externe MySQL-Datenbank angelegt und erreichbar. - Externe MySQL-Datenbank angelegt und erreichbar.
- Datenbankschema/Migrationen fuer Produktion geklaert. - Datenbankschema/Migrationen fuer Produktion geklaert.
- Migration fuer `application_info_logs` angewendet.
- LLDAP Web/GraphQL und LDAP vom Container aus erreichbar. - LLDAP Web/GraphQL und LDAP vom Container aus erreichbar.
- SMTP erreichbar und Absender korrekt gesetzt. - SMTP erreichbar und Absender korrekt gesetzt.
- `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt. - `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt.

View File

@@ -8,6 +8,11 @@
"include": "mail/templates/**/*", "include": "mail/templates/**/*",
"outDir": "dist", "outDir": "dist",
"watchAssets": true "watchAssets": true
},
{
"include": "oidc/templates/**/*",
"outDir": "dist",
"watchAssets": true
} }
] ]
} }

View File

@@ -17,6 +17,7 @@
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

View File

@@ -1,9 +1,6 @@
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs'); const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
const { join } = require('node:path'); const { join } = require('node:path');
const source = join(__dirname, '..', 'src', 'mail', 'templates');
const target = join(__dirname, '..', 'dist', 'mail', 'templates');
function copyDirectory(from, to) { function copyDirectory(from, to) {
if (!existsSync(from)) { if (!existsSync(from)) {
return; return;
@@ -21,4 +18,9 @@ function copyDirectory(from, to) {
} }
} }
copyDirectory(source, target); for (const feature of ['mail', 'oidc']) {
copyDirectory(
join(__dirname, '..', 'src', feature, 'templates'),
join(__dirname, '..', 'dist', feature, 'templates'),
);
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
@@ -11,6 +12,7 @@ import { PasswordModule } from './password/password.module';
import { RegistrationModule } from './registration/registration.module'; import { RegistrationModule } from './registration/registration.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module'; import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module';
import { ApplicationInfoLogModule } from './application-info-log/application-info-log.module';
@Module({ @Module({
imports: [ imports: [
@@ -18,6 +20,7 @@ import { ApplicationErrorLogModule } from './application-error-log/application-e
isGlobal: true, isGlobal: true,
envFilePath: ['.env', '../../.env'], envFilePath: ['.env', '../../.env'],
}), }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([ ThrottlerModule.forRoot([
{ {
ttl: 60_000, ttl: 60_000,
@@ -54,6 +57,7 @@ import { ApplicationErrorLogModule } from './application-error-log/application-e
}, },
}), }),
ApplicationErrorLogModule, ApplicationErrorLogModule,
ApplicationInfoLogModule,
AuditModule, AuditModule,
AdminModule, AdminModule,
MailModule, MailModule,

View File

@@ -0,0 +1,84 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'application_info_logs' })
@Index(['createdAt'])
@Index(['action'])
@Index(['category'])
@Index(['outcome'])
@Index(['correlationId'])
@Index(['userId'])
@Index(['clientId'])
@Index(['tenantId'])
export class ApplicationInfoLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ default: 'info' })
level!: string;
@Column()
action!: string;
@Column({ nullable: true })
category?: string;
@Column({ default: 'SUCCESS' })
outcome!: string;
@Column({ type: 'text', nullable: true })
message?: string;
@Column({ nullable: true })
actorType?: string;
@Column({ nullable: true })
actorId?: string;
@Column({ nullable: true })
userId?: string;
@Column({ nullable: true })
clientId?: string;
@Column({ nullable: true })
tenantId?: string;
@Column({ nullable: true })
backendModule?: string;
@Column({ nullable: true })
service?: string;
@Column({ nullable: true })
operation?: string;
@Column({ nullable: true })
httpMethod?: string;
@Column({ nullable: true })
apiPath?: string;
@Column({ type: 'int', nullable: true })
httpStatusCode?: number;
@Column({ nullable: true })
correlationId?: string;
@Column({ nullable: true })
ipAddress?: string;
@Column({ type: 'text', nullable: true })
userAgent?: string;
@Column({ nullable: true })
environment?: string;
@Column({ nullable: true })
host?: string;
@Column({ type: 'simple-json', nullable: true })
context?: Record<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationInfoLog } from './application-info-log.entity';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
import { ApplicationInfoInterceptor } from './application-info.interceptor';
import { ApplicationLogRetentionService } from './application-log-retention.service';
@Global()
@Module({
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationInfoLog])],
providers: [
ApplicationInfoLoggerService,
ApplicationLogRetentionService,
{ provide: APP_INTERCEPTOR, useClass: ApplicationInfoInterceptor },
],
exports: [ApplicationInfoLoggerService],
})
export class ApplicationInfoLogModule {}

View File

@@ -0,0 +1,26 @@
export type ApplicationInfoActorType = 'USER' | 'CLIENT' | 'SYSTEM';
export type ApplicationInfoOutcome = 'SUCCESS' | 'FAILURE' | 'STARTED' | 'SKIPPED';
export interface ApplicationInfoLogInput {
action: string;
category?: string;
outcome?: ApplicationInfoOutcome;
message?: string;
actorType?: ApplicationInfoActorType;
actorId?: string;
userId?: string;
clientId?: string;
tenantId?: string;
backendModule?: string;
service?: string;
operation?: string;
requestContext?: {
correlationId?: string;
method?: string;
path?: string;
statusCode?: number;
ipAddress?: string;
userAgent?: string;
};
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,80 @@
import { Logger } from '@nestjs/common';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
describe('ApplicationInfoLoggerService', () => {
function createService(save = jest.fn().mockResolvedValue(undefined)) {
const repository = {
create: jest.fn((value) => value),
save,
};
const config = { get: jest.fn().mockReturnValue('test') };
const requestContext = {
get: jest.fn().mockReturnValue({
correlationId: 'correlation-id',
method: 'POST',
path: '/auth/login',
tenantId: 'tenant-id',
}),
};
return {
service: new ApplicationInfoLoggerService(repository as any, config as any, requestContext as any),
repository,
};
}
it('stores a structured action with request and actor context', async () => {
const { service, repository } = createService();
await service.log({
action: 'auth.login_success',
category: 'AUTH',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
requestContext: { ipAddress: '192.0.2.10', userAgent: 'Test Browser' },
});
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
action: 'auth.login_success',
category: 'AUTH',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
correlationId: 'correlation-id',
tenantId: 'tenant-id',
httpMethod: 'POST',
apiPath: '/auth/login',
ipAddress: '192.0.2.10',
environment: 'test',
}));
});
it('removes sensitive nested context and masks email addresses', async () => {
const { service, repository } = createService();
await service.log({
action: 'registration.started',
actorType: 'USER',
actorId: 'maria@example.com',
context: {
email: 'maria@example.com',
nested: { password: 'do-not-store', clientSecret: 'do-not-store', value: 'allowed' },
},
});
const stored = repository.save.mock.calls[0][0];
expect(stored.actorId).toBe('m***@example.com');
expect(stored.context.email).toBe('m***@example.com');
expect(stored.context.nested).toEqual({ value: 'allowed' });
});
it('does not throw when persistence fails', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation();
const { service } = createService(jest.fn().mockRejectedValue(new Error('database unavailable')));
await expect(service.log({ action: 'auth.login_success' })).resolves.toBeUndefined();
expect(Logger.prototype.error).toHaveBeenCalled();
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { hostname } from 'node:os';
import { Repository } from 'typeorm';
import { sanitizeContext, sanitizeString } from '../application-error-log/application-error-sanitizer';
import { RequestContextService } from '../common/request-context.service';
import { ApplicationInfoLog } from './application-info-log.entity';
import { ApplicationInfoLogInput } from './application-info-log.types';
@Injectable()
export class ApplicationInfoLoggerService {
private readonly fallbackLogger = new Logger(ApplicationInfoLoggerService.name);
constructor(
@InjectRepository(ApplicationInfoLog)
private readonly logs: Repository<ApplicationInfoLog>,
private readonly config: ConfigService,
private readonly requestContext: RequestContextService,
) {}
async log(input: ApplicationInfoLogInput): Promise<void> {
const activeRequest = this.requestContext.get();
const request = { ...activeRequest, ...input.requestContext };
try {
await this.logs.save(
this.logs.create({
level: 'info',
action: sanitizeString(input.action),
category: optionalString(input.category),
outcome: input.outcome ?? 'SUCCESS',
message: optionalString(input.message),
actorType: input.actorType,
actorId: optionalString(input.actorId),
userId: optionalString(input.userId ?? request.userId),
clientId: optionalString(input.clientId),
tenantId: optionalString(input.tenantId ?? request.tenantId),
backendModule: optionalString(input.backendModule),
service: optionalString(input.service),
operation: optionalString(input.operation),
httpMethod: optionalString(request.method),
apiPath: optionalString(withoutQuery(request.path)),
httpStatusCode: input.requestContext?.statusCode,
correlationId: optionalString(request.correlationId),
ipAddress: optionalString(input.requestContext?.ipAddress),
userAgent: optionalString(input.requestContext?.userAgent),
environment: this.config.get<string>('NODE_ENV') ?? 'development',
host: hostname(),
context: sanitizeContext(input.context),
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.fallbackLogger.error(
`Application info log write failed for ${sanitizeString(input.action)}: ${sanitizeString(message)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
function optionalString(value?: string): string | undefined {
return value ? sanitizeString(value) : undefined;
}
function withoutQuery(value?: string): string | undefined {
return value?.split('?', 1)[0];
}

View File

@@ -0,0 +1,61 @@
import { BadRequestException, CallHandler, ExecutionContext } from '@nestjs/common';
import { lastValueFrom, of, throwError } from 'rxjs';
import { ApplicationInfoInterceptor } from './application-info.interceptor';
describe('ApplicationInfoInterceptor', () => {
function createContext(path = '/admin/users') {
const request = {
method: 'GET',
path,
originalUrl: `${path}?token=do-not-store`,
route: { path: '/admin/users' },
ip: '192.0.2.10',
headers: { 'user-agent': 'Test Browser', authorization: 'Bearer secret' },
user: { sub: 'user-id', username: 'maria' },
body: { password: 'do-not-store' },
};
const response = { statusCode: 200, getHeader: jest.fn().mockReturnValue('application/json') };
const context = {
getType: jest.fn().mockReturnValue('http'),
switchToHttp: jest.fn().mockReturnValue({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ExecutionContext;
return { context, request, response };
}
it('logs a successful request without body, query or authorization data', async () => {
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
const { context } = createContext();
const next = { handle: () => of({ ok: true }) } as CallHandler;
await lastValueFrom(interceptor.intercept(context, next));
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'http.request_completed',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
requestContext: expect.objectContaining({ path: '/admin/users', statusCode: 200 }),
}));
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('do-not-store');
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('Bearer');
});
it('logs a failed request with its controlled HTTP status', async () => {
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
const { context } = createContext('/admin/users/invalid');
const next = { handle: () => throwError(() => new BadRequestException('invalid')) } as CallHandler;
await expect(lastValueFrom(interceptor.intercept(context, next))).rejects.toBeInstanceOf(BadRequestException);
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'http.request_failed',
outcome: 'FAILURE',
requestContext: expect.objectContaining({ statusCode: 400 }),
}));
});
});

View File

@@ -0,0 +1,66 @@
import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
import { RequestUser } from '../common/request-user';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
@Injectable()
export class ApplicationInfoInterceptor implements NestInterceptor {
constructor(private readonly infoLogger: ApplicationInfoLoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
const response = context.switchToHttp().getResponse<Response>();
return next.handle().pipe(
tap({
next: () => this.logRequest(request, response, 'SUCCESS', response.statusCode),
error: (error: unknown) =>
this.logRequest(
request,
response,
'FAILURE',
error instanceof HttpException
? error.getStatus()
: response.statusCode >= 400
? response.statusCode
: 500,
),
}),
);
}
private logRequest(
request: Request & { user?: RequestUser },
response: Response,
outcome: 'SUCCESS' | 'FAILURE',
statusCode: number,
): void {
void this.infoLogger.log({
action: outcome === 'SUCCESS' ? 'http.request_completed' : 'http.request_failed',
category: 'HTTP',
outcome,
actorType: request.user ? 'USER' : 'SYSTEM',
actorId: request.user?.username,
userId: request.user?.sub ?? request.user?.username,
backendModule: 'HttpModule',
service: ApplicationInfoInterceptor.name,
operation: `${request.method} ${request.route?.path ?? request.path}`,
requestContext: {
method: request.method,
path: request.path,
statusCode,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
},
context: {
route: request.route?.path,
contentType: response.getHeader('content-type'),
},
});
}
}

View File

@@ -0,0 +1,87 @@
import { Logger } from '@nestjs/common';
import { ApplicationLogRetentionService } from './application-log-retention.service';
describe('ApplicationLogRetentionService', () => {
function createService(options: { lock?: boolean; queryError?: Error; enabled?: string } = {}) {
let infoBatch = 0;
const query = jest.fn(async (sql: string, _parameters?: unknown[]) => {
if (options.queryError && sql.startsWith('DELETE')) {
throw options.queryError;
}
if (sql.includes('GET_LOCK')) {
return [{ acquired: options.lock === false ? 0 : 1 }];
}
if (sql.startsWith('DELETE FROM application_info_logs')) {
infoBatch += 1;
return { affectedRows: infoBatch === 1 ? 100 : 10 };
}
if (sql.startsWith('DELETE FROM application_error_logs')) {
return { affectedRows: 5 };
}
return [];
});
const queryRunner = {
connect: jest.fn().mockResolvedValue(undefined),
query,
release: jest.fn().mockResolvedValue(undefined),
isReleased: false,
};
const dataSource = { createQueryRunner: jest.fn().mockReturnValue(queryRunner) };
const values: Record<string, string> = {
APPLICATION_LOG_RETENTION_ENABLED: options.enabled ?? 'true',
APPLICATION_LOG_RETENTION_DAYS: '30',
APPLICATION_LOG_CLEANUP_BATCH_SIZE: '100',
APPLICATION_LOG_CLEANUP_MAX_BATCHES: '3',
};
const config = { get: jest.fn((key: string) => values[key]) };
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
const service = new ApplicationLogRetentionService(dataSource as any, config as any, infoLogger as any);
return { service, dataSource, queryRunner, query, infoLogger };
}
it('deletes expired info and error logs in bounded batches', async () => {
const { service, query, queryRunner, infoLogger } = createService();
const before = Date.now() - 30 * 24 * 60 * 60_000;
await service.removeExpiredLogs();
const infoDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_info_logs'));
const errorDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_error_logs'));
expect(infoDeletes).toHaveLength(2);
expect(errorDeletes).toHaveLength(1);
const firstParameters = infoDeletes[0]?.[1];
expect(firstParameters?.[1]).toBe(100);
expect((firstParameters?.[0] as Date).getTime()).toBeGreaterThanOrEqual(before - 1_000);
expect(queryRunner.release).toHaveBeenCalled();
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'maintenance.application_log_cleanup_completed',
context: expect.objectContaining({ infoLogsDeleted: 110, errorLogsDeleted: 5, retentionDays: 30 }),
}));
});
it('skips cleanup when another instance owns the database lock', async () => {
const { service, query, infoLogger } = createService({ lock: false });
await service.removeExpiredLogs();
expect(query.mock.calls.some(([sql]) => sql.startsWith('DELETE'))).toBe(false);
expect(infoLogger.log).not.toHaveBeenCalled();
});
it('can be disabled through configuration', async () => {
const { service, dataSource } = createService({ enabled: 'false' });
await service.removeExpiredLogs();
expect(dataSource.createQueryRunner).not.toHaveBeenCalled();
});
it('does not throw when cleanup persistence fails', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation();
const { service } = createService({ queryError: new Error('database unavailable') });
await expect(service.removeExpiredLogs()).resolves.toBeUndefined();
expect(Logger.prototype.error).toHaveBeenCalled();
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { DataSource, QueryRunner } from 'typeorm';
import { ApplicationInfoLoggerService } from './application-info-logger.service';
interface CleanupResult {
infoLogsDeleted: number;
errorLogsDeleted: number;
}
@Injectable()
export class ApplicationLogRetentionService {
private readonly logger = new Logger(ApplicationLogRetentionService.name);
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly infoLogger: ApplicationInfoLoggerService,
) {}
@Cron('0 15 3 * * *', { name: 'application-log-retention', waitForCompletion: true })
async removeExpiredLogs(): Promise<void> {
if (this.config.get<string>('APPLICATION_LOG_RETENTION_ENABLED') === 'false') {
return;
}
const retentionDays = this.numberConfig('APPLICATION_LOG_RETENTION_DAYS', 180, 1, 3_650);
const batchSize = this.numberConfig('APPLICATION_LOG_CLEANUP_BATCH_SIZE', 1_000, 100, 10_000);
const maxBatches = this.numberConfig('APPLICATION_LOG_CLEANUP_MAX_BATCHES', 20, 1, 1_000);
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60_000);
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
if (!(await this.acquireLock(queryRunner))) {
this.logger.log('Application log cleanup skipped because another instance holds the lock');
return;
}
const result: CleanupResult = {
infoLogsDeleted: await this.deleteBatches(
queryRunner,
'application_info_logs',
cutoff,
batchSize,
maxBatches,
),
errorLogsDeleted: await this.deleteBatches(
queryRunner,
'application_error_logs',
cutoff,
batchSize,
maxBatches,
),
};
this.logger.log(
`Application log cleanup completed: info=${result.infoLogsDeleted}, errors=${result.errorLogsDeleted}, retentionDays=${retentionDays}`,
);
await this.infoLogger.log({
action: 'maintenance.application_log_cleanup_completed',
category: 'MAINTENANCE',
outcome: 'SUCCESS',
actorType: 'SYSTEM',
actorId: 'application-log-retention',
backendModule: 'ApplicationInfoLogModule',
service: ApplicationLogRetentionService.name,
operation: 'removeExpiredLogs',
context: { ...result, retentionDays, cutoff: cutoff.toISOString() },
});
} catch (error) {
this.logger.error(
`Application log cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
} finally {
if (queryRunner.isReleased === false) {
await this.releaseLock(queryRunner);
await queryRunner.release();
}
}
}
private async deleteBatches(
queryRunner: QueryRunner,
tableName: 'application_info_logs' | 'application_error_logs',
cutoff: Date,
batchSize: number,
maxBatches: number,
): Promise<number> {
let deleted = 0;
for (let batch = 0; batch < maxBatches; batch += 1) {
const result = (await queryRunner.query(
`DELETE FROM ${tableName} WHERE createdAt < ? ORDER BY createdAt ASC LIMIT ?`,
[cutoff, batchSize],
)) as { affectedRows?: number };
const affectedRows = Number(result.affectedRows ?? 0);
deleted += affectedRows;
if (affectedRows < batchSize) {
break;
}
}
return deleted;
}
private async acquireLock(queryRunner: QueryRunner): Promise<boolean> {
const rows = (await queryRunner.query(
"SELECT GET_LOCK('ldap_portal_application_log_cleanup', 0) AS acquired",
)) as Array<{ acquired?: number | string }>;
return Number(rows[0]?.acquired) === 1;
}
private async releaseLock(queryRunner: QueryRunner): Promise<void> {
try {
await queryRunner.query("SELECT RELEASE_LOCK('ldap_portal_application_log_cleanup')");
} catch (error) {
this.logger.warn(
`Application log cleanup lock release failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private numberConfig(key: string, fallback: number, minimum: number, maximum: number): number {
const value = Number(this.config.get<string>(key) ?? fallback);
return Number.isInteger(value) && value >= minimum && value <= maximum ? value : fallback;
}
}

View File

@@ -0,0 +1,82 @@
import { AuditService } from './audit.service';
describe('AuditService application info bridge', () => {
function createService() {
const repository = {
create: jest.fn((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
return {
service: new AuditService(repository as any, infoLogger as any),
repository,
infoLogger,
};
}
it('keeps the existing audit event and logs a user login', async () => {
const { service, repository, infoLogger } = createService();
await service.record({
type: 'auth.login_success',
username: 'maria',
ipAddress: '192.0.2.10',
userAgent: 'Test Browser',
});
expect(repository.save).toHaveBeenCalled();
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'auth.login_success',
category: 'AUTH',
outcome: 'SUCCESS',
actorType: 'USER',
actorId: 'maria',
userId: 'maria',
}));
});
it('identifies a successful OIDC client login', async () => {
const { service, infoLogger } = createService();
await service.record({
type: 'oidc.client_login_success',
username: 'user-id',
metadata: { clientId: 'client-id', scope: 'openid profile' },
});
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
action: 'oidc.client_login_success',
category: 'OIDC',
actorType: 'CLIENT',
actorId: 'client-id',
clientId: 'client-id',
userId: 'user-id',
}));
});
it('uses the administrator as actor and the affected user as target', async () => {
const { service, infoLogger } = createService();
await service.record({
type: 'admin.user_updated',
username: 'target-user',
metadata: { admin: 'administrator' },
});
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
actorType: 'USER',
actorId: 'administrator',
userId: 'target-user',
}));
});
it('marks failed and started actions with distinct outcomes', async () => {
const { service, infoLogger } = createService();
await service.record({ type: 'auth.login_failed', username: 'maria' });
await service.record({ type: 'registration.started', username: 'maria' });
expect(infoLogger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({ outcome: 'FAILURE' }));
expect(infoLogger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({ outcome: 'STARTED' }));
});
});

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ApplicationInfoLoggerService } from '../application-info-log/application-info-logger.service';
import { ApplicationInfoActorType, ApplicationInfoOutcome } from '../application-info-log/application-info-log.types';
import { AuditEvent } from './audit-event.entity'; import { AuditEvent } from './audit-event.entity';
export interface AuditInput { export interface AuditInput {
@@ -16,6 +18,7 @@ export class AuditService {
constructor( constructor(
@InjectRepository(AuditEvent) @InjectRepository(AuditEvent)
private readonly events: Repository<AuditEvent>, private readonly events: Repository<AuditEvent>,
private readonly infoLogger: ApplicationInfoLoggerService,
) {} ) {}
async record(input: AuditInput): Promise<void> { async record(input: AuditInput): Promise<void> {
@@ -25,5 +28,69 @@ export class AuditService {
metadata: input.metadata ?? {}, metadata: input.metadata ?? {},
}), }),
); );
await this.infoLogger.log(this.toApplicationInfo(input));
}
private toApplicationInfo(input: AuditInput) {
const metadata = input.metadata ?? {};
const clientId = stringValue(metadata.clientId);
const admin = stringValue(metadata.admin);
const actorType = this.actorType(input.type, input.username, clientId, admin);
const actorId = admin ?? (actorType === 'CLIENT' ? clientId : input.username);
return {
action: input.type,
category: input.type.split('.', 1)[0]?.toUpperCase() || 'APPLICATION',
outcome: this.outcome(input.type),
actorType,
actorId,
userId: input.username,
clientId,
backendModule: `${titleCase(input.type.split('.', 1)[0] || 'application')}Module`,
service: AuditService.name,
operation: input.type,
requestContext: {
ipAddress: input.ipAddress,
userAgent: input.userAgent,
},
context: metadata,
};
}
private actorType(type: string, username?: string, clientId?: string, admin?: string): ApplicationInfoActorType {
if (admin || username && !this.isClientAction(type, clientId)) {
return 'USER';
}
return this.isClientAction(type, clientId) ? 'CLIENT' : 'SYSTEM';
}
private isClientAction(type: string, clientId?: string): boolean {
return Boolean(
clientId &&
/^oidc\.(client_login|authorization|access_token|grant|interaction|logout|end_session)/.test(type),
);
}
private outcome(type: string): ApplicationInfoOutcome {
if (type.includes('failed') || type.includes('.error')) {
return 'FAILURE';
}
if (type.endsWith('started') || type.endsWith('requested')) {
return 'STARTED';
}
if (type.endsWith('skipped')) {
return 'SKIPPED';
}
return 'SUCCESS';
} }
} }
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value ? value : undefined;
}
function titleCase(value: string): string {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : 'Application';
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { RequestUser } from '../common/request-user'; import { RequestUser } from '../common/request-user';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -19,6 +19,13 @@ export class AuthController {
); );
} }
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(204)
async logout(@Req() request: Request & { user: RequestUser }): Promise<void> {
await this.auth.logout(request.user.username, request.ip, request.headers['user-agent']);
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('me') @Get('me')
me(@Req() request: Request & { user: RequestUser }) { me(@Req() request: Request & { user: RequestUser }) {

View File

@@ -24,4 +24,8 @@ export class AuthService {
user: { username }, user: { username },
}; };
} }
async logout(username: string, ipAddress?: string, userAgent?: string): Promise<void> {
await this.audit.record({ type: 'auth.logout_success', username, ipAddress, userAgent });
}
} }

View File

@@ -0,0 +1,44 @@
import { ConfigService } from '@nestjs/config';
export interface ApplicationBranding {
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
}
export function applicationBranding(config: ConfigService): ApplicationBranding {
return {
productName: configValue(config, 'APP_PRODUCT_NAME', 'MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
companyName: configValue(config, 'APP_COMPANY_NAME', 'MAIL_COMPANY_NAME') ?? 'LDAP Portal',
primaryColor: validColor(configValue(config, 'APP_PRIMARY_COLOR', 'MAIL_PRIMARY_COLOR')),
supportEmail: configValue(config, 'APP_SUPPORT_EMAIL', 'MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
logoUrl: validHttpsUrl(configValue(config, 'APP_LOGO_URL', 'MAIL_LOGO_URL')),
imprintUrl: validHttpsUrl(configValue(config, 'APP_IMPRINT_URL', 'MAIL_IMPRINT_URL')),
privacyUrl: validHttpsUrl(configValue(config, 'APP_PRIVACY_URL', 'MAIL_PRIVACY_URL')),
};
}
function configValue(config: ConfigService, primaryKey: string, fallbackKey: string): string | undefined {
return config.get<string>(primaryKey)?.trim() || config.get<string>(fallbackKey)?.trim() || undefined;
}
function validColor(value?: string): string {
return value && /^#[0-9a-f]{6}$/i.test(value) ? value : '#0f6b6e';
}
function validHttpsUrl(value?: string): string | undefined {
if (!value) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === 'https:' ? url.toString() : undefined;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,52 @@
import { normalizeOidcForwardedProto } from './oidc-forwarded-proto';
describe('normalizeOidcForwardedProto', () => {
it('corrects an OIDC request when the trusted public issuer uses HTTPS', () => {
const request = {
method: 'GET',
originalUrl: '/oidc/auth?client_id=test',
headers: { 'x-forwarded-proto': 'http' },
};
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
expect(result).toEqual({ corrected: true, path: '/oidc/auth', previousProto: 'http' });
expect(request.headers['x-forwarded-proto']).toBe('https');
});
it('also corrects interaction routes used to finish login and consent', () => {
const request = { originalUrl: '/interaction/uid/login', headers: {} as Record<string, string> };
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
expect(result.corrected).toBe(true);
expect(request.headers['x-forwarded-proto']).toBe('https');
});
it('does not alter unrelated API requests', () => {
const request = { originalUrl: '/api/account', headers: { 'x-forwarded-proto': 'http' } };
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
expect(result.corrected).toBe(false);
expect(request.headers['x-forwarded-proto']).toBe('http');
});
it('does not override the protocol when proxy trust is disabled', () => {
const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } };
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', false);
expect(result.corrected).toBe(false);
expect(request.headers['x-forwarded-proto']).toBe('http');
});
it('does not force HTTPS for a configured HTTP issuer', () => {
const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } };
const result = normalizeOidcForwardedProto(request, 'http://localhost:8080', true);
expect(result.corrected).toBe(false);
expect(request.headers['x-forwarded-proto']).toBe('http');
});
});

View File

@@ -0,0 +1,58 @@
import type { IncomingHttpHeaders } from 'node:http';
interface OidcProxyRequest {
method?: string;
originalUrl?: string;
url?: string;
headers: IncomingHttpHeaders;
}
export interface OidcForwardedProtoResult {
corrected: boolean;
path: string;
previousProto?: string;
}
export function normalizeOidcForwardedProto(
request: OidcProxyRequest,
issuer: string | undefined,
trustProxy: boolean,
): OidcForwardedProtoResult {
const path = (request.originalUrl ?? request.url ?? '/').split('?', 1)[0];
const previousProto = headerValue(request.headers['x-forwarded-proto']);
if (!trustProxy || !isOidcPath(path) || !isHttpsUrl(issuer) || previousProto === 'https') {
return { corrected: false, path, previousProto };
}
request.headers['x-forwarded-proto'] = 'https';
return { corrected: true, path, previousProto };
}
function isOidcPath(path: string): boolean {
return (
path === '/oidc' ||
path.startsWith('/oidc/') ||
path === '/interaction' ||
path.startsWith('/interaction/') ||
path === '/.well-known' ||
path.startsWith('/.well-known/')
);
}
function isHttpsUrl(value?: string): boolean {
if (!value) {
return false;
}
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
function headerValue(value: string | string[] | undefined): string | undefined {
const firstValue = Array.isArray(value) ? value[0] : value;
return firstValue?.split(',', 1)[0].trim().toLowerCase() || undefined;
}

View File

@@ -1,23 +1,11 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import {
ApplicationBranding,
applicationBranding,
} from '../common/application-branding';
export interface MailBranding { export type MailBranding = ApplicationBranding;
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
}
export function mailBranding(config: ConfigService): MailBranding { export function mailBranding(config: ConfigService): MailBranding {
return { return applicationBranding(config);
productName: config.get<string>('MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
companyName: config.get<string>('MAIL_COMPANY_NAME') ?? 'LDAP Portal',
primaryColor: config.get<string>('MAIL_PRIMARY_COLOR') ?? '#0f6b6e',
supportEmail: config.get<string>('MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
logoUrl: config.get<string>('MAIL_LOGO_URL') || undefined,
imprintUrl: config.get<string>('MAIL_IMPRINT_URL') || undefined,
privacyUrl: config.get<string>('MAIL_PRIVACY_URL') || undefined,
};
} }

View File

@@ -1,8 +1,9 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import express, { NextFunction, Request, Response } from 'express'; import express, { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { normalizeOidcForwardedProto } from './common/oidc-forwarded-proto';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false }); const app = await NestFactory.create(AppModule, { bodyParser: false });
@@ -12,6 +13,26 @@ async function bootstrap() {
credentials: true, credentials: true,
}); });
const oidcProxyLogger = new Logger('OidcProxyProtocol');
app.use((request: Request, _response: Response, next: NextFunction) => {
const result = normalizeOidcForwardedProto(
request,
process.env.OIDC_ISSUER,
process.env.OIDC_TRUST_PROXY === 'true',
);
if (result.corrected) {
oidcProxyLogger.warn(
`[OIDC_FORWARDED_PROTO_CORRECTED] ${JSON.stringify({
method: request.method,
path: result.path,
receivedProto: result.previousProto,
effectiveProto: 'https',
})}`,
);
}
next();
});
const jsonParser = express.json(); const jsonParser = express.json();
const formParser = express.urlencoded({ extended: false }); const formParser = express.urlencoded({ extended: false });
app.use((request: Request, response: Response, next: NextFunction) => { app.use((request: Request, response: Response, next: NextFunction) => {

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateApplicationInfoLogs1721300000000 implements MigrationInterface {
name = 'CreateApplicationInfoLogs1721300000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'application_info_logs',
columns: [
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
{ name: 'level', type: 'varchar', length: '255', default: "'info'" },
{ name: 'action', type: 'varchar', length: '255' },
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
{ name: 'outcome', type: 'varchar', length: '255', default: "'SUCCESS'" },
{ name: 'message', type: 'text', isNullable: true },
{ name: 'actorType', type: 'varchar', length: '255', isNullable: true },
{ name: 'actorId', type: 'varchar', length: '255', isNullable: true },
{ name: 'userId', type: 'varchar', length: '255', isNullable: true },
{ name: 'clientId', type: 'varchar', length: '255', isNullable: true },
{ name: 'tenantId', type: 'varchar', length: '255', isNullable: true },
{ name: 'backendModule', type: 'varchar', length: '255', isNullable: true },
{ name: 'service', type: 'varchar', length: '255', isNullable: true },
{ name: 'operation', type: 'varchar', length: '255', isNullable: true },
{ name: 'httpMethod', type: 'varchar', length: '255', isNullable: true },
{ name: 'apiPath', type: 'varchar', length: '255', isNullable: true },
{ name: 'httpStatusCode', type: 'int', isNullable: true },
{ name: 'correlationId', type: 'varchar', length: '255', isNullable: true },
{ name: 'ipAddress', type: 'varchar', length: '255', isNullable: true },
{ name: 'userAgent', type: 'text', isNullable: true },
{ name: 'environment', type: 'varchar', length: '255', isNullable: true },
{ name: 'host', type: 'varchar', length: '255', isNullable: true },
{ name: 'context', type: 'text', isNullable: true },
{ name: 'createdAt', type: 'datetime', precision: 6, default: 'CURRENT_TIMESTAMP(6)' },
],
}),
);
for (const columnName of [
'createdAt',
'action',
'category',
'outcome',
'correlationId',
'userId',
'clientId',
'tenantId',
]) {
await queryRunner.createIndex(
'application_info_logs',
new TableIndex({ name: `IDX_application_info_logs_${columnName}`, columnNames: [columnName] }),
);
}
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('application_info_logs');
}
}

View File

@@ -0,0 +1,66 @@
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
describe('OidcInteractionTemplateService', () => {
const values: Record<string, string> = {
APP_PRODUCT_NAME: 'ForgeCore Auth',
APP_COMPANY_NAME: 'ForgeCore',
APP_PRIMARY_COLOR: '#126466',
APP_SUPPORT_EMAIL: 'support@example.com',
APP_LOGO_URL: 'https://cdn.example.com/logo.png',
};
const config = { get: jest.fn((key: string) => values[key]) };
const service = new OidcInteractionTemplateService(config as any);
it('renders the login page with branding and encoded form actions', async () => {
const html = await service.renderLogin({
uid: 'uid/with spaces',
username: 'maria',
registrationUrl: 'https://portal.example.com/register',
});
expect(html).toContain('<title>Anmelden - ForgeCore Auth</title>');
expect(html).toContain('https://cdn.example.com/logo.png');
expect(html).toContain('/interaction/uid%2Fwith%20spaces/login');
expect(html).toContain('value="maria"');
});
it('escapes usernames and error messages', async () => {
const html = await service.renderLogin({
uid: 'uid',
username: '<script>alert(1)</script>',
errorMessage: '<b>Fehler</b>',
registrationUrl: 'https://portal.example.com/register',
});
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&lt;b&gt;Fehler&lt;/b&gt;');
expect(html).not.toContain('<script>');
});
it('renders escaped consent details and scope descriptions', async () => {
const html = await service.renderConsent({
uid: 'consent-id',
clientName: '<Admin Portal>',
redirectUri: 'https://client.example.com/callback?x=1&y=2',
scopes: ['openid', '<custom>'],
});
expect(html).toContain('&lt;Admin Portal&gt;');
expect(html).toContain('https://client.example.com/callback?x&#x3D;1&amp;y&#x3D;2');
expect(html).toContain('Anmeldung per OpenID Connect bestaetigen.');
expect(html).toContain('&lt;custom&gt;');
expect(html).toContain('/interaction/consent-id/confirm');
});
it('falls back to mail branding when application branding is absent', async () => {
const fallbackConfig = {
get: jest.fn((key: string) => ({ MAIL_PRODUCT_NAME: 'Legacy Portal' })[key]),
};
const fallbackService = new OidcInteractionTemplateService(fallbackConfig as any);
const html = await fallbackService.renderError({ title: 'Fehler', message: 'Nicht verfuegbar.' });
expect(html).toContain('<title>Fehler - Legacy Portal</title>');
expect(html).toContain('Nicht verfuegbar.');
});
});

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import Handlebars from 'handlebars';
import { applicationBranding } from '../common/application-branding';
const oidcTemplateDir = join(__dirname, 'templates');
export interface OidcLoginPageInput {
uid: string;
username?: string;
errorMessage?: string;
registrationUrl: string;
}
export interface OidcConsentPageInput {
uid: string;
clientName: string;
redirectUri?: string;
scopes: string[];
}
export interface OidcErrorPageInput {
title: string;
message: string;
}
@Injectable()
export class OidcInteractionTemplateService {
constructor(private readonly config: ConfigService) {}
async renderLogin(input: OidcLoginPageInput): Promise<string> {
const encodedUid = encodeURIComponent(input.uid);
return this.render('login', 'Anmelden', {
username: input.username ?? '',
errorMessage: input.errorMessage,
loginAction: `/interaction/${encodedUid}/login`,
abortAction: `/interaction/${encodedUid}/abort`,
registrationUrl: input.registrationUrl,
});
}
async renderConsent(input: OidcConsentPageInput): Promise<string> {
const encodedUid = encodeURIComponent(input.uid);
return this.render('consent', 'Zugriff erlauben', {
clientName: input.clientName,
redirectUri: input.redirectUri,
scopes: input.scopes.map((name) => ({ name, description: this.scopeDescription(name) })),
confirmAction: `/interaction/${encodedUid}/confirm`,
abortAction: `/interaction/${encodedUid}/abort`,
});
}
async renderError(input: OidcErrorPageInput): Promise<string> {
return this.render('error', input.title, { message: input.message });
}
private async render(templateName: string, title: string, context: Record<string, unknown>): Promise<string> {
const [layoutSource, templateSource] = await Promise.all([
readFile(join(oidcTemplateDir, 'layouts', 'base.hbs'), 'utf8'),
readFile(join(oidcTemplateDir, `${templateName}.hbs`), 'utf8'),
]);
const branding = applicationBranding(this.config);
const body = Handlebars.compile(templateSource)(context);
return Handlebars.compile(layoutSource)({
...context,
branding,
title,
body,
});
}
private scopeDescription(scope: string): string {
const descriptions: Record<string, string> = {
openid: 'Anmeldung per OpenID Connect bestaetigen.',
profile: 'Profilinformationen wie Name und Anzeigename lesen.',
email: 'E-Mail-Adresse lesen.',
groups: 'Gruppenmitgliedschaften lesen.',
offline_access: 'Laengerfristigen Zugriff ueber Refresh Tokens erlauben.',
};
return descriptions[scope] ?? 'Diese Berechtigung wurde von der Anwendung angefordert.';
}
}

View File

@@ -0,0 +1,154 @@
import { Logger, UnauthorizedException } from '@nestjs/common';
import { OidcInteractionController } from './oidc-interaction.controller';
describe('OidcInteractionController', () => {
const request = {
method: 'GET',
originalUrl: '/interaction/uid',
url: '/interaction/uid',
headers: { host: 'auth.example.com', 'x-forwarded-proto': 'https' },
} as any;
function responseMock() {
const response: any = {
send: jest.fn(),
status: jest.fn(),
};
response.status.mockReturnValue(response);
return response;
}
function createController() {
const oidc = {
interactionDetails: jest.fn(),
isFirstPartyClient: jest.fn().mockResolvedValue(false),
finishConsent: jest.fn().mockResolvedValue(undefined),
finishLogin: jest.fn().mockResolvedValue(undefined),
abortInteraction: jest.fn().mockResolvedValue(undefined),
};
const templates = {
renderLogin: jest.fn().mockResolvedValue('<html>login</html>'),
renderConsent: jest.fn().mockResolvedValue('<html>consent</html>'),
renderError: jest.fn().mockResolvedValue('<html>error</html>'),
};
const config = { get: jest.fn().mockReturnValue('https://portal.example.com/') };
const errorLogger = { log: jest.fn().mockResolvedValue(undefined) };
const requestContext = { get: jest.fn().mockReturnValue({ correlationId: 'correlation-id' }) };
const controller = new OidcInteractionController(
oidc as any,
config as any,
templates as any,
errorLogger as any,
requestContext as any,
);
return { controller, oidc, templates, errorLogger };
}
it('renders the login interaction without changing its route', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({ uid: 'uid', prompt: { name: 'login' }, params: {} });
await controller.view('uid', request, response);
expect(templates.renderLogin).toHaveBeenCalledWith({
uid: 'uid',
registrationUrl: 'https://portal.example.com/register',
});
expect(response.send).toHaveBeenCalledWith('<html>login</html>');
});
it('automatically finishes consent for first-party clients', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({
uid: 'uid',
prompt: { name: 'consent' },
params: { client_id: 'first-party' },
});
oidc.isFirstPartyClient.mockResolvedValue(true);
await controller.view('uid', request, response);
expect(oidc.finishConsent).toHaveBeenCalledWith(request, response, 'uid', { autoGranted: true });
expect(templates.renderConsent).not.toHaveBeenCalled();
});
it('renders consent details for third-party clients', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({
uid: 'uid',
prompt: { name: 'consent' },
params: {
client_id: 'client-id',
name: 'Listify',
redirect_uri: 'https://listify.example.com/callback',
scope: 'openid profile email',
},
});
await controller.view('uid', request, response);
expect(templates.renderConsent).toHaveBeenCalledWith({
uid: 'uid',
clientName: 'Listify',
redirectUri: 'https://listify.example.com/callback',
scopes: ['openid', 'profile', 'email'],
});
});
it('keeps a successful login delegated to the provider', async () => {
const { controller, oidc } = createController();
const response = responseMock();
await controller.login('uid', { username: 'maria', password: 'secret' }, request, response);
expect(oidc.finishLogin).toHaveBeenCalledWith(request, response, 'uid', 'maria', 'secret');
expect(response.send).not.toHaveBeenCalled();
});
it('returns the rendered login page for invalid credentials', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.finishLogin.mockRejectedValue(new UnauthorizedException('Ungueltige Zugangsdaten.'));
await controller.login('uid', { username: 'maria', password: 'wrong' }, request, response);
expect(response.status).toHaveBeenCalledWith(401);
expect(templates.renderLogin).toHaveBeenCalledWith(expect.objectContaining({
uid: 'uid',
username: 'maria',
errorMessage: 'Ungueltige Zugangsdaten.',
}));
});
it('logs an expired interaction and returns a controlled page', async () => {
const { controller, oidc, templates, errorLogger } = createController();
const response = responseMock();
oidc.interactionDetails.mockRejectedValue(new Error('SessionNotFound'));
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
await controller.view('uid', request, response);
expect(errorLogger.log).toHaveBeenCalledWith(expect.objectContaining({
code: 'OIDC_INTERACTION_SESSION_NOT_FOUND',
handled: true,
}));
expect(templates.renderError).toHaveBeenCalledWith(expect.objectContaining({ title: 'Anmeldung abgelaufen' }));
expect(response.status).toHaveBeenCalledWith(400);
jest.restoreAllMocks();
});
it('rethrows unexpected login errors for global error handling', async () => {
const { controller, oidc } = createController();
const response = responseMock();
oidc.finishLogin.mockRejectedValue(new Error('provider unavailable'));
await expect(
controller.login('uid', { username: 'maria', password: 'secret' }, request, response),
).rejects.toThrow('provider unavailable');
});
});

View File

@@ -5,6 +5,7 @@ import type { Interaction } from 'oidc-provider';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { RequestContextService } from '../common/request-context.service'; import { RequestContextService } from '../common/request-context.service';
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
import { OidcProviderService } from './oidc-provider.service'; import { OidcProviderService } from './oidc-provider.service';
@Controller('interaction') @Controller('interaction')
@@ -14,6 +15,7 @@ export class OidcInteractionController {
constructor( constructor(
private readonly oidc: OidcProviderService, private readonly oidc: OidcProviderService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly templates: OidcInteractionTemplateService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService, private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
) {} ) {}
@@ -25,21 +27,24 @@ export class OidcInteractionController {
details = await this.oidc.interactionDetails(request, response); details = await this.oidc.interactionDetails(request, response);
} catch (error) { } catch (error) {
await this.logInteractionSessionError(error, uid, request); await this.logInteractionSessionError(error, uid, request);
response.status(400).send( const page = await this.templates.renderError({
this.page( title: 'Anmeldung abgelaufen',
'Anmeldung abgelaufen', message: 'Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.',
'<p>Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.</p>', });
), response.status(400).send(page);
);
return; return;
} }
if (details.uid !== uid) { if (details.uid !== uid) {
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>')); const page = await this.templates.renderError({
title: 'Ungueltige Anfrage',
message: 'Die OIDC-Interaktion ist ungueltig.',
});
response.status(400).send(page);
return; return;
} }
if (details.prompt.name === 'login') { if (details.prompt.name === 'login') {
response.send(this.page('Anmelden', this.loginForm(uid))); response.send(await this.templates.renderLogin({ uid, registrationUrl: this.registrationUrl }));
return; return;
} }
@@ -50,11 +55,21 @@ export class OidcInteractionController {
return; return;
} }
response.send(this.page('Zugriff erlauben', this.consentView(uid, details))); const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung'));
const redirectUri = String(details.params.redirect_uri ?? '');
const scopes = String(details.params.scope ?? 'openid')
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
response.send(await this.templates.renderConsent({ uid, clientName, redirectUri, scopes }));
return; return;
} }
response.status(400).send(this.page('OIDC', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>')); const page = await this.templates.renderError({
title: 'OIDC',
message: 'Diese Interaktion wird noch nicht unterstuetzt.',
});
response.status(400).send(page);
} }
@Post(':uid/login') @Post(':uid/login')
@@ -69,9 +84,13 @@ export class OidcInteractionController {
await this.oidc.finishLogin(request, response, uid, username, body.password ?? ''); await this.oidc.finishLogin(request, response, uid, username, body.password ?? '');
} catch (error) { } catch (error) {
if (this.isInvalidCredentialsError(error)) { if (this.isInvalidCredentialsError(error)) {
response const page = await this.templates.renderLogin({
.status(401) uid,
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.'))); username,
errorMessage: 'Ungueltige Zugangsdaten.',
registrationUrl: this.registrationUrl,
});
response.status(401).send(page);
return; return;
} }
@@ -89,122 +108,6 @@ export class OidcInteractionController {
await this.oidc.abortInteraction(request, response); await this.oidc.abortInteraction(request, response);
} }
private loginForm(uid: string, username = '', errorMessage = ''): string {
const encodedUid = encodeURIComponent(uid);
const error = errorMessage
? `<p class="message error" role="alert">${this.escape(errorMessage)}</p>`
: '';
return `
${error}
<form method="post" action="/interaction/${encodedUid}/login">
<label>Benutzername <input name="username" autocomplete="username" value="${this.escape(username)}" required></label>
<label>Passwort <input name="password" type="password" autocomplete="current-password" required autofocus></label>
<button type="submit">Anmelden</button>
</form>
<form method="post" action="/interaction/${encodedUid}/abort">
<button class="secondary" type="submit">Abbrechen</button>
</form>
<p class="form-link"><a href="${this.escape(this.registrationUrl)}">Neues Konto registrieren</a></p>
`;
}
private consentView(uid: string, details: Interaction): string {
const encodedUid = encodeURIComponent(uid);
const clientId = String(details.params.client_id ?? '');
const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung'));
const redirectUri = String(details.params.redirect_uri ?? '');
const scope = String(details.params.scope ?? 'openid');
return `
<p class="intro">Die Anwendung <strong>${this.escape(clientName)}</strong> moechte auf dein Konto zugreifen.</p>
${redirectUri ? `<dl class="consent-details"><div><dt>Weiterleitung</dt><dd>${this.escape(redirectUri)}</dd></div></dl>` : ''}
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
${this.scopeItems(scope)}
</div>
<form method="post" action="/interaction/${encodedUid}/confirm">
<button type="submit">Zugriff erlauben</button>
</form>
<form method="post" action="/interaction/${encodedUid}/abort">
<button class="secondary" type="submit">Ablehnen</button>
</form>
`;
}
private scopeItems(scope: string): string {
const scopes = scope
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
return scopes
.map(
(item) => `
<section class="scope-item">
<strong>${this.escape(item)}</strong>
<span>${this.escape(this.scopeDescription(item))}</span>
</section>
`,
)
.join('');
}
private scopeDescription(scope: string): string {
const descriptions: Record<string, string> = {
openid: 'Anmeldung per OpenID Connect bestaetigen.',
profile: 'Profilinformationen wie Name und Anzeigename lesen.',
email: 'E-Mail-Adresse lesen.',
groups: 'Gruppenmitgliedschaften lesen.',
offline_access: 'Laengerfristigen Zugriff ueber Refresh Tokens erlauben.',
};
return descriptions[scope] ?? 'Diese Berechtigung wurde von der Anwendung angefordert.';
}
private page(title: string, body: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this.escape(title)} - LDAP Portal</title>
<style>
body { background: #f5f7f9; color: #18202a; font-family: Inter, system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 20px; }
main { background: white; border: 1px solid #d8e0e7; border-radius: 8px; box-shadow: 0 16px 40px rgb(24 32 42 / 8%); max-width: 460px; padding: 28px; width: 100%; }
h1 { font-size: 1.45rem; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; }
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
button.secondary { background: white; color: #0f6b6e; }
a { color: #0f6b6e; font-weight: 700; text-decoration: none; }
a:hover { text-decoration: underline; }
.form-link { margin: 16px 0 0; text-align: center; }
.intro { color: #3a4551; line-height: 1.45; margin: 0 0 16px; }
.message { background: #edf7f4; border: 1px solid #b8ddd3; border-radius: 6px; color: #24564f; margin: 0 0 16px; padding: 12px; }
.message.error { background: #fff1f0; border-color: #efb5ae; color: #8d2b20; }
.consent-details { display: grid; gap: 10px; margin: 0 0 16px; }
.consent-details div { display: grid; gap: 5px; }
dt { color: #637083; font-size: 0.82rem; font-weight: 700; }
dd { margin: 0; overflow-wrap: anywhere; }
.scope-list { display: grid; gap: 8px; margin: 16px 0; }
.scope-item { border: 1px solid #d8e0e7; border-radius: 6px; display: grid; gap: 4px; padding: 10px 12px; }
.scope-item span { color: #637083; font-size: 0.9rem; line-height: 1.35; }
</style>
</head>
<body><main><h1>${this.escape(title)}</h1>${body}</main></body>
</html>`;
}
private escape(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
private get registrationUrl(): string { private get registrationUrl(): string {
const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
return `${publicWebUrl.replace(/\/+$/, '')}/register`; return `${publicWebUrl.replace(/\/+$/, '')}/register`;

View File

@@ -74,15 +74,28 @@ export class OidcProviderService implements OnModuleInit {
} }
const valid = await this.ldapAuth.verifyPassword(username, password); const valid = await this.ldapAuth.verifyPassword(username, password);
const clientId = String(details.params.client_id ?? '');
if (!valid) { if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] }); await this.audit.record({
type: 'oidc.login_failed',
username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId },
});
throw new UnauthorizedException('Ungueltige Zugangsdaten.'); throw new UnauthorizedException('Ungueltige Zugangsdaten.');
} }
const account = await this.lldap.getAccount(username); const account = await this.lldap.getAccount(username);
const subject = account.uuid || account.id; const subject = account.uuid || account.id;
await this.subjects.save(this.subjects.create({ subject, username: account.id })); await this.subjects.save(this.subjects.create({ subject, username: account.id }));
await this.audit.record({ type: 'oidc.login_success', username: account.id, ipAddress: request.ip, userAgent: request.headers['user-agent'] }); await this.audit.record({
type: 'oidc.login_success',
username: account.id,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId },
});
await this.getProvider().interactionFinished( await this.getProvider().interactionFinished(
request, request,
@@ -144,6 +157,7 @@ export class OidcProviderService implements OnModuleInit {
} }
async abortInteraction(request: Request, response: Response): Promise<void> { async abortInteraction(request: Request, response: Response): Promise<void> {
const details = await this.interactionDetails(request, response);
await this.getProvider().interactionFinished( await this.getProvider().interactionFinished(
request, request,
response, response,
@@ -153,6 +167,16 @@ export class OidcProviderService implements OnModuleInit {
}, },
{ mergeWithLastSubmission: false }, { mergeWithLastSubmission: false },
); );
await this.audit.record({
type: 'oidc.interaction_aborted',
username: details.session?.accountId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: {
clientId: String(details.params.client_id ?? ''),
prompt: details.prompt.name,
},
});
} }
async isFirstPartyClient(clientId: string): Promise<boolean> { async isFirstPartyClient(clientId: string): Promise<boolean> {
@@ -284,19 +308,93 @@ export class OidcProviderService implements OnModuleInit {
provider.on('access_token.issued', (token) => { provider.on('access_token.issued', (token) => {
void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } }); void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } });
}); });
provider.on('interaction.started', (interaction) => { provider.on('interaction.started', (ctx, interaction) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({ void this.audit.record({
type: 'oidc.interaction_started', type: 'oidc.interaction_started',
username: interaction?.session?.accountId, username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: { metadata: {
uid: interaction?.uid, uid: activity.interactionId,
prompt: interaction?.prompt?.name, prompt: interaction?.name,
clientId: interaction?.params?.client_id, clientId: activity.clientId,
redirectUri: interaction?.params?.redirect_uri, redirectUri: activity.redirectUri,
scope: interaction?.params?.scope, scope: activity.scope,
}, },
}); });
}); });
provider.on('authorization.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.client_login_success',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: {
clientId: activity.clientId,
redirectUri: activity.redirectUri,
responseType: activity.responseType,
scope: activity.scope,
},
});
});
provider.on('grant.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.client_token_granted',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: { clientId: activity.clientId, grantType: activity.grantType },
});
});
provider.on('end_session.success', (ctx) => {
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: 'oidc.logout_success',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: { clientId: activity.clientId },
});
});
}
private oidcActivityContext(ctx: unknown): {
clientId?: string;
userId?: string;
ipAddress?: string;
userAgent?: string;
redirectUri?: string;
responseType?: string;
scope?: string;
grantType?: string;
interactionId?: string;
} {
const oidcCtx = ctx as {
ip?: string;
headers?: Record<string, unknown>;
oidc?: {
client?: { clientId?: string };
session?: { accountId?: string };
params?: Record<string, unknown>;
entities?: { Interaction?: { uid?: string } };
};
};
const params = oidcCtx?.oidc?.params ?? {};
return {
clientId: oidcCtx?.oidc?.client?.clientId ?? this.stringProperty(params.client_id),
userId: oidcCtx?.oidc?.session?.accountId,
ipAddress: oidcCtx?.ip,
userAgent: this.stringProperty(oidcCtx?.headers?.['user-agent']),
redirectUri: this.stringProperty(params.redirect_uri),
responseType: this.stringProperty(params.response_type),
scope: this.stringProperty(params.scope),
grantType: this.stringProperty(params.grant_type),
interactionId: oidcCtx?.oidc?.entities?.Interaction?.uid,
};
} }
private registerErrorEvents(provider: Provider): void { private registerErrorEvents(provider: Provider): void {
@@ -308,6 +406,7 @@ export class OidcProviderService implements OnModuleInit {
source.on(eventName, (ctx, error) => { source.on(eventName, (ctx, error) => {
const actualError = error ?? ctx; const actualError = error ?? ctx;
this.consoleLogOidcProviderError(eventName, ctx, actualError); this.consoleLogOidcProviderError(eventName, ctx, actualError);
this.auditOidcFailure(eventName, ctx, actualError);
void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => { void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => {
this.logger.error(`OIDC provider error logging failed: ${this.errorProperty(logError, 'message')}`); this.logger.error(`OIDC provider error logging failed: ${this.errorProperty(logError, 'message')}`);
}); });
@@ -393,4 +492,27 @@ export class OidcProviderService implements OnModuleInit {
private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined { private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined {
return error instanceof Error ? error[property] : undefined; return error instanceof Error ? error[property] : undefined;
} }
private auditOidcFailure(eventName: string, ctx: unknown, error: unknown): void {
if (eventName !== 'authorization.error' && eventName !== 'grant.error') {
return;
}
const activity = this.oidcActivityContext(ctx);
void this.audit.record({
type: eventName === 'authorization.error' ? 'oidc.client_login_failed' : 'oidc.client_token_failed',
username: activity.userId,
ipAddress: activity.ipAddress,
userAgent: activity.userAgent,
metadata: {
clientId: activity.clientId,
errorName: this.errorProperty(error, 'name'),
errorMessage: this.errorProperty(error, 'message'),
},
});
}
private stringProperty(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
} }

View File

@@ -11,6 +11,7 @@ import { OidcAdminClientsController } from './oidc-admin-clients.controller';
import { OidcAdminGuard } from './oidc-admin.guard'; import { OidcAdminGuard } from './oidc-admin.guard';
import { OidcClientService } from './oidc-client.service'; import { OidcClientService } from './oidc-client.service';
import { OidcInteractionController } from './oidc-interaction.controller'; import { OidcInteractionController } from './oidc-interaction.controller';
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
import { OidcProviderService } from './oidc-provider.service'; import { OidcProviderService } from './oidc-provider.service';
@Module({ @Module({
@@ -26,6 +27,6 @@ import { OidcProviderService } from './oidc-provider.service';
AuditModule, AuditModule,
], ],
controllers: [OidcAdminClientsController, OidcInteractionController], controllers: [OidcAdminClientsController, OidcInteractionController],
providers: [OidcAdminGuard, OidcClientService, OidcProviderService], providers: [OidcAdminGuard, OidcClientService, OidcInteractionTemplateService, OidcProviderService],
}) })
export class OidcModule {} export class OidcModule {}

View File

@@ -0,0 +1,23 @@
<p class="intro">Die Anwendung <strong>{{clientName}}</strong> m&ouml;chte auf dein Konto zugreifen.</p>
{{#if redirectUri}}
<dl class="consent-details">
<div>
<dt>Weiterleitung</dt>
<dd>{{redirectUri}}</dd>
</div>
</dl>
{{/if}}
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
{{#each scopes}}
<section class="scope-item">
<strong>{{name}}</strong>
<span>{{description}}</span>
</section>
{{/each}}
</div>
<form method="post" action="{{confirmAction}}">
<button type="submit">Zugriff erlauben</button>
</form>
<form method="post" action="{{abortAction}}">
<button class="secondary" type="submit">Ablehnen</button>
</form>

View File

@@ -0,0 +1 @@
<p class="intro">{{message}}</p>

View File

@@ -0,0 +1,113 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{title}} - {{branding.productName}}</title>
<style>
* { box-sizing: border-box; }
body {
background: #f3f6f7;
color: #18202a;
font-family: Arial, Helvetica, sans-serif;
margin: 0;
min-height: 100vh;
padding: 24px;
}
.page {
align-items: center;
display: flex;
justify-content: center;
min-height: calc(100vh - 48px);
}
main {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
box-shadow: 0 16px 40px rgb(24 32 42 / 8%);
max-width: 460px;
overflow: hidden;
width: 100%;
}
.brand {
align-items: center;
border-bottom: 1px solid #e7ecef;
display: flex;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
}
.brand img { display: block; max-height: 36px; max-width: 180px; }
.brand-name { color: #26313d; font-size: 1rem; font-weight: 700; }
.content { padding: 28px; }
h1 { font-size: 1.45rem; letter-spacing: 0; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; }
input {
border: 1px solid #aebbc7;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 44px;
padding: 10px 12px;
width: 100%;
}
input:focus { border-color: {{branding.primaryColor}}; outline: 3px solid rgb(15 107 110 / 18%); }
button {
background: {{branding.primaryColor}};
border: 1px solid {{branding.primaryColor}};
border-radius: 6px;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
min-height: 44px;
padding: 10px 14px;
width: 100%;
}
button:hover { filter: brightness(0.92); }
button:focus-visible, a:focus-visible { outline: 3px solid rgb(15 107 110 / 25%); outline-offset: 2px; }
button.secondary { background: #ffffff; color: {{branding.primaryColor}}; }
a { color: {{branding.primaryColor}}; font-weight: 700; overflow-wrap: anywhere; text-decoration: none; }
a:hover { text-decoration: underline; }
.form-link { margin: 18px 0 0; text-align: center; }
.intro { color: #3a4551; line-height: 1.5; margin: 0 0 16px; }
.message { border-radius: 6px; line-height: 1.45; margin: 0 0 16px; padding: 12px; }
.message.error { background: #fff1f0; border: 1px solid #efb5ae; color: #8d2b20; }
.consent-details { display: grid; gap: 10px; margin: 0 0 16px; }
.consent-details div { display: grid; gap: 5px; }
dt { color: #637083; font-size: 0.82rem; font-weight: 700; }
dd { margin: 0; overflow-wrap: anywhere; }
.scope-list { display: grid; gap: 8px; margin: 16px 0; }
.scope-item { border: 1px solid #d8e0e7; border-radius: 6px; display: grid; gap: 4px; padding: 10px 12px; }
.scope-item span { color: #637083; font-size: 0.9rem; line-height: 1.35; }
footer { color: #637083; font-size: 0.78rem; line-height: 1.4; padding: 0 28px 24px; text-align: center; }
@media (max-width: 520px) {
body { padding: 12px; }
.page { min-height: calc(100vh - 24px); }
.brand { padding: 14px 20px; }
.content { padding: 24px 20px; }
footer { padding: 0 20px 20px; }
}
</style>
</head>
<body>
<div class="page">
<main>
<header class="brand">
{{#if branding.logoUrl}}
<img src="{{branding.logoUrl}}" alt="{{branding.productName}}">
{{else}}
<span class="brand-name">{{branding.productName}}</span>
{{/if}}
</header>
<section class="content">
<h1>{{title}}</h1>
{{{body}}}
</section>
<footer>{{branding.companyName}}</footer>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{{#if errorMessage}}
<p class="message error" role="alert">{{errorMessage}}</p>
{{/if}}
<form method="post" action="{{loginAction}}">
<label>
Benutzername
<input name="username" autocomplete="username" value="{{username}}" required>
</label>
<label>
Passwort
<input name="password" type="password" autocomplete="current-password" required autofocus>
</label>
<button type="submit">Anmelden</button>
</form>
<form method="post" action="{{abortAction}}">
<button class="secondary" type="submit">Abbrechen</button>
</form>
<p class="form-link"><a href="{{registrationUrl}}">Neues Konto registrieren</a></p>

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Inject, Injectable, signal } from '@angular/core'; import { Inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { tap } from 'rxjs'; import { catchError, EMPTY, tap } from 'rxjs';
import { API_BASE_URL } from './api-base-url'; import { API_BASE_URL } from './api-base-url';
interface LoginResponse { interface LoginResponse {
@@ -34,6 +34,7 @@ export class AuthService {
} }
logout(): void { logout(): void {
this.http.post<void>(`${this.apiBaseUrl}/auth/logout`, {}).pipe(catchError(() => EMPTY)).subscribe();
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('username'); localStorage.removeItem('username');
this.username.set(null); this.username.set(null);

46
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -6801,6 +6802,19 @@
"@nestjs/core": "^11.0.0" "@nestjs/core": "^11.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -8806,6 +8820,12 @@
"@types/koa": "*" "@types/koa": "*"
} }
}, },
"node_modules/@types/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==",
"license": "MIT"
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -11494,6 +11514,23 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -16762,6 +16799,15 @@
"url": "https://github.com/sponsors/wellwelwel" "url": "https://github.com/sponsors/wellwelwel"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",