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

208
README.md
View File

@@ -1,10 +1,12 @@
# LDAP Portal # LDAP Portal
Self-Service-Portal fuer LLDAP mit NestJS API und Angular Frontend. Self-Service-Portal fuer LLDAP mit NestJS API, Angular Frontend und eigenem OpenID-Connect-Provider fuer Web-SSO.
Die Anwendung ist als Identity-Portal vor einem bestehenden LLDAP gedacht: Nutzer koennen sich registrieren, E-Mail und Passwort verwalten, Administratoren koennen Registrierungen, Nutzer, Gruppen und OIDC-Clients pflegen.
## Funktionen ## Funktionen
- Registrierung mit E-Mail-Verifikation - Registrierung mit E-Mail-Verifikation und anschliessender Admin-Freigabe
- Login gegen LDAP/LLDAP - Login gegen LDAP/LLDAP
- Passwortaenderung nach erfolgreichem LDAP-Login - Passwortaenderung nach erfolgreichem LDAP-Login
- Passwort-Reset ueber eigene Tokens und SMTP - Passwort-Reset ueber eigene Tokens und SMTP
@@ -12,7 +14,22 @@ Self-Service-Portal fuer LLDAP mit NestJS API und Angular Frontend.
- Admin-Bereiche fuer Registrierungen, Nutzer, Gruppen und Audit - Admin-Bereiche fuer Registrierungen, Nutzer, Gruppen und Audit
- Audit-Events fuer sicherheitsrelevante Aktionen in MySQL - Audit-Events fuer sicherheitsrelevante Aktionen in MySQL
- OpenID Connect Provider fuer Web-SSO - OpenID Connect Provider fuer Web-SSO
- Docker-Compose Setup fuer API und Web mit externer MySQL- und LLDAP-Anbindung - Single-Container-Deployment mit Nginx, Angular und NestJS API
## Architektur
Das Portal besteht aus drei Teilen:
- Angular Web-UI: Login, Registrierung, Konto- und Admin-Oberflaechen.
- NestJS API: REST-Endpunkte, OIDC Provider, Mailversand, Audit, LLDAP-Integration.
- Nginx im Container: liefert die Web-UI aus und routet API/OIDC-Pfade intern zur NestJS API.
Externe Dienste werden nicht mitgeliefert und muessen vorhanden sein:
- MySQL-Datenbank fuer Portal-Daten, Registrierungen, Tokens, Audit und OIDC-Speicher.
- LLDAP HTTP/GraphQL-Endpunkt fuer administrative Operationen.
- LLDAP LDAP-Endpunkt fuer Passwortpruefung und Passwortaenderung.
- SMTP-Server fuer Verifikations- und Passwort-Reset-Mails.
## Lokale Entwicklung ## Lokale Entwicklung
@@ -25,22 +42,39 @@ npm run start:web
In der lokalen Entwicklung laeuft die API standardmaessig auf `http://localhost:3000`, das Frontend auf `http://localhost:4200`. In der lokalen Entwicklung laeuft die API standardmaessig auf `http://localhost:3000`, das Frontend auf `http://localhost:4200`.
## Docker Compose Wichtige lokale URLs:
```bash - Web UI: `http://localhost:4200`
cp .env.example .env - API: `http://localhost:3000`
docker compose up --build - OIDC Discovery bei direkter API-Nutzung: `http://localhost:3000/.well-known/openid-configuration`
```
Passe vor dem Start mindestens `DATABASE_URL`, `JWT_SECRET`, `TOKEN_SECRET`, `LLDAP_*` und `SMTP_*` an. ## Deployment
Fuer OIDC muessen zusaetzlich `OIDC_ISSUER`, `OIDC_COOKIE_SECRET`, `OIDC_ADMIN_GROUP` und `OIDC_ADMIN_GROUP_UUID` gesetzt werden.
## Single Container Image ### Voraussetzungen
Vor dem Start muessen erreichbar sein:
- MySQL, z. B. `mysql://ldap_portal:secret@mysql.example.com:3306/ldap_portal`
- LLDAP Web/GraphQL, z. B. `https://lldap.example.com`
- LLDAP LDAP, z. B. `ldap://lldap.example.com:3890`
- SMTP, z. B. `smtp.example.com:587`
Erzeuge produktive Secrets fuer mindestens:
- `JWT_SECRET`
- `TOKEN_SECRET`
- `OIDC_COOKIE_SECRET`
- `LLDAP_ADMIN_PASSWORD`
- `SMTP_PASS`
`JWT_SECRET`, `TOKEN_SECRET` und `OIDC_COOKIE_SECRET` muessen lang, zufaellig und stabil sein. Wenn sie nach dem Deployment geaendert werden, werden bestehende Sessions, Tokens oder verschluesselte Portal-Daten unbrauchbar.
### Single Container
Das Root-`Dockerfile` baut API und Angular in ein einzelnes Image. Der Container startet: Das Root-`Dockerfile` baut API und Angular in ein einzelnes Image. Der Container startet:
- Nginx Web-UI und Reverse Proxy auf Port `8080` - Nginx Web-UI und Reverse Proxy auf Port `8080`
- NestJS API / IdP nur intern auf Port `3000` - NestJS API / IdP intern auf Port `3000`
Build und Push: Build und Push:
@@ -58,21 +92,105 @@ docker run -d --name ldap-portal-idp \
registry.example.com/ldap-portal/idp:latest registry.example.com/ldap-portal/idp:latest
``` ```
Standardmaessig ist `API_BASE_URL=/api`. Das Frontend ruft damit Backend-Endpunkte unter `/api/...` auf, waehrend Nginx den Prefix intern entfernt und zur NestJS-API routet. Dadurch bleiben Angular-Routen wie `/account` und `/admin/users` auch nach einem Browser-Refresh SPA-Routen. Setze `API_BASE_URL` nur anders, wenn das Frontend bewusst eine andere API-Origin verwenden soll; bei Single-Container-Deployments sollte `/api` bleiben. Docker Compose:
## Externe Dienste ```bash
cp .env.example .env
docker compose up --build
```
Die Anwendung bringt keine Datenbank und keinen LLDAP-Server mehr per Compose mit. Erwartet werden: ### Reverse Proxy und externe URLs
- eine externe MySQL-Datenbank, z. B. `mysql://ldap_portal:secret@mysql.example.com:3306/ldap_portal` Der Container erwartet eingehenden Traffic auf Port `8080`. Ein externer Reverse Proxy kann davor TLS terminieren.
- ein externer LLDAP-HTTP-Endpunkt fuer GraphQL, z. B. `https://lldap.example.com`
- ein externer LDAP-Endpunkt fuer Bind/Login, z. B. `ldap://lldap.example.com:3890`
Setze `DATABASE_SSL=true`, wenn der MySQL-Server TLS verlangt. In `NODE_ENV=production` sollte `synchronize` nicht genutzt werden; fuer produktive Deployments sollten TypeORM-Migrationen ergaenzt werden. Bei produktivem Betrieb hinter `https://idp.example.com` sollten diese Werte zusammenpassen:
## LLDAP-Hinweis ```env
NODE_ENV=production
PUBLIC_WEB_URL=https://idp.example.com
OIDC_ISSUER=https://idp.example.com
OIDC_TRUST_PROXY=true
API_BASE_URL=/api
```
Die API nutzt LDAP-Bind fuer die Passwortpruefung und GraphQL fuer administrative User-Operationen. Falls sich die GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden. `PUBLIC_WEB_URL` wird fuer CORS, E-Mail-Links und den Registrierungslink auf der SSO-Loginseite verwendet.
`OIDC_ISSUER` muss exakt die externe Issuer-URL sein, die SSO-Clients verwenden. Wenn der Dienst hinter TLS laeuft, muss hier die `https://`-URL stehen.
`API_BASE_URL` sollte im Single-Container-Deployment bei `/api` bleiben. Das Frontend ruft dann `/api/...` auf; Nginx entfernt den Prefix intern und routet zur NestJS API. Die OIDC-Pfade `/.well-known/`, `/oidc/` und `/interaction/` werden ebenfalls zur API geroutet.
## Konfiguration
Die wichtigsten Variablen aus `.env.example`:
| Variable | Bedeutung |
| --- | --- |
| `NODE_ENV` | In Produktion `production`; deaktiviert TypeORM `synchronize` und aktiviert sichere OIDC-Cookies. |
| `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`. |
| `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. |
| `TOKEN_SECRET` | Verschluesselung/Hashing fuer Portal-Tokens und gespeicherte Secrets. |
| `LLDAP_URL` | LLDAP Web/GraphQL-Basis-URL ohne `/api/graphql`. |
| `LLDAP_LDAP_URL` | LDAP-Endpunkt fuer Bind und Password Modify. |
| `LLDAP_BASE_DN` | LDAP Base DN, z. B. `dc=example,dc=com`. |
| `LLDAP_ADMIN_USERNAME` | LLDAP Admin-User fuer GraphQL und LDAP-Admin-Bind. |
| `LLDAP_ADMIN_PASSWORD` | Passwort des LLDAP Admin-Users. |
| `LLDAP_GRAPHQL_TOKEN` | Optionaler statischer Bearer Token fuer GraphQL statt Login per Admin-User. |
| `LLDAP_DEFAULT_GROUP` | Optionale numerische LLDAP-Gruppen-ID, in die freigegebene neue Nutzer aufgenommen werden. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | SMTP-Verbindung. |
| `SMTP_USER`, `SMTP_PASS` | Optionale SMTP-Authentifizierung. |
| `SMTP_FROM` | Absenderadresse fuer Portal-Mails. |
| `OIDC_ISSUER` | Externe Issuer-URL des OIDC Providers. |
| `OIDC_COOKIE_SECRET` | Cookie-Secret fuer OIDC Sessions; Fallback ist `TOKEN_SECRET`. |
| `OIDC_ADMIN_GROUP` | Gruppe fuer OIDC-Clientverwaltung, Standard `client_manager`. |
| `OIDC_ADMIN_GROUP_UUID` | Zusaetzlich akzeptierte Gruppen-UUID fuer OIDC-Clientverwaltung. |
| `OIDC_TRUST_PROXY` | `true`, wenn ein Reverse Proxy TLS/Host-Header setzt. |
| `USER_MANAGER_GROUP` | LLDAP-Gruppe, deren Mitglieder per E-Mail benachrichtigt werden, wenn eine Registrierung nach E-Mail-Bestaetigung auf Freigabe wartet. Standard `user_manager`. |
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".
## 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.
In Entwicklung kann TypeORM das Schema automatisch synchronisieren, solange `NODE_ENV` nicht `production` ist.
## Registrierung und Nutzerfluss
Die Registrierung laeuft in zwei Schritten:
1. Nutzer registrieren sich mit E-Mail, Anzeigename und Passwort.
2. Der Nutzer bestaetigt seine E-Mail ueber den Mail-Link.
3. Ein Admin mit Gruppe `registration_manager` gibt die Registrierung frei.
4. Erst bei Freigabe wird der LLDAP-User erstellt und optional in `LLDAP_DEFAULT_GROUP` aufgenommen.
Nach Schritt 2 sendet das Portal eine Benachrichtigung an die E-Mail-Adressen der Mitglieder aus `USER_MANAGER_GROUP`, damit offene Freigaben sichtbar werden. Wenn die Gruppe nicht gefunden wird, keine Mitglieder mit E-Mail-Adresse hat oder der Mailversand fehlschlaegt, bleibt die Registrierung trotzdem im Status `pending_approval`; der Fehler wird im Audit protokolliert.
Passwortregeln:
- mindestens 12 Zeichen
- mindestens ein Kleinbuchstabe
- mindestens ein Grossbuchstabe
- mindestens eine Ziffer
Der Benutzername in LLDAP ist die E-Mail-Adresse der Registrierung.
## Admin-Rollen
Admin-Berechtigungen werden ueber LLDAP-Gruppen gesteuert:
- `client_manager`: OIDC-Clients verwalten.
- `registration_manager`: Registrierungen freigeben oder ablehnen.
- `user_manager`: Nutzer anzeigen, bearbeiten, loeschen und Gruppenmitgliedschaften aendern.
- `group_manager`: Gruppen anzeigen, erstellen, bearbeiten und loeschen.
- `audit_viewer`: Audit-Events anzeigen.
Die allgemeinen Admin-Rollen werden aktuell per Gruppen-Displayname geprueft. Die OIDC-Clientverwaltung akzeptiert zusaetzlich die konfigurierte `OIDC_ADMIN_GROUP_UUID`.
Wichtig fuer die Erstinbetriebnahme: Der erste Admin muss in LLDAP bereits existieren und in die benoetigten Gruppen aufgenommen werden, bevor er die Admin-Bereiche im Portal nutzen kann.
## OpenID Connect ## OpenID Connect
@@ -86,19 +204,49 @@ Die API stellt einen OIDC Provider bereit. Die wichtigsten Endpunkte:
- Logout: `/oidc/session/end` - Logout: `/oidc/session/end`
- Revocation: `/oidc/token/revocation` - Revocation: `/oidc/token/revocation`
- Introspection: `/oidc/token/introspection` - Introspection: `/oidc/token/introspection`
- Interactions/Login/Consent: `/interaction/...`
OIDC-Clients werden im Frontend unter `/admin/oidc-clients` verwaltet. Zugriff erhaelt nur ein eingeloggter Nutzer, der in der LLDAP-Gruppe `client_manager` ist. Standardmaessig wird zusaetzlich die Gruppen-UUID `89aa3d8d-fcbd-3ec9-b99d-901a0cfc405e` akzeptiert. Client Secrets werden nur direkt nach Erstellung angezeigt. OIDC-Clients werden im Frontend unter `/admin/oidc-clients` verwaltet. Zugriff erhaelt ein eingeloggter Nutzer mit OIDC-Admin-Berechtigung. Client Secrets werden nur direkt nach Erstellung oder Secret-Rotation angezeigt.
V1 unterstuetzt Authorization Code Flow mit verpflichtendem PKCE. Dynamic Client Registration und SAML sind nicht aktiviert. V1 unterstuetzt Authorization Code Flow mit verpflichtendem PKCE. Dynamic Client Registration und SAML sind nicht aktiviert.
## Admin-Rollen ### Client-Optionen
Admin-Berechtigungen werden ueber LLDAP-Gruppen gesteuert: - `Redirect URIs`: erlaubte Callback-URLs des SSO-Clients.
- `Logout Redirect URIs`: erlaubte Redirects nach RP-initiated Logout.
- `Scopes`: Scopes, die der Client verwenden darf, z. B. `openid profile email groups`.
- `Public Client ohne Secret`: erstellt einen Client ohne Secret und mit `token_endpoint_auth_method=none`; gedacht fuer Browser/native Apps, die kein Secret sicher halten koennen.
- Confidential Client: Standard, erzeugt ein Client Secret und verwendet `client_secret_basic`.
- `Gruppen-Claim ausgeben`: steuert, ob der `groups` Claim in OIDC-Claims ausgegeben wird.
- `First-Party Client`: fuer eigene/interne Clients. Nach erfolgreichem Login wird die Consent-Seite uebersprungen und die angeforderten Berechtigungen werden automatisch bestaetigt.
- `client_manager`: OIDC-Clients verwalten. Nach der Erstellung koennen Name, Redirect URIs, Logout Redirect URIs, Scopes, First-Party-Flag, Gruppen-Claim und Aktivstatus im Admin-UI bearbeitet werden. Die `client_id` bleibt stabil.
- `registration_manager`: Registrierungen freigeben oder ablehnen.
- `user_manager`: Nutzer anzeigen, bearbeiten, loeschen und Gruppenmitgliedschaften aendern.
- `group_manager`: Gruppen anzeigen, erstellen, bearbeiten und loeschen.
- `audit_viewer`: Audit-Events anzeigen.
Die Registrierung laeuft in zwei Schritten: Nutzer bestaetigen zuerst ihre E-Mail-Adresse, danach muss ein `registration_manager` die Registrierung freigeben. Erst bei der Freigabe wird der LLDAP-User erstellt. Confidential Clients koennen ihr Client Secret im Admin-UI rotieren. Das neue Secret wird nur einmal angezeigt und muss danach in der angebundenen Anwendung hinterlegt werden. Bestehende Grants, Sessions und Refresh Tokens werden durch die Rotation nicht aktiv widerrufen; neue Token-Requests muessen aber das neue Secret verwenden. Public Clients haben kein Secret und koennen daher nicht rotiert werden.
## LLDAP-Hinweise
Die API nutzt:
- LDAP-Bind fuer Login und Passwortpruefung.
- LDAP Password Modify Extended Operation fuer Passwortaenderungen.
- GraphQL fuer User-, Gruppen- und Admin-Operationen.
Der LLDAP Admin-User muss ausreichende Rechte fuer diese Operationen haben.
Falls sich GraphQL-Mutationsnamen zwischen LLDAP-Versionen unterscheiden, muessen die Queries in `apps/api/src/lldap/lldap.service.ts` an die Zielversion angepasst werden.
## Produktions-Checkliste
- Externe MySQL-Datenbank angelegt und erreichbar.
- Datenbankschema/Migrationen fuer Produktion geklaert.
- LLDAP Web/GraphQL und LDAP vom Container aus erreichbar.
- SMTP erreichbar und Absender korrekt gesetzt.
- `PUBLIC_WEB_URL` und `OIDC_ISSUER` auf die externe HTTPS-URL gesetzt.
- `OIDC_TRUST_PROXY=true`, wenn TLS am Reverse Proxy terminiert.
- `API_BASE_URL=/api` fuer Single-Container-Deployment gesetzt.
- Lange zufaellige Secrets gesetzt und gesichert.
- LLDAP-Gruppen fuer Admin-Rollen angelegt.
- Mindestens ein bestehender Admin-User ist Mitglied der benoetigten Gruppen.
- OIDC-Clients mit korrekten Redirect URIs angelegt.
- Reverse Proxy leitet `/.well-known/`, `/oidc/`, `/interaction/`, `/api/` und die Web-UI an den Container weiter.

View File

@@ -294,6 +294,11 @@ export class LldapService {
return response.groups; 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> { async getGroup(groupId: number): Promise<LldapGroup> {
const response = await this.graphql<{ group: LldapGroup }>( const response = await this.graphql<{ group: LldapGroup }>(
`query Group($groupId: Int!) { `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 { private get publicWebUrl(): string {
return this.config.get<string>('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; 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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { CreateOidcClientDto } from './dto/create-oidc-client.dto'; import { CreateOidcClientDto } from './dto/create-oidc-client.dto';
import { UpdateOidcClientDto } from './dto/update-oidc-client.dto'; import { UpdateOidcClientDto } from './dto/update-oidc-client.dto';
import { OidcAdminGuard } from './oidc-admin.guard'; import { OidcAdminGuard } from './oidc-admin.guard';
@@ -8,7 +11,10 @@ import { OidcClientService } from './oidc-client.service';
@Controller('admin/oidc/clients') @Controller('admin/oidc/clients')
@UseGuards(JwtAuthGuard, OidcAdminGuard) @UseGuards(JwtAuthGuard, OidcAdminGuard)
export class OidcAdminClientsController { export class OidcAdminClientsController {
constructor(private readonly clients: OidcClientService) {} constructor(
private readonly clients: OidcClientService,
private readonly audit: AuditService,
) {}
@Get() @Get()
list() { list() {
@@ -16,17 +22,53 @@ export class OidcAdminClientsController {
} }
@Post() @Post()
create(@Body() dto: CreateOidcClientDto) { async create(@Body() dto: CreateOidcClientDto, @Req() request: Request & { user: RequestUser }) {
return this.clients.create(dto); 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') @Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) { async update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto, @Req() request: Request & { user: RequestUser }) {
return this.clients.update(id, dto); 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(':id')
delete(@Param('id') id: string) { async delete(@Param('id') id: string, @Req() request: Request & { user: RequestUser }) {
return this.clients.delete(id); 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 { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -25,6 +25,10 @@ export interface OidcClientSummary {
updatedAt: Date; updatedAt: Date;
} }
export interface OidcClientWithSecret extends OidcClientSummary {
clientSecret?: string;
}
@Injectable() @Injectable()
export class OidcClientService { export class OidcClientService {
constructor( constructor(
@@ -38,7 +42,7 @@ export class OidcClientService {
return clients.map((client) => this.toSummary(client)); return clients.map((client) => this.toSummary(client));
} }
async create(dto: CreateOidcClientDto) { async create(dto: CreateOidcClientDto): Promise<OidcClientWithSecret> {
const publicClient = dto.publicClient ?? false; const publicClient = dto.publicClient ?? false;
const clientSecret = publicClient ? undefined : randomToken(); const clientSecret = publicClient ? undefined : randomToken();
const client = await this.clients.save( 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> { async findByClientId(clientId: string): Promise<OidcClientEntity | null> {
return this.clients.findOneBy({ clientId, enabled: true }); 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 { Body, Controller, Get, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import type { Interaction } from 'oidc-provider';
import { OidcProviderService } from './oidc-provider.service'; import { OidcProviderService } from './oidc-provider.service';
@Controller('interaction') @Controller('interaction')
@@ -14,38 +15,23 @@ export class OidcInteractionController {
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) { async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
const details = await this.oidc.interactionDetails(request, response); const details = await this.oidc.interactionDetails(request, response);
if (details.uid !== uid) { 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; return;
} }
if (details.prompt.name === 'login') { if (details.prompt.name === 'login') {
response.send(this.page('Anmelden', this.loginForm(uid)));
response.send(
this.page(
'Anmelden',
this.loginForm(uid),
),
);
return; return;
} }
if (details.prompt.name === 'consent') { if (details.prompt.name === 'consent') {
console.log(details) const clientId = String(details.params.client_id ?? '');
response.send( if (clientId && (await this.oidc.isFirstPartyClient(clientId))) {
this.page( await this.oidc.finishConsent(request, response, uid, { autoGranted: true });
'Zugriff erlauben', return;
` }
<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> response.send(this.page('Zugriff erlauben', this.consentView(uid, details)));
<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>
`,
),
);
return; return;
} }
@@ -66,7 +52,7 @@ export class OidcInteractionController {
if (this.isInvalidCredentialsError(error)) { if (this.isInvalidCredentialsError(error)) {
response response
.status(401) .status(401)
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungültige Zugangsdaten.'))); .send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.')));
return; 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 { private page(title: string, body: string): string {
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
@@ -113,7 +151,7 @@ export class OidcInteractionController {
<title>${this.escape(title)} - LDAP Portal</title> <title>${this.escape(title)} - LDAP Portal</title>
<style> <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; } 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; } h1 { font-size: 1.45rem; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; } form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; } 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 { color: #0f6b6e; font-weight: 700; text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
.form-link { margin: 16px 0 0; text-align: center; } .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 { 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; } .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> </style>
</head> </head>
<body><main><h1>${this.escape(title)}</h1>${body}</main></body> <body><main><h1>${this.escape(title)}</h1>${body}</main></body>
@@ -147,6 +192,6 @@ export class OidcInteractionController {
} }
private isInvalidCredentialsError(error: unknown): boolean { 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> { ): Promise<void> {
const details = await this.interactionDetails(request, response); const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'login') { 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); const valid = await this.ldapAuth.verifyPassword(username, password);
if (!valid) { if (!valid) {
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] }); 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); 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); const details = await this.interactionDetails(request, response);
if (details.uid !== uid || details.prompt.name !== 'consent') { 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 ?? ''); const clientId = String(details.params.client_id ?? '');
@@ -110,13 +115,18 @@ export class OidcProviderService implements OnModuleInit {
? await Grant.find(details.grantId) ? await Grant.find(details.grantId)
: new Grant({ accountId, clientId }); : 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) { if (details.prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(details.prompt.details.missingOIDCClaims); grant.addOIDCClaims(details.prompt.details.missingOIDCClaims);
} }
const grantId = await grant.save(); 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( await this.getProvider().interactionFinished(
request, 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 { private buildConfiguration(jwks: { keys: Record<string, unknown>[] }): Configuration {
return { return {
adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients), adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients),

View File

@@ -96,6 +96,14 @@ export class RegistrationService {
userAgent, 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.' }; return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
} }
@@ -157,4 +165,32 @@ export class RegistrationService {
private get tokenSecret(): string { private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET'); 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';
}
} }

View File

@@ -61,7 +61,7 @@ interface CreatedOidcClient extends OidcClient {
</label> </label>
<label class="check-row"> <label class="check-row">
<input type="checkbox" formControlName="firstParty"> <input type="checkbox" formControlName="firstParty">
First-Party Client First-Party Client (Consent ueberspringen)
</label> </label>
<label class="check-row"> <label class="check-row">
<input type="checkbox" formControlName="includeGroups"> <input type="checkbox" formControlName="includeGroups">
@@ -76,6 +76,7 @@ interface CreatedOidcClient extends OidcClient {
<div class="secret-box"> <div class="secret-box">
<strong>Client Secret</strong> <strong>Client Secret</strong>
<code>{{ createdSecret() }}</code> <code>{{ createdSecret() }}</code>
<small>Dieses Secret wird nur einmal angezeigt.</small>
</div> </div>
} }
</article> </article>
@@ -128,7 +129,7 @@ interface CreatedOidcClient extends OidcClient {
</label> </label>
<label class="check-row"> <label class="check-row">
<input type="checkbox" formControlName="firstParty"> <input type="checkbox" formControlName="firstParty">
First-Party Client First-Party Client (Consent ueberspringen)
</label> </label>
<label class="check-row"> <label class="check-row">
<input type="checkbox" formControlName="includeGroups"> <input type="checkbox" formControlName="includeGroups">
@@ -162,6 +163,11 @@ interface CreatedOidcClient extends OidcClient {
<button type="button" class="secondary-action" (click)="toggle(client)"> <button type="button" class="secondary-action" (click)="toggle(client)">
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }} {{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
</button> </button>
@if (canRotateSecret(client)) {
<button type="button" class="secondary-action" (click)="rotateSecret(client)">Secret rotieren</button>
} @else {
<span class="muted-action">Public Client ohne Secret</span>
}
<button type="button" class="danger-action" (click)="delete(client)">Loeschen</button> <button type="button" class="danger-action" (click)="delete(client)">Loeschen</button>
</div> </div>
} }
@@ -252,6 +258,39 @@ export class AdminOidcClientsComponent implements OnInit {
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) }); .subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
} }
canRotateSecret(client: OidcClient): boolean {
return client.tokenEndpointAuthMethod !== 'none';
}
rotateSecret(client: OidcClient): void {
const confirmed = window.confirm(
'Das Client Secret wird neu erzeugt und nur einmal angezeigt. Bestehende Apps muessen danach das neue Secret verwenden.',
);
if (!confirmed) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.createdSecret.set('');
this.http
.post<CreatedOidcClient>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}/secret/rotate`, {})
.subscribe({
next: (updatedClient) => {
this.createdSecret.set(updatedClient.clientSecret ?? '');
this.message.set('Client Secret wurde rotiert.');
this.load();
},
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
startEdit(client: OidcClient): void { startEdit(client: OidcClient): void {
this.failed.set(false); this.failed.set(false);
this.message.set(''); this.message.set('');

View File

@@ -383,6 +383,14 @@ code {
gap: 8px; gap: 8px;
} }
.muted-action {
align-items: center;
color: #637083;
display: inline-flex;
font-size: 0.9rem;
min-height: 38px;
}
.inline-form { .inline-form {
align-items: end; align-items: end;
display: flex; display: flex;