diff --git a/README.md b/README.md index d23f5ae..26ea757 100644 --- a/README.md +++ b/README.md @@ -245,10 +245,19 @@ 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. +## 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. Als Ausgangspunkt fuer die Aufbewahrung gelten 180 Tage; eine automatische Loeschung ist bewusst nicht aktiviert und muss mit der betrieblichen Audit-Policy abgestimmt werden. + ## Produktions-Checkliste - Externe MySQL-Datenbank angelegt und erreichbar. - Datenbankschema/Migrationen fuer Produktion geklaert. +- Migration fuer `application_info_logs` angewendet. - LLDAP Web/GraphQL und LDAP vom Container aus erreichbar. - SMTP erreichbar und Absender korrekt gesetzt. - `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1d059b5..e4caf0e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { PasswordModule } from './password/password.module'; import { RegistrationModule } from './registration/registration.module'; import { AccountModule } from './account/account.module'; import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module'; +import { ApplicationInfoLogModule } from './application-info-log/application-info-log.module'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { ApplicationErrorLogModule } from './application-error-log/application-e }, }), ApplicationErrorLogModule, + ApplicationInfoLogModule, AuditModule, AdminModule, MailModule, diff --git a/apps/api/src/application-info-log/application-info-log.entity.ts b/apps/api/src/application-info-log/application-info-log.entity.ts new file mode 100644 index 0000000..1e3d560 --- /dev/null +++ b/apps/api/src/application-info-log/application-info-log.entity.ts @@ -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; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/apps/api/src/application-info-log/application-info-log.module.ts b/apps/api/src/application-info-log/application-info-log.module.ts new file mode 100644 index 0000000..348adb8 --- /dev/null +++ b/apps/api/src/application-info-log/application-info-log.module.ts @@ -0,0 +1,18 @@ +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'; + +@Global() +@Module({ + imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationInfoLog])], + providers: [ + ApplicationInfoLoggerService, + { provide: APP_INTERCEPTOR, useClass: ApplicationInfoInterceptor }, + ], + exports: [ApplicationInfoLoggerService], +}) +export class ApplicationInfoLogModule {} diff --git a/apps/api/src/application-info-log/application-info-log.types.ts b/apps/api/src/application-info-log/application-info-log.types.ts new file mode 100644 index 0000000..3150cdf --- /dev/null +++ b/apps/api/src/application-info-log/application-info-log.types.ts @@ -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; +} diff --git a/apps/api/src/application-info-log/application-info-logger.service.spec.ts b/apps/api/src/application-info-log/application-info-logger.service.spec.ts new file mode 100644 index 0000000..f8587a2 --- /dev/null +++ b/apps/api/src/application-info-log/application-info-logger.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/application-info-log/application-info-logger.service.ts b/apps/api/src/application-info-log/application-info-logger.service.ts new file mode 100644 index 0000000..7e2db0c --- /dev/null +++ b/apps/api/src/application-info-log/application-info-logger.service.ts @@ -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, + private readonly config: ConfigService, + private readonly requestContext: RequestContextService, + ) {} + + async log(input: ApplicationInfoLogInput): Promise { + 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('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]; +} diff --git a/apps/api/src/application-info-log/application-info.interceptor.spec.ts b/apps/api/src/application-info-log/application-info.interceptor.spec.ts new file mode 100644 index 0000000..85b7b14 --- /dev/null +++ b/apps/api/src/application-info-log/application-info.interceptor.spec.ts @@ -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 }), + })); + }); +}); diff --git a/apps/api/src/application-info-log/application-info.interceptor.ts b/apps/api/src/application-info-log/application-info.interceptor.ts new file mode 100644 index 0000000..aa264df --- /dev/null +++ b/apps/api/src/application-info-log/application-info.interceptor.ts @@ -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 { + if (context.getType() !== 'http') { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + 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'), + }, + }); + } +} diff --git a/apps/api/src/audit/audit.service.spec.ts b/apps/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..9819210 --- /dev/null +++ b/apps/api/src/audit/audit.service.spec.ts @@ -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' })); + }); +}); diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts index 19ac5c0..c61dae6 100644 --- a/apps/api/src/audit/audit.service.ts +++ b/apps/api/src/audit/audit.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/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'; export interface AuditInput { @@ -16,6 +18,7 @@ export class AuditService { constructor( @InjectRepository(AuditEvent) private readonly events: Repository, + private readonly infoLogger: ApplicationInfoLoggerService, ) {} async record(input: AuditInput): Promise { @@ -25,5 +28,69 @@ export class AuditService { 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'; +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 1906ba7..bb61224 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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 { RequestUser } from '../common/request-user'; 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 { + await this.auth.logout(request.user.username, request.ip, request.headers['user-agent']); + } + @UseGuards(JwtAuthGuard) @Get('me') me(@Req() request: Request & { user: RequestUser }) { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d2addfd..11f5439 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -24,4 +24,8 @@ export class AuthService { user: { username }, }; } + + async logout(username: string, ipAddress?: string, userAgent?: string): Promise { + await this.audit.record({ type: 'auth.logout_success', username, ipAddress, userAgent }); + } } diff --git a/apps/api/src/migrations/1721300000000-CreateApplicationInfoLogs.ts b/apps/api/src/migrations/1721300000000-CreateApplicationInfoLogs.ts new file mode 100644 index 0000000..ccf5c68 --- /dev/null +++ b/apps/api/src/migrations/1721300000000-CreateApplicationInfoLogs.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateApplicationInfoLogs1721300000000 implements MigrationInterface { + name = 'CreateApplicationInfoLogs1721300000000'; + + async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('application_info_logs'); + } +} diff --git a/apps/api/src/oidc/oidc-provider.service.ts b/apps/api/src/oidc/oidc-provider.service.ts index 34fcce4..a573fd7 100644 --- a/apps/api/src/oidc/oidc-provider.service.ts +++ b/apps/api/src/oidc/oidc-provider.service.ts @@ -74,15 +74,28 @@ export class OidcProviderService implements OnModuleInit { } const valid = await this.ldapAuth.verifyPassword(username, password); + const clientId = String(details.params.client_id ?? ''); 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.'); } const account = await this.lldap.getAccount(username); const subject = account.uuid || 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( request, @@ -144,6 +157,7 @@ export class OidcProviderService implements OnModuleInit { } async abortInteraction(request: Request, response: Response): Promise { + const details = await this.interactionDetails(request, response); await this.getProvider().interactionFinished( request, response, @@ -153,6 +167,16 @@ export class OidcProviderService implements OnModuleInit { }, { 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 { @@ -284,19 +308,93 @@ export class OidcProviderService implements OnModuleInit { provider.on('access_token.issued', (token) => { 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({ type: 'oidc.interaction_started', - username: interaction?.session?.accountId, + username: activity.userId, + ipAddress: activity.ipAddress, + userAgent: activity.userAgent, metadata: { - uid: interaction?.uid, - prompt: interaction?.prompt?.name, - clientId: interaction?.params?.client_id, - redirectUri: interaction?.params?.redirect_uri, - scope: interaction?.params?.scope, + uid: activity.interactionId, + prompt: interaction?.name, + clientId: activity.clientId, + redirectUri: activity.redirectUri, + 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; + oidc?: { + client?: { clientId?: string }; + session?: { accountId?: string }; + params?: Record; + 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 { @@ -308,6 +406,7 @@ export class OidcProviderService implements OnModuleInit { source.on(eventName, (ctx, error) => { const actualError = error ?? ctx; this.consoleLogOidcProviderError(eventName, ctx, actualError); + this.auditOidcFailure(eventName, ctx, actualError); void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => { 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 { 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; + } } diff --git a/apps/web/src/app/shared/auth.service.ts b/apps/web/src/app/shared/auth.service.ts index b8aff50..ccb5322 100644 --- a/apps/web/src/app/shared/auth.service.ts +++ b/apps/web/src/app/shared/auth.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { tap } from 'rxjs'; +import { catchError, EMPTY, tap } from 'rxjs'; import { API_BASE_URL } from './api-base-url'; interface LoginResponse { @@ -34,6 +34,7 @@ export class AuthService { } logout(): void { + this.http.post(`${this.apiBaseUrl}/auth/logout`, {}).pipe(catchError(() => EMPTY)).subscribe(); localStorage.removeItem('accessToken'); localStorage.removeItem('username'); this.username.set(null);