This commit is contained in:
Bastian Wagner
2026-07-15 17:59:25 +02:00
parent 7a6e08d2c5
commit ba7029ac7b
10 changed files with 469 additions and 76 deletions

View File

@@ -294,6 +294,11 @@ export class LldapService {
return response.groups;
}
async findGroupByDisplayName(displayName: string): Promise<LldapGroup | null> {
const groups = await this.listGroups();
return groups.find((group) => group.displayName === displayName) ?? null;
}
async getGroup(groupId: number): Promise<LldapGroup> {
const response = await this.graphql<{ group: LldapGroup }>(
`query Group($groupId: Int!) {

View File

@@ -39,7 +39,39 @@ export class PortalMailService {
});
}
async sendRegistrationPendingApprovalMail(
to: string[],
registration: { email: string; displayName: string },
): Promise<void> {
const url = `${this.publicWebUrl}/admin/registrations`;
await this.mailer.sendMail({
to,
subject: 'LDAP Portal: Registrierung wartet auf Freigabe',
html: `
<p>Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.</p>
<p><strong>Name:</strong> ${this.escapeHtml(registration.displayName)}<br>
<strong>E-Mail:</strong> ${this.escapeHtml(registration.email)}</p>
<p><a href="${url}">${url}</a></p>
`,
text: [
'Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.',
`Name: ${registration.displayName}`,
`E-Mail: ${registration.email}`,
`Admin-Bereich: ${url}`,
].join('\n'),
});
}
private get publicWebUrl(): string {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200';
}
private escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
}

View File

@@ -1,5 +1,8 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
import { UpdateOidcClientDto } from './dto/update-oidc-client.dto';
import { OidcAdminGuard } from './oidc-admin.guard';
@@ -8,7 +11,10 @@ import { OidcClientService } from './oidc-client.service';
@Controller('admin/oidc/clients')
@UseGuards(JwtAuthGuard, OidcAdminGuard)
export class OidcAdminClientsController {
constructor(private readonly clients: OidcClientService) {}
constructor(
private readonly clients: OidcClientService,
private readonly audit: AuditService,
) {}
@Get()
list() {
@@ -16,17 +22,53 @@ export class OidcAdminClientsController {
}
@Post()
create(@Body() dto: CreateOidcClientDto) {
return this.clients.create(dto);
async create(@Body() dto: CreateOidcClientDto, @Req() request: Request & { user: RequestUser }) {
const client = await this.clients.create(dto);
await this.audit.record({
type: 'oidc.client_created',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId: client.clientId, id: client.id },
});
return client;
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) {
return this.clients.update(id, dto);
async update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto, @Req() request: Request & { user: RequestUser }) {
const client = await this.clients.update(id, dto);
await this.audit.record({
type: 'oidc.client_updated',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId: client.clientId, id: client.id, fields: Object.keys(dto) },
});
return client;
}
@Post(':id/secret/rotate')
async rotateSecret(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
const client = await this.clients.rotateSecret(id);
await this.audit.record({
type: 'oidc.client_secret_rotated',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { clientId: client.clientId, id: client.id },
});
return client;
}
@Delete(':id')
delete(@Param('id') id: string) {
return this.clients.delete(id);
async delete(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
await this.clients.delete(id);
await this.audit.record({
type: 'oidc.client_deleted',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { id },
});
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
@@ -25,6 +25,10 @@ export interface OidcClientSummary {
updatedAt: Date;
}
export interface OidcClientWithSecret extends OidcClientSummary {
clientSecret?: string;
}
@Injectable()
export class OidcClientService {
constructor(
@@ -38,7 +42,7 @@ export class OidcClientService {
return clients.map((client) => this.toSummary(client));
}
async create(dto: CreateOidcClientDto) {
async create(dto: CreateOidcClientDto): Promise<OidcClientWithSecret> {
const publicClient = dto.publicClient ?? false;
const clientSecret = publicClient ? undefined : randomToken();
const client = await this.clients.save(
@@ -90,6 +94,25 @@ export class OidcClientService {
}
}
async rotateSecret(id: string): Promise<OidcClientWithSecret> {
const client = await this.clients.findOneBy({ id });
if (!client) {
throw new NotFoundException('OIDC client not found');
}
if (client.tokenEndpointAuthMethod === 'none') {
throw new BadRequestException('Public clients do not have a client secret.');
}
const clientSecret = randomToken();
client.encryptedClientSecret = encryptSecret(clientSecret, this.tokenSecret);
return {
...this.toSummary(await this.clients.save(client)),
clientSecret,
};
}
async findByClientId(clientId: string): Promise<OidcClientEntity | null> {
return this.clients.findOneBy({ clientId, enabled: true });
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, 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 { OidcProviderService } from './oidc-provider.service';
@Controller('interaction')
@@ -14,38 +15,23 @@ export class OidcInteractionController {
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
const details = await this.oidc.interactionDetails(request, response);
if (details.uid !== uid) {
response.status(400).send(this.page('Ungültige Anfrage', '<p>Die OIDC-Interaktion ist ungültig.</p>'));
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),
),
);
response.send(this.page('Anmelden', this.loginForm(uid)));
return;
}
if (details.prompt.name === 'consent') {
console.log(details)
response.send(
this.page(
'Zugriff erlauben',
`
<p>Client <strong>${this.escape(String(details.params.name ?? ''))}</strong> möchte Zugriff auf folgende Scopes:</p>
<p class="scopes">${this.escape(String(details.params.scope ?? 'openid'))}</p>
<form method="post" action="/interaction/${encodeURIComponent(uid)}/confirm">
<button type="submit">Erlauben</button>
</form>
<form method="post" action="/interaction/${encodeURIComponent(uid)}/abort">
<button class="secondary" type="submit">Ablehnen</button>
</form>
`,
),
);
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;
}
@@ -66,7 +52,7 @@ export class OidcInteractionController {
if (this.isInvalidCredentialsError(error)) {
response
.status(401)
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungültige Zugangsdaten.')));
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.')));
return;
}
@@ -104,6 +90,58 @@ export class OidcInteractionController {
`;
}
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">
@@ -113,7 +151,7 @@ export class OidcInteractionController {
<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: 420px; padding: 28px; width: 100%; }
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; }
@@ -123,9 +161,16 @@ export class OidcInteractionController {
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; }
.scopes { background: #edf2f5; border-radius: 6px; padding: 10px; word-break: break-word; }
.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>
@@ -147,6 +192,6 @@ export class OidcInteractionController {
}
private isInvalidCredentialsError(error: unknown): boolean {
return error instanceof UnauthorizedException && error.message === 'Ungültige Zugangsdaten.';
return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.';
}
}

View File

@@ -63,13 +63,13 @@ export class OidcProviderService implements OnModuleInit {
): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'login') {
throw new UnauthorizedException('Ungültige OIDC-Interaktion.');
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const valid = await this.ldapAuth.verifyPassword(username, password);
if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
throw new UnauthorizedException('Ungültige Zugangsdaten.');
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
}
const account = await this.lldap.getAccount(username);
@@ -93,10 +93,15 @@ export class OidcProviderService implements OnModuleInit {
);
}
async finishConsent(request: Request, response: Response, uid: string): Promise<void> {
async finishConsent(
request: Request,
response: Response,
uid: string,
options: { autoGranted?: boolean } = {},
): Promise<void> {
const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'consent') {
throw new UnauthorizedException('Ungültige OIDC-Interaktion.');
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
}
const clientId = String(details.params.client_id ?? '');
@@ -110,13 +115,18 @@ export class OidcProviderService implements OnModuleInit {
? await Grant.find(details.grantId)
: new Grant({ accountId, clientId });
grant.addOIDCScope(String(details.params.scope ?? 'openid'));
const scope = String(details.params.scope ?? 'openid');
grant.addOIDCScope(scope);
if (details.prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(details.prompt.details.missingOIDCClaims);
}
const grantId = await grant.save();
await this.audit.record({ type: 'oidc.consent_granted', username: accountId, metadata: { clientId } });
await this.audit.record({
type: options.autoGranted ? 'oidc.consent_auto_granted' : 'oidc.consent_granted',
username: accountId,
metadata: { clientId, scope },
});
await this.getProvider().interactionFinished(
request,
@@ -138,6 +148,11 @@ export class OidcProviderService implements OnModuleInit {
);
}
async isFirstPartyClient(clientId: string): Promise<boolean> {
const client = await this.clients.findByClientId(clientId);
return client?.firstParty === true;
}
private buildConfiguration(jwks: { keys: Record<string, unknown>[] }): Configuration {
return {
adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients),

View File

@@ -96,6 +96,14 @@ export class RegistrationService {
userAgent,
});
await this.notifyUserManagers(registration).catch((error) =>
this.audit.record({
type: 'registration.approval_notification_failed',
username: registration.username,
metadata: { error: this.errorMessage(error) },
}),
);
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
}
@@ -157,4 +165,32 @@ export class RegistrationService {
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
private async notifyUserManagers(registration: RegistrationRequest): Promise<void> {
const groupName = this.config.get<string>('USER_MANAGER_GROUP') ?? 'user_manager';
const group = await this.lldap.findGroupByDisplayName(groupName);
const recipients = [...new Set(group?.users?.map((user) => user.email).filter((email): email is string => Boolean(email)) ?? [])];
if (!recipients.length) {
await this.audit.record({
type: 'registration.approval_notification_skipped',
username: registration.username,
metadata: { groupName, reason: 'no recipients' },
});
return;
}
await this.mail.sendRegistrationPendingApprovalMail(recipients, {
email: registration.email,
displayName: registration.displayName,
});
await this.audit.record({
type: 'registration.approval_notification_sent',
username: registration.username,
metadata: { groupName, recipientCount: recipients.length },
});
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error';
}
}