angular
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"include": "mail/templates/**/*",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
},
|
||||
{
|
||||
"include": "oidc/templates/**/*",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>('MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
|
||||
companyName: config.get<string>('MAIL_COMPANY_NAME') ?? 'LDAP Portal',
|
||||
primaryColor: config.get<string>('MAIL_PRIMARY_COLOR') ?? '#0f6b6e',
|
||||
supportEmail: config.get<string>('MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
|
||||
logoUrl: config.get<string>('MAIL_LOGO_URL') || undefined,
|
||||
imprintUrl: config.get<string>('MAIL_IMPRINT_URL') || undefined,
|
||||
privacyUrl: config.get<string>('MAIL_PRIVACY_URL') || undefined,
|
||||
};
|
||||
return applicationBranding(config);
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@Controller('interaction')
|
||||
@@ -14,6 +15,7 @@ export class OidcInteractionController {
|
||||
constructor(
|
||||
private readonly oidc: OidcProviderService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly templates: OidcInteractionTemplateService,
|
||||
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||
private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
@@ -25,21 +27,24 @@ export class OidcInteractionController {
|
||||
details = await this.oidc.interactionDetails(request, response);
|
||||
} catch (error) {
|
||||
await this.logInteractionSessionError(error, uid, request);
|
||||
response.status(400).send(
|
||||
this.page(
|
||||
'Anmeldung abgelaufen',
|
||||
'<p>Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.</p>',
|
||||
),
|
||||
);
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (details.prompt.name === 'login') {
|
||||
response.send(this.page('Anmelden', this.loginForm(uid)));
|
||||
response.send(await this.templates.renderLogin({ uid, registrationUrl: this.registrationUrl }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -50,11 +55,21 @@ export class OidcInteractionController {
|
||||
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;
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -69,9 +84,13 @@ export class OidcInteractionController {
|
||||
await this.oidc.finishLogin(request, response, uid, username, body.password ?? '');
|
||||
} catch (error) {
|
||||
if (this.isInvalidCredentialsError(error)) {
|
||||
response
|
||||
.status(401)
|
||||
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.')));
|
||||
const page = await this.templates.renderLogin({
|
||||
uid,
|
||||
username,
|
||||
errorMessage: 'Ungueltige Zugangsdaten.',
|
||||
registrationUrl: this.registrationUrl,
|
||||
});
|
||||
response.status(401).send(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,122 +108,6 @@ export class OidcInteractionController {
|
||||
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 {
|
||||
const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
|
||||
return `${publicWebUrl.replace(/\/+$/, '')}/register`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OidcAdminClientsController } from './oidc-admin-clients.controller';
|
||||
import { OidcAdminGuard } from './oidc-admin.guard';
|
||||
import { OidcClientService } from './oidc-client.service';
|
||||
import { OidcInteractionController } from './oidc-interaction.controller';
|
||||
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
|
||||
@Module({
|
||||
@@ -26,6 +27,6 @@ import { OidcProviderService } from './oidc-provider.service';
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [OidcAdminClientsController, OidcInteractionController],
|
||||
providers: [OidcAdminGuard, OidcClientService, OidcProviderService],
|
||||
providers: [OidcAdminGuard, OidcClientService, OidcInteractionTemplateService, OidcProviderService],
|
||||
})
|
||||
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>
|
||||
Reference in New Issue
Block a user