angular
This commit is contained in:
@@ -5,6 +5,14 @@ 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=
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ 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. |
|
||||||
| `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. |
|
||||||
@@ -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".
|
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.
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
"include": "mail/templates/**/*",
|
"include": "mail/templates/**/*",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"watchAssets": true
|
"watchAssets": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "oidc/templates/**/*",
|
||||||
|
"outDir": "dist",
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
|
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
|
||||||
const { join } = require('node:path');
|
const { join } = require('node:path');
|
||||||
|
|
||||||
const source = join(__dirname, '..', 'src', 'mail', 'templates');
|
|
||||||
const target = join(__dirname, '..', 'dist', 'mail', 'templates');
|
|
||||||
|
|
||||||
function copyDirectory(from, to) {
|
function copyDirectory(from, to) {
|
||||||
if (!existsSync(from)) {
|
if (!existsSync(from)) {
|
||||||
return;
|
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 { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
ApplicationBranding,
|
||||||
|
applicationBranding,
|
||||||
|
} from '../common/application-branding';
|
||||||
|
|
||||||
export interface MailBranding {
|
export type MailBranding = ApplicationBranding;
|
||||||
productName: string;
|
|
||||||
companyName: string;
|
|
||||||
primaryColor: string;
|
|
||||||
supportEmail: string;
|
|
||||||
logoUrl?: string;
|
|
||||||
imprintUrl?: string;
|
|
||||||
privacyUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mailBranding(config: ConfigService): MailBranding {
|
export function mailBranding(config: ConfigService): MailBranding {
|
||||||
return {
|
return applicationBranding(config);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
||||||
import { RequestContextService } from '../common/request-context.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')
|
||||||
@@ -14,6 +15,7 @@ export class OidcInteractionController {
|
|||||||
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 applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
@@ -25,21 +27,24 @@ export class OidcInteractionController {
|
|||||||
details = await this.oidc.interactionDetails(request, response);
|
details = await this.oidc.interactionDetails(request, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.logInteractionSessionError(error, uid, request);
|
await this.logInteractionSessionError(error, uid, request);
|
||||||
response.status(400).send(
|
const page = await this.templates.renderError({
|
||||||
this.page(
|
title: 'Anmeldung abgelaufen',
|
||||||
'Anmeldung abgelaufen',
|
message: 'Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.',
|
||||||
'<p>Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.</p>',
|
});
|
||||||
),
|
response.status(400).send(page);
|
||||||
);
|
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,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')
|
||||||
@@ -69,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,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`;
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user