Compare commits
6 Commits
28d0cdf019
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d1561bbc | ||
|
|
5973658582 | ||
|
|
e89ac1481e | ||
|
|
d30d9cfdde | ||
| 357642ad22 | |||
|
|
45a41c2077 |
20
.env.example
20
.env.example
@@ -5,6 +5,19 @@ WEB_PORT=4200
|
|||||||
PUBLIC_WEB_URL=http://localhost:4200
|
PUBLIC_WEB_URL=http://localhost:4200
|
||||||
API_BASE_URL=/api
|
API_BASE_URL=/api
|
||||||
|
|
||||||
|
APP_PRODUCT_NAME=LDAP Portal
|
||||||
|
APP_COMPANY_NAME=LDAP Portal
|
||||||
|
APP_PRIMARY_COLOR=#0f6b6e
|
||||||
|
APP_SUPPORT_EMAIL=support@example.com
|
||||||
|
APP_LOGO_URL=
|
||||||
|
APP_IMPRINT_URL=
|
||||||
|
APP_PRIVACY_URL=
|
||||||
|
|
||||||
|
APPLICATION_LOG_RETENTION_ENABLED=true
|
||||||
|
APPLICATION_LOG_RETENTION_DAYS=180
|
||||||
|
APPLICATION_LOG_CLEANUP_BATCH_SIZE=1000
|
||||||
|
APPLICATION_LOG_CLEANUP_MAX_BATCHES=20
|
||||||
|
|
||||||
DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal
|
DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal
|
||||||
DATABASE_SSL=false
|
DATABASE_SSL=false
|
||||||
JWT_SECRET=change-me-long-random-jwt-secret
|
JWT_SECRET=change-me-long-random-jwt-secret
|
||||||
@@ -23,6 +36,13 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=portal@example.com
|
SMTP_USER=portal@example.com
|
||||||
SMTP_PASS=change-me
|
SMTP_PASS=change-me
|
||||||
SMTP_FROM="LDAP Portal <portal@example.com>"
|
SMTP_FROM="LDAP Portal <portal@example.com>"
|
||||||
|
MAIL_PRODUCT_NAME=LDAP Portal
|
||||||
|
MAIL_COMPANY_NAME=LDAP Portal
|
||||||
|
MAIL_PRIMARY_COLOR=#0f6b6e
|
||||||
|
MAIL_SUPPORT_EMAIL=support@example.com
|
||||||
|
MAIL_LOGO_URL=
|
||||||
|
MAIL_IMPRINT_URL=
|
||||||
|
MAIL_PRIVACY_URL=
|
||||||
|
|
||||||
OIDC_ISSUER=http://localhost:8080
|
OIDC_ISSUER=http://localhost:8080
|
||||||
OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret
|
OIDC_COOKIE_SECRET=change-me-long-random-oidc-cookie-secret
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -130,6 +130,13 @@ Die wichtigsten Variablen aus `.env.example`:
|
|||||||
| `API_PORT` | Interner Port der NestJS API, im Container standardmaessig `3000`. |
|
| `API_PORT` | Interner Port der NestJS API, im Container standardmaessig `3000`. |
|
||||||
| `PUBLIC_WEB_URL` | Externe Web-URL fuer CORS, Mail-Links und Registrierung aus dem SSO-Login. |
|
| `PUBLIC_WEB_URL` | Externe Web-URL fuer CORS, Mail-Links und Registrierung aus dem SSO-Login. |
|
||||||
| `API_BASE_URL` | API-Basis aus Sicht des Browsers, im Single Container `/api`. |
|
| `API_BASE_URL` | API-Basis aus Sicht des Browsers, im Single Container `/api`. |
|
||||||
|
| `APP_PRODUCT_NAME`, `APP_COMPANY_NAME` | Zentrale Branding-Namen fuer OIDC-Seiten und Mail-Templates. |
|
||||||
|
| `APP_PRIMARY_COLOR` | Zentrale Primaerfarbe als sechsstelliger Hex-Wert. |
|
||||||
|
| `APP_SUPPORT_EMAIL`, `APP_LOGO_URL`, `APP_IMPRINT_URL`, `APP_PRIVACY_URL` | Zentrale Branding-Werte; Bild- und Link-URLs muessen HTTPS verwenden. |
|
||||||
|
| `APPLICATION_LOG_RETENTION_ENABLED` | Aktiviert den taeglichen Cleanup von Application Info- und Error-Logs; Standard `true`. |
|
||||||
|
| `APPLICATION_LOG_RETENTION_DAYS` | Aufbewahrungsdauer beider Logtabellen in Tagen; Standard `180`. |
|
||||||
|
| `APPLICATION_LOG_CLEANUP_BATCH_SIZE` | Datensaetze pro Loeschbatch; Standard `1000`. |
|
||||||
|
| `APPLICATION_LOG_CLEANUP_MAX_BATCHES` | Maximale Batches pro Tabelle und Lauf; Standard `20`. |
|
||||||
| `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. |
|
| `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. |
|
||||||
| `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. |
|
| `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. |
|
||||||
| `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. |
|
| `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. |
|
||||||
@@ -144,6 +151,9 @@ Die wichtigsten Variablen aus `.env.example`:
|
|||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
|
||||||
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
|
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
|
||||||
| `SMTP_FROM` | Absenderadresse fuer Portal-Mails. |
|
| `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_ISSUER` | Externe Issuer-URL des OIDC Providers. |
|
||||||
| `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
|
| `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
|
||||||
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |
|
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |
|
||||||
@@ -153,6 +163,8 @@ Die wichtigsten Variablen aus `.env.example`:
|
|||||||
|
|
||||||
Hinweis: `REGISTRATION_MANAGER_GROUP`, `GROUP_MANAGER_GROUP` und `AUDIT_VIEWER_GROUP` stehen aktuell in `.env.example`, werden im Code aber nicht ausgewertet. Die Admin-Gruppennamen sind derzeit fest verdrahtet, siehe "Admin-Rollen".
|
Hinweis: `REGISTRATION_MANAGER_GROUP`, `GROUP_MANAGER_GROUP` und `AUDIT_VIEWER_GROUP` stehen aktuell in `.env.example`, werden im Code aber nicht ausgewertet. Die Admin-Gruppennamen sind derzeit fest verdrahtet, siehe "Admin-Rollen".
|
||||||
|
|
||||||
|
Die `APP_*`-Werte haben Vorrang. Solange sie nicht gesetzt sind, verwendet die Anwendung fuer die Rueckwaertskompatibilitaet die entsprechenden `MAIL_*`-Werte. Die OIDC-Seiten liegen unter `apps/api/src/oidc/templates` und werden beim API-Build nach `dist/oidc/templates` kopiert. Sie werden ohne clientseitiges JavaScript direkt durch NestJS gerendert.
|
||||||
|
|
||||||
## Datenbank
|
## Datenbank
|
||||||
|
|
||||||
Die App nutzt TypeORM mit MySQL. In `NODE_ENV=production` ist `synchronize` deaktiviert. Fuer produktive Deployments muss das Schema vorab vorhanden sein oder es muessen Migrationen ergaenzt und ausgefuehrt werden.
|
Die App nutzt TypeORM mit MySQL. In `NODE_ENV=production` ist `synchronize` deaktiviert. Fuer produktive Deployments muss das Schema vorab vorhanden sein oder es muessen Migrationen ergaenzt und ausgefuehrt werden.
|
||||||
@@ -237,10 +249,21 @@ Der LLDAP Admin-User muss ausreichende Rechte fuer diese Operationen haben.
|
|||||||
|
|
||||||
Falls sich GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden.
|
Falls sich GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden.
|
||||||
|
|
||||||
|
## Application Info Log
|
||||||
|
|
||||||
|
Fachliche, sicherheitsrelevante und technische Aktivitaeten werden dauerhaft in `application_info_logs` gespeichert. Das bestehende `audit_events` bleibt unveraendert bestehen; jeder Aufruf von `AuditService.record(...)` erzeugt zusaetzlich einen strukturierten Info-Logeintrag. Ein globaler Interceptor erfasst abgeschlossene und fehlgeschlagene NestJS-HTTP-Requests ohne Request-Body und ohne Query-Parameter.
|
||||||
|
|
||||||
|
Wichtige Felder sind `action`, `category`, `outcome`, `actorType`, `actorId`, `userId`, `clientId`, `correlationId`, HTTP-Metadaten und bereinigter JSON-Kontext. Passwoerter, Tokens, Secrets, Cookies und Authorization-Daten werden entfernt; E-Mail-Adressen werden maskiert. Fehler beim Schreiben des Info-Logs werden auf dem Standardlogger ausgegeben und beeinflussen die protokollierte Aktion nicht.
|
||||||
|
|
||||||
|
Erfasst werden unter anderem Portal-Login und -Logout, fehlgeschlagene Logins, OIDC-Benutzerlogin, erfolgreiche Client-Autorisierung, Token-Grants, OIDC-Logout, Consent, Konto-, Passwort-, Registrierungs- und administrative Aenderungen. Die Migration `1721300000000-CreateApplicationInfoLogs.ts` muss in Produktion vor dem Deployment angewendet werden.
|
||||||
|
|
||||||
|
Der NestJS-Scheduler entfernt taeglich um 03:15 Serverzeit Info- und Error-Logs, die aelter als `APPLICATION_LOG_RETENTION_DAYS` sind. Die Loeschung erfolgt in begrenzten Batches und wird ueber einen MySQL Advisory Lock zwischen mehreren Instanzen koordiniert. Ein erfolgreicher Lauf erzeugt `maintenance.application_log_cleanup_completed`; Fehler werden nur im Standardlogger ausgegeben und stoppen die Anwendung nicht.
|
||||||
|
|
||||||
## Produktions-Checkliste
|
## Produktions-Checkliste
|
||||||
|
|
||||||
- Externe MySQL-Datenbank angelegt und erreichbar.
|
- Externe MySQL-Datenbank angelegt und erreichbar.
|
||||||
- Datenbankschema/Migrationen fuer Produktion geklaert.
|
- Datenbankschema/Migrationen fuer Produktion geklaert.
|
||||||
|
- Migration fuer `application_info_logs` angewendet.
|
||||||
- LLDAP Web/GraphQL und LDAP vom Container aus erreichbar.
|
- LLDAP Web/GraphQL und LDAP vom Container aus erreichbar.
|
||||||
- SMTP erreichbar und Absender korrekt gesetzt.
|
- SMTP erreichbar und Absender korrekt gesetzt.
|
||||||
- `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt.
|
- `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt.
|
||||||
|
|||||||
7
apps/api/jest.config.js
Normal file
7
apps/api/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
rootDir: '.',
|
||||||
|
testMatch: ['<rootDir>/src/**/*.spec.ts'],
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
|
};
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "mail/templates/**/*",
|
||||||
|
"outDir": "dist",
|
||||||
|
"watchAssets": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "oidc/templates/**/*",
|
||||||
|
"outDir": "dist",
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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": "node dist/main.js",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
|
"preview:mails": "ts-node scripts/render-mail-previews.ts",
|
||||||
"test": "jest --passWithNoTests",
|
"test": "jest --passWithNoTests",
|
||||||
"lint": "eslint \"src/**/*.ts\""
|
"lint": "eslint \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
26
apps/api/scripts/copy-mail-assets.js
Normal file
26
apps/api/scripts/copy-mail-assets.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of ['mail', 'oidc']) {
|
||||||
|
copyDirectory(
|
||||||
|
join(__dirname, '..', 'src', feature, 'templates'),
|
||||||
|
join(__dirname, '..', 'dist', feature, 'templates'),
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/api/scripts/render-mail-previews.ts
Normal file
95
apps/api/scripts/render-mail-previews.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mailBranding } from '../src/mail/mail-branding';
|
||||||
|
import { MailTemplateName } from '../src/mail/mail-template.constants';
|
||||||
|
import { MailTemplateRendererService } from '../src/mail/mail-template-renderer.service';
|
||||||
|
|
||||||
|
const outputDir = join(process.cwd(), 'tmp', 'mail-previews');
|
||||||
|
const config = new ConfigService({
|
||||||
|
MAIL_PRODUCT_NAME: 'LDAP Portal',
|
||||||
|
MAIL_COMPANY_NAME: 'Example Corp',
|
||||||
|
MAIL_PRIMARY_COLOR: '#0f6b6e',
|
||||||
|
MAIL_SUPPORT_EMAIL: 'support@example.com',
|
||||||
|
});
|
||||||
|
const branding = mailBranding(config);
|
||||||
|
const renderer = new MailTemplateRendererService();
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
const expiresAtText = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(Date.now() + 30 * 60_000),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previews: Array<{ name: MailTemplateName; context: Record<string, unknown> }> = [
|
||||||
|
{
|
||||||
|
name: MailTemplateName.PASSWORD_RESET,
|
||||||
|
context: {
|
||||||
|
branding,
|
||||||
|
title: 'Passwort zuruecksetzen',
|
||||||
|
preheader: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
|
||||||
|
greeting: 'Hallo Maria,',
|
||||||
|
intro: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert. Ueber die folgende Schaltflaeche kannst du ein neues Passwort vergeben.',
|
||||||
|
action: { label: 'Passwort zuruecksetzen', url: 'https://portal.example.com/reset-password?token=preview' },
|
||||||
|
resetUrl: 'https://portal.example.com/reset-password?token=preview',
|
||||||
|
expiresAtText,
|
||||||
|
warningBox: {
|
||||||
|
title: 'Sicherheitshinweis',
|
||||||
|
text: 'Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: MailTemplateName.ACCOUNT_CREATED,
|
||||||
|
context: {
|
||||||
|
branding,
|
||||||
|
title: 'Willkommen bei LDAP Portal',
|
||||||
|
greeting: 'Hallo Maria,',
|
||||||
|
intro: 'Dein Benutzerkonto wurde angelegt. Du kannst dich jetzt anmelden.',
|
||||||
|
action: { label: 'Zur Anwendung', url: 'https://portal.example.com' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: MailTemplateName.INVITATION,
|
||||||
|
context: {
|
||||||
|
branding,
|
||||||
|
title: 'Einladung zu LDAP Portal',
|
||||||
|
intro: 'Du wurdest eingeladen, die Anwendung zu nutzen.',
|
||||||
|
invitedBy: 'Max Mustermann',
|
||||||
|
action: { label: 'Einladung annehmen', url: 'https://portal.example.com/invite/preview' },
|
||||||
|
invitationUrl: 'https://portal.example.com/invite/preview',
|
||||||
|
expiresAtText,
|
||||||
|
infoBox: { text: `Diese Einladung ist gueltig bis ${expiresAtText}.` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: MailTemplateName.GENERIC_NOTIFICATION,
|
||||||
|
context: {
|
||||||
|
branding,
|
||||||
|
title: 'Neue Benachrichtigung',
|
||||||
|
intro: 'Es gibt eine neue Systembenachrichtigung.',
|
||||||
|
paragraphs: ['Der Status deines Vorgangs wurde aktualisiert.'],
|
||||||
|
action: { label: 'Details anzeigen', url: 'https://portal.example.com/notifications/preview' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: MailTemplateName.WARNING_NOTIFICATION,
|
||||||
|
context: {
|
||||||
|
branding,
|
||||||
|
title: 'Warnmeldung',
|
||||||
|
intro: 'Eine Aktion erfordert Aufmerksamkeit.',
|
||||||
|
warningBox: { text: 'Bitte pruefe die betroffene Konfiguration.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const preview of previews) {
|
||||||
|
const html = await renderer.renderHtml(preview.name, preview.context);
|
||||||
|
await writeFile(join(outputDir, `${preview.name}.html`), html, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -3,9 +3,13 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from '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 { AuditService } from '../audit/audit.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RequestUser } from '../common/request-user';
|
import { RequestUser } from '../common/request-user';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { hashToken, randomToken } from '../common/token.util';
|
import { hashToken, randomToken } from '../common/token.util';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { PortalMailService } from '../mail/portal-mail.service';
|
import { PortalMailService } from '../mail/portal-mail.service';
|
||||||
@@ -24,6 +28,8 @@ export class AccountController {
|
|||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
@InjectRepository(EmailChangeRequest)
|
@InjectRepository(EmailChangeRequest)
|
||||||
private readonly emailChanges: Repository<EmailChangeRequest>,
|
private readonly emailChanges: Repository<EmailChangeRequest>,
|
||||||
@InjectRepository(AccountDeleteRequest)
|
@InjectRepository(AccountDeleteRequest)
|
||||||
@@ -64,7 +70,28 @@ export class AccountController {
|
|||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
|
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
await this.mail.sendEmailChangeMail(dto.newEmail, token);
|
await this.mail.sendEmailChangeMail(dto.newEmail, token);
|
||||||
|
} catch (error) {
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED,
|
||||||
|
module: 'AccountModule',
|
||||||
|
service: AccountController.name,
|
||||||
|
operation: 'sendEmailChangeMail',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: request.user.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(dto.newEmail.toLowerCase()),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
type: 'account.email_change_requested',
|
type: 'account.email_change_requested',
|
||||||
username: request.user.username,
|
username: request.user.username,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
@@ -10,6 +11,8 @@ import { OidcModule } from './oidc/oidc.module';
|
|||||||
import { PasswordModule } from './password/password.module';
|
import { PasswordModule } from './password/password.module';
|
||||||
import { RegistrationModule } from './registration/registration.module';
|
import { RegistrationModule } from './registration/registration.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
|
import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module';
|
||||||
|
import { ApplicationInfoLogModule } from './application-info-log/application-info-log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,6 +20,7 @@ import { AccountModule } from './account/account.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: ['.env', '../../.env'],
|
envFilePath: ['.env', '../../.env'],
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
ThrottlerModule.forRoot([
|
ThrottlerModule.forRoot([
|
||||||
{
|
{
|
||||||
ttl: 60_000,
|
ttl: 60_000,
|
||||||
@@ -52,6 +56,8 @@ import { AccountModule } from './account/account.module';
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
ApplicationErrorLogModule,
|
||||||
|
ApplicationInfoLogModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'application_error_logs' })
|
||||||
|
@Index(['createdAt'])
|
||||||
|
@Index(['code'])
|
||||||
|
@Index(['category'])
|
||||||
|
@Index(['correlationId'])
|
||||||
|
@Index(['userId'])
|
||||||
|
@Index(['tenantId'])
|
||||||
|
@Index(['httpStatusCode'])
|
||||||
|
export class ApplicationErrorLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ default: 'error' })
|
||||||
|
level!: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
stackTrace?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
errorType?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
backendModule?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
service?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
operation?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
httpMethod?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
apiPath?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
httpStatusCode?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
correlationId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
tenantId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
environment?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
host?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
handled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -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('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface ApplicationErrorRequestContext {
|
||||||
|
correlationId?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationErrorLogInput {
|
||||||
|
error: unknown;
|
||||||
|
level?: string;
|
||||||
|
category?: string;
|
||||||
|
code?: string;
|
||||||
|
module?: string;
|
||||||
|
service?: string;
|
||||||
|
operation?: string;
|
||||||
|
requestContext?: ApplicationErrorRequestContext;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
handled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { ApplicationErrorLoggerService } from './application-error-logger.service';
|
||||||
|
import { sanitizeContext, maskEmail } from './application-error-sanitizer';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
|
||||||
|
|
||||||
|
describe('ApplicationErrorLoggerService', () => {
|
||||||
|
const config = { get: jest.fn((key: string) => (key === 'NODE_ENV' ? 'test' : undefined)) };
|
||||||
|
|
||||||
|
function createService(save = jest.fn().mockResolvedValue(undefined)) {
|
||||||
|
const repo = {
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
return { service: new ApplicationErrorLoggerService(repo as any, config as any), repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores normal Error objects with stack and type', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
const error = new Error('SMTP failed');
|
||||||
|
await service.log({ error, code: 'TEST_ERROR', category: ApplicationErrorCategory.EMAIL, handled: true });
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ message: 'SMTP failed', errorType: 'Error', code: 'TEST_ERROR', handled: true }));
|
||||||
|
expect(repo.save.mock.calls[0][0].stackTrace).toContain('Error: SMTP failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles NestJS HTTP exceptions', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
await service.log({ error: new BadRequestException({ code: 'BAD_INPUT', message: 'Invalid data' }) });
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ httpStatusCode: 400, code: 'BAD_INPUT', level: 'warning' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown error values safely', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
await service.log({ error: { reason: 'broken' } });
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ message: '{"reason":"broken"}', errorType: 'object' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes nested sensitive fields and masks email addresses', () => {
|
||||||
|
const circular: Record<string, unknown> = { email: 'max.mustermann@example.com', nested: { token: 'secret', password: 'x' } };
|
||||||
|
circular.self = circular;
|
||||||
|
expect(sanitizeContext(circular)).toEqual({
|
||||||
|
email: 'm***@example.com',
|
||||||
|
nested: {},
|
||||||
|
self: '[Circular]',
|
||||||
|
});
|
||||||
|
expect(maskEmail('maria@example.com')).toBe('m***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores request, user and tenant context', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed'),
|
||||||
|
requestContext: {
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ correlationId: 'corr-1', userId: 'user-1', tenantId: 'tenant-1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows database write errors', async () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const { service } = createService(jest.fn().mockRejectedValue(new Error('db down')));
|
||||||
|
await expect(service.log({ error: new Error('original') })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist the same error twice', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
const error = new Error('only once');
|
||||||
|
await service.log({ error });
|
||||||
|
await service.log({ error });
|
||||||
|
expect(repo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can record background job failures', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
await service.log({
|
||||||
|
error: new Error('job failed'),
|
||||||
|
category: ApplicationErrorCategory.BACKGROUND_JOB,
|
||||||
|
code: ApplicationErrorCode.BACKGROUND_JOB_FAILED,
|
||||||
|
operation: 'dailyImport',
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ category: 'BACKGROUND_JOB', code: 'BACKGROUND_JOB_FAILED', operation: 'dailyImport' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { HttpException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { hostname } from 'node:os';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApplicationErrorLog } from './application-error-log.entity';
|
||||||
|
import { ApplicationErrorLogInput } from './application-error-log.types';
|
||||||
|
import { sanitizeContext, sanitizeString, sanitizeValue } from './application-error-sanitizer';
|
||||||
|
import { markErrorAsLogged, wasErrorLogged } from './logged-error-marker';
|
||||||
|
|
||||||
|
interface ExtractedError {
|
||||||
|
message: string;
|
||||||
|
stackTrace?: string;
|
||||||
|
errorType?: string;
|
||||||
|
httpStatusCode?: number;
|
||||||
|
responseCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationErrorLoggerService {
|
||||||
|
private readonly fallbackLogger = new Logger(ApplicationErrorLoggerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApplicationErrorLog)
|
||||||
|
private readonly logs: Repository<ApplicationErrorLog>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(input: ApplicationErrorLogInput): Promise<void> {
|
||||||
|
if (wasErrorLogged(input.error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = this.extractError(input.error);
|
||||||
|
try {
|
||||||
|
await this.logs.save(
|
||||||
|
this.logs.create({
|
||||||
|
level: input.level ?? this.levelForStatus(input.requestContext?.statusCode ?? extracted.httpStatusCode),
|
||||||
|
category: input.category,
|
||||||
|
code: input.code ?? extracted.responseCode,
|
||||||
|
message: extracted.message,
|
||||||
|
stackTrace: extracted.stackTrace,
|
||||||
|
errorType: extracted.errorType,
|
||||||
|
backendModule: input.module,
|
||||||
|
service: input.service,
|
||||||
|
operation: input.operation,
|
||||||
|
httpMethod: input.requestContext?.method,
|
||||||
|
apiPath: input.requestContext?.path,
|
||||||
|
httpStatusCode: input.requestContext?.statusCode ?? extracted.httpStatusCode,
|
||||||
|
correlationId: input.requestContext?.correlationId,
|
||||||
|
userId: input.requestContext?.userId,
|
||||||
|
tenantId: input.requestContext?.tenantId,
|
||||||
|
environment: this.config.get<string>('NODE_ENV') ?? 'development',
|
||||||
|
host: hostname(),
|
||||||
|
context: sanitizeContext(input.context),
|
||||||
|
handled: input.handled ?? false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
markErrorAsLogged(input.error);
|
||||||
|
} catch (logError) {
|
||||||
|
this.fallbackLogger.error(
|
||||||
|
`Application error log write failed: ${this.extractError(logError).message}; original: ${extracted.message}`,
|
||||||
|
this.extractError(logError).stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractError(error: unknown): ExtractedError {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
const response = error.getResponse();
|
||||||
|
const responseObject = typeof response === 'object' && response !== null ? response : undefined;
|
||||||
|
const responseCode =
|
||||||
|
responseObject && 'code' in responseObject && typeof responseObject.code === 'string'
|
||||||
|
? responseObject.code
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: sanitizeString(error.message || this.messageFromResponse(response)),
|
||||||
|
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
|
||||||
|
errorType: error.constructor.name,
|
||||||
|
httpStatusCode: error.getStatus(),
|
||||||
|
responseCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: sanitizeString(error.message || error.name),
|
||||||
|
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
|
||||||
|
errorType: error.constructor.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return { message: sanitizeString(error), errorType: 'String' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: sanitizeString(JSON.stringify(sanitizeValue(error)) ?? 'Unknown non-error value'),
|
||||||
|
errorType: error === null ? 'Null' : typeof error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageFromResponse(response: string | object): string {
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('message' in response) {
|
||||||
|
const message = response.message;
|
||||||
|
return Array.isArray(message) ? message.join('; ') : String(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'HTTP exception';
|
||||||
|
}
|
||||||
|
|
||||||
|
private levelForStatus(statusCode?: number): string {
|
||||||
|
return !statusCode || statusCode >= 500 ? 'error' : 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
const sensitiveKeyFragments = [
|
||||||
|
'password',
|
||||||
|
'currentpassword',
|
||||||
|
'newpassword',
|
||||||
|
'token',
|
||||||
|
'accesstoken',
|
||||||
|
'refreshtoken',
|
||||||
|
'idtoken',
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'secret',
|
||||||
|
'apikey',
|
||||||
|
'clientsecret',
|
||||||
|
'resettoken',
|
||||||
|
'sessionid',
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxDepth = 5;
|
||||||
|
const maxObjectKeys = 50;
|
||||||
|
const maxArrayItems = 20;
|
||||||
|
const maxStringLength = 2_000;
|
||||||
|
const maxContextLength = 16_000;
|
||||||
|
|
||||||
|
export function isSensitiveKey(key: string): boolean {
|
||||||
|
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
return sensitiveKeyFragments.some((fragment) => normalized.includes(fragment));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split('@');
|
||||||
|
if (!localPart || !domain) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${localPart[0] ?? '*'}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeString(value: string): string {
|
||||||
|
const withoutBearer = value.replace(/bearer\s+[a-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]');
|
||||||
|
const withMaskedEmails = withoutBearer.replace(
|
||||||
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
|
||||||
|
(email) => maskEmail(email),
|
||||||
|
);
|
||||||
|
|
||||||
|
return truncate(withMaskedEmails, maxStringLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeContext(input: unknown): Record<string, unknown> | undefined {
|
||||||
|
const sanitized = sanitizeValue(input, 0, new WeakSet<object>());
|
||||||
|
if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(sanitized);
|
||||||
|
if (serialized.length <= maxContextLength) {
|
||||||
|
return sanitized as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
truncated: true,
|
||||||
|
originalLength: serialized.length,
|
||||||
|
preview: serialized.slice(0, maxContextLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeValue(input: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return sanitizeString(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'number' || typeof input === 'boolean') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'bigint') {
|
||||||
|
return input.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'symbol' || typeof input === 'function') {
|
||||||
|
return `[${typeof input}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Date) {
|
||||||
|
return input.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
message: sanitizeString(input.message),
|
||||||
|
stack: input.stack ? sanitizeString(input.stack) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= maxDepth) {
|
||||||
|
return '[MaxDepth]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(input)) {
|
||||||
|
return '[Circular]';
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(input);
|
||||||
|
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.slice(0, maxArrayItems).map((item) => sanitizeValue(item, depth + 1, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(input).slice(0, maxObjectKeys)) {
|
||||||
|
if (isSensitiveKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[key] = sanitizeValue(value, depth + 1, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, maxLength: number): string {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, maxLength)}...[truncated]`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { RequestUser } from '../common/request-user';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from './application-error-logger.service';
|
||||||
|
import { wasErrorLogged } from './logged-error-marker';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationErrorFilter implements ExceptionFilter {
|
||||||
|
constructor(
|
||||||
|
private readonly httpAdapterHost: HttpAdapterHost,
|
||||||
|
private readonly errorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const http = host.switchToHttp();
|
||||||
|
const request = http.getRequest<Request & { user?: RequestUser }>();
|
||||||
|
const response = http.getResponse();
|
||||||
|
const statusCode = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
const context = this.requestContext.get();
|
||||||
|
|
||||||
|
if (this.shouldLog(exception, statusCode)) {
|
||||||
|
void this.errorLogger.log({
|
||||||
|
error: exception,
|
||||||
|
category: ApplicationErrorCategory.UNHANDLED,
|
||||||
|
code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION,
|
||||||
|
module: 'HTTP',
|
||||||
|
operation: `${request.method} ${request.route?.path ?? request.path}`,
|
||||||
|
requestContext: {
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
statusCode,
|
||||||
|
userId: request.user?.username ?? request.user?.sub,
|
||||||
|
},
|
||||||
|
context: { query: request.query, params: request.params },
|
||||||
|
handled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.httpAdapterHost.httpAdapter.reply(response, this.responseBody(exception, statusCode, context.correlationId), statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(exception: unknown, statusCode: number): boolean {
|
||||||
|
if (wasErrorLogged(exception)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(exception instanceof HttpException) || statusCode >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
private responseBody(exception: unknown, statusCode: number, correlationId?: string): unknown {
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
return { statusCode, message: exceptionResponse, ...(statusCode >= 500 && correlationId ? { correlationId } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...exceptionResponse, ...(statusCode >= 500 && correlationId ? { correlationId } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statusCode, message: 'Internal server error', ...(correlationId ? { correlationId } : {}) };
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/api/src/application-error-log/logged-error-marker.ts
Normal file
21
apps/api/src/application-error-log/logged-error-marker.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const loggedErrorMarker = Symbol('applicationErrorLogged');
|
||||||
|
|
||||||
|
export function markErrorAsLogged(error: unknown): void {
|
||||||
|
if (!error || (typeof error !== 'object' && typeof error !== 'function') || wasErrorLogged(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(error, loggedErrorMarker, {
|
||||||
|
value: true,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasErrorLogged(error: unknown): boolean {
|
||||||
|
return Boolean(
|
||||||
|
error &&
|
||||||
|
(typeof error === 'object' || typeof error === 'function') &&
|
||||||
|
(error as Record<symbol, unknown>)[loggedErrorMarker],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'application_info_logs' })
|
||||||
|
@Index(['createdAt'])
|
||||||
|
@Index(['action'])
|
||||||
|
@Index(['category'])
|
||||||
|
@Index(['outcome'])
|
||||||
|
@Index(['correlationId'])
|
||||||
|
@Index(['userId'])
|
||||||
|
@Index(['clientId'])
|
||||||
|
@Index(['tenantId'])
|
||||||
|
export class ApplicationInfoLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ default: 'info' })
|
||||||
|
level!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@Column({ default: 'SUCCESS' })
|
||||||
|
outcome!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
actorType?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
actorId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
tenantId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
backendModule?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
service?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
operation?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
httpMethod?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
apiPath?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
httpStatusCode?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
correlationId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
ipAddress?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
userAgent?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
environment?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
host?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApplicationInfoLog } from './application-info-log.entity';
|
||||||
|
import { ApplicationInfoLoggerService } from './application-info-logger.service';
|
||||||
|
import { ApplicationInfoInterceptor } from './application-info.interceptor';
|
||||||
|
import { ApplicationLogRetentionService } from './application-log-retention.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationInfoLog])],
|
||||||
|
providers: [
|
||||||
|
ApplicationInfoLoggerService,
|
||||||
|
ApplicationLogRetentionService,
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: ApplicationInfoInterceptor },
|
||||||
|
],
|
||||||
|
exports: [ApplicationInfoLoggerService],
|
||||||
|
})
|
||||||
|
export class ApplicationInfoLogModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type ApplicationInfoActorType = 'USER' | 'CLIENT' | 'SYSTEM';
|
||||||
|
export type ApplicationInfoOutcome = 'SUCCESS' | 'FAILURE' | 'STARTED' | 'SKIPPED';
|
||||||
|
|
||||||
|
export interface ApplicationInfoLogInput {
|
||||||
|
action: string;
|
||||||
|
category?: string;
|
||||||
|
outcome?: ApplicationInfoOutcome;
|
||||||
|
message?: string;
|
||||||
|
actorType?: ApplicationInfoActorType;
|
||||||
|
actorId?: string;
|
||||||
|
userId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
backendModule?: string;
|
||||||
|
service?: string;
|
||||||
|
operation?: string;
|
||||||
|
requestContext?: {
|
||||||
|
correlationId?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
};
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ApplicationInfoLoggerService } from './application-info-logger.service';
|
||||||
|
|
||||||
|
describe('ApplicationInfoLoggerService', () => {
|
||||||
|
function createService(save = jest.fn().mockResolvedValue(undefined)) {
|
||||||
|
const repository = {
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
const config = { get: jest.fn().mockReturnValue('test') };
|
||||||
|
const requestContext = {
|
||||||
|
get: jest.fn().mockReturnValue({
|
||||||
|
correlationId: 'correlation-id',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/auth/login',
|
||||||
|
tenantId: 'tenant-id',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new ApplicationInfoLoggerService(repository as any, config as any, requestContext as any),
|
||||||
|
repository,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores a structured action with request and actor context', async () => {
|
||||||
|
const { service, repository } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
action: 'auth.login_success',
|
||||||
|
category: 'AUTH',
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'maria',
|
||||||
|
userId: 'maria',
|
||||||
|
requestContext: { ipAddress: '192.0.2.10', userAgent: 'Test Browser' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'auth.login_success',
|
||||||
|
category: 'AUTH',
|
||||||
|
outcome: 'SUCCESS',
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'maria',
|
||||||
|
userId: 'maria',
|
||||||
|
correlationId: 'correlation-id',
|
||||||
|
tenantId: 'tenant-id',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
apiPath: '/auth/login',
|
||||||
|
ipAddress: '192.0.2.10',
|
||||||
|
environment: 'test',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes sensitive nested context and masks email addresses', async () => {
|
||||||
|
const { service, repository } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
action: 'registration.started',
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'maria@example.com',
|
||||||
|
context: {
|
||||||
|
email: 'maria@example.com',
|
||||||
|
nested: { password: 'do-not-store', clientSecret: 'do-not-store', value: 'allowed' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = repository.save.mock.calls[0][0];
|
||||||
|
expect(stored.actorId).toBe('m***@example.com');
|
||||||
|
expect(stored.context.email).toBe('m***@example.com');
|
||||||
|
expect(stored.context.nested).toEqual({ value: 'allowed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when persistence fails', async () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||||
|
const { service } = createService(jest.fn().mockRejectedValue(new Error('database unavailable')));
|
||||||
|
|
||||||
|
await expect(service.log({ action: 'auth.login_success' })).resolves.toBeUndefined();
|
||||||
|
expect(Logger.prototype.error).toHaveBeenCalled();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { hostname } from 'node:os';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { sanitizeContext, sanitizeString } from '../application-error-log/application-error-sanitizer';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
|
import { ApplicationInfoLog } from './application-info-log.entity';
|
||||||
|
import { ApplicationInfoLogInput } from './application-info-log.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationInfoLoggerService {
|
||||||
|
private readonly fallbackLogger = new Logger(ApplicationInfoLoggerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApplicationInfoLog)
|
||||||
|
private readonly logs: Repository<ApplicationInfoLog>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(input: ApplicationInfoLogInput): Promise<void> {
|
||||||
|
const activeRequest = this.requestContext.get();
|
||||||
|
const request = { ...activeRequest, ...input.requestContext };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.logs.save(
|
||||||
|
this.logs.create({
|
||||||
|
level: 'info',
|
||||||
|
action: sanitizeString(input.action),
|
||||||
|
category: optionalString(input.category),
|
||||||
|
outcome: input.outcome ?? 'SUCCESS',
|
||||||
|
message: optionalString(input.message),
|
||||||
|
actorType: input.actorType,
|
||||||
|
actorId: optionalString(input.actorId),
|
||||||
|
userId: optionalString(input.userId ?? request.userId),
|
||||||
|
clientId: optionalString(input.clientId),
|
||||||
|
tenantId: optionalString(input.tenantId ?? request.tenantId),
|
||||||
|
backendModule: optionalString(input.backendModule),
|
||||||
|
service: optionalString(input.service),
|
||||||
|
operation: optionalString(input.operation),
|
||||||
|
httpMethod: optionalString(request.method),
|
||||||
|
apiPath: optionalString(withoutQuery(request.path)),
|
||||||
|
httpStatusCode: input.requestContext?.statusCode,
|
||||||
|
correlationId: optionalString(request.correlationId),
|
||||||
|
ipAddress: optionalString(input.requestContext?.ipAddress),
|
||||||
|
userAgent: optionalString(input.requestContext?.userAgent),
|
||||||
|
environment: this.config.get<string>('NODE_ENV') ?? 'development',
|
||||||
|
host: hostname(),
|
||||||
|
context: sanitizeContext(input.context),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.fallbackLogger.error(
|
||||||
|
`Application info log write failed for ${sanitizeString(input.action)}: ${sanitizeString(message)}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value?: string): string | undefined {
|
||||||
|
return value ? sanitizeString(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutQuery(value?: string): string | undefined {
|
||||||
|
return value?.split('?', 1)[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BadRequestException, CallHandler, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { lastValueFrom, of, throwError } from 'rxjs';
|
||||||
|
import { ApplicationInfoInterceptor } from './application-info.interceptor';
|
||||||
|
|
||||||
|
describe('ApplicationInfoInterceptor', () => {
|
||||||
|
function createContext(path = '/admin/users') {
|
||||||
|
const request = {
|
||||||
|
method: 'GET',
|
||||||
|
path,
|
||||||
|
originalUrl: `${path}?token=do-not-store`,
|
||||||
|
route: { path: '/admin/users' },
|
||||||
|
ip: '192.0.2.10',
|
||||||
|
headers: { 'user-agent': 'Test Browser', authorization: 'Bearer secret' },
|
||||||
|
user: { sub: 'user-id', username: 'maria' },
|
||||||
|
body: { password: 'do-not-store' },
|
||||||
|
};
|
||||||
|
const response = { statusCode: 200, getHeader: jest.fn().mockReturnValue('application/json') };
|
||||||
|
const context = {
|
||||||
|
getType: jest.fn().mockReturnValue('http'),
|
||||||
|
switchToHttp: jest.fn().mockReturnValue({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => response,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
return { context, request, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('logs a successful request without body, query or authorization data', async () => {
|
||||||
|
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
|
||||||
|
const { context } = createContext();
|
||||||
|
const next = { handle: () => of({ ok: true }) } as CallHandler;
|
||||||
|
|
||||||
|
await lastValueFrom(interceptor.intercept(context, next));
|
||||||
|
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'http.request_completed',
|
||||||
|
outcome: 'SUCCESS',
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'maria',
|
||||||
|
requestContext: expect.objectContaining({ path: '/admin/users', statusCode: 200 }),
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('do-not-store');
|
||||||
|
expect(JSON.stringify(infoLogger.log.mock.calls[0][0])).not.toContain('Bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a failed request with its controlled HTTP status', async () => {
|
||||||
|
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const interceptor = new ApplicationInfoInterceptor(infoLogger as any);
|
||||||
|
const { context } = createContext('/admin/users/invalid');
|
||||||
|
const next = { handle: () => throwError(() => new BadRequestException('invalid')) } as CallHandler;
|
||||||
|
|
||||||
|
await expect(lastValueFrom(interceptor.intercept(context, next))).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'http.request_failed',
|
||||||
|
outcome: 'FAILURE',
|
||||||
|
requestContext: expect.objectContaining({ statusCode: 400 }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
import { RequestUser } from '../common/request-user';
|
||||||
|
import { ApplicationInfoLoggerService } from './application-info-logger.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationInfoInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly infoLogger: ApplicationInfoLoggerService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
if (context.getType() !== 'http') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { user?: RequestUser }>();
|
||||||
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: () => this.logRequest(request, response, 'SUCCESS', response.statusCode),
|
||||||
|
error: (error: unknown) =>
|
||||||
|
this.logRequest(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
'FAILURE',
|
||||||
|
error instanceof HttpException
|
||||||
|
? error.getStatus()
|
||||||
|
: response.statusCode >= 400
|
||||||
|
? response.statusCode
|
||||||
|
: 500,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private logRequest(
|
||||||
|
request: Request & { user?: RequestUser },
|
||||||
|
response: Response,
|
||||||
|
outcome: 'SUCCESS' | 'FAILURE',
|
||||||
|
statusCode: number,
|
||||||
|
): void {
|
||||||
|
void this.infoLogger.log({
|
||||||
|
action: outcome === 'SUCCESS' ? 'http.request_completed' : 'http.request_failed',
|
||||||
|
category: 'HTTP',
|
||||||
|
outcome,
|
||||||
|
actorType: request.user ? 'USER' : 'SYSTEM',
|
||||||
|
actorId: request.user?.username,
|
||||||
|
userId: request.user?.sub ?? request.user?.username,
|
||||||
|
backendModule: 'HttpModule',
|
||||||
|
service: ApplicationInfoInterceptor.name,
|
||||||
|
operation: `${request.method} ${request.route?.path ?? request.path}`,
|
||||||
|
requestContext: {
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
statusCode,
|
||||||
|
ipAddress: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
route: request.route?.path,
|
||||||
|
contentType: response.getHeader('content-type'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ApplicationLogRetentionService } from './application-log-retention.service';
|
||||||
|
|
||||||
|
describe('ApplicationLogRetentionService', () => {
|
||||||
|
function createService(options: { lock?: boolean; queryError?: Error; enabled?: string } = {}) {
|
||||||
|
let infoBatch = 0;
|
||||||
|
const query = jest.fn(async (sql: string, _parameters?: unknown[]) => {
|
||||||
|
if (options.queryError && sql.startsWith('DELETE')) {
|
||||||
|
throw options.queryError;
|
||||||
|
}
|
||||||
|
if (sql.includes('GET_LOCK')) {
|
||||||
|
return [{ acquired: options.lock === false ? 0 : 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('DELETE FROM application_info_logs')) {
|
||||||
|
infoBatch += 1;
|
||||||
|
return { affectedRows: infoBatch === 1 ? 100 : 10 };
|
||||||
|
}
|
||||||
|
if (sql.startsWith('DELETE FROM application_error_logs')) {
|
||||||
|
return { affectedRows: 5 };
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const queryRunner = {
|
||||||
|
connect: jest.fn().mockResolvedValue(undefined),
|
||||||
|
query,
|
||||||
|
release: jest.fn().mockResolvedValue(undefined),
|
||||||
|
isReleased: false,
|
||||||
|
};
|
||||||
|
const dataSource = { createQueryRunner: jest.fn().mockReturnValue(queryRunner) };
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
APPLICATION_LOG_RETENTION_ENABLED: options.enabled ?? 'true',
|
||||||
|
APPLICATION_LOG_RETENTION_DAYS: '30',
|
||||||
|
APPLICATION_LOG_CLEANUP_BATCH_SIZE: '100',
|
||||||
|
APPLICATION_LOG_CLEANUP_MAX_BATCHES: '3',
|
||||||
|
};
|
||||||
|
const config = { get: jest.fn((key: string) => values[key]) };
|
||||||
|
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new ApplicationLogRetentionService(dataSource as any, config as any, infoLogger as any);
|
||||||
|
return { service, dataSource, queryRunner, query, infoLogger };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('deletes expired info and error logs in bounded batches', async () => {
|
||||||
|
const { service, query, queryRunner, infoLogger } = createService();
|
||||||
|
const before = Date.now() - 30 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
await service.removeExpiredLogs();
|
||||||
|
|
||||||
|
const infoDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_info_logs'));
|
||||||
|
const errorDeletes = query.mock.calls.filter(([sql]) => sql.startsWith('DELETE FROM application_error_logs'));
|
||||||
|
expect(infoDeletes).toHaveLength(2);
|
||||||
|
expect(errorDeletes).toHaveLength(1);
|
||||||
|
const firstParameters = infoDeletes[0]?.[1];
|
||||||
|
expect(firstParameters?.[1]).toBe(100);
|
||||||
|
expect((firstParameters?.[0] as Date).getTime()).toBeGreaterThanOrEqual(before - 1_000);
|
||||||
|
expect(queryRunner.release).toHaveBeenCalled();
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'maintenance.application_log_cleanup_completed',
|
||||||
|
context: expect.objectContaining({ infoLogsDeleted: 110, errorLogsDeleted: 5, retentionDays: 30 }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips cleanup when another instance owns the database lock', async () => {
|
||||||
|
const { service, query, infoLogger } = createService({ lock: false });
|
||||||
|
|
||||||
|
await service.removeExpiredLogs();
|
||||||
|
|
||||||
|
expect(query.mock.calls.some(([sql]) => sql.startsWith('DELETE'))).toBe(false);
|
||||||
|
expect(infoLogger.log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be disabled through configuration', async () => {
|
||||||
|
const { service, dataSource } = createService({ enabled: 'false' });
|
||||||
|
|
||||||
|
await service.removeExpiredLogs();
|
||||||
|
|
||||||
|
expect(dataSource.createQueryRunner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when cleanup persistence fails', async () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||||
|
const { service } = createService({ queryError: new Error('database unavailable') });
|
||||||
|
|
||||||
|
await expect(service.removeExpiredLogs()).resolves.toBeUndefined();
|
||||||
|
expect(Logger.prototype.error).toHaveBeenCalled();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { DataSource, QueryRunner } from 'typeorm';
|
||||||
|
import { ApplicationInfoLoggerService } from './application-info-logger.service';
|
||||||
|
|
||||||
|
interface CleanupResult {
|
||||||
|
infoLogsDeleted: number;
|
||||||
|
errorLogsDeleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationLogRetentionService {
|
||||||
|
private readonly logger = new Logger(ApplicationLogRetentionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly infoLogger: ApplicationInfoLoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('0 15 3 * * *', { name: 'application-log-retention', waitForCompletion: true })
|
||||||
|
async removeExpiredLogs(): Promise<void> {
|
||||||
|
if (this.config.get<string>('APPLICATION_LOG_RETENTION_ENABLED') === 'false') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retentionDays = this.numberConfig('APPLICATION_LOG_RETENTION_DAYS', 180, 1, 3_650);
|
||||||
|
const batchSize = this.numberConfig('APPLICATION_LOG_CLEANUP_BATCH_SIZE', 1_000, 100, 10_000);
|
||||||
|
const maxBatches = this.numberConfig('APPLICATION_LOG_CLEANUP_MAX_BATCHES', 20, 1, 1_000);
|
||||||
|
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60_000);
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryRunner.connect();
|
||||||
|
if (!(await this.acquireLock(queryRunner))) {
|
||||||
|
this.logger.log('Application log cleanup skipped because another instance holds the lock');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CleanupResult = {
|
||||||
|
infoLogsDeleted: await this.deleteBatches(
|
||||||
|
queryRunner,
|
||||||
|
'application_info_logs',
|
||||||
|
cutoff,
|
||||||
|
batchSize,
|
||||||
|
maxBatches,
|
||||||
|
),
|
||||||
|
errorLogsDeleted: await this.deleteBatches(
|
||||||
|
queryRunner,
|
||||||
|
'application_error_logs',
|
||||||
|
cutoff,
|
||||||
|
batchSize,
|
||||||
|
maxBatches,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Application log cleanup completed: info=${result.infoLogsDeleted}, errors=${result.errorLogsDeleted}, retentionDays=${retentionDays}`,
|
||||||
|
);
|
||||||
|
await this.infoLogger.log({
|
||||||
|
action: 'maintenance.application_log_cleanup_completed',
|
||||||
|
category: 'MAINTENANCE',
|
||||||
|
outcome: 'SUCCESS',
|
||||||
|
actorType: 'SYSTEM',
|
||||||
|
actorId: 'application-log-retention',
|
||||||
|
backendModule: 'ApplicationInfoLogModule',
|
||||||
|
service: ApplicationLogRetentionService.name,
|
||||||
|
operation: 'removeExpiredLogs',
|
||||||
|
context: { ...result, retentionDays, cutoff: cutoff.toISOString() },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Application log cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (queryRunner.isReleased === false) {
|
||||||
|
await this.releaseLock(queryRunner);
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteBatches(
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
tableName: 'application_info_logs' | 'application_error_logs',
|
||||||
|
cutoff: Date,
|
||||||
|
batchSize: number,
|
||||||
|
maxBatches: number,
|
||||||
|
): Promise<number> {
|
||||||
|
let deleted = 0;
|
||||||
|
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||||
|
const result = (await queryRunner.query(
|
||||||
|
`DELETE FROM ${tableName} WHERE createdAt < ? ORDER BY createdAt ASC LIMIT ?`,
|
||||||
|
[cutoff, batchSize],
|
||||||
|
)) as { affectedRows?: number };
|
||||||
|
const affectedRows = Number(result.affectedRows ?? 0);
|
||||||
|
deleted += affectedRows;
|
||||||
|
if (affectedRows < batchSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async acquireLock(queryRunner: QueryRunner): Promise<boolean> {
|
||||||
|
const rows = (await queryRunner.query(
|
||||||
|
"SELECT GET_LOCK('ldap_portal_application_log_cleanup', 0) AS acquired",
|
||||||
|
)) as Array<{ acquired?: number | string }>;
|
||||||
|
return Number(rows[0]?.acquired) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseLock(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
try {
|
||||||
|
await queryRunner.query("SELECT RELEASE_LOCK('ldap_portal_application_log_cleanup')");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Application log cleanup lock release failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberConfig(key: string, fallback: number, minimum: number, maximum: number): number {
|
||||||
|
const value = Number(this.config.get<string>(key) ?? fallback);
|
||||||
|
return Number.isInteger(value) && value >= minimum && value <= maximum ? value : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
apps/api/src/audit/audit.service.spec.ts
Normal file
82
apps/api/src/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
describe('AuditService application info bridge', () => {
|
||||||
|
function createService() {
|
||||||
|
const repository = {
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const infoLogger = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
return {
|
||||||
|
service: new AuditService(repository as any, infoLogger as any),
|
||||||
|
repository,
|
||||||
|
infoLogger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps the existing audit event and logs a user login', async () => {
|
||||||
|
const { service, repository, infoLogger } = createService();
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
type: 'auth.login_success',
|
||||||
|
username: 'maria',
|
||||||
|
ipAddress: '192.0.2.10',
|
||||||
|
userAgent: 'Test Browser',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repository.save).toHaveBeenCalled();
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'auth.login_success',
|
||||||
|
category: 'AUTH',
|
||||||
|
outcome: 'SUCCESS',
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'maria',
|
||||||
|
userId: 'maria',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies a successful OIDC client login', async () => {
|
||||||
|
const { service, infoLogger } = createService();
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
type: 'oidc.client_login_success',
|
||||||
|
username: 'user-id',
|
||||||
|
metadata: { clientId: 'client-id', scope: 'openid profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: 'oidc.client_login_success',
|
||||||
|
category: 'OIDC',
|
||||||
|
actorType: 'CLIENT',
|
||||||
|
actorId: 'client-id',
|
||||||
|
clientId: 'client-id',
|
||||||
|
userId: 'user-id',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the administrator as actor and the affected user as target', async () => {
|
||||||
|
const { service, infoLogger } = createService();
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
type: 'admin.user_updated',
|
||||||
|
username: 'target-user',
|
||||||
|
metadata: { admin: 'administrator' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(infoLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
actorType: 'USER',
|
||||||
|
actorId: 'administrator',
|
||||||
|
userId: 'target-user',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks failed and started actions with distinct outcomes', async () => {
|
||||||
|
const { service, infoLogger } = createService();
|
||||||
|
|
||||||
|
await service.record({ type: 'auth.login_failed', username: 'maria' });
|
||||||
|
await service.record({ type: 'registration.started', username: 'maria' });
|
||||||
|
|
||||||
|
expect(infoLogger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({ outcome: 'FAILURE' }));
|
||||||
|
expect(infoLogger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({ outcome: 'STARTED' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApplicationInfoLoggerService } from '../application-info-log/application-info-logger.service';
|
||||||
|
import { ApplicationInfoActorType, ApplicationInfoOutcome } from '../application-info-log/application-info-log.types';
|
||||||
import { AuditEvent } from './audit-event.entity';
|
import { AuditEvent } from './audit-event.entity';
|
||||||
|
|
||||||
export interface AuditInput {
|
export interface AuditInput {
|
||||||
@@ -16,6 +18,7 @@ export class AuditService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AuditEvent)
|
@InjectRepository(AuditEvent)
|
||||||
private readonly events: Repository<AuditEvent>,
|
private readonly events: Repository<AuditEvent>,
|
||||||
|
private readonly infoLogger: ApplicationInfoLoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async record(input: AuditInput): Promise<void> {
|
async record(input: AuditInput): Promise<void> {
|
||||||
@@ -25,5 +28,69 @@ export class AuditService {
|
|||||||
metadata: input.metadata ?? {},
|
metadata: input.metadata ?? {},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.infoLogger.log(this.toApplicationInfo(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toApplicationInfo(input: AuditInput) {
|
||||||
|
const metadata = input.metadata ?? {};
|
||||||
|
const clientId = stringValue(metadata.clientId);
|
||||||
|
const admin = stringValue(metadata.admin);
|
||||||
|
const actorType = this.actorType(input.type, input.username, clientId, admin);
|
||||||
|
const actorId = admin ?? (actorType === 'CLIENT' ? clientId : input.username);
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: input.type,
|
||||||
|
category: input.type.split('.', 1)[0]?.toUpperCase() || 'APPLICATION',
|
||||||
|
outcome: this.outcome(input.type),
|
||||||
|
actorType,
|
||||||
|
actorId,
|
||||||
|
userId: input.username,
|
||||||
|
clientId,
|
||||||
|
backendModule: `${titleCase(input.type.split('.', 1)[0] || 'application')}Module`,
|
||||||
|
service: AuditService.name,
|
||||||
|
operation: input.type,
|
||||||
|
requestContext: {
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
},
|
||||||
|
context: metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private actorType(type: string, username?: string, clientId?: string, admin?: string): ApplicationInfoActorType {
|
||||||
|
if (admin || username && !this.isClientAction(type, clientId)) {
|
||||||
|
return 'USER';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isClientAction(type, clientId) ? 'CLIENT' : 'SYSTEM';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isClientAction(type: string, clientId?: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
clientId &&
|
||||||
|
/^oidc\.(client_login|authorization|access_token|grant|interaction|logout|end_session)/.test(type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private outcome(type: string): ApplicationInfoOutcome {
|
||||||
|
if (type.includes('failed') || type.includes('.error')) {
|
||||||
|
return 'FAILURE';
|
||||||
|
}
|
||||||
|
if (type.endsWith('started') || type.endsWith('requested')) {
|
||||||
|
return 'STARTED';
|
||||||
|
}
|
||||||
|
if (type.endsWith('skipped')) {
|
||||||
|
return 'SKIPPED';
|
||||||
|
}
|
||||||
|
return 'SUCCESS';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value: string): string {
|
||||||
|
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : 'Application';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { RequestUser } from '../common/request-user';
|
import { RequestUser } from '../common/request-user';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -19,6 +19,13 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
async logout(@Req() request: Request & { user: RequestUser }): Promise<void> {
|
||||||
|
await this.auth.logout(request.user.username, request.ip, request.headers['user-agent']);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() request: Request & { user: RequestUser }) {
|
me(@Req() request: Request & { user: RequestUser }) {
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export class AuthService {
|
|||||||
user: { username },
|
user: { username },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async logout(username: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||||
|
await this.audit.record({ type: 'auth.logout_success', username, ipAddress, userAgent });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/api/src/common/application-branding.ts
Normal file
44
apps/api/src/common/application-branding.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface ApplicationBranding {
|
||||||
|
productName: string;
|
||||||
|
companyName: string;
|
||||||
|
primaryColor: string;
|
||||||
|
supportEmail: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
imprintUrl?: string;
|
||||||
|
privacyUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applicationBranding(config: ConfigService): ApplicationBranding {
|
||||||
|
return {
|
||||||
|
productName: configValue(config, 'APP_PRODUCT_NAME', 'MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
|
||||||
|
companyName: configValue(config, 'APP_COMPANY_NAME', 'MAIL_COMPANY_NAME') ?? 'LDAP Portal',
|
||||||
|
primaryColor: validColor(configValue(config, 'APP_PRIMARY_COLOR', 'MAIL_PRIMARY_COLOR')),
|
||||||
|
supportEmail: configValue(config, 'APP_SUPPORT_EMAIL', 'MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
|
||||||
|
logoUrl: validHttpsUrl(configValue(config, 'APP_LOGO_URL', 'MAIL_LOGO_URL')),
|
||||||
|
imprintUrl: validHttpsUrl(configValue(config, 'APP_IMPRINT_URL', 'MAIL_IMPRINT_URL')),
|
||||||
|
privacyUrl: validHttpsUrl(configValue(config, 'APP_PRIVACY_URL', 'MAIL_PRIVACY_URL')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configValue(config: ConfigService, primaryKey: string, fallbackKey: string): string | undefined {
|
||||||
|
return config.get<string>(primaryKey)?.trim() || config.get<string>(fallbackKey)?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validColor(value?: string): string {
|
||||||
|
return value && /^#[0-9a-f]{6}$/i.test(value) ? value : '#0f6b6e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validHttpsUrl(value?: string): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'https:' ? url.toString() : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/api/src/common/correlation-id.middleware.ts
Normal file
26
apps/api/src/common/correlation-id.middleware.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { RequestContextService } from './request-context.service';
|
||||||
|
|
||||||
|
export const correlationIdHeader = 'x-correlation-id';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CorrelationIdMiddleware implements NestMiddleware {
|
||||||
|
constructor(private readonly requestContext: RequestContextService) {}
|
||||||
|
|
||||||
|
use(request: Request, response: Response, next: NextFunction): void {
|
||||||
|
const header = request.headers[correlationIdHeader];
|
||||||
|
const correlationId = Array.isArray(header) ? header[0] : header || randomUUID();
|
||||||
|
|
||||||
|
response.setHeader('X-Correlation-ID', correlationId);
|
||||||
|
this.requestContext.run(
|
||||||
|
{
|
||||||
|
correlationId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
},
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/api/src/common/oidc-forwarded-proto.spec.ts
Normal file
52
apps/api/src/common/oidc-forwarded-proto.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { normalizeOidcForwardedProto } from './oidc-forwarded-proto';
|
||||||
|
|
||||||
|
describe('normalizeOidcForwardedProto', () => {
|
||||||
|
it('corrects an OIDC request when the trusted public issuer uses HTTPS', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'GET',
|
||||||
|
originalUrl: '/oidc/auth?client_id=test',
|
||||||
|
headers: { 'x-forwarded-proto': 'http' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
|
||||||
|
|
||||||
|
expect(result).toEqual({ corrected: true, path: '/oidc/auth', previousProto: 'http' });
|
||||||
|
expect(request.headers['x-forwarded-proto']).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also corrects interaction routes used to finish login and consent', () => {
|
||||||
|
const request = { originalUrl: '/interaction/uid/login', headers: {} as Record<string, string> };
|
||||||
|
|
||||||
|
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
|
||||||
|
|
||||||
|
expect(result.corrected).toBe(true);
|
||||||
|
expect(request.headers['x-forwarded-proto']).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not alter unrelated API requests', () => {
|
||||||
|
const request = { originalUrl: '/api/account', headers: { 'x-forwarded-proto': 'http' } };
|
||||||
|
|
||||||
|
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true);
|
||||||
|
|
||||||
|
expect(result.corrected).toBe(false);
|
||||||
|
expect(request.headers['x-forwarded-proto']).toBe('http');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not override the protocol when proxy trust is disabled', () => {
|
||||||
|
const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } };
|
||||||
|
|
||||||
|
const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', false);
|
||||||
|
|
||||||
|
expect(result.corrected).toBe(false);
|
||||||
|
expect(request.headers['x-forwarded-proto']).toBe('http');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not force HTTPS for a configured HTTP issuer', () => {
|
||||||
|
const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } };
|
||||||
|
|
||||||
|
const result = normalizeOidcForwardedProto(request, 'http://localhost:8080', true);
|
||||||
|
|
||||||
|
expect(result.corrected).toBe(false);
|
||||||
|
expect(request.headers['x-forwarded-proto']).toBe('http');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/api/src/common/oidc-forwarded-proto.ts
Normal file
58
apps/api/src/common/oidc-forwarded-proto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
|
|
||||||
|
interface OidcProxyRequest {
|
||||||
|
method?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
headers: IncomingHttpHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcForwardedProtoResult {
|
||||||
|
corrected: boolean;
|
||||||
|
path: string;
|
||||||
|
previousProto?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOidcForwardedProto(
|
||||||
|
request: OidcProxyRequest,
|
||||||
|
issuer: string | undefined,
|
||||||
|
trustProxy: boolean,
|
||||||
|
): OidcForwardedProtoResult {
|
||||||
|
const path = (request.originalUrl ?? request.url ?? '/').split('?', 1)[0];
|
||||||
|
const previousProto = headerValue(request.headers['x-forwarded-proto']);
|
||||||
|
|
||||||
|
if (!trustProxy || !isOidcPath(path) || !isHttpsUrl(issuer) || previousProto === 'https') {
|
||||||
|
return { corrected: false, path, previousProto };
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers['x-forwarded-proto'] = 'https';
|
||||||
|
return { corrected: true, path, previousProto };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOidcPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path === '/oidc' ||
|
||||||
|
path.startsWith('/oidc/') ||
|
||||||
|
path === '/interaction' ||
|
||||||
|
path.startsWith('/interaction/') ||
|
||||||
|
path === '/.well-known' ||
|
||||||
|
path.startsWith('/.well-known/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpsUrl(value?: string): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value).protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||||
|
return firstValue?.split(',', 1)[0].trim().toLowerCase() || undefined;
|
||||||
|
}
|
||||||
23
apps/api/src/common/request-context.service.ts
Normal file
23
apps/api/src/common/request-context.service.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
export interface RequestContextData {
|
||||||
|
correlationId?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestContextService {
|
||||||
|
private readonly storage = new AsyncLocalStorage<RequestContextData>();
|
||||||
|
|
||||||
|
run<T>(context: RequestContextData, callback: () => T): T {
|
||||||
|
return this.storage.run(context, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): RequestContextData {
|
||||||
|
return this.storage.getStore() ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Ber, BerWriter, Client } from 'ldapts';
|
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 {
|
interface LldapUserInput {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -75,7 +79,11 @@ export class LldapService {
|
|||||||
|
|
||||||
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
|
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async createUser(input: LldapUserInput): Promise<void> {
|
async createUser(input: LldapUserInput): Promise<void> {
|
||||||
await this.graphql(
|
await this.graphql(
|
||||||
@@ -113,7 +121,26 @@ export class LldapService {
|
|||||||
this.passwordModifyRequestValue(this.userDn(username), password),
|
this.passwordModifyRequestValue(this.userDn(username), password),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
await client.unbind().catch(() => undefined);
|
await client.unbind().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -393,6 +420,8 @@ export class LldapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
|
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||||||
|
const operation = this.graphqlOperationName(query);
|
||||||
|
try {
|
||||||
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
|
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
|
||||||
const headers = await this.adminHeaders();
|
const headers = await this.adminHeaders();
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
@@ -414,14 +443,33 @@ export class LldapService {
|
|||||||
if (/not found/i.test(message)) {
|
if (/not found/i.test(message)) {
|
||||||
throw new NotFoundException('LLDAP user not found');
|
throw new NotFoundException('LLDAP user not found');
|
||||||
}
|
}
|
||||||
throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.data) {
|
if (!payload.data) {
|
||||||
throw new InternalServerErrorException('LLDAP GraphQL response did not contain data');
|
const exception = new InternalServerErrorException('LLDAP GraphQL response did not contain data');
|
||||||
|
await this.logGraphqlFailure(exception, operation, variables, { responseStatus: response.status });
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.data;
|
return payload.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.logGraphqlFailure(error, operation, variables, { phase: 'request' });
|
||||||
|
markErrorAsLogged(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async adminHeaders(): Promise<Record<string, string>> {
|
private async adminHeaders(): Promise<Record<string, string>> {
|
||||||
@@ -484,4 +532,32 @@ export class LldapService {
|
|||||||
private errorMessage(error: unknown): string {
|
private errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : 'unknown error';
|
return error instanceof Error ? error.message : 'unknown error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private graphqlOperationName(query: string): string {
|
||||||
|
return query.match(/\b(query|mutation)\s+([A-Za-z0-9_]+)/)?.[2] ?? 'graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logGraphqlFailure(
|
||||||
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EXTERNAL_API,
|
||||||
|
code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED,
|
||||||
|
module: 'LldapModule',
|
||||||
|
service: LldapService.name,
|
||||||
|
operation,
|
||||||
|
requestContext: this.requestContext.get(),
|
||||||
|
context: {
|
||||||
|
provider: 'LLDAP',
|
||||||
|
protocol: 'GraphQL',
|
||||||
|
variables,
|
||||||
|
...context,
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/api/src/mail/mail-branding.ts
Normal file
11
apps/api/src/mail/mail-branding.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
ApplicationBranding,
|
||||||
|
applicationBranding,
|
||||||
|
} from '../common/application-branding';
|
||||||
|
|
||||||
|
export type MailBranding = ApplicationBranding;
|
||||||
|
|
||||||
|
export function mailBranding(config: ConfigService): MailBranding {
|
||||||
|
return applicationBranding(config);
|
||||||
|
}
|
||||||
23
apps/api/src/mail/mail-errors.ts
Normal file
23
apps/api/src/mail/mail-errors.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export class PortalMailTemplateError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly templateName: string,
|
||||||
|
options?: { cause?: unknown },
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PortalMailTemplateError';
|
||||||
|
this.cause = options?.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalMailDeliveryError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly templateName: string,
|
||||||
|
options?: { cause?: unknown },
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PortalMailDeliveryError';
|
||||||
|
this.cause = options?.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/mail/mail-template-renderer.service.ts
Normal file
44
apps/api/src/mail/mail-template-renderer.service.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
import { mailTemplateDir, MailTemplateName } from './mail-template.constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailTemplateRendererService {
|
||||||
|
private partialsRegistered = false;
|
||||||
|
|
||||||
|
async renderHtml(templateName: MailTemplateName | string, context: Record<string, unknown>): Promise<string> {
|
||||||
|
await this.registerPartials();
|
||||||
|
const [layoutSource, templateSource] = await Promise.all([
|
||||||
|
readFile(join(mailTemplateDir, 'layouts', 'base.hbs'), 'utf8'),
|
||||||
|
readFile(join(mailTemplateDir, `${templateName}.hbs`), 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = Handlebars.compile(templateSource)(context);
|
||||||
|
return Handlebars.compile(layoutSource)({ ...context, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderText(templateName: MailTemplateName | string, context: Record<string, unknown>): Promise<string> {
|
||||||
|
const source = await readFile(join(mailTemplateDir, `${templateName}.text.hbs`), 'utf8');
|
||||||
|
return Handlebars.compile(source)(context).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerPartials(): Promise<void> {
|
||||||
|
if (this.partialsRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialDir = join(mailTemplateDir, 'partials');
|
||||||
|
const files = await readdir(partialDir);
|
||||||
|
await Promise.all(
|
||||||
|
files
|
||||||
|
.filter((file) => file.endsWith('.hbs'))
|
||||||
|
.map(async (file) => {
|
||||||
|
const source = await readFile(join(partialDir, file), 'utf8');
|
||||||
|
Handlebars.registerPartial(file.replace(/\.hbs$/, ''), source);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.partialsRegistered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/mail/mail-template.constants.ts
Normal file
14
apps/api/src/mail/mail-template.constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const mailTemplateDir = join(__dirname, 'templates');
|
||||||
|
|
||||||
|
export enum MailTemplateName {
|
||||||
|
VERIFICATION = 'verification',
|
||||||
|
PASSWORD_RESET = 'password-reset',
|
||||||
|
EMAIL_CHANGE = 'email-change',
|
||||||
|
REGISTRATION_PENDING_APPROVAL = 'registration-pending-approval',
|
||||||
|
ACCOUNT_CREATED = 'account-created',
|
||||||
|
INVITATION = 'invitation',
|
||||||
|
GENERIC_NOTIFICATION = 'generic-notification',
|
||||||
|
WARNING_NOTIFICATION = 'warning-notification',
|
||||||
|
}
|
||||||
49
apps/api/src/mail/mail-template.types.ts
Normal file
49
apps/api/src/mail/mail-template.types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { MailBranding } from './mail-branding';
|
||||||
|
|
||||||
|
export interface MailActionContext {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailInfoBoxContext {
|
||||||
|
title?: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailBaseContext {
|
||||||
|
branding: MailBranding;
|
||||||
|
preheader?: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
greeting?: string;
|
||||||
|
intro?: string;
|
||||||
|
action?: MailActionContext;
|
||||||
|
secondaryLink?: MailActionContext;
|
||||||
|
infoBox?: MailInfoBoxContext;
|
||||||
|
warningBox?: MailInfoBoxContext;
|
||||||
|
footerNote?: string;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetMailContext extends MailBaseContext {
|
||||||
|
resetUrl: string;
|
||||||
|
expiresAtText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationMailContext extends MailBaseContext {
|
||||||
|
verificationUrl: string;
|
||||||
|
expiresAtText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailChangeMailContext extends MailBaseContext {
|
||||||
|
confirmUrl: string;
|
||||||
|
expiresAtText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationPendingApprovalContext extends MailBaseContext {
|
||||||
|
registration: {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
adminUrl: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MailerModule } from '@nestjs-modules/mailer';
|
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';
|
import { PortalMailService } from './portal-mail.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,10 +27,23 @@ import { PortalMailService } from './portal-mail.service';
|
|||||||
defaults: {
|
defaults: {
|
||||||
from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>',
|
from: config.get<string>('SMTP_FROM') ?? 'LDAP Portal <no-reply@example.com>',
|
||||||
},
|
},
|
||||||
|
template: {
|
||||||
|
dir: mailTemplateDir,
|
||||||
|
adapter: new HandlebarsAdapter(undefined, {
|
||||||
|
inlineCssEnabled: true,
|
||||||
|
}),
|
||||||
|
options: {
|
||||||
|
strict: false,
|
||||||
|
layout: 'layouts/base',
|
||||||
|
partials: {
|
||||||
|
dir: join(mailTemplateDir, 'partials'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [PortalMailService],
|
providers: [PortalMailService, MailTemplateRendererService],
|
||||||
exports: [PortalMailService],
|
exports: [PortalMailService],
|
||||||
})
|
})
|
||||||
export class MailModule {}
|
export class MailModule {}
|
||||||
|
|||||||
100
apps/api/src/mail/portal-mail.service.spec.ts
Normal file
100
apps/api/src/mail/portal-mail.service.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PortalMailService } from './portal-mail.service';
|
||||||
|
import { mailTemplateDir } from './mail-template.constants';
|
||||||
|
import { MailTemplateRendererService } from './mail-template-renderer.service';
|
||||||
|
import { PortalMailDeliveryError, PortalMailTemplateError } from './mail-errors';
|
||||||
|
|
||||||
|
describe('PortalMailService templates', () => {
|
||||||
|
const config = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
PUBLIC_WEB_URL: 'https://portal.example.com',
|
||||||
|
MAIL_PRODUCT_NAME: 'LDAP Portal',
|
||||||
|
MAIL_COMPANY_NAME: 'Example Corp',
|
||||||
|
MAIL_PRIMARY_COLOR: '#0f6b6e',
|
||||||
|
MAIL_SUPPORT_EMAIL: 'support@example.com',
|
||||||
|
};
|
||||||
|
return values[key];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService(sendMail = jest.fn().mockResolvedValue(undefined)) {
|
||||||
|
const renderer = new MailTemplateRendererService();
|
||||||
|
return { service: new PortalMailService({ sendMail } as any, config as any, renderer), sendMail, renderer };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders password reset HTML with escaped display name and action URL', async () => {
|
||||||
|
const { renderer } = createService();
|
||||||
|
const html = await renderer.renderHtml('password-reset', {
|
||||||
|
branding: {
|
||||||
|
productName: 'LDAP Portal',
|
||||||
|
companyName: 'Example Corp',
|
||||||
|
primaryColor: '#0f6b6e',
|
||||||
|
supportEmail: 'support@example.com',
|
||||||
|
},
|
||||||
|
title: 'Passwort zuruecksetzen',
|
||||||
|
greeting: 'Hallo <script>alert(1)</script>,',
|
||||||
|
intro: 'Intro',
|
||||||
|
action: { label: 'Passwort zuruecksetzen', url: 'https://portal.example.com/reset?token=abc' },
|
||||||
|
resetUrl: 'https://portal.example.com/reset?token=abc',
|
||||||
|
expiresAtText: '17.07.2026, 12:30',
|
||||||
|
warningBox: { text: 'Warnung' },
|
||||||
|
});
|
||||||
|
expect(html).toContain('Passwort zuruecksetzen');
|
||||||
|
expect(html).toContain('https://portal.example.com/reset?token=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('<a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports missing optional display name', async () => {
|
||||||
|
const { service, sendMail } = createService();
|
||||||
|
await service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc', expiresAt: new Date('2026-07-17T12:30:00Z') });
|
||||||
|
expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
template: 'password-reset',
|
||||||
|
subject: 'Passwort fuer LDAP Portal zuruecksetzen',
|
||||||
|
text: expect.stringContaining('Passwort zuruecksetzen'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds branding and passes the expected template name to the mailer', async () => {
|
||||||
|
const { service, sendMail } = createService();
|
||||||
|
await service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc', expiresAt: new Date('2026-07-17T12:30:00Z') });
|
||||||
|
expect(sendMail.mock.calls[0][0].context.branding.productName).toBe('LDAP Portal');
|
||||||
|
expect(sendMail.mock.calls[0][0].template).toBe('password-reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows optional notification fields to be omitted', async () => {
|
||||||
|
const { service, sendMail } = createService();
|
||||||
|
await service.sendNotificationMail({ recipient: 'admin@example.com', title: 'Hinweis' });
|
||||||
|
expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({ template: 'generic-notification' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps templates available for build asset copying', () => {
|
||||||
|
expect(existsSync(join(mailTemplateDir, 'password-reset.hbs'))).toBe(true);
|
||||||
|
expect(existsSync(join(mailTemplateDir, 'partials', 'button.hbs'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps template errors separately from delivery errors', async () => {
|
||||||
|
const renderer = { renderHtml: jest.fn().mockRejectedValue(new Error('missing partial')), renderText: jest.fn() };
|
||||||
|
const service = new PortalMailService({ sendMail: jest.fn() } as any, config as any, renderer as any);
|
||||||
|
await expect(service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc' })).rejects.toBeInstanceOf(PortalMailTemplateError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps delivery errors without treating them as template errors', async () => {
|
||||||
|
const { service } = createService(jest.fn().mockRejectedValue(new Error('smtp down')));
|
||||||
|
await expect(service.sendPasswordResetMail({ recipient: 'maria@example.com', token: 'abc' })).rejects.toBeInstanceOf(PortalMailDeliveryError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,137 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MailerService } from '@nestjs-modules/mailer';
|
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()
|
@Injectable()
|
||||||
export class PortalMailService {
|
export class PortalMailService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly mailer: MailerService,
|
private readonly mailer: MailerService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly renderer: MailTemplateRendererService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendVerificationMail(to: string, token: string): Promise<void> {
|
async sendVerificationMail(to: string, token: string): Promise<void> {
|
||||||
const url = `${this.publicWebUrl}/verify-email?token=${encodeURIComponent(token)}`;
|
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,
|
to,
|
||||||
subject: 'LDAP Portal: E-Mail bestaetigen',
|
subject: `E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`,
|
||||||
html: `<p>Bitte bestaetige deine Registrierung:</p><p><a href="${url}">${url}</a></p>`,
|
templateName: MailTemplateName.VERIFICATION,
|
||||||
text: `Bitte bestaetige deine Registrierung: ${url}`,
|
context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPasswordResetMail(to: string, token: string): Promise<void> {
|
async sendPasswordResetMail(input: SendPasswordResetMailInput): Promise<void>;
|
||||||
const url = `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
async sendPasswordResetMail(to: string, token: string): Promise<void>;
|
||||||
await this.mailer.sendMail({
|
async sendPasswordResetMail(inputOrTo: SendPasswordResetMailInput | string, token?: string): Promise<void> {
|
||||||
to,
|
const input =
|
||||||
subject: 'LDAP Portal: Passwort zuruecksetzen',
|
typeof inputOrTo === 'string'
|
||||||
html: `<p>Du kannst dein Passwort ueber diesen Link zuruecksetzen:</p><p><a href="${url}">${url}</a></p>`,
|
? { recipient: inputOrTo, token }
|
||||||
text: `Du kannst dein Passwort ueber diesen Link zuruecksetzen: ${url}`,
|
: inputOrTo;
|
||||||
|
const resetUrl =
|
||||||
|
input.resetUrl ?? `${this.publicWebUrl}/reset-password?token=${encodeURIComponent(input.token ?? '')}`;
|
||||||
|
const expiresAt = input.expiresAt ?? new Date(Date.now() + 60 * 60_000);
|
||||||
|
const context = this.baseContext({
|
||||||
|
title: 'Passwort zuruecksetzen',
|
||||||
|
preheader: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert.',
|
||||||
|
greeting: input.displayName ? `Hallo ${input.displayName},` : 'Hallo,',
|
||||||
|
intro: 'Fuer dein Benutzerkonto wurde das Zuruecksetzen des Passworts angefordert. Ueber die folgende Schaltflaeche kannst du ein neues Passwort vergeben.',
|
||||||
|
action: { label: 'Passwort zuruecksetzen', url: resetUrl },
|
||||||
|
warningBox: {
|
||||||
|
title: 'Sicherheitshinweis',
|
||||||
|
text: 'Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren. Dein bestehendes Passwort bleibt unveraendert.',
|
||||||
|
},
|
||||||
|
resetUrl,
|
||||||
|
expiresAtText: this.formatDateTime(expiresAt, input.locale),
|
||||||
|
locale: input.locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendTemplateMail({
|
||||||
|
to: input.recipient,
|
||||||
|
subject: `Passwort fuer ${context.branding.productName} zuruecksetzen`,
|
||||||
|
templateName: MailTemplateName.PASSWORD_RESET,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEmailChangeMail(to: string, token: string): Promise<void> {
|
async sendEmailChangeMail(to: string, token: string): Promise<void> {
|
||||||
const url = `${this.publicWebUrl}/account/email?token=${encodeURIComponent(token)}`;
|
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,
|
to,
|
||||||
subject: 'LDAP Portal: neue E-Mail bestaetigen',
|
subject: `Neue E-Mail-Adresse fuer ${context.branding.productName} bestaetigen`,
|
||||||
html: `<p>Bitte bestaetige deine neue E-Mail-Adresse:</p><p><a href="${url}">${url}</a></p>`,
|
templateName: MailTemplateName.EMAIL_CHANGE,
|
||||||
text: `Bitte bestaetige deine neue E-Mail-Adresse: ${url}`,
|
context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,35 +139,134 @@ export class PortalMailService {
|
|||||||
to: string[],
|
to: string[],
|
||||||
registration: { email: string; displayName: string },
|
registration: { email: string; displayName: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = `${this.publicWebUrl}/admin/registrations`;
|
const adminUrl = `${this.publicWebUrl}/admin/registrations`;
|
||||||
await this.mailer.sendMail({
|
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,
|
to,
|
||||||
subject: 'LDAP Portal: Registrierung wartet auf Freigabe',
|
subject: `${context.branding.productName}: Registrierung wartet auf Freigabe`,
|
||||||
html: `
|
templateName: MailTemplateName.REGISTRATION_PENDING_APPROVAL,
|
||||||
<p>Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.</p>
|
context,
|
||||||
<p><strong>Name:</strong> ${this.escapeHtml(registration.displayName)}<br>
|
|
||||||
<strong>E-Mail:</strong> ${this.escapeHtml(registration.email)}</p>
|
|
||||||
<p><a href="${url}">${url}</a></p>
|
|
||||||
`,
|
|
||||||
text: [
|
|
||||||
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.',
|
|
||||||
`Name: ${registration.displayName}`,
|
|
||||||
`E-Mail: ${registration.email}`,
|
|
||||||
`Admin-Bereich: ${url}`,
|
|
||||||
].join('\n'),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private get publicWebUrl(): string {
|
async sendAccountCreatedMail(input: SendAccountCreatedMailInput): Promise<void> {
|
||||||
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
|
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 {
|
async sendInvitationMail(input: SendInvitationMailInput): Promise<void> {
|
||||||
return value
|
const context = this.baseContext({
|
||||||
.replaceAll('&', '&')
|
title: `Einladung zu ${mailBranding(this.config).productName}`,
|
||||||
.replaceAll('<', '<')
|
preheader: 'Du wurdest eingeladen.',
|
||||||
.replaceAll('>', '>')
|
intro: input.organizationName
|
||||||
.replaceAll('"', '"')
|
? `Du wurdest zu ${input.organizationName} eingeladen.`
|
||||||
.replaceAll("'", ''');
|
: 'Du wurdest eingeladen, die Anwendung zu nutzen.',
|
||||||
|
action: { label: 'Einladung annehmen', url: input.invitationUrl },
|
||||||
|
infoBox: input.expiresAt
|
||||||
|
? { text: `Diese Einladung ist gueltig bis ${this.formatDateTime(input.expiresAt, input.locale)}.` }
|
||||||
|
: undefined,
|
||||||
|
invitedBy: input.invitedBy,
|
||||||
|
invitationUrl: input.invitationUrl,
|
||||||
|
expiresAtText: input.expiresAt ? this.formatDateTime(input.expiresAt, input.locale) : undefined,
|
||||||
|
locale: input.locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendTemplateMail({
|
||||||
|
to: input.recipient,
|
||||||
|
subject: `Sie wurden zu ${context.branding.productName} eingeladen`,
|
||||||
|
templateName: MailTemplateName.INVITATION,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNotificationMail(input: SendNotificationMailInput): Promise<void> {
|
||||||
|
const templateName = input.warningText ? MailTemplateName.WARNING_NOTIFICATION : MailTemplateName.GENERIC_NOTIFICATION;
|
||||||
|
const context = this.baseContext({
|
||||||
|
title: input.title,
|
||||||
|
preheader: input.intro ?? input.title,
|
||||||
|
intro: input.intro,
|
||||||
|
action: input.action,
|
||||||
|
infoBox: input.infoText ? { text: input.infoText } : undefined,
|
||||||
|
warningBox: input.warningText ? { text: input.warningText } : undefined,
|
||||||
|
footerNote: input.footerNote,
|
||||||
|
paragraphs: input.paragraphs ?? [],
|
||||||
|
locale: input.locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendTemplateMail({
|
||||||
|
to: input.recipient,
|
||||||
|
subject: `Neue Benachrichtigung in ${context.branding.productName}`,
|
||||||
|
templateName,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendTemplateMail(input: TemplateMailInput): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
await this.renderer.renderHtml(input.templateName, input.context);
|
||||||
|
text = await this.renderer.renderText(input.templateName, input.context);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PortalMailTemplateError(`Mail template rendering failed: ${input.templateName}`, input.templateName, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailer.sendMail({
|
||||||
|
to: input.to,
|
||||||
|
subject: input.subject,
|
||||||
|
template: input.templateName,
|
||||||
|
context: input.context,
|
||||||
|
text,
|
||||||
|
headers: {
|
||||||
|
'X-Mail-Template': input.templateName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new PortalMailDeliveryError(`Mail delivery failed: ${input.templateName}`, input.templateName, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseContext<T extends Record<string, unknown>>(context: T): T & MailTemplateContext {
|
||||||
|
return {
|
||||||
|
branding: mailBranding(this.config),
|
||||||
|
...context,
|
||||||
|
} as T & MailTemplateContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateTime(value: Date, locale = 'de-DE'): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get publicWebUrl(): string {
|
||||||
|
return (this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200').replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/api/src/mail/templates/account-created.hbs
Normal file
5
apps/api/src/mail/templates/account-created.hbs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{#if greeting}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{greeting}}</p>{{/if}}
|
||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
{{#if action}}{{> button label=action.label url=action.url}}{{/if}}
|
||||||
|
{{#if action}}{{> secondary-link url=action.url}}{{/if}}
|
||||||
|
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
|
||||||
11
apps/api/src/mail/templates/account-created.text.hbs
Normal file
11
apps/api/src/mail/templates/account-created.text.hbs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{#if greeting}}{{greeting}}
|
||||||
|
|
||||||
|
{{/if}}{{intro}}
|
||||||
|
|
||||||
|
{{#if action}}{{action.label}}:
|
||||||
|
{{{action.url}}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
Support: {{branding.supportEmail}}
|
||||||
5
apps/api/src/mail/templates/email-change.hbs
Normal file
5
apps/api/src/mail/templates/email-change.hbs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
{{> button label=action.label url=action.url}}
|
||||||
|
{{> secondary-link url=confirmUrl}}
|
||||||
|
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
|
||||||
|
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
|
||||||
10
apps/api/src/mail/templates/email-change.text.hbs
Normal file
10
apps/api/src/mail/templates/email-change.text.hbs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{intro}}
|
||||||
|
|
||||||
|
E-Mail-Adresse bestaetigen:
|
||||||
|
{{{confirmUrl}}}
|
||||||
|
|
||||||
|
Der Link ist gueltig bis {{expiresAtText}}.
|
||||||
|
|
||||||
|
Falls du diese Aenderung nicht angefordert hast, kontaktiere bitte den Support: {{branding.supportEmail}}
|
||||||
5
apps/api/src/mail/templates/generic-notification.hbs
Normal file
5
apps/api/src/mail/templates/generic-notification.hbs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{#if intro}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>{{/if}}
|
||||||
|
{{#each paragraphs}}<p style="font-size:15px; line-height:1.6; margin:0 0 16px;">{{this}}</p>{{/each}}
|
||||||
|
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
|
||||||
|
{{#if action}}{{> button label=action.label url=action.url}}{{> secondary-link url=action.url}}{{/if}}
|
||||||
|
{{#if secondaryLink}}<p style="font-size:15px; line-height:1.6; margin:20px 0 0;"><a href="{{secondaryLink.url}}" style="color:{{branding.primaryColor}};">{{secondaryLink.label}}</a></p>{{/if}}
|
||||||
14
apps/api/src/mail/templates/generic-notification.text.hbs
Normal file
14
apps/api/src/mail/templates/generic-notification.text.hbs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{#if intro}}{{intro}}
|
||||||
|
|
||||||
|
{{/if}}{{#each paragraphs}}{{this}}
|
||||||
|
|
||||||
|
{{/each}}{{#if infoBox}}{{infoBox.text}}
|
||||||
|
|
||||||
|
{{/if}}{{#if action}}{{action.label}}:
|
||||||
|
{{{action.url}}}
|
||||||
|
|
||||||
|
{{/if}}{{#if secondaryLink}}{{secondaryLink.label}}:
|
||||||
|
{{{secondaryLink.url}}}
|
||||||
|
{{/if}}
|
||||||
6
apps/api/src/mail/templates/invitation.hbs
Normal file
6
apps/api/src/mail/templates/invitation.hbs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
{{#if invitedBy}}<p style="font-size:15px; line-height:1.6; margin:0 0 18px;">Eingeladen von: <strong>{{invitedBy}}</strong></p>{{/if}}
|
||||||
|
{{> button label=action.label url=action.url}}
|
||||||
|
{{> secondary-link url=invitationUrl}}
|
||||||
|
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
|
||||||
|
{{#if warningBox}}{{> warning-box text=warningBox.text}}{{/if}}
|
||||||
11
apps/api/src/mail/templates/invitation.text.hbs
Normal file
11
apps/api/src/mail/templates/invitation.text.hbs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{intro}}
|
||||||
|
{{#if invitedBy}}Eingeladen von: {{invitedBy}}
|
||||||
|
{{/if}}
|
||||||
|
Einladung annehmen:
|
||||||
|
{{{invitationUrl}}}
|
||||||
|
|
||||||
|
{{#if expiresAtText}}Diese Einladung ist gueltig bis {{expiresAtText}}.
|
||||||
|
{{/if}}
|
||||||
|
Falls du diese Einladung nicht erwartest, ignoriere diese E-Mail.
|
||||||
41
apps/api/src/mail/templates/layouts/base.hbs
Normal file
41
apps/api/src/mail/templates/layouts/base.hbs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{#if locale}}{{locale}}{{else}}de{{/if}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<style>
|
||||||
|
@media screen and (max-width: 640px) {
|
||||||
|
.outer { padding: 18px 10px !important; }
|
||||||
|
.container { width: 100% !important; }
|
||||||
|
.content { padding: 28px 22px !important; }
|
||||||
|
.button { display: block !important; width: 100% !important; box-sizing: border-box !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f4f7f9; color:#1f2933; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
<div style="display:none; max-height:0; overflow:hidden; opacity:0;">{{preheader}}</div>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f7f9; border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td class="outer" align="center" style="padding:32px 16px;">
|
||||||
|
<table class="container" role="presentation" width="640" cellpadding="0" cellspacing="0" style="width:640px; max-width:640px; border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td>{{> header}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="content" style="background:#ffffff; border:1px solid #dbe4ea; border-radius:8px; padding:40px 44px;">
|
||||||
|
<h1 style="font-size:24px; line-height:1.25; margin:0 0 12px; color:#17212b;">{{title}}</h1>
|
||||||
|
{{#if subtitle}}<p style="font-size:16px; line-height:1.5; margin:0 0 24px; color:#516173;">{{subtitle}}</p>{{/if}}
|
||||||
|
{{{body}}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{{> footer}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
apps/api/src/mail/templates/partials/button.hbs
Normal file
7
apps/api/src/mail/templates/partials/button.hbs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:26px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="button" href="{{url}}" style="background:{{branding.primaryColor}}; border-radius:6px; color:#ffffff; display:inline-block; font-size:16px; font-weight:700; line-height:20px; padding:14px 22px; text-align:center; text-decoration:none;">{{label}}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
3
apps/api/src/mail/templates/partials/divider.hbs
Normal file
3
apps/api/src/mail/templates/partials/divider.hbs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
|
||||||
|
<tr><td style="border-top:1px solid #e5edf2; font-size:0; line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
14
apps/api/src/mail/templates/partials/footer.hbs
Normal file
14
apps/api/src/mail/templates/partials/footer.hbs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 4px 0; color:#6b7785; font-size:12px; line-height:1.55;">
|
||||||
|
<p style="margin:0 0 8px;">Diese E-Mail wurde automatisch erstellt. Bitte antworte nicht direkt auf diese Nachricht.</p>
|
||||||
|
{{#if footerNote}}<p style="margin:0 0 8px;">{{footerNote}}</p>{{/if}}
|
||||||
|
<p style="margin:0 0 8px;">Support: <a href="mailto:{{branding.supportEmail}}" style="color:{{branding.primaryColor}};">{{branding.supportEmail}}</a></p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
{{branding.companyName}}
|
||||||
|
{{#if branding.imprintUrl}} · <a href="{{branding.imprintUrl}}" style="color:{{branding.primaryColor}};">Impressum</a>{{/if}}
|
||||||
|
{{#if branding.privacyUrl}} · <a href="{{branding.privacyUrl}}" style="color:{{branding.primaryColor}};">Datenschutz</a>{{/if}}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
11
apps/api/src/mail/templates/partials/header.hbs
Normal file
11
apps/api/src/mail/templates/partials/header.hbs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:0 0 18px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 4px 10px;">
|
||||||
|
{{#if branding.logoUrl}}
|
||||||
|
<img src="{{branding.logoUrl}}" alt="{{branding.productName}}" width="140" style="border:0; display:block; max-width:140px; height:auto;">
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size:18px; font-weight:700; color:#17212b;">{{branding.productName}}</div>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
8
apps/api/src/mail/templates/partials/info-box.hbs
Normal file
8
apps/api/src/mail/templates/partials/info-box.hbs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#eef8f7; border:1px solid #c7e3df; border-radius:6px; color:#224f4d; font-size:14px; line-height:1.5; padding:14px 16px;">
|
||||||
|
{{#if title}}<strong style="display:block; margin:0 0 4px;">{{title}}</strong>{{/if}}
|
||||||
|
{{text}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
8
apps/api/src/mail/templates/partials/key-value.hbs
Normal file
8
apps/api/src/mail/templates/partials/key-value.hbs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:20px 0;">
|
||||||
|
{{#each items}}
|
||||||
|
<tr>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">{{label}}</td>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{value}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</table>
|
||||||
4
apps/api/src/mail/templates/partials/secondary-link.hbs
Normal file
4
apps/api/src/mail/templates/partials/secondary-link.hbs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<p style="font-size:13px; line-height:1.5; margin:18px 0 0; color:#617080;">
|
||||||
|
Falls die Schaltflaeche nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||||
|
<a href="{{url}}" style="color:{{branding.primaryColor}}; overflow-wrap:anywhere; word-break:break-word;">{{url}}</a>
|
||||||
|
</p>
|
||||||
8
apps/api/src/mail/templates/partials/warning-box.hbs
Normal file
8
apps/api/src/mail/templates/partials/warning-box.hbs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#fff7ed; border:1px solid #fed7aa; border-radius:6px; color:#7c2d12; font-size:14px; line-height:1.5; padding:14px 16px;">
|
||||||
|
{{#if title}}<strong style="display:block; margin:0 0 4px;">{{title}}</strong>{{/if}}
|
||||||
|
{{text}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
7
apps/api/src/mail/templates/password-reset.hbs
Normal file
7
apps/api/src/mail/templates/password-reset.hbs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{#if greeting}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{greeting}}</p>{{/if}}
|
||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
{{> button label=action.label url=action.url}}
|
||||||
|
{{> secondary-link url=resetUrl}}
|
||||||
|
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
|
||||||
|
{{#if warningBox}}{{> warning-box title=warningBox.title text=warningBox.text}}{{/if}}
|
||||||
|
<p style="font-size:15px; line-height:1.6; margin:0;">Bei Fragen hilft dir der Support unter <a href="mailto:{{branding.supportEmail}}" style="color:{{branding.primaryColor}};">{{branding.supportEmail}}</a>.</p>
|
||||||
14
apps/api/src/mail/templates/password-reset.text.hbs
Normal file
14
apps/api/src/mail/templates/password-reset.text.hbs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{#if greeting}}{{greeting}}
|
||||||
|
|
||||||
|
{{/if}}{{intro}}
|
||||||
|
|
||||||
|
Passwort zuruecksetzen:
|
||||||
|
{{{resetUrl}}}
|
||||||
|
|
||||||
|
Der Link ist gueltig bis {{expiresAtText}}.
|
||||||
|
|
||||||
|
Falls du diese Anfrage nicht selbst gestellt hast, kannst du diese E-Mail ignorieren. Dein bestehendes Passwort bleibt unveraendert.
|
||||||
|
|
||||||
|
Support: {{branding.supportEmail}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; margin:20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">Name</td>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{registration.displayName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#617080; font-size:13px; padding:10px 12px 10px 0; width:34%;">E-Mail</td>
|
||||||
|
<td style="border-top:1px solid #e5edf2; color:#1f2933; font-size:14px; padding:10px 0;">{{registration.email}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{> button label=action.label url=action.url}}
|
||||||
|
{{> secondary-link url=adminUrl}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{intro}}
|
||||||
|
|
||||||
|
Name: {{registration.displayName}}
|
||||||
|
E-Mail: {{registration.email}}
|
||||||
|
|
||||||
|
Admin-Bereich:
|
||||||
|
{{{adminUrl}}}
|
||||||
5
apps/api/src/mail/templates/verification.hbs
Normal file
5
apps/api/src/mail/templates/verification.hbs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>
|
||||||
|
{{> button label=action.label url=action.url}}
|
||||||
|
{{> secondary-link url=verificationUrl}}
|
||||||
|
<p style="font-size:15px; line-height:1.6; margin:22px 0 0;">Der Link ist gueltig bis <strong>{{expiresAtText}}</strong>.</p>
|
||||||
|
{{#if infoBox}}{{> info-box text=infoBox.text}}{{/if}}
|
||||||
10
apps/api/src/mail/templates/verification.text.hbs
Normal file
10
apps/api/src/mail/templates/verification.text.hbs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{intro}}
|
||||||
|
|
||||||
|
E-Mail bestaetigen:
|
||||||
|
{{{verificationUrl}}}
|
||||||
|
|
||||||
|
Der Link ist gueltig bis {{expiresAtText}}.
|
||||||
|
|
||||||
|
Falls du diese Registrierung nicht gestartet hast, kannst du diese E-Mail ignorieren.
|
||||||
4
apps/api/src/mail/templates/warning-notification.hbs
Normal file
4
apps/api/src/mail/templates/warning-notification.hbs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{{#if intro}}<p style="font-size:16px; line-height:1.6; margin:0 0 18px;">{{intro}}</p>{{/if}}
|
||||||
|
{{#each paragraphs}}<p style="font-size:15px; line-height:1.6; margin:0 0 16px;">{{this}}</p>{{/each}}
|
||||||
|
{{#if warningBox}}{{> warning-box text=warningBox.text}}{{/if}}
|
||||||
|
{{#if action}}{{> button label=action.label url=action.url}}{{> secondary-link url=action.url}}{{/if}}
|
||||||
12
apps/api/src/mail/templates/warning-notification.text.hbs
Normal file
12
apps/api/src/mail/templates/warning-notification.text.hbs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{{title}}
|
||||||
|
|
||||||
|
{{#if intro}}{{intro}}
|
||||||
|
|
||||||
|
{{/if}}{{#each paragraphs}}{{this}}
|
||||||
|
|
||||||
|
{{/each}}{{#if warningBox}}Warnung:
|
||||||
|
{{warningBox.text}}
|
||||||
|
|
||||||
|
{{/if}}{{#if action}}{{action.label}}:
|
||||||
|
{{{action.url}}}
|
||||||
|
{{/if}}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import express, { NextFunction, Request, Response } from 'express';
|
import express, { NextFunction, Request, Response } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { normalizeOidcForwardedProto } from './common/oidc-forwarded-proto';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||||
@@ -12,6 +13,26 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const oidcProxyLogger = new Logger('OidcProxyProtocol');
|
||||||
|
app.use((request: Request, _response: Response, next: NextFunction) => {
|
||||||
|
const result = normalizeOidcForwardedProto(
|
||||||
|
request,
|
||||||
|
process.env.OIDC_ISSUER,
|
||||||
|
process.env.OIDC_TRUST_PROXY === 'true',
|
||||||
|
);
|
||||||
|
if (result.corrected) {
|
||||||
|
oidcProxyLogger.warn(
|
||||||
|
`[OIDC_FORWARDED_PROTO_CORRECTED] ${JSON.stringify({
|
||||||
|
method: request.method,
|
||||||
|
path: result.path,
|
||||||
|
receivedProto: result.previousProto,
|
||||||
|
effectiveProto: 'https',
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const jsonParser = express.json();
|
const jsonParser = express.json();
|
||||||
const formParser = express.urlencoded({ extended: false });
|
const formParser = express.urlencoded({ extended: false });
|
||||||
app.use((request: Request, response: Response, next: NextFunction) => {
|
app.use((request: Request, response: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateApplicationErrorLogs1721200000000 implements MigrationInterface {
|
||||||
|
name = 'CreateApplicationErrorLogs1721200000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'application_error_logs',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
|
||||||
|
{ name: 'level', type: 'varchar', length: '255', default: "'error'" },
|
||||||
|
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'code', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'message', type: 'text' },
|
||||||
|
{ name: 'stackTrace', type: 'text', isNullable: true },
|
||||||
|
{ name: 'errorType', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'backendModule', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'service', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'operation', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpMethod', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'apiPath', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpStatusCode', type: 'int', isNullable: true },
|
||||||
|
{ name: 'correlationId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'userId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'tenantId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'environment', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'host', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'context', type: 'text', isNullable: true },
|
||||||
|
{ name: 'handled', type: 'tinyint', default: 0 },
|
||||||
|
{ name: 'createdAt', type: 'datetime', precision: 6, default: 'CURRENT_TIMESTAMP(6)' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const columnName of ['createdAt', 'code', 'category', 'correlationId', 'userId', 'tenantId', 'httpStatusCode']) {
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'application_error_logs',
|
||||||
|
new TableIndex({ name: `IDX_application_error_logs_${columnName}`, columnNames: [columnName] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('application_error_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateApplicationInfoLogs1721300000000 implements MigrationInterface {
|
||||||
|
name = 'CreateApplicationInfoLogs1721300000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'application_info_logs',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
|
||||||
|
{ name: 'level', type: 'varchar', length: '255', default: "'info'" },
|
||||||
|
{ name: 'action', type: 'varchar', length: '255' },
|
||||||
|
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'outcome', type: 'varchar', length: '255', default: "'SUCCESS'" },
|
||||||
|
{ name: 'message', type: 'text', isNullable: true },
|
||||||
|
{ name: 'actorType', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'actorId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'userId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'clientId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'tenantId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'backendModule', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'service', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'operation', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpMethod', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'apiPath', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpStatusCode', type: 'int', isNullable: true },
|
||||||
|
{ name: 'correlationId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'ipAddress', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'userAgent', type: 'text', isNullable: true },
|
||||||
|
{ name: 'environment', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'host', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'context', type: 'text', isNullable: true },
|
||||||
|
{ name: 'createdAt', type: 'datetime', precision: 6, default: 'CURRENT_TIMESTAMP(6)' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const columnName of [
|
||||||
|
'createdAt',
|
||||||
|
'action',
|
||||||
|
'category',
|
||||||
|
'outcome',
|
||||||
|
'correlationId',
|
||||||
|
'userId',
|
||||||
|
'clientId',
|
||||||
|
'tenantId',
|
||||||
|
]) {
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'application_info_logs',
|
||||||
|
new TableIndex({ name: `IDX_application_info_logs_${columnName}`, columnNames: [columnName] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('application_info_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { decryptSecret, encryptSecret, randomToken } from '../common/token.util';
|
import { decryptSecret, encryptSecret, randomToken } from '../common/token.util';
|
||||||
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
|
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
|
||||||
@@ -131,7 +131,23 @@ export class OidcClientService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (client.encryptedClientSecret) {
|
if (client.encryptedClientSecret) {
|
||||||
|
try {
|
||||||
metadata.client_secret = decryptSecret(client.encryptedClientSecret, this.tokenSecret);
|
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;
|
metadata.client_secret_expires_at = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,4 +176,8 @@ export class OidcClientService {
|
|||||||
private get tokenSecret(): string {
|
private get tokenSecret(): string {
|
||||||
return this.config.getOrThrow<string>('TOKEN_SECRET');
|
return this.config.getOrThrow<string>('TOKEN_SECRET');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get tokenSecretFingerprint(): string {
|
||||||
|
return createHash('sha256').update(this.tokenSecret).digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
apps/api/src/oidc/oidc-interaction-template.service.spec.ts
Normal file
66
apps/api/src/oidc/oidc-interaction-template.service.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
|
||||||
|
|
||||||
|
describe('OidcInteractionTemplateService', () => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
APP_PRODUCT_NAME: 'ForgeCore Auth',
|
||||||
|
APP_COMPANY_NAME: 'ForgeCore',
|
||||||
|
APP_PRIMARY_COLOR: '#126466',
|
||||||
|
APP_SUPPORT_EMAIL: 'support@example.com',
|
||||||
|
APP_LOGO_URL: 'https://cdn.example.com/logo.png',
|
||||||
|
};
|
||||||
|
const config = { get: jest.fn((key: string) => values[key]) };
|
||||||
|
const service = new OidcInteractionTemplateService(config as any);
|
||||||
|
|
||||||
|
it('renders the login page with branding and encoded form actions', async () => {
|
||||||
|
const html = await service.renderLogin({
|
||||||
|
uid: 'uid/with spaces',
|
||||||
|
username: 'maria',
|
||||||
|
registrationUrl: 'https://portal.example.com/register',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('<title>Anmelden - ForgeCore Auth</title>');
|
||||||
|
expect(html).toContain('https://cdn.example.com/logo.png');
|
||||||
|
expect(html).toContain('/interaction/uid%2Fwith%20spaces/login');
|
||||||
|
expect(html).toContain('value="maria"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes usernames and error messages', async () => {
|
||||||
|
const html = await service.renderLogin({
|
||||||
|
uid: 'uid',
|
||||||
|
username: '<script>alert(1)</script>',
|
||||||
|
errorMessage: '<b>Fehler</b>',
|
||||||
|
registrationUrl: 'https://portal.example.com/register',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('<script>alert(1)</script>');
|
||||||
|
expect(html).toContain('<b>Fehler</b>');
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders escaped consent details and scope descriptions', async () => {
|
||||||
|
const html = await service.renderConsent({
|
||||||
|
uid: 'consent-id',
|
||||||
|
clientName: '<Admin Portal>',
|
||||||
|
redirectUri: 'https://client.example.com/callback?x=1&y=2',
|
||||||
|
scopes: ['openid', '<custom>'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('<Admin Portal>');
|
||||||
|
expect(html).toContain('https://client.example.com/callback?x=1&y=2');
|
||||||
|
expect(html).toContain('Anmeldung per OpenID Connect bestaetigen.');
|
||||||
|
expect(html).toContain('<custom>');
|
||||||
|
expect(html).toContain('/interaction/consent-id/confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to mail branding when application branding is absent', async () => {
|
||||||
|
const fallbackConfig = {
|
||||||
|
get: jest.fn((key: string) => ({ MAIL_PRODUCT_NAME: 'Legacy Portal' })[key]),
|
||||||
|
};
|
||||||
|
const fallbackService = new OidcInteractionTemplateService(fallbackConfig as any);
|
||||||
|
|
||||||
|
const html = await fallbackService.renderError({ title: 'Fehler', message: 'Nicht verfuegbar.' });
|
||||||
|
|
||||||
|
expect(html).toContain('<title>Fehler - Legacy Portal</title>');
|
||||||
|
expect(html).toContain('Nicht verfuegbar.');
|
||||||
|
});
|
||||||
|
});
|
||||||
86
apps/api/src/oidc/oidc-interaction-template.service.ts
Normal file
86
apps/api/src/oidc/oidc-interaction-template.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
import { applicationBranding } from '../common/application-branding';
|
||||||
|
|
||||||
|
const oidcTemplateDir = join(__dirname, 'templates');
|
||||||
|
|
||||||
|
export interface OidcLoginPageInput {
|
||||||
|
uid: string;
|
||||||
|
username?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
registrationUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcConsentPageInput {
|
||||||
|
uid: string;
|
||||||
|
clientName: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcErrorPageInput {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OidcInteractionTemplateService {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
async renderLogin(input: OidcLoginPageInput): Promise<string> {
|
||||||
|
const encodedUid = encodeURIComponent(input.uid);
|
||||||
|
return this.render('login', 'Anmelden', {
|
||||||
|
username: input.username ?? '',
|
||||||
|
errorMessage: input.errorMessage,
|
||||||
|
loginAction: `/interaction/${encodedUid}/login`,
|
||||||
|
abortAction: `/interaction/${encodedUid}/abort`,
|
||||||
|
registrationUrl: input.registrationUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderConsent(input: OidcConsentPageInput): Promise<string> {
|
||||||
|
const encodedUid = encodeURIComponent(input.uid);
|
||||||
|
return this.render('consent', 'Zugriff erlauben', {
|
||||||
|
clientName: input.clientName,
|
||||||
|
redirectUri: input.redirectUri,
|
||||||
|
scopes: input.scopes.map((name) => ({ name, description: this.scopeDescription(name) })),
|
||||||
|
confirmAction: `/interaction/${encodedUid}/confirm`,
|
||||||
|
abortAction: `/interaction/${encodedUid}/abort`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderError(input: OidcErrorPageInput): Promise<string> {
|
||||||
|
return this.render('error', input.title, { message: input.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async render(templateName: string, title: string, context: Record<string, unknown>): Promise<string> {
|
||||||
|
const [layoutSource, templateSource] = await Promise.all([
|
||||||
|
readFile(join(oidcTemplateDir, 'layouts', 'base.hbs'), 'utf8'),
|
||||||
|
readFile(join(oidcTemplateDir, `${templateName}.hbs`), 'utf8'),
|
||||||
|
]);
|
||||||
|
const branding = applicationBranding(this.config);
|
||||||
|
const body = Handlebars.compile(templateSource)(context);
|
||||||
|
|
||||||
|
return Handlebars.compile(layoutSource)({
|
||||||
|
...context,
|
||||||
|
branding,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scopeDescription(scope: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
openid: 'Anmeldung per OpenID Connect bestaetigen.',
|
||||||
|
profile: 'Profilinformationen wie Name und Anzeigename lesen.',
|
||||||
|
email: 'E-Mail-Adresse lesen.',
|
||||||
|
groups: 'Gruppenmitgliedschaften lesen.',
|
||||||
|
offline_access: 'Laengerfristigen Zugriff ueber Refresh Tokens erlauben.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[scope] ?? 'Diese Berechtigung wurde von der Anwendung angefordert.';
|
||||||
|
}
|
||||||
|
}
|
||||||
154
apps/api/src/oidc/oidc-interaction.controller.spec.ts
Normal file
154
apps/api/src/oidc/oidc-interaction.controller.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { OidcInteractionController } from './oidc-interaction.controller';
|
||||||
|
|
||||||
|
describe('OidcInteractionController', () => {
|
||||||
|
const request = {
|
||||||
|
method: 'GET',
|
||||||
|
originalUrl: '/interaction/uid',
|
||||||
|
url: '/interaction/uid',
|
||||||
|
headers: { host: 'auth.example.com', 'x-forwarded-proto': 'https' },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function responseMock() {
|
||||||
|
const response: any = {
|
||||||
|
send: jest.fn(),
|
||||||
|
status: jest.fn(),
|
||||||
|
};
|
||||||
|
response.status.mockReturnValue(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createController() {
|
||||||
|
const oidc = {
|
||||||
|
interactionDetails: jest.fn(),
|
||||||
|
isFirstPartyClient: jest.fn().mockResolvedValue(false),
|
||||||
|
finishConsent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
finishLogin: jest.fn().mockResolvedValue(undefined),
|
||||||
|
abortInteraction: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const templates = {
|
||||||
|
renderLogin: jest.fn().mockResolvedValue('<html>login</html>'),
|
||||||
|
renderConsent: jest.fn().mockResolvedValue('<html>consent</html>'),
|
||||||
|
renderError: jest.fn().mockResolvedValue('<html>error</html>'),
|
||||||
|
};
|
||||||
|
const config = { get: jest.fn().mockReturnValue('https://portal.example.com/') };
|
||||||
|
const errorLogger = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const requestContext = { get: jest.fn().mockReturnValue({ correlationId: 'correlation-id' }) };
|
||||||
|
const controller = new OidcInteractionController(
|
||||||
|
oidc as any,
|
||||||
|
config as any,
|
||||||
|
templates as any,
|
||||||
|
errorLogger as any,
|
||||||
|
requestContext as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { controller, oidc, templates, errorLogger };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the login interaction without changing its route', async () => {
|
||||||
|
const { controller, oidc, templates } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.interactionDetails.mockResolvedValue({ uid: 'uid', prompt: { name: 'login' }, params: {} });
|
||||||
|
|
||||||
|
await controller.view('uid', request, response);
|
||||||
|
|
||||||
|
expect(templates.renderLogin).toHaveBeenCalledWith({
|
||||||
|
uid: 'uid',
|
||||||
|
registrationUrl: 'https://portal.example.com/register',
|
||||||
|
});
|
||||||
|
expect(response.send).toHaveBeenCalledWith('<html>login</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically finishes consent for first-party clients', async () => {
|
||||||
|
const { controller, oidc, templates } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.interactionDetails.mockResolvedValue({
|
||||||
|
uid: 'uid',
|
||||||
|
prompt: { name: 'consent' },
|
||||||
|
params: { client_id: 'first-party' },
|
||||||
|
});
|
||||||
|
oidc.isFirstPartyClient.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await controller.view('uid', request, response);
|
||||||
|
|
||||||
|
expect(oidc.finishConsent).toHaveBeenCalledWith(request, response, 'uid', { autoGranted: true });
|
||||||
|
expect(templates.renderConsent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders consent details for third-party clients', async () => {
|
||||||
|
const { controller, oidc, templates } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.interactionDetails.mockResolvedValue({
|
||||||
|
uid: 'uid',
|
||||||
|
prompt: { name: 'consent' },
|
||||||
|
params: {
|
||||||
|
client_id: 'client-id',
|
||||||
|
name: 'Listify',
|
||||||
|
redirect_uri: 'https://listify.example.com/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.view('uid', request, response);
|
||||||
|
|
||||||
|
expect(templates.renderConsent).toHaveBeenCalledWith({
|
||||||
|
uid: 'uid',
|
||||||
|
clientName: 'Listify',
|
||||||
|
redirectUri: 'https://listify.example.com/callback',
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a successful login delegated to the provider', async () => {
|
||||||
|
const { controller, oidc } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
|
||||||
|
await controller.login('uid', { username: 'maria', password: 'secret' }, request, response);
|
||||||
|
|
||||||
|
expect(oidc.finishLogin).toHaveBeenCalledWith(request, response, 'uid', 'maria', 'secret');
|
||||||
|
expect(response.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the rendered login page for invalid credentials', async () => {
|
||||||
|
const { controller, oidc, templates } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.finishLogin.mockRejectedValue(new UnauthorizedException('Ungueltige Zugangsdaten.'));
|
||||||
|
|
||||||
|
await controller.login('uid', { username: 'maria', password: 'wrong' }, request, response);
|
||||||
|
|
||||||
|
expect(response.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(templates.renderLogin).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
uid: 'uid',
|
||||||
|
username: 'maria',
|
||||||
|
errorMessage: 'Ungueltige Zugangsdaten.',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs an expired interaction and returns a controlled page', async () => {
|
||||||
|
const { controller, oidc, templates, errorLogger } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.interactionDetails.mockRejectedValue(new Error('SessionNotFound'));
|
||||||
|
jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||||
|
|
||||||
|
await controller.view('uid', request, response);
|
||||||
|
|
||||||
|
expect(errorLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
code: 'OIDC_INTERACTION_SESSION_NOT_FOUND',
|
||||||
|
handled: true,
|
||||||
|
}));
|
||||||
|
expect(templates.renderError).toHaveBeenCalledWith(expect.objectContaining({ title: 'Anmeldung abgelaufen' }));
|
||||||
|
expect(response.status).toHaveBeenCalledWith(400);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows unexpected login errors for global error handling', async () => {
|
||||||
|
const { controller, oidc } = createController();
|
||||||
|
const response = responseMock();
|
||||||
|
oidc.finishLogin.mockRejectedValue(new Error('provider unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.login('uid', { username: 'maria', password: 'secret' }, request, response),
|
||||||
|
).rejects.toThrow('provider unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,50 @@
|
|||||||
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 { ConfigService } from '@nestjs/config';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import type { Interaction } from 'oidc-provider';
|
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 { OidcInteractionTemplateService } from './oidc-interaction-template.service';
|
||||||
import { OidcProviderService } from './oidc-provider.service';
|
import { OidcProviderService } from './oidc-provider.service';
|
||||||
|
|
||||||
@Controller('interaction')
|
@Controller('interaction')
|
||||||
export class OidcInteractionController {
|
export class OidcInteractionController {
|
||||||
|
private readonly logger = new Logger(OidcInteractionController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly oidc: OidcProviderService,
|
private readonly oidc: OidcProviderService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly templates: OidcInteractionTemplateService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
|
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);
|
||||||
|
const page = await this.templates.renderError({
|
||||||
|
title: 'Anmeldung abgelaufen',
|
||||||
|
message: 'Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.',
|
||||||
|
});
|
||||||
|
response.status(400).send(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (details.uid !== uid) {
|
if (details.uid !== uid) {
|
||||||
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
|
const page = await this.templates.renderError({
|
||||||
|
title: 'Ungueltige Anfrage',
|
||||||
|
message: 'Die OIDC-Interaktion ist ungueltig.',
|
||||||
|
});
|
||||||
|
response.status(400).send(page);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (details.prompt.name === 'login') {
|
if (details.prompt.name === 'login') {
|
||||||
response.send(this.page('Anmelden', this.loginForm(uid)));
|
response.send(await this.templates.renderLogin({ uid, registrationUrl: this.registrationUrl }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +55,21 @@ export class OidcInteractionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.send(this.page('Zugriff erlauben', this.consentView(uid, details)));
|
const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung'));
|
||||||
|
const redirectUri = String(details.params.redirect_uri ?? '');
|
||||||
|
const scopes = String(details.params.scope ?? 'openid')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
response.send(await this.templates.renderConsent({ uid, clientName, redirectUri, scopes }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(400).send(this.page('OIDC', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>'));
|
const page = await this.templates.renderError({
|
||||||
|
title: 'OIDC',
|
||||||
|
message: 'Diese Interaktion wird noch nicht unterstuetzt.',
|
||||||
|
});
|
||||||
|
response.status(400).send(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':uid/login')
|
@Post(':uid/login')
|
||||||
@@ -50,9 +84,13 @@ export class OidcInteractionController {
|
|||||||
await this.oidc.finishLogin(request, response, uid, username, body.password ?? '');
|
await this.oidc.finishLogin(request, response, uid, username, body.password ?? '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isInvalidCredentialsError(error)) {
|
if (this.isInvalidCredentialsError(error)) {
|
||||||
response
|
const page = await this.templates.renderLogin({
|
||||||
.status(401)
|
uid,
|
||||||
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.')));
|
username,
|
||||||
|
errorMessage: 'Ungueltige Zugangsdaten.',
|
||||||
|
registrationUrl: this.registrationUrl,
|
||||||
|
});
|
||||||
|
response.status(401).send(page);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,122 +108,6 @@ export class OidcInteractionController {
|
|||||||
await this.oidc.abortInteraction(request, response);
|
await this.oidc.abortInteraction(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loginForm(uid: string, username = '', errorMessage = ''): string {
|
|
||||||
const encodedUid = encodeURIComponent(uid);
|
|
||||||
const error = errorMessage
|
|
||||||
? `<p class="message error" role="alert">${this.escape(errorMessage)}</p>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
${error}
|
|
||||||
<form method="post" action="/interaction/${encodedUid}/login">
|
|
||||||
<label>Benutzername <input name="username" autocomplete="username" value="${this.escape(username)}" required></label>
|
|
||||||
<label>Passwort <input name="password" type="password" autocomplete="current-password" required autofocus></label>
|
|
||||||
<button type="submit">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/interaction/${encodedUid}/abort">
|
|
||||||
<button class="secondary" type="submit">Abbrechen</button>
|
|
||||||
</form>
|
|
||||||
<p class="form-link"><a href="${this.escape(this.registrationUrl)}">Neues Konto registrieren</a></p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private consentView(uid: string, details: Interaction): string {
|
|
||||||
const encodedUid = encodeURIComponent(uid);
|
|
||||||
const clientId = String(details.params.client_id ?? '');
|
|
||||||
const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung'));
|
|
||||||
const redirectUri = String(details.params.redirect_uri ?? '');
|
|
||||||
const scope = String(details.params.scope ?? 'openid');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<p class="intro">Die Anwendung <strong>${this.escape(clientName)}</strong> moechte auf dein Konto zugreifen.</p>
|
|
||||||
${redirectUri ? `<dl class="consent-details"><div><dt>Weiterleitung</dt><dd>${this.escape(redirectUri)}</dd></div></dl>` : ''}
|
|
||||||
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
|
|
||||||
${this.scopeItems(scope)}
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/interaction/${encodedUid}/confirm">
|
|
||||||
<button type="submit">Zugriff erlauben</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/interaction/${encodedUid}/abort">
|
|
||||||
<button class="secondary" type="submit">Ablehnen</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopeItems(scope: string): string {
|
|
||||||
const scopes = scope
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return scopes
|
|
||||||
.map(
|
|
||||||
(item) => `
|
|
||||||
<section class="scope-item">
|
|
||||||
<strong>${this.escape(item)}</strong>
|
|
||||||
<span>${this.escape(this.scopeDescription(item))}</span>
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopeDescription(scope: string): string {
|
|
||||||
const descriptions: Record<string, string> = {
|
|
||||||
openid: 'Anmeldung per OpenID Connect bestaetigen.',
|
|
||||||
profile: 'Profilinformationen wie Name und Anzeigename lesen.',
|
|
||||||
email: 'E-Mail-Adresse lesen.',
|
|
||||||
groups: 'Gruppenmitgliedschaften lesen.',
|
|
||||||
offline_access: 'Laengerfristigen Zugriff ueber Refresh Tokens erlauben.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return descriptions[scope] ?? 'Diese Berechtigung wurde von der Anwendung angefordert.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private page(title: string, body: string): string {
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>${this.escape(title)} - LDAP Portal</title>
|
|
||||||
<style>
|
|
||||||
body { background: #f5f7f9; color: #18202a; font-family: Inter, system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 20px; }
|
|
||||||
main { background: white; border: 1px solid #d8e0e7; border-radius: 8px; box-shadow: 0 16px 40px rgb(24 32 42 / 8%); max-width: 460px; padding: 28px; width: 100%; }
|
|
||||||
h1 { font-size: 1.45rem; margin: 0 0 22px; }
|
|
||||||
form { display: grid; gap: 16px; margin-top: 16px; }
|
|
||||||
label { display: grid; gap: 7px; font-weight: 700; }
|
|
||||||
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
|
|
||||||
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
|
|
||||||
button.secondary { background: white; color: #0f6b6e; }
|
|
||||||
a { color: #0f6b6e; font-weight: 700; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.form-link { margin: 16px 0 0; text-align: center; }
|
|
||||||
.intro { color: #3a4551; line-height: 1.45; margin: 0 0 16px; }
|
|
||||||
.message { background: #edf7f4; border: 1px solid #b8ddd3; border-radius: 6px; color: #24564f; margin: 0 0 16px; padding: 12px; }
|
|
||||||
.message.error { background: #fff1f0; border-color: #efb5ae; color: #8d2b20; }
|
|
||||||
.consent-details { display: grid; gap: 10px; margin: 0 0 16px; }
|
|
||||||
.consent-details div { display: grid; gap: 5px; }
|
|
||||||
dt { color: #637083; font-size: 0.82rem; font-weight: 700; }
|
|
||||||
dd { margin: 0; overflow-wrap: anywhere; }
|
|
||||||
.scope-list { display: grid; gap: 8px; margin: 16px 0; }
|
|
||||||
.scope-item { border: 1px solid #d8e0e7; border-radius: 6px; display: grid; gap: 4px; padding: 10px 12px; }
|
|
||||||
.scope-item span { color: #637083; font-size: 0.9rem; line-height: 1.35; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body><main><h1>${this.escape(title)}</h1>${body}</main></body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private escape(value: string): string {
|
|
||||||
return value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
private get registrationUrl(): string {
|
private get registrationUrl(): string {
|
||||||
const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
|
const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
|
||||||
return `${publicWebUrl.replace(/\/+$/, '')}/register`;
|
return `${publicWebUrl.replace(/\/+$/, '')}/register`;
|
||||||
@@ -194,4 +116,35 @@ export class OidcInteractionController {
|
|||||||
private isInvalidCredentialsError(error: unknown): boolean {
|
private isInvalidCredentialsError(error: unknown): boolean {
|
||||||
return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.';
|
return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async logInteractionSessionError(error: unknown, uid: string, request: Request): Promise<void> {
|
||||||
|
const logPayload = {
|
||||||
|
uid,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
host: request.headers.host,
|
||||||
|
forwardedProto: request.headers['x-forwarded-proto'],
|
||||||
|
errorName: error instanceof Error ? error.name : typeof error,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
console.error('[OIDC_INTERACTION_SESSION_NOT_FOUND]', JSON.stringify(logPayload), error instanceof Error ? error.stack : '');
|
||||||
|
this.logger.warn(`OIDC interaction session not found: ${logPayload.errorMessage}`);
|
||||||
|
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.OIDC,
|
||||||
|
code: ApplicationErrorCode.OIDC_INTERACTION_SESSION_NOT_FOUND,
|
||||||
|
module: 'OidcModule',
|
||||||
|
service: OidcInteractionController.name,
|
||||||
|
operation: 'interactionDetails',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
context: logPayload,
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { HttpAdapterHost } from '@nestjs/core';
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -6,7 +6,10 @@ import { Request, Response } from 'express';
|
|||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
|
import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider';
|
||||||
import { Repository } from 'typeorm';
|
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 { AuditService } from '../audit/audit.service';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
|
import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity';
|
||||||
@@ -20,6 +23,7 @@ type JoseImport = typeof import('jose');
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OidcProviderService implements OnModuleInit {
|
export class OidcProviderService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OidcProviderService.name);
|
||||||
private provider?: Provider;
|
private provider?: Provider;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -35,6 +39,8 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
private readonly ldapAuth: LdapAuthService,
|
private readonly ldapAuth: LdapAuthService,
|
||||||
private readonly lldap: LldapService,
|
private readonly lldap: LldapService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -45,6 +51,7 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
|
this.provider = new oidc.default(issuer, this.buildConfiguration(jwks));
|
||||||
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
|
this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true';
|
||||||
this.registerAuditEvents(this.provider);
|
this.registerAuditEvents(this.provider);
|
||||||
|
this.registerErrorEvents(this.provider);
|
||||||
|
|
||||||
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
|
const expressApp = this.httpAdapterHost.httpAdapter.getInstance();
|
||||||
expressApp.use(this.provider.callback());
|
expressApp.use(this.provider.callback());
|
||||||
@@ -67,15 +74,28 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const valid = await this.ldapAuth.verifyPassword(username, password);
|
const valid = await this.ldapAuth.verifyPassword(username, password);
|
||||||
|
const clientId = String(details.params.client_id ?? '');
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
|
await this.audit.record({
|
||||||
|
type: 'oidc.login_failed',
|
||||||
|
username,
|
||||||
|
ipAddress: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
metadata: { clientId },
|
||||||
|
});
|
||||||
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
|
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await this.lldap.getAccount(username);
|
const account = await this.lldap.getAccount(username);
|
||||||
const subject = account.uuid || account.id;
|
const subject = account.uuid || account.id;
|
||||||
await this.subjects.save(this.subjects.create({ subject, username: account.id }));
|
await this.subjects.save(this.subjects.create({ subject, username: account.id }));
|
||||||
await this.audit.record({ type: 'oidc.login_success', username: account.id, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
|
await this.audit.record({
|
||||||
|
type: 'oidc.login_success',
|
||||||
|
username: account.id,
|
||||||
|
ipAddress: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
metadata: { clientId },
|
||||||
|
});
|
||||||
|
|
||||||
await this.getProvider().interactionFinished(
|
await this.getProvider().interactionFinished(
|
||||||
request,
|
request,
|
||||||
@@ -137,6 +157,7 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async abortInteraction(request: Request, response: Response): Promise<void> {
|
async abortInteraction(request: Request, response: Response): Promise<void> {
|
||||||
|
const details = await this.interactionDetails(request, response);
|
||||||
await this.getProvider().interactionFinished(
|
await this.getProvider().interactionFinished(
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
@@ -146,6 +167,16 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
{ mergeWithLastSubmission: false },
|
{ mergeWithLastSubmission: false },
|
||||||
);
|
);
|
||||||
|
await this.audit.record({
|
||||||
|
type: 'oidc.interaction_aborted',
|
||||||
|
username: details.session?.accountId,
|
||||||
|
ipAddress: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
metadata: {
|
||||||
|
clientId: String(details.params.client_id ?? ''),
|
||||||
|
prompt: details.prompt.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async isFirstPartyClient(clientId: string): Promise<boolean> {
|
async isFirstPartyClient(clientId: string): Promise<boolean> {
|
||||||
@@ -277,6 +308,170 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
provider.on('access_token.issued', (token) => {
|
provider.on('access_token.issued', (token) => {
|
||||||
void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } });
|
void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } });
|
||||||
});
|
});
|
||||||
|
provider.on('interaction.started', (ctx, interaction) => {
|
||||||
|
const activity = this.oidcActivityContext(ctx);
|
||||||
|
void this.audit.record({
|
||||||
|
type: 'oidc.interaction_started',
|
||||||
|
username: activity.userId,
|
||||||
|
ipAddress: activity.ipAddress,
|
||||||
|
userAgent: activity.userAgent,
|
||||||
|
metadata: {
|
||||||
|
uid: activity.interactionId,
|
||||||
|
prompt: interaction?.name,
|
||||||
|
clientId: activity.clientId,
|
||||||
|
redirectUri: activity.redirectUri,
|
||||||
|
scope: activity.scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
provider.on('authorization.success', (ctx) => {
|
||||||
|
const activity = this.oidcActivityContext(ctx);
|
||||||
|
void this.audit.record({
|
||||||
|
type: 'oidc.client_login_success',
|
||||||
|
username: activity.userId,
|
||||||
|
ipAddress: activity.ipAddress,
|
||||||
|
userAgent: activity.userAgent,
|
||||||
|
metadata: {
|
||||||
|
clientId: activity.clientId,
|
||||||
|
redirectUri: activity.redirectUri,
|
||||||
|
responseType: activity.responseType,
|
||||||
|
scope: activity.scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
provider.on('grant.success', (ctx) => {
|
||||||
|
const activity = this.oidcActivityContext(ctx);
|
||||||
|
void this.audit.record({
|
||||||
|
type: 'oidc.client_token_granted',
|
||||||
|
username: activity.userId,
|
||||||
|
ipAddress: activity.ipAddress,
|
||||||
|
userAgent: activity.userAgent,
|
||||||
|
metadata: { clientId: activity.clientId, grantType: activity.grantType },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
provider.on('end_session.success', (ctx) => {
|
||||||
|
const activity = this.oidcActivityContext(ctx);
|
||||||
|
void this.audit.record({
|
||||||
|
type: 'oidc.logout_success',
|
||||||
|
username: activity.userId,
|
||||||
|
ipAddress: activity.ipAddress,
|
||||||
|
userAgent: activity.userAgent,
|
||||||
|
metadata: { clientId: activity.clientId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private oidcActivityContext(ctx: unknown): {
|
||||||
|
clientId?: string;
|
||||||
|
userId?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
responseType?: string;
|
||||||
|
scope?: string;
|
||||||
|
grantType?: string;
|
||||||
|
interactionId?: string;
|
||||||
|
} {
|
||||||
|
const oidcCtx = ctx as {
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
oidc?: {
|
||||||
|
client?: { clientId?: string };
|
||||||
|
session?: { accountId?: string };
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
entities?: { Interaction?: { uid?: string } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const params = oidcCtx?.oidc?.params ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: oidcCtx?.oidc?.client?.clientId ?? this.stringProperty(params.client_id),
|
||||||
|
userId: oidcCtx?.oidc?.session?.accountId,
|
||||||
|
ipAddress: oidcCtx?.ip,
|
||||||
|
userAgent: this.stringProperty(oidcCtx?.headers?.['user-agent']),
|
||||||
|
redirectUri: this.stringProperty(params.redirect_uri),
|
||||||
|
responseType: this.stringProperty(params.response_type),
|
||||||
|
scope: this.stringProperty(params.scope),
|
||||||
|
grantType: this.stringProperty(params.grant_type),
|
||||||
|
interactionId: oidcCtx?.oidc?.entities?.Interaction?.uid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerErrorEvents(provider: Provider): void {
|
||||||
|
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);
|
||||||
|
this.auditOidcFailure(eventName, ctx, actualError);
|
||||||
|
void this.logOidcProviderError(eventName, ctx, actualError).catch((logError) => {
|
||||||
|
this.logger.error(`OIDC provider error logging failed: ${this.errorProperty(logError, 'message')}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private consoleLogOidcProviderError(eventName: string, ctx: unknown, error: unknown): void {
|
||||||
|
const payload = this.oidcErrorContext(eventName, ctx, error);
|
||||||
|
console.error('[OIDC_PROVIDER_ERROR]', JSON.stringify(payload), error instanceof Error ? error.stack : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logOidcProviderError(eventName: string, ctx: unknown, error: unknown): Promise<void> {
|
||||||
|
const context = this.oidcErrorContext(eventName, ctx, error);
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.OIDC,
|
||||||
|
code:
|
||||||
|
eventName === 'server_error'
|
||||||
|
? ApplicationErrorCode.OIDC_PROVIDER_ERROR
|
||||||
|
: ApplicationErrorCode.OIDC_AUTHORIZATION_ERROR,
|
||||||
|
module: 'OidcModule',
|
||||||
|
service: OidcProviderService.name,
|
||||||
|
operation: eventName,
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
method: typeof context.method === 'string' ? context.method : undefined,
|
||||||
|
path: typeof context.path === 'string' ? context.path : undefined,
|
||||||
|
statusCode: typeof context.status === 'number' ? context.status : undefined,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
handled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private oidcErrorContext(eventName: string, ctx: unknown, error: unknown): Record<string, unknown> {
|
||||||
|
const oidcCtx = ctx as {
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: number;
|
||||||
|
oidc?: {
|
||||||
|
route?: string;
|
||||||
|
client?: { clientId?: string };
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
host?: string;
|
||||||
|
};
|
||||||
|
const params = oidcCtx?.oidc?.params ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventName,
|
||||||
|
errorName: this.errorProperty(error, 'name'),
|
||||||
|
errorMessage: this.errorProperty(error, 'message'),
|
||||||
|
method: oidcCtx?.method,
|
||||||
|
path: oidcCtx?.path,
|
||||||
|
status: oidcCtx?.status,
|
||||||
|
route: oidcCtx?.oidc?.route,
|
||||||
|
clientId: oidcCtx?.oidc?.client?.clientId ?? params.client_id,
|
||||||
|
redirectUri: params.redirect_uri,
|
||||||
|
responseType: params.response_type,
|
||||||
|
scope: params.scope,
|
||||||
|
host: oidcCtx?.host ?? oidcCtx?.headers?.host,
|
||||||
|
forwardedProto: oidcCtx?.headers?.['x-forwarded-proto'],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProvider(): Provider {
|
private getProvider(): Provider {
|
||||||
@@ -293,4 +488,31 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
private async importJose(): Promise<JoseImport> {
|
private async importJose(): Promise<JoseImport> {
|
||||||
return new Function('specifier', 'return import(specifier)')('jose') as Promise<JoseImport>;
|
return new Function('specifier', 'return import(specifier)')('jose') as Promise<JoseImport>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private errorProperty(error: unknown, property: 'name' | 'message'): string | undefined {
|
||||||
|
return error instanceof Error ? error[property] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditOidcFailure(eventName: string, ctx: unknown, error: unknown): void {
|
||||||
|
if (eventName !== 'authorization.error' && eventName !== 'grant.error') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = this.oidcActivityContext(ctx);
|
||||||
|
void this.audit.record({
|
||||||
|
type: eventName === 'authorization.error' ? 'oidc.client_login_failed' : 'oidc.client_token_failed',
|
||||||
|
username: activity.userId,
|
||||||
|
ipAddress: activity.ipAddress,
|
||||||
|
userAgent: activity.userAgent,
|
||||||
|
metadata: {
|
||||||
|
clientId: activity.clientId,
|
||||||
|
errorName: this.errorProperty(error, 'name'),
|
||||||
|
errorMessage: this.errorProperty(error, 'message'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringProperty(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OidcAdminClientsController } from './oidc-admin-clients.controller';
|
|||||||
import { OidcAdminGuard } from './oidc-admin.guard';
|
import { OidcAdminGuard } from './oidc-admin.guard';
|
||||||
import { OidcClientService } from './oidc-client.service';
|
import { OidcClientService } from './oidc-client.service';
|
||||||
import { OidcInteractionController } from './oidc-interaction.controller';
|
import { OidcInteractionController } from './oidc-interaction.controller';
|
||||||
|
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
|
||||||
import { OidcProviderService } from './oidc-provider.service';
|
import { OidcProviderService } from './oidc-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -26,6 +27,6 @@ import { OidcProviderService } from './oidc-provider.service';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
],
|
],
|
||||||
controllers: [OidcAdminClientsController, OidcInteractionController],
|
controllers: [OidcAdminClientsController, OidcInteractionController],
|
||||||
providers: [OidcAdminGuard, OidcClientService, OidcProviderService],
|
providers: [OidcAdminGuard, OidcClientService, OidcInteractionTemplateService, OidcProviderService],
|
||||||
})
|
})
|
||||||
export class OidcModule {}
|
export class OidcModule {}
|
||||||
|
|||||||
23
apps/api/src/oidc/templates/consent.hbs
Normal file
23
apps/api/src/oidc/templates/consent.hbs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<p class="intro">Die Anwendung <strong>{{clientName}}</strong> möchte auf dein Konto zugreifen.</p>
|
||||||
|
{{#if redirectUri}}
|
||||||
|
<dl class="consent-details">
|
||||||
|
<div>
|
||||||
|
<dt>Weiterleitung</dt>
|
||||||
|
<dd>{{redirectUri}}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{{/if}}
|
||||||
|
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
|
||||||
|
{{#each scopes}}
|
||||||
|
<section class="scope-item">
|
||||||
|
<strong>{{name}}</strong>
|
||||||
|
<span>{{description}}</span>
|
||||||
|
</section>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{confirmAction}}">
|
||||||
|
<button type="submit">Zugriff erlauben</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{abortAction}}">
|
||||||
|
<button class="secondary" type="submit">Ablehnen</button>
|
||||||
|
</form>
|
||||||
1
apps/api/src/oidc/templates/error.hbs
Normal file
1
apps/api/src/oidc/templates/error.hbs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p class="intro">{{message}}</p>
|
||||||
113
apps/api/src/oidc/templates/layouts/base.hbs
Normal file
113
apps/api/src/oidc/templates/layouts/base.hbs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>{{title}} - {{branding.productName}}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #f3f6f7;
|
||||||
|
color: #18202a;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d8e0e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 16px 40px rgb(24 32 42 / 8%);
|
||||||
|
max-width: 460px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #e7ecef;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
}
|
||||||
|
.brand img { display: block; max-height: 36px; max-width: 180px; }
|
||||||
|
.brand-name { color: #26313d; font-size: 1rem; font-weight: 700; }
|
||||||
|
.content { padding: 28px; }
|
||||||
|
h1 { font-size: 1.45rem; letter-spacing: 0; margin: 0 0 22px; }
|
||||||
|
form { display: grid; gap: 16px; margin-top: 16px; }
|
||||||
|
label { display: grid; gap: 7px; font-weight: 700; }
|
||||||
|
input {
|
||||||
|
border: 1px solid #aebbc7;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #18202a;
|
||||||
|
font: inherit;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus { border-color: {{branding.primaryColor}}; outline: 3px solid rgb(15 107 110 / 18%); }
|
||||||
|
button {
|
||||||
|
background: {{branding.primaryColor}};
|
||||||
|
border: 1px solid {{branding.primaryColor}};
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(0.92); }
|
||||||
|
button:focus-visible, a:focus-visible { outline: 3px solid rgb(15 107 110 / 25%); outline-offset: 2px; }
|
||||||
|
button.secondary { background: #ffffff; color: {{branding.primaryColor}}; }
|
||||||
|
a { color: {{branding.primaryColor}}; font-weight: 700; overflow-wrap: anywhere; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.form-link { margin: 18px 0 0; text-align: center; }
|
||||||
|
.intro { color: #3a4551; line-height: 1.5; margin: 0 0 16px; }
|
||||||
|
.message { border-radius: 6px; line-height: 1.45; margin: 0 0 16px; padding: 12px; }
|
||||||
|
.message.error { background: #fff1f0; border: 1px solid #efb5ae; color: #8d2b20; }
|
||||||
|
.consent-details { display: grid; gap: 10px; margin: 0 0 16px; }
|
||||||
|
.consent-details div { display: grid; gap: 5px; }
|
||||||
|
dt { color: #637083; font-size: 0.82rem; font-weight: 700; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.scope-list { display: grid; gap: 8px; margin: 16px 0; }
|
||||||
|
.scope-item { border: 1px solid #d8e0e7; border-radius: 6px; display: grid; gap: 4px; padding: 10px 12px; }
|
||||||
|
.scope-item span { color: #637083; font-size: 0.9rem; line-height: 1.35; }
|
||||||
|
footer { color: #637083; font-size: 0.78rem; line-height: 1.4; padding: 0 28px 24px; text-align: center; }
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.page { min-height: calc(100vh - 24px); }
|
||||||
|
.brand { padding: 14px 20px; }
|
||||||
|
.content { padding: 24px 20px; }
|
||||||
|
footer { padding: 0 20px 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<main>
|
||||||
|
<header class="brand">
|
||||||
|
{{#if branding.logoUrl}}
|
||||||
|
<img src="{{branding.logoUrl}}" alt="{{branding.productName}}">
|
||||||
|
{{else}}
|
||||||
|
<span class="brand-name">{{branding.productName}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</header>
|
||||||
|
<section class="content">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
{{{body}}}
|
||||||
|
</section>
|
||||||
|
<footer>{{branding.companyName}}</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
apps/api/src/oidc/templates/login.hbs
Normal file
18
apps/api/src/oidc/templates/login.hbs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{{#if errorMessage}}
|
||||||
|
<p class="message error" role="alert">{{errorMessage}}</p>
|
||||||
|
{{/if}}
|
||||||
|
<form method="post" action="{{loginAction}}">
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input name="username" autocomplete="username" value="{{username}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required autofocus>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{abortAction}}">
|
||||||
|
<button class="secondary" type="submit">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
<p class="form-link"><a href="{{registrationUrl}}">Neues Konto registrieren</a></p>
|
||||||
31
apps/api/src/password/password.service.spec.ts
Normal file
31
apps/api/src/password/password.service.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { PasswordService } from './password.service';
|
||||||
|
|
||||||
|
describe('PasswordService', () => {
|
||||||
|
it('logs password reset mail delivery failures with stable error code', async () => {
|
||||||
|
const resetTokens = {
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => ({ ...value, id: 'token-id' })),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new PasswordService(
|
||||||
|
resetTokens as any,
|
||||||
|
{ get: jest.fn((key: string) => (key === 'SMTP_HOST' ? 'smtp.example.com' : 'secret')), getOrThrow: jest.fn(() => 'secret') } as any,
|
||||||
|
{} as any,
|
||||||
|
{ findUserByEmail: jest.fn().mockResolvedValue({ id: 'user-1', email: 'maria@example.com' }) } as any,
|
||||||
|
{ sendPasswordResetMail: jest.fn().mockRejectedValue(new Error('SMTP unavailable')) } as any,
|
||||||
|
{ record: jest.fn().mockResolvedValue(undefined) } as any,
|
||||||
|
{ log: jest.fn().mockResolvedValue(undefined) } as any,
|
||||||
|
{ get: jest.fn(() => ({ correlationId: 'corr-1' })) } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.requestReset('maria@example.com')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
expect((service as any).applicationErrorLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
code: 'PASSWORD_RESET_EMAIL_SEND_FAILED',
|
||||||
|
category: 'EMAIL',
|
||||||
|
requestContext: expect.objectContaining({ correlationId: 'corr-1', userId: 'user-1' }),
|
||||||
|
context: expect.objectContaining({ maskedRecipient: 'm***@example.com', mailProvider: 'smtp.example.com' }),
|
||||||
|
handled: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,13 @@ import { BadRequestException, Injectable, ServiceUnavailableException, Unauthori
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from '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 { AuditService } from '../audit/audit.service';
|
||||||
import { assertPasswordPolicy } from '../common/password-policy';
|
import { assertPasswordPolicy } from '../common/password-policy';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { hashToken, randomToken } from '../common/token.util';
|
import { hashToken, randomToken } from '../common/token.util';
|
||||||
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
@@ -20,6 +25,8 @@ export class PasswordService {
|
|||||||
private readonly lldap: LldapService,
|
private readonly lldap: LldapService,
|
||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@@ -70,9 +77,30 @@ export class PasswordService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mail.sendPasswordResetMail(user.email, token);
|
await this.mail.sendPasswordResetMail({
|
||||||
|
recipient: user.email,
|
||||||
|
token,
|
||||||
|
expiresAt: resetToken.expiresAt,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
|
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED,
|
||||||
|
module: 'PasswordModule',
|
||||||
|
service: PasswordService.name,
|
||||||
|
operation: 'sendPasswordResetEmail',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(user.email),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
await this.audit
|
await this.audit
|
||||||
.record({
|
.record({
|
||||||
type: 'password.reset_mail_failed',
|
type: 'password.reset_mail_failed',
|
||||||
@@ -82,9 +110,11 @@ export class PasswordService {
|
|||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
throw new ServiceUnavailableException(
|
const exception = new ServiceUnavailableException(
|
||||||
'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
||||||
);
|
);
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { BadRequestException, ConflictException, Injectable, ServiceUnavailableE
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, IsNull, Repository } from '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 { AuditService } from '../audit/audit.service';
|
||||||
import { assertPasswordPolicy } from '../common/password-policy';
|
import { assertPasswordPolicy } from '../common/password-policy';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
|
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { PortalMailService } from '../mail/portal-mail.service';
|
import { PortalMailService } from '../mail/portal-mail.service';
|
||||||
@@ -22,6 +27,8 @@ export class RegistrationService {
|
|||||||
private readonly lldap: LldapService,
|
private readonly lldap: LldapService,
|
||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
||||||
@@ -65,6 +72,23 @@ export class RegistrationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined);
|
await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined);
|
||||||
await this.registrations.delete({ id: registration.id }).catch(() => undefined);
|
await this.registrations.delete({ id: registration.id }).catch(() => undefined);
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED,
|
||||||
|
module: 'RegistrationModule',
|
||||||
|
service: RegistrationService.name,
|
||||||
|
operation: 'sendVerificationMail',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: registration.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(registration.email),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
await this.audit
|
await this.audit
|
||||||
.record({
|
.record({
|
||||||
type: 'registration.verification_mail_failed',
|
type: 'registration.verification_mail_failed',
|
||||||
@@ -74,9 +98,11 @@ export class RegistrationService {
|
|||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
throw new ServiceUnavailableException(
|
const exception = new ServiceUnavailableException(
|
||||||
'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
||||||
);
|
);
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
@@ -114,13 +140,30 @@ export class RegistrationService {
|
|||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.notifyUserManagers(registration).catch((error) =>
|
await this.notifyUserManagers(registration).catch(async (error) => {
|
||||||
this.audit.record({
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.REGISTRATION_APPROVAL_NOTIFICATION_FAILED,
|
||||||
|
module: 'RegistrationModule',
|
||||||
|
service: RegistrationService.name,
|
||||||
|
operation: 'notifyUserManagers',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: registration.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(registration.email),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
return this.audit.record({
|
||||||
type: 'registration.approval_notification_failed',
|
type: 'registration.approval_notification_failed',
|
||||||
username: registration.username,
|
username: registration.username,
|
||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
|
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true
|
"removeComments": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["src/**/*.spec.ts", "dist", "node_modules", "test"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"types": ["node"]
|
"types": ["node", "jest"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules", "test"]
|
"exclude": ["dist", "node_modules", "test"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Inject, Injectable, signal } from '@angular/core';
|
import { Inject, Injectable, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { tap } from 'rxjs';
|
import { catchError, EMPTY, tap } from 'rxjs';
|
||||||
import { API_BASE_URL } from './api-base-url';
|
import { API_BASE_URL } from './api-base-url';
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
@@ -34,6 +34,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
|
this.http.post<void>(`${this.apiBaseUrl}/auth/logout`, {}).pipe(catchError(() => EMPTY)).subscribe();
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
this.username.set(null);
|
this.username.set(null);
|
||||||
|
|||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -6801,6 +6802,19 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schedule": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron": "4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -8806,6 +8820,12 @@
|
|||||||
"@types/koa": "*"
|
"@types/koa": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -11494,6 +11514,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/luxon": "~3.7.0",
|
||||||
|
"luxon": "~3.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.x"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/intcreator"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -16762,6 +16799,15 @@
|
|||||||
"url": "https://github.com/sponsors/wellwelwel"
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user