This commit is contained in:
Bastian Wagner
2026-07-17 14:07:04 +02:00
parent 8c6ad294b2
commit 45a41c2077
64 changed files with 2032 additions and 83 deletions

View File

@@ -23,6 +23,13 @@ SMTP_SECURE=false
SMTP_USER=portal@example.com
SMTP_PASS=change-me
SMTP_FROM="LDAP Portal <portal@example.com>"
MAIL_PRODUCT_NAME=LDAP Portal
MAIL_COMPANY_NAME=LDAP Portal
MAIL_PRIMARY_COLOR=#0f6b6e
MAIL_SUPPORT_EMAIL=support@example.com
MAIL_LOGO_URL=
MAIL_IMPRINT_URL=
MAIL_PRIVACY_URL=
OIDC_ISSUER=http://localhost:8080
OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret

View File

@@ -143,6 +143,9 @@ Die wichtigsten Variablen aus `.env.example`:
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
| `SMTP_FROM` | Absenderadresse fuer Portal-Mails. |
| `MAIL_PRODUCT_NAME`, `MAIL_COMPANY_NAME` | Zentrale Branding-Namen fuer Mail-Templates. |
| `MAIL_PRIMARY_COLOR` | Primaerfarbe fuer Mail-Buttons und Links. |
| `MAIL_SUPPORT_EMAIL`, `MAIL_LOGO_URL`, `MAIL_IMPRINT_URL`, `MAIL_PRIVACY_URL` | Optionale Branding- und Footer-Werte fuer Mails. |
| `OIDC_ISSUER` | Externe Issuer-URL des OIDC Providers. |
| `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |

7
apps/api/jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/src/**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
};

View File

@@ -2,6 +2,13 @@
"$schema": "https://json.schemastore.org/nest-cli",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "mail/templates/**/*",
"outDir": "dist",
"watchAssets": true
}
]
}
}

View File

@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && node scripts/copy-mail-assets.js",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"preview:mails": "ts-node scripts/render-mail-previews.ts",
"test": "jest --passWithNoTests",
"lint": "eslint \"src/**/*.ts\""
},

View File

@@ -0,0 +1,24 @@
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
const { join } = require('node:path');
const source = join(__dirname, '..', 'src', 'mail', 'templates');
const target = join(__dirname, '..', 'dist', 'mail', 'templates');
function copyDirectory(from, to) {
if (!existsSync(from)) {
return;
}
mkdirSync(to, { recursive: true });
for (const entry of readdirSync(from)) {
const sourcePath = join(from, entry);
const targetPath = join(to, entry);
if (statSync(sourcePath).isDirectory()) {
copyDirectory(sourcePath, targetPath);
} else {
copyFileSync(sourcePath, targetPath);
}
}
}
copyDirectory(source, target);

View File

@@ -0,0 +1,95 @@
import { ConfigService } from '@nestjs/config';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { mailBranding } from '../src/mail/mail-branding';
import { MailTemplateName } from '../src/mail/mail-template.constants';
import { MailTemplateRendererService } from '../src/mail/mail-template-renderer.service';
const outputDir = join(process.cwd(), 'tmp', 'mail-previews');
const config = new ConfigService({
MAIL_PRODUCT_NAME: 'LDAP Portal',
MAIL_COMPANY_NAME: 'Example Corp',
MAIL_PRIMARY_COLOR: '#0f6b6e',
MAIL_SUPPORT_EMAIL: 'support@example.com',
});
const branding = mailBranding(config);
const renderer = new MailTemplateRendererService();
async function main(): Promise<void> {
await mkdir(outputDir, { recursive: true });
const expiresAtText = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(Date.now() + 30 * 60_000),
);
const previews: Array<{ name: MailTemplateName; context: Record<string, unknown> }> = [
{
name: MailTemplateName.PASSWORD_RESET,
context: {
branding,
title: 'Passwort zuruecksetzen',
preheader: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
greeting: 'Hallo Maria,',
intro: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert. Ueber die folgende Schaltflaeche kannst du ein neues Passwort vergeben.',
action: { label: 'Passwort zuruecksetzen', url: 'https://portal.example.com/reset-password?token=preview' },
resetUrl: 'https://portal.example.com/reset-password?token=preview',
expiresAtText,
warningBox: {
title: 'Sicherheitshinweis',
text: 'Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren.',
},
},
},
{
name: MailTemplateName.ACCOUNT_CREATED,
context: {
branding,
title: 'Willkommen bei LDAP Portal',
greeting: 'Hallo Maria,',
intro: 'Dein Benutzerkonto wurde angelegt. Du kannst dich jetzt anmelden.',
action: { label: 'Zur Anwendung', url: 'https://portal.example.com' },
},
},
{
name: MailTemplateName.INVITATION,
context: {
branding,
title: 'Einladung zu LDAP Portal',
intro: 'Du wurdest eingeladen, die Anwendung zu nutzen.',
invitedBy: 'Max Mustermann',
action: { label: 'Einladung annehmen', url: 'https://portal.example.com/invite/preview' },
invitationUrl: 'https://portal.example.com/invite/preview',
expiresAtText,
infoBox: { text: `Diese Einladung ist gueltig bis ${expiresAtText}.` },
},
},
{
name: MailTemplateName.GENERIC_NOTIFICATION,
context: {
branding,
title: 'Neue Benachrichtigung',
intro: 'Es gibt eine neue Systembenachrichtigung.',
paragraphs: ['Der Status deines Vorgangs wurde aktualisiert.'],
action: { label: 'Details anzeigen', url: 'https://portal.example.com/notifications/preview' },
},
},
{
name: MailTemplateName.WARNING_NOTIFICATION,
context: {
branding,
title: 'Warnmeldung',
intro: 'Eine Aktion erfordert Aufmerksamkeit.',
warningBox: { text: 'Bitte pruefe die betroffene Konfiguration.' },
},
},
];
for (const preview of previews) {
const html = await renderer.renderHtml(preview.name, preview.context);
await writeFile(join(outputDir, `${preview.name}.html`), html, 'utf8');
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -3,9 +3,13 @@ import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { maskEmail } from '../application-error-log/application-error-sanitizer';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { RequestContextService } from '../common/request-context.service';
import { hashToken, randomToken } from '../common/token.util';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
@@ -24,6 +28,8 @@ export class AccountController {
private readonly mail: PortalMailService,
private readonly config: ConfigService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
@InjectRepository(EmailChangeRequest)
private readonly emailChanges: Repository<EmailChangeRequest>,
@InjectRepository(AccountDeleteRequest)
@@ -64,7 +70,28 @@ export class AccountController {
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
}),
);
await this.mail.sendEmailChangeMail(dto.newEmail, token);
try {
await this.mail.sendEmailChangeMail(dto.newEmail, token);
} catch (error) {
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EMAIL,
code: ApplicationErrorCode.ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED,
module: 'AccountModule',
service: AccountController.name,
operation: 'sendEmailChangeMail',
requestContext: {
...this.requestContext.get(),
userId: request.user.username,
},
context: {
maskedRecipient: maskEmail(dto.newEmail.toLowerCase()),
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
},
handled: true,
});
throw error;
}
await this.audit.record({
type: 'account.email_change_requested',
username: request.user.username,

View File

@@ -10,6 +10,7 @@ import { OidcModule } from './oidc/oidc.module';
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';
@Module({
imports: [
@@ -52,6 +53,7 @@ import { AccountModule } from './account/account.module';
};
},
}),
ApplicationErrorLogModule,
AuditModule,
AdminModule,
MailModule,

View File

@@ -0,0 +1,26 @@
export enum ApplicationErrorCategory {
BACKGROUND_JOB = 'BACKGROUND_JOB',
DATABASE = 'DATABASE',
EMAIL = 'EMAIL',
EXTERNAL_API = 'EXTERNAL_API',
FILE = 'FILE',
OIDC = 'OIDC',
UNHANDLED = 'UNHANDLED',
}
export enum ApplicationErrorCode {
ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED = 'ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED',
BACKGROUND_JOB_FAILED = 'BACKGROUND_JOB_FAILED',
DATABASE_OPERATION_FAILED = 'DATABASE_OPERATION_FAILED',
EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE',
EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_FAILED',
FILE_GENERATION_FAILED = 'FILE_GENERATION_FAILED',
OIDC_AUTHORIZATION_ERROR = 'OIDC_AUTHORIZATION_ERROR',
OIDC_CLIENT_SECRET_DECRYPT_FAILED = 'OIDC_CLIENT_SECRET_DECRYPT_FAILED',
OIDC_INTERACTION_SESSION_NOT_FOUND = 'OIDC_INTERACTION_SESSION_NOT_FOUND',
OIDC_PROVIDER_ERROR = 'OIDC_PROVIDER_ERROR',
PASSWORD_RESET_EMAIL_SEND_FAILED = 'PASSWORD_RESET_EMAIL_SEND_FAILED',
REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_FAILED',
REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED = 'REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED',
UNHANDLED_BACKEND_EXCEPTION = 'UNHANDLED_BACKEND_EXCEPTION',
}

View File

@@ -0,0 +1,74 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'application_error_logs' })
@Index(['createdAt'])
@Index(['code'])
@Index(['category'])
@Index(['correlationId'])
@Index(['userId'])
@Index(['tenantId'])
@Index(['httpStatusCode'])
export class ApplicationErrorLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ default: 'error' })
level!: string;
@Column({ nullable: true })
category?: string;
@Column({ nullable: true })
code?: string;
@Column({ type: 'text' })
message!: string;
@Column({ type: 'text', nullable: true })
stackTrace?: string;
@Column({ nullable: true })
errorType?: 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 })
userId?: string;
@Column({ nullable: true })
tenantId?: string;
@Column({ nullable: true })
environment?: string;
@Column({ nullable: true })
host?: string;
@Column({ type: 'simple-json', nullable: true })
context?: Record<string, unknown>;
@Column({ default: false })
handled!: boolean;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,26 @@
import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CorrelationIdMiddleware } from '../common/correlation-id.middleware';
import { RequestContextService } from '../common/request-context.service';
import { ApplicationErrorFilter } from './application-error.filter';
import { ApplicationErrorLog } from './application-error-log.entity';
import { ApplicationErrorLoggerService } from './application-error-logger.service';
@Global()
@Module({
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationErrorLog])],
providers: [
RequestContextService,
CorrelationIdMiddleware,
ApplicationErrorLoggerService,
{ provide: APP_FILTER, useClass: ApplicationErrorFilter },
],
exports: [ApplicationErrorLoggerService, RequestContextService],
})
export class ApplicationErrorLogModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,21 @@
export interface ApplicationErrorRequestContext {
correlationId?: string;
method?: string;
path?: string;
statusCode?: number;
userId?: string;
tenantId?: string;
}
export interface ApplicationErrorLogInput {
error: unknown;
level?: string;
category?: string;
code?: string;
module?: string;
service?: string;
operation?: string;
requestContext?: ApplicationErrorRequestContext;
context?: Record<string, unknown>;
handled?: boolean;
}

View File

@@ -0,0 +1,88 @@
import { BadRequestException, Logger } from '@nestjs/common';
import { ApplicationErrorLoggerService } from './application-error-logger.service';
import { sanitizeContext, maskEmail } from './application-error-sanitizer';
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
describe('ApplicationErrorLoggerService', () => {
const config = { get: jest.fn((key: string) => (key === 'NODE_ENV' ? 'test' : undefined)) };
function createService(save = jest.fn().mockResolvedValue(undefined)) {
const repo = {
create: jest.fn((value) => value),
save,
};
return { service: new ApplicationErrorLoggerService(repo as any, config as any), repo };
}
it('stores normal Error objects with stack and type', async () => {
const { service, repo } = createService();
const error = new Error('SMTP failed');
await service.log({ error, code: 'TEST_ERROR', category: ApplicationErrorCategory.EMAIL, handled: true });
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ message: 'SMTP failed', errorType: 'Error', code: 'TEST_ERROR', handled: true }));
expect(repo.save.mock.calls[0][0].stackTrace).toContain('Error: SMTP failed');
});
it('handles NestJS HTTP exceptions', async () => {
const { service, repo } = createService();
await service.log({ error: new BadRequestException({ code: 'BAD_INPUT', message: 'Invalid data' }) });
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ httpStatusCode: 400, code: 'BAD_INPUT', level: 'warning' }));
});
it('handles unknown error values safely', async () => {
const { service, repo } = createService();
await service.log({ error: { reason: 'broken' } });
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ message: '{"reason":"broken"}', errorType: 'object' }));
});
it('sanitizes nested sensitive fields and masks email addresses', () => {
const circular: Record<string, unknown> = { email: 'max.mustermann@example.com', nested: { token: 'secret', password: 'x' } };
circular.self = circular;
expect(sanitizeContext(circular)).toEqual({
email: 'm***@example.com',
nested: {},
self: '[Circular]',
});
expect(maskEmail('maria@example.com')).toBe('m***@example.com');
});
it('stores request, user and tenant context', async () => {
const { service, repo } = createService();
await service.log({
error: new Error('failed'),
requestContext: {
correlationId: 'corr-1',
userId: 'user-1',
tenantId: 'tenant-1',
method: 'POST',
path: '/api/test',
},
});
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ correlationId: 'corr-1', userId: 'user-1', tenantId: 'tenant-1' }));
});
it('swallows database write errors', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const { service } = createService(jest.fn().mockRejectedValue(new Error('db down')));
await expect(service.log({ error: new Error('original') })).resolves.toBeUndefined();
});
it('does not persist the same error twice', async () => {
const { service, repo } = createService();
const error = new Error('only once');
await service.log({ error });
await service.log({ error });
expect(repo.save).toHaveBeenCalledTimes(1);
});
it('can record background job failures', async () => {
const { service, repo } = createService();
await service.log({
error: new Error('job failed'),
category: ApplicationErrorCategory.BACKGROUND_JOB,
code: ApplicationErrorCode.BACKGROUND_JOB_FAILED,
operation: 'dailyImport',
handled: true,
});
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ category: 'BACKGROUND_JOB', code: 'BACKGROUND_JOB_FAILED', operation: 'dailyImport' }));
});
});

View File

@@ -0,0 +1,120 @@
import { HttpException, 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 { ApplicationErrorLog } from './application-error-log.entity';
import { ApplicationErrorLogInput } from './application-error-log.types';
import { sanitizeContext, sanitizeString, sanitizeValue } from './application-error-sanitizer';
import { markErrorAsLogged, wasErrorLogged } from './logged-error-marker';
interface ExtractedError {
message: string;
stackTrace?: string;
errorType?: string;
httpStatusCode?: number;
responseCode?: string;
}
@Injectable()
export class ApplicationErrorLoggerService {
private readonly fallbackLogger = new Logger(ApplicationErrorLoggerService.name);
constructor(
@InjectRepository(ApplicationErrorLog)
private readonly logs: Repository<ApplicationErrorLog>,
private readonly config: ConfigService,
) {}
async log(input: ApplicationErrorLogInput): Promise<void> {
if (wasErrorLogged(input.error)) {
return;
}
const extracted = this.extractError(input.error);
try {
await this.logs.save(
this.logs.create({
level: input.level ?? this.levelForStatus(input.requestContext?.statusCode ?? extracted.httpStatusCode),
category: input.category,
code: input.code ?? extracted.responseCode,
message: extracted.message,
stackTrace: extracted.stackTrace,
errorType: extracted.errorType,
backendModule: input.module,
service: input.service,
operation: input.operation,
httpMethod: input.requestContext?.method,
apiPath: input.requestContext?.path,
httpStatusCode: input.requestContext?.statusCode ?? extracted.httpStatusCode,
correlationId: input.requestContext?.correlationId,
userId: input.requestContext?.userId,
tenantId: input.requestContext?.tenantId,
environment: this.config.get<string>('NODE_ENV') ?? 'development',
host: hostname(),
context: sanitizeContext(input.context),
handled: input.handled ?? false,
}),
);
markErrorAsLogged(input.error);
} catch (logError) {
this.fallbackLogger.error(
`Application error log write failed: ${this.extractError(logError).message}; original: ${extracted.message}`,
this.extractError(logError).stackTrace,
);
}
}
private extractError(error: unknown): ExtractedError {
if (error instanceof HttpException) {
const response = error.getResponse();
const responseObject = typeof response === 'object' && response !== null ? response : undefined;
const responseCode =
responseObject && 'code' in responseObject && typeof responseObject.code === 'string'
? responseObject.code
: undefined;
return {
message: sanitizeString(error.message || this.messageFromResponse(response)),
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
errorType: error.constructor.name,
httpStatusCode: error.getStatus(),
responseCode,
};
}
if (error instanceof Error) {
return {
message: sanitizeString(error.message || error.name),
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
errorType: error.constructor.name,
};
}
if (typeof error === 'string') {
return { message: sanitizeString(error), errorType: 'String' };
}
return {
message: sanitizeString(JSON.stringify(sanitizeValue(error)) ?? 'Unknown non-error value'),
errorType: error === null ? 'Null' : typeof error,
};
}
private messageFromResponse(response: string | object): string {
if (typeof response === 'string') {
return response;
}
if ('message' in response) {
const message = response.message;
return Array.isArray(message) ? message.join('; ') : String(message);
}
return 'HTTP exception';
}
private levelForStatus(statusCode?: number): string {
return !statusCode || statusCode >= 500 ? 'error' : 'warning';
}
}

View File

@@ -0,0 +1,131 @@
const sensitiveKeyFragments = [
'password',
'currentpassword',
'newpassword',
'token',
'accesstoken',
'refreshtoken',
'idtoken',
'authorization',
'cookie',
'secret',
'apikey',
'clientsecret',
'resettoken',
'sessionid',
];
const maxDepth = 5;
const maxObjectKeys = 50;
const maxArrayItems = 20;
const maxStringLength = 2_000;
const maxContextLength = 16_000;
export function isSensitiveKey(key: string): boolean {
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
return sensitiveKeyFragments.some((fragment) => normalized.includes(fragment));
}
export function maskEmail(email: string): string {
const [localPart, domain] = email.split('@');
if (!localPart || !domain) {
return email;
}
return `${localPart[0] ?? '*'}***@${domain}`;
}
export function sanitizeString(value: string): string {
const withoutBearer = value.replace(/bearer\s+[a-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]');
const withMaskedEmails = withoutBearer.replace(
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
(email) => maskEmail(email),
);
return truncate(withMaskedEmails, maxStringLength);
}
export function sanitizeContext(input: unknown): Record<string, unknown> | undefined {
const sanitized = sanitizeValue(input, 0, new WeakSet<object>());
if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) {
return undefined;
}
const serialized = JSON.stringify(sanitized);
if (serialized.length <= maxContextLength) {
return sanitized as Record<string, unknown>;
}
return {
truncated: true,
originalLength: serialized.length,
preview: serialized.slice(0, maxContextLength),
};
}
export function sanitizeValue(input: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
if (input === null || input === undefined) {
return input;
}
if (typeof input === 'string') {
return sanitizeString(input);
}
if (typeof input === 'number' || typeof input === 'boolean') {
return input;
}
if (typeof input === 'bigint') {
return input.toString();
}
if (typeof input === 'symbol' || typeof input === 'function') {
return `[${typeof input}]`;
}
if (input instanceof Date) {
return input.toISOString();
}
if (input instanceof Error) {
return {
name: input.name,
message: sanitizeString(input.message),
stack: input.stack ? sanitizeString(input.stack) : undefined,
};
}
if (depth >= maxDepth) {
return '[MaxDepth]';
}
if (seen.has(input)) {
return '[Circular]';
}
seen.add(input);
if (Array.isArray(input)) {
return input.slice(0, maxArrayItems).map((item) => sanitizeValue(item, depth + 1, seen));
}
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input).slice(0, maxObjectKeys)) {
if (isSensitiveKey(key)) {
continue;
}
output[key] = sanitizeValue(value, depth + 1, seen);
}
return output;
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength)}...[truncated]`;
}

View File

@@ -0,0 +1,44 @@
import { BadRequestException } from '@nestjs/common';
import { ApplicationErrorFilter } from './application-error.filter';
describe('ApplicationErrorFilter', () => {
const adapter = { reply: jest.fn() };
const logger = { log: jest.fn().mockResolvedValue(undefined) };
const requestContext = { get: jest.fn(() => ({ correlationId: 'corr-1' })) };
function host(exceptionRequest = {}) {
return {
switchToHttp: () => ({
getRequest: () => ({
method: 'GET',
path: '/broken',
url: '/broken',
originalUrl: '/broken',
query: {},
params: {},
...exceptionRequest,
}),
getResponse: () => ({}),
}),
} as any;
}
beforeEach(() => jest.clearAllMocks());
it('does not log expected validation errors as critical errors', () => {
const filter = new ApplicationErrorFilter({ httpAdapter: adapter } as any, logger as any, requestContext as any);
filter.catch(new BadRequestException('invalid'), host());
expect(logger.log).not.toHaveBeenCalled();
expect(adapter.reply).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ statusCode: 400 }), 400);
});
it('logs unexpected exceptions globally with correlation id', () => {
const filter = new ApplicationErrorFilter({ httpAdapter: adapter } as any, logger as any, requestContext as any);
filter.catch(new Error('boom'), host({ user: { username: 'user-1' } }));
expect(logger.log).toHaveBeenCalledWith(expect.objectContaining({
code: 'UNHANDLED_BACKEND_EXCEPTION',
requestContext: expect.objectContaining({ correlationId: 'corr-1', userId: 'user-1' }),
}));
expect(adapter.reply).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ correlationId: 'corr-1' }), 500);
});
});

View File

@@ -0,0 +1,68 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Request } from 'express';
import { RequestUser } from '../common/request-user';
import { RequestContextService } from '../common/request-context.service';
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
import { ApplicationErrorLoggerService } from './application-error-logger.service';
import { wasErrorLogged } from './logged-error-marker';
@Catch()
@Injectable()
export class ApplicationErrorFilter implements ExceptionFilter {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly errorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<Request & { user?: RequestUser }>();
const response = http.getResponse();
const statusCode = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const context = this.requestContext.get();
if (this.shouldLog(exception, statusCode)) {
void this.errorLogger.log({
error: exception,
category: ApplicationErrorCategory.UNHANDLED,
code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION,
module: 'HTTP',
operation: `${request.method} ${request.route?.path ?? request.path}`,
requestContext: {
correlationId: context.correlationId,
method: request.method,
path: request.originalUrl || request.url,
statusCode,
userId: request.user?.username ?? request.user?.sub,
},
context: { query: request.query, params: request.params },
handled: false,
});
}
this.httpAdapterHost.httpAdapter.reply(response, this.responseBody(exception, statusCode, context.correlationId), statusCode);
}
private shouldLog(exception: unknown, statusCode: number): boolean {
if (wasErrorLogged(exception)) {
return false;
}
return !(exception instanceof HttpException) || statusCode >= 500;
}
private responseBody(exception: unknown, statusCode: number, correlationId?: string): unknown {
if (exception instanceof HttpException) {
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
return { statusCode, message: exceptionResponse, ...(statusCode >= 500 && correlationId ? { correlationId } : {}) };
}
return { ...exceptionResponse, ...(statusCode >= 500 && correlationId ? { correlationId } : {}) };
}
return { statusCode, message: 'Internal server error', ...(correlationId ? { correlationId } : {}) };
}
}

View File

@@ -0,0 +1,21 @@
const loggedErrorMarker = Symbol('applicationErrorLogged');
export function markErrorAsLogged(error: unknown): void {
if (!error || (typeof error !== 'object' && typeof error !== 'function') || wasErrorLogged(error)) {
return;
}
Object.defineProperty(error, loggedErrorMarker, {
value: true,
configurable: false,
enumerable: false,
});
}
export function wasErrorLogged(error: unknown): boolean {
return Boolean(
error &&
(typeof error === 'object' || typeof error === 'function') &&
(error as Record<symbol, unknown>)[loggedErrorMarker],
);
}

View File

@@ -0,0 +1,26 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { NextFunction, Request, Response } from 'express';
import { RequestContextService } from './request-context.service';
export const correlationIdHeader = 'x-correlation-id';
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
constructor(private readonly requestContext: RequestContextService) {}
use(request: Request, response: Response, next: NextFunction): void {
const header = request.headers[correlationIdHeader];
const correlationId = Array.isArray(header) ? header[0] : header || randomUUID();
response.setHeader('X-Correlation-ID', correlationId);
this.requestContext.run(
{
correlationId,
method: request.method,
path: request.originalUrl || request.url,
},
next,
);
}
}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContextData {
correlationId?: string;
method?: string;
path?: string;
userId?: string;
tenantId?: string;
}
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestContextData>();
run<T>(context: RequestContextData, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestContextData {
return this.storage.getStore() ?? {};
}
}

View File

@@ -1,6 +1,10 @@
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Ber, BerWriter, Client } from 'ldapts';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
import { RequestContextService } from '../common/request-context.service';
interface LldapUserInput {
username: string;
@@ -75,7 +79,11 @@ export class LldapService {
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async createUser(input: LldapUserInput): Promise<void> {
await this.graphql(
@@ -113,7 +121,26 @@ export class LldapService {
this.passwordModifyRequestValue(this.userDn(username), password),
);
} catch (error) {
throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EXTERNAL_API,
code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED,
module: 'LldapModule',
service: LldapService.name,
operation: 'setPassword',
requestContext: {
...this.requestContext.get(),
userId: username,
},
context: {
provider: 'LLDAP',
protocol: 'LDAP',
},
handled: true,
});
const exception = new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
markErrorAsLogged(exception);
throw exception;
} finally {
await client.unbind().catch(() => undefined);
}
@@ -393,35 +420,56 @@ export class LldapService {
}
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
const headers = await this.adminHeaders();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify({ query, variables }),
});
const operation = this.graphqlOperationName(query);
try {
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
const headers = await this.adminHeaders();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json().catch(() => ({}))) as {
data?: T;
errors?: Array<{ message?: string }>;
};
const payload = (await response.json().catch(() => ({}))) as {
data?: T;
errors?: Array<{ message?: string }>;
};
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).join('; ') || response.statusText;
if (/not found/i.test(message)) {
throw new NotFoundException('LLDAP user not found');
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).join('; ') || response.statusText;
if (/not found/i.test(message)) {
throw new NotFoundException('LLDAP user not found');
}
const exception = new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
await this.logGraphqlFailure(exception, operation, variables, {
responseStatus: response.status,
responseStatusText: response.statusText,
errorMessages: payload.errors?.map((error) => error.message),
});
markErrorAsLogged(exception);
throw exception;
}
throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
}
if (!payload.data) {
throw new InternalServerErrorException('LLDAP GraphQL response did not contain data');
}
if (!payload.data) {
const exception = new InternalServerErrorException('LLDAP GraphQL response did not contain data');
await this.logGraphqlFailure(exception, operation, variables, { responseStatus: response.status });
markErrorAsLogged(exception);
throw exception;
}
return payload.data;
return payload.data;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
await this.logGraphqlFailure(error, operation, variables, { phase: 'request' });
markErrorAsLogged(error);
throw error;
}
}
private async adminHeaders(): Promise<Record<string, string>> {
@@ -484,4 +532,32 @@ export class LldapService {
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error';
}
private graphqlOperationName(query: string): string {
return query.match(/\b(query|mutation)\s+([A-Za-z0-9_]+)/)?.[2] ?? 'graphql';
}
private async logGraphqlFailure(
error: unknown,
operation: string,
variables: Record<string, unknown>,
context: Record<string, unknown>,
): Promise<void> {
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EXTERNAL_API,
code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED,
module: 'LldapModule',
service: LldapService.name,
operation,
requestContext: this.requestContext.get(),
context: {
provider: 'LLDAP',
protocol: 'GraphQL',
variables,
...context,
},
handled: true,
});
}
}

View File

@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config';
export interface MailBranding {
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
}
export function mailBranding(config: ConfigService): MailBranding {
return {
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

@@ -0,0 +1,23 @@
export class PortalMailTemplateError extends Error {
constructor(
message: string,
readonly templateName: string,
options?: { cause?: unknown },
) {
super(message);
this.name = 'PortalMailTemplateError';
this.cause = options?.cause;
}
}
export class PortalMailDeliveryError extends Error {
constructor(
message: string,
readonly templateName: string,
options?: { cause?: unknown },
) {
super(message);
this.name = 'PortalMailDeliveryError';
this.cause = options?.cause;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import Handlebars from 'handlebars';
import { mailTemplateDir, MailTemplateName } from './mail-template.constants';
@Injectable()
export class MailTemplateRendererService {
private partialsRegistered = false;
async renderHtml(templateName: MailTemplateName | string, context: Record<string, unknown>): Promise<string> {
await this.registerPartials();
const [layoutSource, templateSource] = await Promise.all([
readFile(join(mailTemplateDir, 'layouts', 'base.hbs'), 'utf8'),
readFile(join(mailTemplateDir, `${templateName}.hbs`), 'utf8'),
]);
const body = Handlebars.compile(templateSource)(context);
return Handlebars.compile(layoutSource)({ ...context, body });
}
async renderText(templateName: MailTemplateName | string, context: Record<string, unknown>): Promise<string> {
const source = await readFile(join(mailTemplateDir, `${templateName}.text.hbs`), 'utf8');
return Handlebars.compile(source)(context).trim();
}
private async registerPartials(): Promise<void> {
if (this.partialsRegistered) {
return;
}
const partialDir = join(mailTemplateDir, 'partials');
const files = await readdir(partialDir);
await Promise.all(
files
.filter((file) => file.endsWith('.hbs'))
.map(async (file) => {
const source = await readFile(join(partialDir, file), 'utf8');
Handlebars.registerPartial(file.replace(/\.hbs$/, ''), source);
}),
);
this.partialsRegistered = true;
}
}

View File

@@ -0,0 +1,14 @@
import { join } from 'node:path';
export const mailTemplateDir = join(__dirname, 'templates');
export enum MailTemplateName {
VERIFICATION = 'verification',
PASSWORD_RESET = 'password-reset',
EMAIL_CHANGE = 'email-change',
REGISTRATION_PENDING_APPROVAL = 'registration-pending-approval',
ACCOUNT_CREATED = 'account-created',
INVITATION = 'invitation',
GENERIC_NOTIFICATION = 'generic-notification',
WARNING_NOTIFICATION = 'warning-notification',
}

View File

@@ -0,0 +1,49 @@
import { MailBranding } from './mail-branding';
export interface MailActionContext {
label: string;
url: string;
}
export interface MailInfoBoxContext {
title?: string;
text: string;
}
export interface MailBaseContext {
branding: MailBranding;
preheader?: string;
title: string;
subtitle?: string;
greeting?: string;
intro?: string;
action?: MailActionContext;
secondaryLink?: MailActionContext;
infoBox?: MailInfoBoxContext;
warningBox?: MailInfoBoxContext;
footerNote?: string;
locale?: string;
}
export interface PasswordResetMailContext extends MailBaseContext {
resetUrl: string;
expiresAtText: string;
}
export interface VerificationMailContext extends MailBaseContext {
verificationUrl: string;
expiresAtText: string;
}
export interface EmailChangeMailContext extends MailBaseContext {
confirmUrl: string;
expiresAtText: string;
}
export interface RegistrationPendingApprovalContext extends MailBaseContext {
registration: {
email: string;
displayName: string;
};
adminUrl: string;
}

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/adapters/handlebars.adapter';
import { join } from 'node:path';
import { mailTemplateDir } from './mail-template.constants';
import { MailTemplateRendererService } from './mail-template-renderer.service';
import { PortalMailService } from './portal-mail.service';
@Module({
@@ -23,10 +27,23 @@ import { PortalMailService } from './portal-mail.service';
defaults: {
from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>',
},
template: {
dir: mailTemplateDir,
adapter: new HandlebarsAdapter(undefined, {
inlineCssEnabled: true,
}),
options: {
strict: false,
layout: 'layouts/base',
partials: {
dir: join(mailTemplateDir, 'partials'),
},
},
},
}),
}),
],
providers: [PortalMailService],
providers: [PortalMailService, MailTemplateRendererService],
exports: [PortalMailService],
})
export class MailModule {}

View File

@@ -0,0 +1,100 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { PortalMailService } from './portal-mail.service';
import { mailTemplateDir } from './mail-template.constants';
import { MailTemplateRendererService } from './mail-template-renderer.service';
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
describe('PortalMailService templates', () => {
const config = {
get: jest.fn((key: string) => {
const values: Record<string, string> = {
PUBLIC_WEB_URL: 'https://portal.example.com',
MAIL_PRODUCT_NAME: 'LDAP Portal',
MAIL_COMPANY_NAME: 'Example Corp',
MAIL_PRIMARY_COLOR: '#0f6b6e',
MAIL_SUPPORT_EMAIL: 'support@example.com',
};
return values[key];
}),
};
function createService(sendMail = jest.fn().mockResolvedValue(undefined)) {
const renderer = new MailTemplateRendererService();
return { service: new PortalMailService({ sendMail } as any, config as any, renderer), sendMail, renderer };
}
it('renders password reset HTML with escaped display name and action URL', async () => {
const { renderer } = createService();
const html = await renderer.renderHtml('password-reset', {
branding: {
productName: 'LDAP Portal',
companyName: 'Example Corp',
primaryColor: '#0f6b6e',
supportEmail: 'support@example.com',
},
title: 'Passwort zuruecksetzen',
greeting: 'Hallo <script>alert(1)</script>,',
intro: 'Intro',
action: { label: 'Passwort zuruecksetzen', url: 'https://portal.example.com/reset?token=abc' },
resetUrl: 'https://portal.example.com/reset?token=abc',
expiresAtText: '17.07.2026, 12:30',
warningBox: { text: 'Warnung' },
});
expect(html).toContain('Passwort zuruecksetzen');
expect(html).toContain('https://portal.example.com/reset?token&#x3D;abc');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
it('renders password reset text with the full action URL', async () => {
const { renderer } = createService();
const text = await renderer.renderText('password-reset', {
branding: { supportEmail: 'support@example.com' },
title: 'Passwort zuruecksetzen',
intro: 'Intro',
resetUrl: 'https://portal.example.com/reset?token=abc&x=1',
expiresAtText: '17.07.2026, 12:30',
});
expect(text).toContain('https://portal.example.com/reset?token=abc&x=1');
expect(text).not.toContain('<a');
});
it('supports missing optional display name', async () => {
const { service, sendMail } = createService();
await service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc', expiresAt: new Date('2026-07-17T12:30:00Z') });
expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({
template: 'password-reset',
subject: 'Passwort fuer LDAP Portal zuruecksetzen',
text: expect.stringContaining('Passwort zuruecksetzen'),
}));
});
it('adds branding and passes the expected template name to the mailer', async () => {
const { service, sendMail } = createService();
await service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc', expiresAt: new Date('2026-07-17T12:30:00Z') });
expect(sendMail.mock.calls[0][0].context.branding.productName).toBe('LDAP Portal');
expect(sendMail.mock.calls[0][0].template).toBe('password-reset');
});
it('allows optional notification fields to be omitted', async () => {
const { service, sendMail } = createService();
await service.sendNotificationMail({ recipient: 'admin@example.com', title: 'Hinweis' });
expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({ template: 'generic-notification' }));
});
it('keeps templates available for build asset copying', () => {
expect(existsSync(join(mailTemplateDir, 'password-reset.hbs'))).toBe(true);
expect(existsSync(join(mailTemplateDir, 'partials', 'button.hbs'))).toBe(true);
});
it('wraps template errors separately from delivery errors', async () => {
const renderer = { renderHtml: jest.fn().mockRejectedValue(new Error('missing partial')), renderText: jest.fn() };
const service = new PortalMailService({ sendMail: jest.fn() } as any, config as any, renderer as any);
await expect(service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc' })).rejects.toBeInstanceOf(PortalMailTemplateError);
});
it('wraps delivery errors without treating them as template errors', async () => {
const { service } = createService(jest.fn().mockRejectedValue(new Error('smtp down')));
await expect(service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc' })).rejects.toBeInstanceOf(PortalMailDeliveryError);
});
});

View File

@@ -1,41 +1,137 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import { mailBranding } from './mail-branding';
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
import { MailTemplateName } from './mail-template.constants';
import { MailActionContext, MailBaseContext } from './mail-template.types';
import { MailTemplateRendererService } from './mail-template-renderer.service';
export interface SendPasswordResetMailInput {
recipient: string;
displayName?: string;
token?: string;
resetUrl?: string;
expiresAt?: Date;
locale?: string;
correlationId?: string;
}
export interface SendAccountCreatedMailInput {
recipient: string;
displayName?: string;
loginUrl?: string;
locale?: string;
}
export interface SendInvitationMailInput {
recipient: string;
invitedBy?: string;
organizationName?: string;
invitationUrl: string;
expiresAt?: Date;
locale?: string;
}
export interface SendNotificationMailInput {
recipient: string | string[];
title: string;
intro?: string;
paragraphs?: string[];
action?: MailActionContext;
infoText?: string;
warningText?: string;
footerNote?: string;
locale?: string;
}
type MailTemplateContext = MailBaseContext & { [key: string]: unknown };
interface TemplateMailInput {
to: string | string[];
subject: string;
templateName: MailTemplateName;
context: MailTemplateContext;
}
@Injectable()
export class PortalMailService {
constructor(
private readonly mailer: MailerService,
private readonly config: ConfigService,
private readonly renderer: MailTemplateRendererService,
) {}
async sendVerificationMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
const context = this.baseContext({
title: 'E-Mail-Adresse bestaetigen',
preheader: 'Bitte bestaetige deine E-Mail-Adresse, um die Registrierung fortzusetzen.',
intro: 'Bitte bestaetige deine Registrierung ueber die folgende Schaltflaeche.',
action: { label: 'E-Mail bestaetigen', url },
infoBox: { text: 'Falls du diese Registrierung nicht gestartet hast, kannst du diese E-Mail ignorieren.' },
verificationUrl: url,
expiresAtText: this.formatDateTime(new Date(Date.now() + 24 * 60 * 60_000)),
});
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine Registrierung:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine Registrierung: ${url}`,
subject: `E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`,
templateName: MailTemplateName.VERIFICATION,
context,
});
}
async sendPasswordResetMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: Passwort zuruecksetzen',
html: `<p>Du kannst dein Passwort ueber diesen Link zuruecksetzen:</p><p><a href="${url}">${url}</a></p>`,
text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`,
async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise<void>;
async sendPasswordResetMail(to: string, token: string): Promise<void>;
async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise<void> {
const input =
typeof inputOrTo === 'string'
? { recipient: inputOrTo, token }
: inputOrTo;
const resetUrl =
input.resetUrl ?? `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(input.token ?? '')}`;
const expiresAt = input.expiresAt ?? new Date(Date.now() + 60 * 60_000);
const context = this.baseContext({
title: 'Passwort zuruecksetzen',
preheader: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
greeting: input.displayName ? `Hallo ${input.displayName},` : 'Hallo,',
intro: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert. Ueber die folgende Schaltflaeche kannst du ein neues Passwort vergeben.',
action: { label: 'Passwort zuruecksetzen', url: resetUrl },
warningBox: {
title: 'Sicherheitshinweis',
text: 'Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren. Dein bestehendes Passwort bleibt unveraendert.',
},
resetUrl,
expiresAtText: this.formatDateTime(expiresAt, input.locale),
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Passwort fuer ${context.branding.productName} zuruecksetzen`,
templateName: MailTemplateName.PASSWORD_RESET,
context,
});
}
async sendEmailChangeMail(to: string, token: string): Promise<void> {
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
await this.mailer.sendMail({
const context = this.baseContext({
title: 'Neue E-Mail-Adresse bestaetigen',
preheader: 'Bitte bestaetige deine neue E-Mail-Adresse.',
intro: 'Bitte bestaetige deine neue E-Mail-Adresse ueber die folgende Schaltflaeche.',
action: { label: 'E-Mail-Adresse bestaetigen', url },
infoBox: { text: 'Falls du diese Aenderung nicht angefordert hast, kontaktiere bitte den Support.' },
confirmUrl: url,
expiresAtText: this.formatDateTime(new Date(Date.now() + 24 * 60 * 60_000)),
});
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: neue E-Mail bestaetigen',
html: `<p>Bitte bestaetige deine neue E-Mail-Adresse:</p><p><a href="${url}">${url}</a></p>`,
text: `Bitte bestaetige deine neue E-Mail-Adresse: ${url}`,
subject: `Neue E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`,
templateName: MailTemplateName.EMAIL_CHANGE,
context,
});
}
@@ -43,35 +139,134 @@ export class PortalMailService {
to: string[],
registration: { email: string; displayName: string },
): Promise<void> {
const url = `${this.publicWebUrl}/admin/registrations`;
await this.mailer.sendMail({
const adminUrl = `${this.publicWebUrl}/admin/registrations`;
const context = this.baseContext({
title: 'Registrierung wartet auf Freigabe',
preheader: 'Eine neue Registrierung wurde bestaetigt und wartet auf Freigabe.',
intro: 'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf administrative Freigabe.',
action: { label: 'Registrierungen pruefen', url: adminUrl },
registration,
adminUrl,
});
await this.sendTemplateMail({
to,
subject: 'LDAP Portal: Registrierung wartet auf Freigabe',
html: `
<p>Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.</p>
<p><strong>Name:</strong> ${this.escapeHtml(registration.displayName)}<br>
<strong>E-Mail:</strong> ${this.escapeHtml(registration.email)}</p>
<p><a href="${url}">${url}</a></p>
`,
text: [
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.',
`Name: ${registration.displayName}`,
`E-Mail: ${registration.email}`,
`Admin-Bereich: ${url}`,
].join('\n'),
subject: `${context.branding.productName}: Registrierung wartet auf Freigabe`,
templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL,
context,
});
}
private get publicWebUrl(): string {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise<void> {
const loginUrl = input.loginUrl ?? this.publicWebUrl;
const context = this.baseContext({
title: `Willkommen bei ${mailBranding(this.config).productName}`,
preheader: 'Dein Benutzerkonto wurde angelegt.',
greeting: input.displayName ? `Hallo ${input.displayName},` : 'Hallo,',
intro: 'Dein Benutzerkonto wurde angelegt. Du kannst dich jetzt anmelden.',
action: { label: 'Zur Anwendung', url: loginUrl },
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Willkommen bei ${context.branding.productName}`,
templateName: MailTemplateName.ACCOUNT_CREATED,
context,
});
}
private escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
async sendInvitationMail(input: SendInvitationMailInput): Promise<void> {
const context = this.baseContext({
title: `Einladung zu ${mailBranding(this.config).productName}`,
preheader: 'Du wurdest eingeladen.',
intro: input.organizationName
? `Du wurdest zu ${input.organizationName} eingeladen.`
: 'Du wurdest eingeladen, die Anwendung zu nutzen.',
action: { label: 'Einladung annehmen', url: input.invitationUrl },
infoBox: input.expiresAt
? { text: `Diese Einladung ist gueltig bis ${this.formatDateTime(input.expiresAt, input.locale)}.` }
: undefined,
invitedBy: input.invitedBy,
invitationUrl: input.invitationUrl,
expiresAtText: input.expiresAt ? this.formatDateTime(input.expiresAt, input.locale) : undefined,
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Sie wurden zu ${context.branding.productName} eingeladen`,
templateName: MailTemplateName.INVITATION,
context,
});
}
async sendNotificationMail(input: SendNotificationMailInput): Promise<void> {
const templateName = input.warningText ? MailTemplateName.WARNING_NOTIFICATION : MailTemplateName.GENERIC_NOTIFICATION;
const context = this.baseContext({
title: input.title,
preheader: input.intro ?? input.title,
intro: input.intro,
action: input.action,
infoBox: input.infoText ? { text: input.infoText } : undefined,
warningBox: input.warningText ? { text: input.warningText } : undefined,
footerNote: input.footerNote,
paragraphs: input.paragraphs ?? [],
locale: input.locale,
});
await this.sendTemplateMail({
to: input.recipient,
subject: `Neue Benachrichtigung in ${context.branding.productName}`,
templateName,
context,
});
}
private async sendTemplateMail(input: TemplateMailInput): Promise<void> {
let text: string;
try {
await this.renderer.renderHtml(input.templateName, input.context);
text = await this.renderer.renderText(input.templateName, input.context);
} catch (error) {
throw new PortalMailTemplateError(`Mail template rendering failed: ${input.templateName}`, input.templateName, {
cause: error,
});
}
try {
await this.mailer.sendMail({
to: input.to,
subject: input.subject,
template: input.templateName,
context: input.context,
text,
headers: {
'X-Mail-Template': input.templateName,
},
});
} catch (error) {
throw new PortalMailDeliveryError(`Mail delivery failed: ${input.templateName}`, input.templateName, {
cause: error,
});
}
}
private baseContext<T extends Record<string, unknown>>(context: T): T & MailTemplateContext {
return {
branding: mailBranding(this.config),
...context,
} as T & MailTemplateContext;
}
private formatDateTime(value: Date, locale = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value);
}
private get publicWebUrl(): string {
return (this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200').replace(/\/+$/, '');
}
}

View File

@@ -0,0 +1,5 @@
{{#if greeting}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{greeting}}</p>{{/if}}
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
{{#if action}}{{> button label=action.label url=action.url}}{{/if}}
{{#if action}}{{> secondary-link url=action.url}}{{/if}}
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}

View File

@@ -0,0 +1,11 @@
{{title}}
{{#if greeting}}{{greeting}}
{{/if}}{{intro}}
{{#if action}}{{action.label}}:
{{{action.url}}}
{{/if}}
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,5 @@
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
{{> button label=action.label url=action.url}}
{{> secondary-link url=confirmUrl}}
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}

View File

@@ -0,0 +1,10 @@
{{title}}
{{intro}}
E-Mail-Adresse bestaetigen:
{{{confirmUrl}}}
Der Link ist gueltig bis {{expiresAtText}}.
Falls du diese Aenderung nicht angefordert hast, kontaktiere bitte den Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,5 @@
{{#if intro}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>{{/if}}
{{#each paragraphs}}<p style="font-size:15px; line-height:1.6; margin:0 0 16px;">{{this}}</p>{{/each}}
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
{{#if action}}{{> button label=action.label url=action.url}}{{> secondary-link url=action.url}}{{/if}}
{{#if secondaryLink}}<p style="font-size:15px; line-height:1.6; margin:20px 0 0;"><a href="{{secondaryLink.url}}" style="color:{{branding.primaryColor}};">{{secondaryLink.label}}</a></p>{{/if}}

View File

@@ -0,0 +1,14 @@
{{title}}
{{#if intro}}{{intro}}
{{/if}}{{#each paragraphs}}{{this}}
{{/each}}{{#if infoBox}}{{infoBox.text}}
{{/if}}{{#if action}}{{action.label}}:
{{{action.url}}}
{{/if}}{{#if secondaryLink}}{{secondaryLink.label}}:
{{{secondaryLink.url}}}
{{/if}}

View File

@@ -0,0 +1,6 @@
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
{{#if invitedBy}}<p style="font-size:15px; line-height:1.6; margin:0 0 18px;">Eingeladen von: <strong>{{invitedBy}}</strong></p>{{/if}}
{{> button label=action.label url=action.url}}
{{> secondary-link url=invitationUrl}}
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
{{#if warningBox}}{{> warning-box text=warningBox.text}}{{/if}}

View File

@@ -0,0 +1,11 @@
{{title}}
{{intro}}
{{#if invitedBy}}Eingeladen von: {{invitedBy}}
{{/if}}
Einladung annehmen:
{{{invitationUrl}}}
{{#if expiresAtText}}Diese Einladung ist gueltig bis {{expiresAtText}}.
{{/if}}
Falls du diese Einladung nicht erwartest, ignoriere diese E-Mail.

View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="{{#if locale}}{{locale}}{{else}}de{{/if}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<title>{{title}}</title>
<style>
@media screen and (max-width: 640px) {
.outer { padding: 18px 10px !important; }
.container { width: 100% !important; }
.content { padding: 28px 22px !important; }
.button { display: block !important; width: 100% !important; box-sizing: border-box !important; }
}
</style>
</head>
<body style="margin:0; padding:0; background:#f4f7f9; color:#1f2933; font-family:Arial, Helvetica, sans-serif;">
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">{{preheader}}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f7f9; border-collapse:collapse;">
<tr>
<td class="outer" align="center" style="padding:32px 16px;">
<table class="container" role="presentation" width="640" cellpadding="0" cellspacing="0" style="width:640px; max-width:640px; border-collapse:collapse;">
<tr>
<td>{{> header}}</td>
</tr>
<tr>
<td class="content" style="background:#ffffff; border:1px solid #dbe4ea; border-radius:8px; padding:40px 44px;">
<h1 style="font-size:24px; line-height:1.25; margin:0 0 12px; color:#17212b;">{{title}}</h1>
{{#if subtitle}}<p style="font-size:16px; line-height:1.5; margin:0 0 24px; color:#516173;">{{subtitle}}</p>{{/if}}
{{{body}}}
</td>
</tr>
<tr>
<td>{{> footer}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,7 @@
<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:26px 0;">
<tr>
<td>
<a class="button" href="{{url}}" style="background:{{branding.primaryColor}}; border-radius:6px; color:#ffffff; display:inline-block; font-size:16px; font-weight:700; line-height:20px; padding:14px 22px; text-align:center; text-decoration:none;">{{label}}</a>
</td>
</tr>
</table>

View File

@@ -0,0 +1,3 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
<tr><td style="border-top:1px solid #e5edf2; font-size:0; line-height:0;">&nbsp;</td></tr>
</table>

View File

@@ -0,0 +1,14 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="padding:20px 4px 0; color:#6b7785; font-size:12px; line-height:1.55;">
<p style="margin:0 0 8px;">Diese E-Mail wurde automatisch erstellt. Bitte antworte nicht direkt auf diese Nachricht.</p>
{{#if footerNote}}<p style="margin:0 0 8px;">{{footerNote}}</p>{{/if}}
<p style="margin:0 0 8px;">Support: <a href="mailto:{{branding.supportEmail}}" style="color:{{branding.primaryColor}};">{{branding.supportEmail}}</a></p>
<p style="margin:0;">
{{branding.companyName}}
{{#if branding.imprintUrl}} · <a href="{{branding.imprintUrl}}" style="color:{{branding.primaryColor}};">Impressum</a>{{/if}}
{{#if branding.privacyUrl}} · <a href="{{branding.privacyUrl}}" style="color:{{branding.primaryColor}};">Datenschutz</a>{{/if}}
</p>
</td>
</tr>
</table>

View File

@@ -0,0 +1,11 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:0 0 18px;">
<tr>
<td style="padding:0 4px 10px;">
{{#if branding.logoUrl}}
<img src="{{branding.logoUrl}}" alt="{{branding.productName}}" width="140" style="border:0; display:block; max-width:140px; height:auto;">
{{else}}
<div style="font-size:18px; font-weight:700; color:#17212b;">{{branding.productName}}</div>
{{/if}}
</td>
</tr>
</table>

View File

@@ -0,0 +1,8 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
<tr>
<td style="background:#eef8f7; border:1px solid #c7e3df; border-radius:6px; color:#224f4d; font-size:14px; line-height:1.5; padding:14px 16px;">
{{#if title}}<strong style="display:block; margin:0 0 4px;">{{title}}</strong>{{/if}}
{{text}}
</td>
</tr>
</table>

View File

@@ -0,0 +1,8 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:20px 0;">
{{#each items}}
<tr>
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">{{label}}</td>
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{value}}</td>
</tr>
{{/each}}
</table>

View File

@@ -0,0 +1,4 @@
<p style="font-size:13px; line-height:1.5; margin:18px 0 0; color:#617080;">
Falls die Schaltflaeche nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="{{url}}" style="color:{{branding.primaryColor}}; overflow-wrap:anywhere; word-break:break-word;">{{url}}</a>
</p>

View File

@@ -0,0 +1,8 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
<tr>
<td style="background:#fff7ed; border:1px solid #fed7aa; border-radius:6px; color:#7c2d12; font-size:14px; line-height:1.5; padding:14px 16px;">
{{#if title}}<strong style="display:block; margin:0 0 4px;">{{title}}</strong>{{/if}}
{{text}}
</td>
</tr>
</table>

View File

@@ -0,0 +1,7 @@
{{#if greeting}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{greeting}}</p>{{/if}}
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
{{> button label=action.label url=action.url}}
{{> secondary-link url=resetUrl}}
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
{{#if warningBox}}{{> warning-box title=warningBox.title text=warningBox.text}}{{/if}}
<p style="font-size:15px; line-height:1.6; margin:0;">Bei Fragen hilft dir der Support unter <a href="mailto:{{branding.supportEmail}}" style="color:{{branding.primaryColor}};">{{branding.supportEmail}}</a>.</p>

View File

@@ -0,0 +1,14 @@
{{title}}
{{#if greeting}}{{greeting}}
{{/if}}{{intro}}
Passwort zuruecksetzen:
{{{resetUrl}}}
Der Link ist gueltig bis {{expiresAtText}}.
Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren. Dein bestehendes Passwort bleibt unveraendert.
Support: {{branding.supportEmail}}

View File

@@ -0,0 +1,13 @@
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:20px 0;">
<tr>
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">Name</td>
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{registration.displayName}}</td>
</tr>
<tr>
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">E-Mail</td>
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{registration.email}}</td>
</tr>
</table>
{{> button label=action.label url=action.url}}
{{> secondary-link url=adminUrl}}

View File

@@ -0,0 +1,9 @@
{{title}}
{{intro}}
Name: {{registration.displayName}}
E-Mail: {{registration.email}}
Admin-Bereich:
{{{adminUrl}}}

View File

@@ -0,0 +1,5 @@
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
{{> button label=action.label url=action.url}}
{{> secondary-link url=verificationUrl}}
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}

View File

@@ -0,0 +1,10 @@
{{title}}
{{intro}}
E-Mail bestaetigen:
{{{verificationUrl}}}
Der Link ist gueltig bis {{expiresAtText}}.
Falls du diese Registrierung nicht gestartet hast, kannst du diese E-Mail ignorieren.

View File

@@ -0,0 +1,4 @@
{{#if intro}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>{{/if}}
{{#each paragraphs}}<p style="font-size:15px; line-height:1.6; margin:0 0 16px;">{{this}}</p>{{/each}}
{{#if warningBox}}{{> warning-box text=warningBox.text}}{{/if}}
{{#if action}}{{> button label=action.label url=action.url}}{{> secondary-link url=action.url}}{{/if}}

View File

@@ -0,0 +1,12 @@
{{title}}
{{#if intro}}{{intro}}
{{/if}}{{#each paragraphs}}{{this}}
{{/each}}{{#if warningBox}}Warnung:
{{warningBox.text}}
{{/if}}{{#if action}}{{action.label}}:
{{{action.url}}}
{{/if}}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateApplicationErrorLogs1721200000000 implements MigrationInterface {
name = 'CreateApplicationErrorLogs1721200000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'application_error_logs',
columns: [
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
{ name: 'level', type: 'varchar', length: '255', default: "'error'" },
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
{ name: 'code', type: 'varchar', length: '255', isNullable: true },
{ name: 'message', type: 'text' },
{ name: 'stackTrace', type: 'text', isNullable: true },
{ name: 'errorType', 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: 'userId', type: 'varchar', length: '255', isNullable: true },
{ name: 'tenantId', type: 'varchar', length: '255', 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: 'handled', type: 'tinyint', default: 0 },
{ name: 'createdAt', type: 'datetime', precision: 6, default: 'CURRENT_TIMESTAMP(6)' },
],
}),
);
for (const columnName of ['createdAt', 'code', 'category', 'correlationId', 'userId', 'tenantId', 'httpStatusCode']) {
await queryRunner.createIndex(
'application_error_logs',
new TableIndex({ name: `IDX_application_error_logs_${columnName}`, columnNames: [columnName] }),
);
}
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('application_error_logs');
}
}

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { Repository } from 'typeorm';
import { decryptSecret, encryptSecret, randomToken } from '../common/token.util';
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
@@ -131,7 +131,23 @@ export class OidcClientService {
};
if (client.encryptedClientSecret) {
metadata.client_secret = decryptSecret(client.encryptedClientSecret, this.tokenSecret);
try {
metadata.client_secret = decryptSecret(client.encryptedClientSecret, this.tokenSecret);
} catch (error) {
console.error(
'[OIDC_CLIENT_SECRET_DECRYPT_FAILED]',
JSON.stringify({
clientId: client.clientId,
clientName: client.clientName,
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
tokenSecretFingerprint: this.tokenSecretFingerprint,
encryptedClientSecretParts: client.encryptedClientSecret.split(':').length,
errorMessage: error instanceof Error ? error.message : String(error),
}),
error instanceof Error ? error.stack : '',
);
throw error;
}
metadata.client_secret_expires_at = 0;
}
@@ -160,4 +176,8 @@ export class OidcClientService {
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
private get tokenSecretFingerprint(): string {
return createHash('sha256').update(this.tokenSecret).digest('hex').slice(0, 12);
}
}

View File

@@ -1,19 +1,38 @@
import { Body, Controller, Get, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Get, Logger, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
import type { Interaction } from 'oidc-provider';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { RequestContextService } from '../common/request-context.service';
import { OidcProviderService } from './oidc-provider.service';
@Controller('interaction')
export class OidcInteractionController {
private readonly logger = new Logger(OidcInteractionController.name);
constructor(
private readonly oidc: OidcProviderService,
private readonly config: ConfigService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
@Get(':uid')
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
const details = await this.oidc.interactionDetails(request, response);
let details: Interaction;
try {
details = await this.oidc.interactionDetails(request, response);
} catch (error) {
await this.logInteractionSessionError(error, uid, request);
response.status(400).send(
this.page(
'Anmeldung abgelaufen',
'<p>Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.</p>',
),
);
return;
}
if (details.uid !== uid) {
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
return;
@@ -194,4 +213,35 @@ export class OidcInteractionController {
private isInvalidCredentialsError(error: unknown): boolean {
return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.';
}
private async logInteractionSessionError(error: unknown, uid: string, request: Request): Promise<void> {
const logPayload = {
uid,
method: request.method,
path: request.originalUrl || request.url,
host: request.headers.host,
forwardedProto: request.headers['x-forwarded-proto'],
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
};
console.error('[OIDC_INTERACTION_SESSION_NOT_FOUND]', JSON.stringify(logPayload), error instanceof Error ? error.stack : '');
this.logger.warn(`OIDC interaction session not found: ${logPayload.errorMessage}`);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.OIDC,
code: ApplicationErrorCode.OIDC_INTERACTION_SESSION_NOT_FOUND,
module: 'OidcModule',
service: OidcInteractionController.name,
operation: 'interactionDetails',
requestContext: {
...this.requestContext.get(),
method: request.method,
path: request.originalUrl || request.url,
statusCode: 400,
},
context: logPayload,
handled: true,
});
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, InternalServerErrorException, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpAdapterHost } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
@@ -6,7 +6,10 @@ import { Request, Response } from 'express';
import type Provider from 'oidc-provider';
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
import { Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { AuditService } from '../audit/audit.service';
import { RequestContextService } from '../common/request-context.service';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
@@ -20,6 +23,7 @@ type JoseImport = typeof import('jose');
@Injectable()
export class OidcProviderService implements OnModuleInit {
private readonly logger = new Logger(OidcProviderService.name);
private provider?: Provider;
constructor(
@@ -35,6 +39,8 @@ export class OidcProviderService implements OnModuleInit {
private readonly ldapAuth: LdapAuthService,
private readonly lldap: LldapService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async onModuleInit(): Promise<void> {
@@ -45,6 +51,7 @@ export class OidcProviderService implements OnModuleInit {
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
this.registerAuditEvents(this.provider);
this.registerErrorEvents(this.provider);
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
expressApp.use(this.provider.callback());
@@ -277,6 +284,95 @@ 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) => {
void this.audit.record({
type: 'oidc.interaction_started',
username: interaction?.session?.accountId,
metadata: {
uid: interaction?.uid,
prompt: interaction?.prompt?.name,
clientId: interaction?.params?.client_id,
redirectUri: interaction?.params?.redirect_uri,
scope: interaction?.params?.scope,
},
});
});
}
private registerErrorEvents(provider: Provider): void {
const source = provider as unknown as {
on(eventName: string, listener: (ctx: unknown, error?: unknown) => void): void;
};
for (const eventName of ['server_error', 'authorization.error', 'grant.error', 'introspection.error', 'revocation.error']) {
source.on(eventName, (ctx, error) => {
const actualError = error ?? ctx;
this.consoleLogOidcProviderError(eventName, ctx, actualError);
void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => {
this.logger.error(`OIDC provider error logging failed: ${this.errorProperty(logError, 'message')}`);
});
});
}
}
private consoleLogOidcProviderError(eventName: string, ctx: unknown, error: unknown): void {
const payload = this.oidcErrorContext(eventName, ctx, error);
console.error('[OIDC_PROVIDER_ERROR]', JSON.stringify(payload), error instanceof Error ? error.stack : '');
}
private async logOidcProviderError(eventName: string, ctx: unknown, error: unknown): Promise<void> {
const context = this.oidcErrorContext(eventName, ctx, error);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.OIDC,
code:
eventName === 'server_error'
? ApplicationErrorCode.OIDC_PROVIDER_ERROR
: ApplicationErrorCode.OIDC_AUTHORIZATION_ERROR,
module: 'OidcModule',
service: OidcProviderService.name,
operation: eventName,
requestContext: {
...this.requestContext.get(),
method: typeof context.method === 'string' ? context.method : undefined,
path: typeof context.path === 'string' ? context.path : undefined,
statusCode: typeof context.status === 'number' ? context.status : undefined,
},
context,
handled: false,
});
}
private oidcErrorContext(eventName: string, ctx: unknown, error: unknown): Record<string, unknown> {
const oidcCtx = ctx as {
method?: string;
path?: string;
status?: number;
oidc?: {
route?: string;
client?: { clientId?: string };
params?: Record<string, unknown>;
};
headers?: Record<string, unknown>;
host?: string;
};
const params = oidcCtx?.oidc?.params ?? {};
return {
eventName,
errorName: this.errorProperty(error, 'name'),
errorMessage: this.errorProperty(error, 'message'),
method: oidcCtx?.method,
path: oidcCtx?.path,
status: oidcCtx?.status,
route: oidcCtx?.oidc?.route,
clientId: oidcCtx?.oidc?.client?.clientId ?? params.client_id,
redirectUri: params.redirect_uri,
responseType: params.response_type,
scope: params.scope,
host: oidcCtx?.host ?? oidcCtx?.headers?.host,
forwardedProto: oidcCtx?.headers?.['x-forwarded-proto'],
};
}
private getProvider(): Provider {
@@ -293,4 +389,8 @@ export class OidcProviderService implements OnModuleInit {
private async importJose(): Promise<JoseImport> {
return new Function('specifier', 'return import(specifier)')('jose') as Promise<JoseImport>;
}
private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined {
return error instanceof Error ? error[property] : undefined;
}
}

View File

@@ -0,0 +1,31 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { PasswordService } from './password.service';
describe('PasswordService', () => {
it('logs password reset mail delivery failures with stable error code', async () => {
const resetTokens = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 'token-id' })),
delete: jest.fn().mockResolvedValue(undefined),
};
const service = new PasswordService(
resetTokens as any,
{ get: jest.fn((key: string) => (key === 'SMTP_HOST' ? 'smtp.example.com' : 'secret')), getOrThrow: jest.fn(() => 'secret') } as any,
{} as any,
{ findUserByEmail: jest.fn().mockResolvedValue({ id: 'user-1', email: 'maria@example.com' }) } as any,
{ sendPasswordResetMail: jest.fn().mockRejectedValue(new Error('SMTP unavailable')) } as any,
{ record: jest.fn().mockResolvedValue(undefined) } as any,
{ log: jest.fn().mockResolvedValue(undefined) } as any,
{ get: jest.fn(() => ({ correlationId: 'corr-1' })) } as any,
);
await expect(service.requestReset('maria@example.com')).rejects.toBeInstanceOf(ServiceUnavailableException);
expect((service as any).applicationErrorLogger.log).toHaveBeenCalledWith(expect.objectContaining({
code: 'PASSWORD_RESET_EMAIL_SEND_FAILED',
category: 'EMAIL',
requestContext: expect.objectContaining({ correlationId: 'corr-1', userId: 'user-1' }),
context: expect.objectContaining({ maskedRecipient: 'm***@example.com', mailProvider: 'smtp.example.com' }),
handled: true,
}));
});
});

View File

@@ -2,8 +2,13 @@ import { BadRequestException, Injectable, ServiceUnavailableException, Unauthori
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { maskEmail } from '../application-error-log/application-error-sanitizer';
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
import { AuditService } from '../audit/audit.service';
import { assertPasswordPolicy } from '../common/password-policy';
import { RequestContextService } from '../common/request-context.service';
import { hashToken, randomToken } from '../common/token.util';
import { LdapAuthService } from '../lldap/ldap-auth.service';
import { LldapService } from '../lldap/lldap.service';
@@ -20,6 +25,8 @@ export class PasswordService {
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async changePassword(
@@ -70,9 +77,30 @@ export class PasswordService {
);
try {
await this.mail.sendPasswordResetMail(user.email, token);
await this.mail.sendPasswordResetMail({
recipient: user.email,
token,
expiresAt: resetToken.expiresAt,
});
} catch (error) {
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EMAIL,
code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED,
module: 'PasswordModule',
service: PasswordService.name,
operation: 'sendPasswordResetEmail',
requestContext: {
...this.requestContext.get(),
userId: user.id,
},
context: {
maskedRecipient: maskEmail(user.email),
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
},
handled: true,
});
await this.audit
.record({
type: 'password.reset_mail_failed',
@@ -82,9 +110,11 @@ export class PasswordService {
metadata: { error: this.errorMessage(error) },
})
.catch(() => undefined);
throw new ServiceUnavailableException(
const exception = new ServiceUnavailableException(
'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
);
markErrorAsLogged(exception);
throw exception;
}
await this.audit.record({

View File

@@ -2,8 +2,13 @@ import { BadRequestException, ConflictException, Injectable, ServiceUnavailableE
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
import { maskEmail } from '../application-error-log/application-error-sanitizer';
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
import { AuditService } from '../audit/audit.service';
import { assertPasswordPolicy } from '../common/password-policy';
import { RequestContextService } from '../common/request-context.service';
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
@@ -22,6 +27,8 @@ export class RegistrationService {
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly audit: AuditService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
@@ -65,6 +72,23 @@ export class RegistrationService {
} catch (error) {
await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined);
await this.registrations.delete({ id: registration.id }).catch(() => undefined);
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EMAIL,
code: ApplicationErrorCode.REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED,
module: 'RegistrationModule',
service: RegistrationService.name,
operation: 'sendVerificationMail',
requestContext: {
...this.requestContext.get(),
userId: registration.username,
},
context: {
maskedRecipient: maskEmail(registration.email),
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
},
handled: true,
});
await this.audit
.record({
type: 'registration.verification_mail_failed',
@@ -74,9 +98,11 @@ export class RegistrationService {
metadata: { error: this.errorMessage(error) },
})
.catch(() => undefined);
throw new ServiceUnavailableException(
const exception = new ServiceUnavailableException(
'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
);
markErrorAsLogged(exception);
throw exception;
}
await this.audit.record({
@@ -114,13 +140,30 @@ export class RegistrationService {
userAgent,
});
await this.notifyUserManagers(registration).catch((error) =>
this.audit.record({
await this.notifyUserManagers(registration).catch(async (error) => {
await this.applicationErrorLogger.log({
error,
category: ApplicationErrorCategory.EMAIL,
code: ApplicationErrorCode.REGISTRATION_APPROVAL_NOTIFICATION_FAILED,
module: 'RegistrationModule',
service: RegistrationService.name,
operation: 'notifyUserManagers',
requestContext: {
...this.requestContext.get(),
userId: registration.username,
},
context: {
maskedRecipient: maskEmail(registration.email),
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
},
handled: true,
});
return this.audit.record({
type: 'registration.approval_notification_failed',
username: registration.username,
metadata: { error: this.errorMessage(error) },
}),
);
});
});
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
}

View File

@@ -3,5 +3,6 @@
"compilerOptions": {
"declaration": true,
"removeComments": true
}
},
"exclude": ["src/**/*.spec.ts", "dist", "node_modules", "test"]
}

View File

@@ -4,7 +4,7 @@
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"types": ["node"]
"types": ["node", "jest"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "test"]