diff --git a/.env.example b/.env.example index 1da0198..5f196be 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,13 @@ SMTP_SECURE=false SMTP_USER=portal@example.com SMTP_PASS=change-me SMTP_FROM="LDAP Portal " +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 diff --git a/README.md b/README.md index c835e47..aaa2386 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..395b788 --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/src/**/*.spec.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], +}; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index 9b3d2ab..0db9dc9 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -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 + } + ] } } diff --git a/apps/api/package.json b/apps/api/package.json index 2ffb077..2b87071 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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\"" }, diff --git a/apps/api/scripts/copy-mail-assets.js b/apps/api/scripts/copy-mail-assets.js new file mode 100644 index 0000000..8a0e17c --- /dev/null +++ b/apps/api/scripts/copy-mail-assets.js @@ -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); diff --git a/apps/api/scripts/render-mail-previews.ts b/apps/api/scripts/render-mail-previews.ts new file mode 100644 index 0000000..2f258ea --- /dev/null +++ b/apps/api/scripts/render-mail-previews.ts @@ -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 { + 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 }> = [ + { + 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; +}); diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index 6f493e2..5d6fb22 100644 --- a/apps/api/src/account/account.controller.ts +++ b/apps/api/src/account/account.controller.ts @@ -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, @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('SMTP_HOST') ?? 'smtp', + }, + handled: true, + }); + throw error; + } await this.audit.record({ type: 'account.email_change_requested', username: request.user.username, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index eaf4878..1d059b5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/application-error-log/application-error-codes.ts b/apps/api/src/application-error-log/application-error-codes.ts new file mode 100644 index 0000000..b909ce9 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-codes.ts @@ -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', +} diff --git a/apps/api/src/application-error-log/application-error-log.entity.ts b/apps/api/src/application-error-log/application-error-log.entity.ts new file mode 100644 index 0000000..2a0c9b2 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.entity.ts @@ -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; + + @Column({ default: false }) + handled!: boolean; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/apps/api/src/application-error-log/application-error-log.module.ts b/apps/api/src/application-error-log/application-error-log.module.ts new file mode 100644 index 0000000..1627131 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.module.ts @@ -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('*'); + } +} diff --git a/apps/api/src/application-error-log/application-error-log.types.ts b/apps/api/src/application-error-log/application-error-log.types.ts new file mode 100644 index 0000000..00fba79 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.types.ts @@ -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; + handled?: boolean; +} diff --git a/apps/api/src/application-error-log/application-error-logger.service.spec.ts b/apps/api/src/application-error-log/application-error-logger.service.spec.ts new file mode 100644 index 0000000..cd71720 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-logger.service.spec.ts @@ -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 = { 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' })); + }); +}); diff --git a/apps/api/src/application-error-log/application-error-logger.service.ts b/apps/api/src/application-error-log/application-error-logger.service.ts new file mode 100644 index 0000000..6749a7d --- /dev/null +++ b/apps/api/src/application-error-log/application-error-logger.service.ts @@ -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, + private readonly config: ConfigService, + ) {} + + async log(input: ApplicationErrorLogInput): Promise { + 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('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'; + } +} diff --git a/apps/api/src/application-error-log/application-error-sanitizer.ts b/apps/api/src/application-error-log/application-error-sanitizer.ts new file mode 100644 index 0000000..d840be4 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-sanitizer.ts @@ -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 | undefined { + const sanitized = sanitizeValue(input, 0, new WeakSet()); + if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) { + return undefined; + } + + const serialized = JSON.stringify(sanitized); + if (serialized.length <= maxContextLength) { + return sanitized as Record; + } + + return { + truncated: true, + originalLength: serialized.length, + preview: serialized.slice(0, maxContextLength), + }; +} + +export function sanitizeValue(input: unknown, depth = 0, seen = new WeakSet()): 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 = {}; + 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]`; +} diff --git a/apps/api/src/application-error-log/application-error.filter.spec.ts b/apps/api/src/application-error-log/application-error.filter.spec.ts new file mode 100644 index 0000000..6601aec --- /dev/null +++ b/apps/api/src/application-error-log/application-error.filter.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/application-error-log/application-error.filter.ts b/apps/api/src/application-error-log/application-error.filter.ts new file mode 100644 index 0000000..dbd6c23 --- /dev/null +++ b/apps/api/src/application-error-log/application-error.filter.ts @@ -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(); + 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 } : {}) }; + } +} diff --git a/apps/api/src/application-error-log/logged-error-marker.ts b/apps/api/src/application-error-log/logged-error-marker.ts new file mode 100644 index 0000000..3be5bb4 --- /dev/null +++ b/apps/api/src/application-error-log/logged-error-marker.ts @@ -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)[loggedErrorMarker], + ); +} diff --git a/apps/api/src/common/correlation-id.middleware.ts b/apps/api/src/common/correlation-id.middleware.ts new file mode 100644 index 0000000..c4bdf1b --- /dev/null +++ b/apps/api/src/common/correlation-id.middleware.ts @@ -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, + ); + } +} diff --git a/apps/api/src/common/request-context.service.ts b/apps/api/src/common/request-context.service.ts new file mode 100644 index 0000000..4e7dbfa --- /dev/null +++ b/apps/api/src/common/request-context.service.ts @@ -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(); + + run(context: RequestContextData, callback: () => T): T { + return this.storage.run(context, callback); + } + + get(): RequestContextData { + return this.storage.getStore() ?? {}; + } +} diff --git a/apps/api/src/lldap/lldap.service.ts b/apps/api/src/lldap/lldap.service.ts index a00a859..98fdd7f 100644 --- a/apps/api/src/lldap/lldap.service.ts +++ b/apps/api/src/lldap/lldap.service.ts @@ -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 }; - constructor(private readonly config: ConfigService) {} + constructor( + private readonly config: ConfigService, + private readonly applicationErrorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, + ) {} async createUser(input: LldapUserInput): Promise { 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(query: string, variables: Record): Promise { - const endpoint = `${this.config.getOrThrow('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('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> { @@ -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, + context: Record, + ): Promise { + 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, + }); + } } diff --git a/apps/api/src/mail/mail-branding.ts b/apps/api/src/mail/mail-branding.ts new file mode 100644 index 0000000..80b65ea --- /dev/null +++ b/apps/api/src/mail/mail-branding.ts @@ -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('MAIL_PRODUCT_NAME') ?? 'LDAP Portal', + companyName: config.get('MAIL_COMPANY_NAME') ?? 'LDAP Portal', + primaryColor: config.get('MAIL_PRIMARY_COLOR') ?? '#0f6b6e', + supportEmail: config.get('MAIL_SUPPORT_EMAIL') ?? 'support@example.com', + logoUrl: config.get('MAIL_LOGO_URL') || undefined, + imprintUrl: config.get('MAIL_IMPRINT_URL') || undefined, + privacyUrl: config.get('MAIL_PRIVACY_URL') || undefined, + }; +} diff --git a/apps/api/src/mail/mail-errors.ts b/apps/api/src/mail/mail-errors.ts new file mode 100644 index 0000000..72438b1 --- /dev/null +++ b/apps/api/src/mail/mail-errors.ts @@ -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; + } +} diff --git a/apps/api/src/mail/mail-template-renderer.service.ts b/apps/api/src/mail/mail-template-renderer.service.ts new file mode 100644 index 0000000..042f2a9 --- /dev/null +++ b/apps/api/src/mail/mail-template-renderer.service.ts @@ -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): Promise { + 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): Promise { + const source = await readFile(join(mailTemplateDir, `${templateName}.text.hbs`), 'utf8'); + return Handlebars.compile(source)(context).trim(); + } + + private async registerPartials(): Promise { + 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; + } +} diff --git a/apps/api/src/mail/mail-template.constants.ts b/apps/api/src/mail/mail-template.constants.ts new file mode 100644 index 0000000..050e19a --- /dev/null +++ b/apps/api/src/mail/mail-template.constants.ts @@ -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', +} diff --git a/apps/api/src/mail/mail-template.types.ts b/apps/api/src/mail/mail-template.types.ts new file mode 100644 index 0000000..fc1f8b5 --- /dev/null +++ b/apps/api/src/mail/mail-template.types.ts @@ -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; +} diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index 99d263c..aec0d6b 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -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('SMTP_FROM') ?? 'LDAP Portal ', }, + 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 {} diff --git a/apps/api/src/mail/portal-mail.service.spec.ts b/apps/api/src/mail/portal-mail.service.spec.ts new file mode 100644 index 0000000..4c58e5e --- /dev/null +++ b/apps/api/src/mail/portal-mail.service.spec.ts @@ -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 = { + 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 ,', + 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=abc'); + expect(html).toContain('<script>alert(1)</script>'); + }); + + 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(' { + 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); + }); +}); diff --git a/apps/api/src/mail/portal-mail.service.ts b/apps/api/src/mail/portal-mail.service.ts index a9d1145..f732ab7 100644 --- a/apps/api/src/mail/portal-mail.service.ts +++ b/apps/api/src/mail/portal-mail.service.ts @@ -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 { 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: `

Bitte bestaetige deine Registrierung:

${url}

`, - 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 { - const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`; - await this.mailer.sendMail({ - to, - subject: 'LDAP Portal: Passwort zuruecksetzen', - html: `

Du kannst dein Passwort ueber diesen Link zuruecksetzen:

${url}

`, - text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`, + async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise; + async sendPasswordResetMail(to: string, token: string): Promise; + async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise { + 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 { 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: `

Bitte bestaetige deine neue E-Mail-Adresse:

${url}

`, - 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 { - 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: ` -

Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.

-

Name: ${this.escapeHtml(registration.displayName)}
- E-Mail: ${this.escapeHtml(registration.email)}

-

${url}

- `, - 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('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; + async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise { + 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('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); + async sendInvitationMail(input: SendInvitationMailInput): Promise { + 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 { + 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 { + 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>(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('PUBLIC_WEB_URL') ?? 'http://localhost:4200').replace(/\/+$/, ''); } } diff --git a/apps/api/src/mail/templates/account-created.hbs b/apps/api/src/mail/templates/account-created.hbs new file mode 100644 index 0000000..784b485 --- /dev/null +++ b/apps/api/src/mail/templates/account-created.hbs @@ -0,0 +1,5 @@ +{{#if greeting}}

{{greeting}}

{{/if}} +

{{intro}}

+{{#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}} diff --git a/apps/api/src/mail/templates/account-created.text.hbs b/apps/api/src/mail/templates/account-created.text.hbs new file mode 100644 index 0000000..b7d1c99 --- /dev/null +++ b/apps/api/src/mail/templates/account-created.text.hbs @@ -0,0 +1,11 @@ +{{title}} + +{{#if greeting}}{{greeting}} + +{{/if}}{{intro}} + +{{#if action}}{{action.label}}: +{{{action.url}}} +{{/if}} + +Support: {{branding.supportEmail}} diff --git a/apps/api/src/mail/templates/email-change.hbs b/apps/api/src/mail/templates/email-change.hbs new file mode 100644 index 0000000..b66a4c5 --- /dev/null +++ b/apps/api/src/mail/templates/email-change.hbs @@ -0,0 +1,5 @@ +

{{intro}}

+{{> button label=action.label url=action.url}} +{{> secondary-link url=confirmUrl}} +

Der Link ist gueltig bis {{expiresAtText}}.

+{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}} diff --git a/apps/api/src/mail/templates/email-change.text.hbs b/apps/api/src/mail/templates/email-change.text.hbs new file mode 100644 index 0000000..c9e19e4 --- /dev/null +++ b/apps/api/src/mail/templates/email-change.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/generic-notification.hbs b/apps/api/src/mail/templates/generic-notification.hbs new file mode 100644 index 0000000..50b45c4 --- /dev/null +++ b/apps/api/src/mail/templates/generic-notification.hbs @@ -0,0 +1,5 @@ +{{#if intro}}

{{intro}}

{{/if}} +{{#each paragraphs}}

{{this}}

{{/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}}

{{secondaryLink.label}}

{{/if}} diff --git a/apps/api/src/mail/templates/generic-notification.text.hbs b/apps/api/src/mail/templates/generic-notification.text.hbs new file mode 100644 index 0000000..2ff0ae8 --- /dev/null +++ b/apps/api/src/mail/templates/generic-notification.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/invitation.hbs b/apps/api/src/mail/templates/invitation.hbs new file mode 100644 index 0000000..a58e82a --- /dev/null +++ b/apps/api/src/mail/templates/invitation.hbs @@ -0,0 +1,6 @@ +

{{intro}}

+{{#if invitedBy}}

Eingeladen von: {{invitedBy}}

{{/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}} diff --git a/apps/api/src/mail/templates/invitation.text.hbs b/apps/api/src/mail/templates/invitation.text.hbs new file mode 100644 index 0000000..6b9d75c --- /dev/null +++ b/apps/api/src/mail/templates/invitation.text.hbs @@ -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. diff --git a/apps/api/src/mail/templates/layouts/base.hbs b/apps/api/src/mail/templates/layouts/base.hbs new file mode 100644 index 0000000..06fdb35 --- /dev/null +++ b/apps/api/src/mail/templates/layouts/base.hbs @@ -0,0 +1,41 @@ + + + + + + + {{title}} + + + +
{{preheader}}
+ + + + +
+ + + + + + + + + + + +
+ + diff --git a/apps/api/src/mail/templates/partials/button.hbs b/apps/api/src/mail/templates/partials/button.hbs new file mode 100644 index 0000000..77138f8 --- /dev/null +++ b/apps/api/src/mail/templates/partials/button.hbs @@ -0,0 +1,7 @@ + + + + +
+ {{label}} +
diff --git a/apps/api/src/mail/templates/partials/divider.hbs b/apps/api/src/mail/templates/partials/divider.hbs new file mode 100644 index 0000000..970aa1e --- /dev/null +++ b/apps/api/src/mail/templates/partials/divider.hbs @@ -0,0 +1,3 @@ + + +
 
diff --git a/apps/api/src/mail/templates/partials/footer.hbs b/apps/api/src/mail/templates/partials/footer.hbs new file mode 100644 index 0000000..67fc8b5 --- /dev/null +++ b/apps/api/src/mail/templates/partials/footer.hbs @@ -0,0 +1,14 @@ + + + + +
+

Diese E-Mail wurde automatisch erstellt. Bitte antworte nicht direkt auf diese Nachricht.

+ {{#if footerNote}}

{{footerNote}}

{{/if}} +

Support: {{branding.supportEmail}}

+

+ {{branding.companyName}} + {{#if branding.imprintUrl}} · Impressum{{/if}} + {{#if branding.privacyUrl}} · Datenschutz{{/if}} +

+
diff --git a/apps/api/src/mail/templates/partials/header.hbs b/apps/api/src/mail/templates/partials/header.hbs new file mode 100644 index 0000000..3e5263b --- /dev/null +++ b/apps/api/src/mail/templates/partials/header.hbs @@ -0,0 +1,11 @@ + + + + +
+ {{#if branding.logoUrl}} + {{branding.productName}} + {{else}} +
{{branding.productName}}
+ {{/if}} +
diff --git a/apps/api/src/mail/templates/partials/info-box.hbs b/apps/api/src/mail/templates/partials/info-box.hbs new file mode 100644 index 0000000..5763d98 --- /dev/null +++ b/apps/api/src/mail/templates/partials/info-box.hbs @@ -0,0 +1,8 @@ + + + + +
+ {{#if title}}{{title}}{{/if}} + {{text}} +
diff --git a/apps/api/src/mail/templates/partials/key-value.hbs b/apps/api/src/mail/templates/partials/key-value.hbs new file mode 100644 index 0000000..b2b6a6e --- /dev/null +++ b/apps/api/src/mail/templates/partials/key-value.hbs @@ -0,0 +1,8 @@ + + {{#each items}} + + + + + {{/each}} +
{{label}}{{value}}
diff --git a/apps/api/src/mail/templates/partials/secondary-link.hbs b/apps/api/src/mail/templates/partials/secondary-link.hbs new file mode 100644 index 0000000..ddbb11c --- /dev/null +++ b/apps/api/src/mail/templates/partials/secondary-link.hbs @@ -0,0 +1,4 @@ +

+ Falls die Schaltflaeche nicht funktioniert, kopiere diesen Link in deinen Browser:
+ {{url}} +

diff --git a/apps/api/src/mail/templates/partials/warning-box.hbs b/apps/api/src/mail/templates/partials/warning-box.hbs new file mode 100644 index 0000000..be580dc --- /dev/null +++ b/apps/api/src/mail/templates/partials/warning-box.hbs @@ -0,0 +1,8 @@ + + + + +
+ {{#if title}}{{title}}{{/if}} + {{text}} +
diff --git a/apps/api/src/mail/templates/password-reset.hbs b/apps/api/src/mail/templates/password-reset.hbs new file mode 100644 index 0000000..2444918 --- /dev/null +++ b/apps/api/src/mail/templates/password-reset.hbs @@ -0,0 +1,7 @@ +{{#if greeting}}

{{greeting}}

{{/if}} +

{{intro}}

+{{> button label=action.label url=action.url}} +{{> secondary-link url=resetUrl}} +

Der Link ist gueltig bis {{expiresAtText}}.

+{{#if warningBox}}{{> warning-box title=warningBox.title text=warningBox.text}}{{/if}} +

Bei Fragen hilft dir der Support unter {{branding.supportEmail}}.

diff --git a/apps/api/src/mail/templates/password-reset.text.hbs b/apps/api/src/mail/templates/password-reset.text.hbs new file mode 100644 index 0000000..167e189 --- /dev/null +++ b/apps/api/src/mail/templates/password-reset.text.hbs @@ -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}} diff --git a/apps/api/src/mail/templates/registration-pending-approval.hbs b/apps/api/src/mail/templates/registration-pending-approval.hbs new file mode 100644 index 0000000..0d02b66 --- /dev/null +++ b/apps/api/src/mail/templates/registration-pending-approval.hbs @@ -0,0 +1,13 @@ +

{{intro}}

+ + + + + + + + + +
Name{{registration.displayName}}
E-Mail{{registration.email}}
+{{> button label=action.label url=action.url}} +{{> secondary-link url=adminUrl}} diff --git a/apps/api/src/mail/templates/registration-pending-approval.text.hbs b/apps/api/src/mail/templates/registration-pending-approval.text.hbs new file mode 100644 index 0000000..02202ab --- /dev/null +++ b/apps/api/src/mail/templates/registration-pending-approval.text.hbs @@ -0,0 +1,9 @@ +{{title}} + +{{intro}} + +Name: {{registration.displayName}} +E-Mail: {{registration.email}} + +Admin-Bereich: +{{{adminUrl}}} diff --git a/apps/api/src/mail/templates/verification.hbs b/apps/api/src/mail/templates/verification.hbs new file mode 100644 index 0000000..c321c11 --- /dev/null +++ b/apps/api/src/mail/templates/verification.hbs @@ -0,0 +1,5 @@ +

{{intro}}

+{{> button label=action.label url=action.url}} +{{> secondary-link url=verificationUrl}} +

Der Link ist gueltig bis {{expiresAtText}}.

+{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}} diff --git a/apps/api/src/mail/templates/verification.text.hbs b/apps/api/src/mail/templates/verification.text.hbs new file mode 100644 index 0000000..f7b9288 --- /dev/null +++ b/apps/api/src/mail/templates/verification.text.hbs @@ -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. diff --git a/apps/api/src/mail/templates/warning-notification.hbs b/apps/api/src/mail/templates/warning-notification.hbs new file mode 100644 index 0000000..a3fea39 --- /dev/null +++ b/apps/api/src/mail/templates/warning-notification.hbs @@ -0,0 +1,4 @@ +{{#if intro}}

{{intro}}

{{/if}} +{{#each paragraphs}}

{{this}}

{{/each}} +{{#if warningBox}}{{> warning-box text=warningBox.text}}{{/if}} +{{#if action}}{{> button label=action.label url=action.url}}{{> secondary-link url=action.url}}{{/if}} diff --git a/apps/api/src/mail/templates/warning-notification.text.hbs b/apps/api/src/mail/templates/warning-notification.text.hbs new file mode 100644 index 0000000..2d1be78 --- /dev/null +++ b/apps/api/src/mail/templates/warning-notification.text.hbs @@ -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}} diff --git a/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts b/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts new file mode 100644 index 0000000..3b9e1a3 --- /dev/null +++ b/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateApplicationErrorLogs1721200000000 implements MigrationInterface { + name = 'CreateApplicationErrorLogs1721200000000'; + + async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('application_error_logs'); + } +} diff --git a/apps/api/src/oidc/oidc-client.service.ts b/apps/api/src/oidc/oidc-client.service.ts index ebc985e..dd5b6c3 100644 --- a/apps/api/src/oidc/oidc-client.service.ts +++ b/apps/api/src/oidc/oidc-client.service.ts @@ -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('TOKEN_SECRET'); } + + private get tokenSecretFingerprint(): string { + return createHash('sha256').update(this.tokenSecret).digest('hex').slice(0, 12); + } } diff --git a/apps/api/src/oidc/oidc-interaction.controller.ts b/apps/api/src/oidc/oidc-interaction.controller.ts index 9a192d1..8bdc746 100644 --- a/apps/api/src/oidc/oidc-interaction.controller.ts +++ b/apps/api/src/oidc/oidc-interaction.controller.ts @@ -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', + '

Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.

', + ), + ); + return; + } if (details.uid !== uid) { response.status(400).send(this.page('Ungueltige Anfrage', '

Die OIDC-Interaktion ist ungueltig.

')); 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 { + 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, + }); + } } diff --git a/apps/api/src/oidc/oidc-provider.service.ts b/apps/api/src/oidc/oidc-provider.service.ts index ee740e7..34fcce4 100644 --- a/apps/api/src/oidc/oidc-provider.service.ts +++ b/apps/api/src/oidc/oidc-provider.service.ts @@ -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 { @@ -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 { + 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 { + const oidcCtx = ctx as { + method?: string; + path?: string; + status?: number; + oidc?: { + route?: string; + client?: { clientId?: string }; + params?: Record; + }; + headers?: Record; + 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 { return new Function('specifier', 'return import(specifier)')('jose') as Promise; } + + private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined { + return error instanceof Error ? error[property] : undefined; + } } diff --git a/apps/api/src/password/password.service.spec.ts b/apps/api/src/password/password.service.spec.ts new file mode 100644 index 0000000..aa8cdf0 --- /dev/null +++ b/apps/api/src/password/password.service.spec.ts @@ -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, + })); + }); +}); diff --git a/apps/api/src/password/password.service.ts b/apps/api/src/password/password.service.ts index 77b963a..a756312 100644 --- a/apps/api/src/password/password.service.ts +++ b/apps/api/src/password/password.service.ts @@ -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('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({ diff --git a/apps/api/src/registration/registration.service.ts b/apps/api/src/registration/registration.service.ts index 2a1654c..cda0985 100644 --- a/apps/api/src/registration/registration.service.ts +++ b/apps/api/src/registration/registration.service.ts @@ -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('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('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.' }; } diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 4ac4be8..0da80fc 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -3,5 +3,6 @@ "compilerOptions": { "declaration": true, "removeComments": true - } + }, + "exclude": ["src/**/*.spec.ts", "dist", "node_modules", "test"] } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 603cc6f..11b4843 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "./dist", "rootDir": "./src", "baseUrl": "./src", - "types": ["node"] + "types": ["node", "jest"] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "test"]