diff --git a/.env.example b/.env.example index 5f196be..1d8d160 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,14 @@ WEB_PORT=4200 PUBLIC_WEB_URL=http://localhost:4200 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= + DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal DATABASE_SSL=false JWT_SECRET=change-me-long-random-jwt-secret diff --git a/README.md b/README.md index 9a6ded4..d23f5ae 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,9 @@ Die wichtigsten Variablen aus `.env.example`: | `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. | | `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. | | `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. | | `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. | | `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. | @@ -156,6 +159,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". +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 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. diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index 0db9dc9..3a9e52f 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -8,6 +8,11 @@ "include": "mail/templates/**/*", "outDir": "dist", "watchAssets": true + }, + { + "include": "oidc/templates/**/*", + "outDir": "dist", + "watchAssets": true } ] } diff --git a/apps/api/scripts/copy-mail-assets.js b/apps/api/scripts/copy-mail-assets.js index 8a0e17c..e9ed87b 100644 --- a/apps/api/scripts/copy-mail-assets.js +++ b/apps/api/scripts/copy-mail-assets.js @@ -1,9 +1,6 @@ const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs'); const { join } = require('node:path'); -const source = join(__dirname, '..', 'src', 'mail', 'templates'); -const target = join(__dirname, '..', 'dist', 'mail', 'templates'); - function copyDirectory(from, to) { if (!existsSync(from)) { return; @@ -21,4 +18,9 @@ function copyDirectory(from, to) { } } -copyDirectory(source, target); +for (const feature of ['mail', 'oidc']) { + copyDirectory( + join(__dirname, '..', 'src', feature, 'templates'), + join(__dirname, '..', 'dist', feature, 'templates'), + ); +} diff --git a/apps/api/src/common/application-branding.ts b/apps/api/src/common/application-branding.ts new file mode 100644 index 0000000..a5301c6 --- /dev/null +++ b/apps/api/src/common/application-branding.ts @@ -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(primaryKey)?.trim() || config.get(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; + } +} diff --git a/apps/api/src/mail/mail-branding.ts b/apps/api/src/mail/mail-branding.ts index 80b65ea..a2e5349 100644 --- a/apps/api/src/mail/mail-branding.ts +++ b/apps/api/src/mail/mail-branding.ts @@ -1,23 +1,11 @@ import { ConfigService } from '@nestjs/config'; +import { + ApplicationBranding, + applicationBranding, +} from '../common/application-branding'; -export interface MailBranding { - productName: string; - companyName: string; - primaryColor: string; - supportEmail: string; - logoUrl?: string; - imprintUrl?: string; - privacyUrl?: string; -} +export type MailBranding = ApplicationBranding; export function mailBranding(config: ConfigService): MailBranding { - return { - productName: config.get('MAIL_PRODUCT_NAME') ?? 'LDAP Portal', - companyName: config.get('MAIL_COMPANY_NAME') ?? 'LDAP Portal', - primaryColor: config.get('MAIL_PRIMARY_COLOR') ?? '#0f6b6e', - supportEmail: config.get('MAIL_SUPPORT_EMAIL') ?? 'support@example.com', - logoUrl: config.get('MAIL_LOGO_URL') || undefined, - imprintUrl: config.get('MAIL_IMPRINT_URL') || undefined, - privacyUrl: config.get('MAIL_PRIVACY_URL') || undefined, - }; + return applicationBranding(config); } diff --git a/apps/api/src/oidc/oidc-interaction-template.service.spec.ts b/apps/api/src/oidc/oidc-interaction-template.service.spec.ts new file mode 100644 index 0000000..695bbd3 --- /dev/null +++ b/apps/api/src/oidc/oidc-interaction-template.service.spec.ts @@ -0,0 +1,66 @@ +import { OidcInteractionTemplateService } from './oidc-interaction-template.service'; + +describe('OidcInteractionTemplateService', () => { + const values: Record = { + 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('Anmelden - ForgeCore Auth'); + 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: '', + errorMessage: 'Fehler', + registrationUrl: 'https://portal.example.com/register', + }); + + expect(html).toContain('<script>alert(1)</script>'); + expect(html).toContain('<b>Fehler</b>'); + expect(html).not.toContain('