278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import { Body, Controller, Get, Logger, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Request, Response } from 'express';
|
|
import type { Interaction } from 'oidc-provider';
|
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
|
import { RequestContextService } from '../common/request-context.service';
|
|
import { OidcProviderService } from './oidc-provider.service';
|
|
|
|
@Controller('interaction')
|
|
export class OidcInteractionController {
|
|
private readonly logger = new Logger(OidcInteractionController.name);
|
|
|
|
constructor(
|
|
private readonly oidc: OidcProviderService,
|
|
private readonly config: ConfigService,
|
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
|
private readonly requestContext: RequestContextService,
|
|
) {}
|
|
|
|
@Get(':uid')
|
|
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
|
|
let details: Interaction;
|
|
try {
|
|
details = await this.oidc.interactionDetails(request, response);
|
|
} catch (error) {
|
|
this.logInteractionSessionError(error, uid, request).catch((logError) => {
|
|
const message = logError instanceof Error ? logError.message : String(logError);
|
|
this.logger.error(`OIDC interaction session logging failed: ${message}`);
|
|
});
|
|
response
|
|
.status(400)
|
|
.send(
|
|
this.page(
|
|
'Anmeldung abgelaufen',
|
|
'<p class="message error" role="alert">Die SSO-Anmeldung ist abgelaufen oder konnte nicht zugeordnet werden. Bitte starten Sie die Anmeldung erneut aus der Anwendung.</p>',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (details.uid !== uid) {
|
|
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
|
|
return;
|
|
}
|
|
|
|
if (details.prompt.name === 'login') {
|
|
response.send(this.page('Anmelden', this.loginForm(uid)));
|
|
return;
|
|
}
|
|
|
|
if (details.prompt.name === 'consent') {
|
|
const clientId = String(details.params.client_id ?? '');
|
|
if (clientId && (await this.oidc.isFirstPartyClient(clientId))) {
|
|
await this.oidc.finishConsent(request, response, uid, { autoGranted: true });
|
|
return;
|
|
}
|
|
|
|
response.send(this.page('Zugriff erlauben', this.consentView(uid, details)));
|
|
return;
|
|
}
|
|
|
|
response.status(400).send(this.page('OIDC', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>'));
|
|
}
|
|
|
|
@Post(':uid/login')
|
|
async login(
|
|
@Param('uid') uid: string,
|
|
@Body() body: { username?: string; password?: string },
|
|
@Req() request: Request,
|
|
@Res() response: Response,
|
|
) {
|
|
const username = body.username ?? '';
|
|
try {
|
|
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.')));
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@Post(':uid/confirm')
|
|
async confirm(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
|
|
await this.oidc.finishConsent(request, response, uid);
|
|
}
|
|
|
|
@Post(':uid/abort')
|
|
async abort(@Req() request: Request, @Res() response: 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 {
|
|
const publicWebUrl = this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
|
|
return `${publicWebUrl.replace(/\/+$/, '')}/register`;
|
|
}
|
|
|
|
private isInvalidCredentialsError(error: unknown): boolean {
|
|
return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.';
|
|
}
|
|
|
|
private async logInteractionSessionError(error: unknown, uid: string, request: Request): Promise<void> {
|
|
const cookieHeader = request.headers.cookie ?? '';
|
|
const hasCookieHeader = Boolean(cookieHeader);
|
|
const hasInteractionCookie = /(?:^|;\s*)_interaction=/.test(cookieHeader);
|
|
const errorName = error instanceof Error ? error.name : typeof error;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
const logPayload = {
|
|
uid,
|
|
errorName,
|
|
errorMessage,
|
|
hasCookieHeader,
|
|
hasInteractionCookie,
|
|
method: request.method,
|
|
path: request.originalUrl || request.url,
|
|
host: request.headers.host,
|
|
forwardedProto: request.headers['x-forwarded-proto'],
|
|
forwardedHost: request.headers['x-forwarded-host'],
|
|
referer: request.headers.referer,
|
|
userAgent: request.headers['user-agent'],
|
|
correlationId: this.requestContext.get().correlationId,
|
|
};
|
|
|
|
console.error('[OIDC_INTERACTION_SESSION_NOT_FOUND]', JSON.stringify(logPayload), errorStack ?? '');
|
|
this.logger.warn(
|
|
`OIDC interaction session not found uid=${uid} error=${errorName}:${errorMessage} hasCookieHeader=${hasCookieHeader} hasInteractionCookie=${hasInteractionCookie} host=${request.headers.host ?? ''} forwardedProto=${request.headers['x-forwarded-proto'] ?? ''}`,
|
|
);
|
|
|
|
await this.applicationErrorLogger.log({
|
|
error,
|
|
category: ApplicationErrorCategory.OIDC,
|
|
code: ApplicationErrorCode.OIDC_INTERACTION_SESSION_NOT_FOUND,
|
|
module: 'OidcModule',
|
|
service: OidcInteractionController.name,
|
|
operation: 'viewInteraction',
|
|
requestContext: {
|
|
...this.requestContext.get(),
|
|
method: request.method,
|
|
path: request.originalUrl || request.url,
|
|
statusCode: 400,
|
|
},
|
|
context: {
|
|
uid,
|
|
hasCookieHeader,
|
|
hasInteractionCookie,
|
|
forwardedProto: request.headers['x-forwarded-proto'],
|
|
forwardedHost: request.headers['x-forwarded-host'],
|
|
host: request.headers.host,
|
|
referer: request.headers.referer,
|
|
userAgent: request.headers['user-agent'],
|
|
},
|
|
handled: true,
|
|
});
|
|
}
|
|
}
|