logging
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
82
apps/api/src/audit/audit.service.spec.ts
Normal file
82
apps/api/src/audit/audit.service.spec.ts
Normal 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' }));
|
||||
});
|
||||
});
|
||||
@@ -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<AuditEvent>,
|
||||
private readonly infoLogger: ApplicationInfoLoggerService,
|
||||
) {}
|
||||
|
||||
async record(input: AuditInput): Promise<void> {
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await this.auth.logout(request.user.username, request.ip, request.headers['user-agent']);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@Req() request: Request & { user: RequestUser }) {
|
||||
|
||||
@@ -24,4 +24,8 @@ export class AuthService {
|
||||
user: { username },
|
||||
};
|
||||
}
|
||||
|
||||
async logout(username: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
await this.audit.record({ type: 'auth.logout_success', username, ipAddress, userAgent });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
@@ -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<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 {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>(`${this.apiBaseUrl}/auth/logout`, {}).pipe(catchError(() => EMPTY)).subscribe();
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('username');
|
||||
this.username.set(null);
|
||||
|
||||
Reference in New Issue
Block a user