diff --git a/README.md b/README.md index 3870b91..c835e47 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # 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 -- Registrierung mit E-Mail-Verifikation +- Registrierung mit E-Mail-Verifikation und anschliessender Admin-Freigabe - Login gegen LDAP/LLDAP - Passwortaenderung nach erfolgreichem LDAP-Login - 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 - Audit-Events fuer sicherheitsrelevante Aktionen in MySQL - 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 @@ -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`. -## Docker Compose +Wichtige lokale URLs: -```bash -cp .env.example .env -docker compose up --build -``` +- Web UI: `http://localhost:4200` +- API: `http://localhost:3000` +- 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. -Fuer OIDC muessen zusaetzlich `OIDC_ISSUER`, `OIDC_COOKIE_SECRET`, `OIDC_ADMIN_GROUP` und `OIDC_ADMIN_GROUP_UUID` gesetzt werden. +## Deployment -## 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: - 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: @@ -58,21 +92,105 @@ docker run -d --name ldap-portal-idp \ 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` -- 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` +Der Container erwartet eingehenden Traffic auf Port `8080`. Ein externer Reverse Proxy kann davor TLS terminieren. -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 @@ -86,19 +204,49 @@ Die API stellt einen OIDC Provider bereit. Die wichtigsten Endpunkte: - Logout: `/oidc/session/end` - Revocation: `/oidc/token/revocation` - 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. -## 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. -- `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. +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. -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. diff --git a/apps/api/src/lldap/lldap.service.ts b/apps/api/src/lldap/lldap.service.ts index c264001..a00a859 100644 --- a/apps/api/src/lldap/lldap.service.ts +++ b/apps/api/src/lldap/lldap.service.ts @@ -294,6 +294,11 @@ export class LldapService { return response.groups; } + async findGroupByDisplayName(displayName: string): Promise { + const groups = await this.listGroups(); + return groups.find((group) => group.displayName === displayName) ?? null; + } + async getGroup(groupId: number): Promise { const response = await this.graphql<{ group: LldapGroup }>( `query Group($groupId: Int!) { diff --git a/apps/api/src/mail/portal-mail.service.ts b/apps/api/src/mail/portal-mail.service.ts index c8e86d5..a9d1145 100644 --- a/apps/api/src/mail/portal-mail.service.ts +++ b/apps/api/src/mail/portal-mail.service.ts @@ -39,7 +39,39 @@ export class PortalMailService { }); } + async sendRegistrationPendingApprovalMail( + to: string[], + registration: { email: string; displayName: string }, + ): Promise { + const url = `${this.publicWebUrl}/admin/registrations`; + await this.mailer.sendMail({ + to, + subject: 'LDAP Portal: Registrierung wartet auf Freigabe', + html: ` +

Eine Registrierung wurde per E-Mail bestaetigt und wartet jetzt auf Freigabe.

+

Name: ${this.escapeHtml(registration.displayName)}
+ E-Mail: ${this.escapeHtml(registration.email)}

+

${url}

+ `, + 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('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; } + + private escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } } diff --git a/apps/api/src/oidc/oidc-admin-clients.controller.ts b/apps/api/src/oidc/oidc-admin-clients.controller.ts index ee1bb15..3950d65 100644 --- a/apps/api/src/oidc/oidc-admin-clients.controller.ts +++ b/apps/api/src/oidc/oidc-admin-clients.controller.ts @@ -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 }, + }); } } diff --git a/apps/api/src/oidc/oidc-client.service.ts b/apps/api/src/oidc/oidc-client.service.ts index 55949bd..ebc985e 100644 --- a/apps/api/src/oidc/oidc-client.service.ts +++ b/apps/api/src/oidc/oidc-client.service.ts @@ -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 { 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 { + 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 { return this.clients.findOneBy({ clientId, enabled: true }); } diff --git a/apps/api/src/oidc/oidc-interaction.controller.ts b/apps/api/src/oidc/oidc-interaction.controller.ts index 5cb945d..9a192d1 100644 --- a/apps/api/src/oidc/oidc-interaction.controller.ts +++ b/apps/api/src/oidc/oidc-interaction.controller.ts @@ -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', '

Die OIDC-Interaktion ist ungültig.

')); + response.status(400).send(this.page('Ungueltige Anfrage', '

Die OIDC-Interaktion ist ungueltig.

')); 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', - ` -

Client ${this.escape(String(details.params.name ?? ''))} möchte Zugriff auf folgende Scopes:

-

${this.escape(String(details.params.scope ?? 'openid'))}

-
- -
-
- -
- `, - ), - ); + 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 ` +

Die Anwendung ${this.escape(clientName)} moechte auf dein Konto zugreifen.

+ ${redirectUri ? `` : ''} +
+ ${this.scopeItems(scope)} +
+
+ +
+
+ +
+ `; + } + + private scopeItems(scope: string): string { + const scopes = scope + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); + + return scopes + .map( + (item) => ` +
+ ${this.escape(item)} + ${this.escape(this.scopeDescription(item))} +
+ `, + ) + .join(''); + } + + private scopeDescription(scope: string): string { + const descriptions: Record = { + 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 ` @@ -113,7 +151,7 @@ export class OidcInteractionController { ${this.escape(title)} - LDAP Portal

${this.escape(title)}

${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.'; } } diff --git a/apps/api/src/oidc/oidc-provider.service.ts b/apps/api/src/oidc/oidc-provider.service.ts index f6595f1..ee740e7 100644 --- a/apps/api/src/oidc/oidc-provider.service.ts +++ b/apps/api/src/oidc/oidc-provider.service.ts @@ -63,13 +63,13 @@ export class OidcProviderService implements OnModuleInit { ): Promise { 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 { + async finishConsent( + request: Request, + response: Response, + uid: string, + options: { autoGranted?: boolean } = {}, + ): Promise { 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 { + const client = await this.clients.findByClientId(clientId); + return client?.firstParty === true; + } + private buildConfiguration(jwks: { keys: Record[] }): Configuration { return { adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients), diff --git a/apps/api/src/registration/registration.service.ts b/apps/api/src/registration/registration.service.ts index e328793..338e436 100644 --- a/apps/api/src/registration/registration.service.ts +++ b/apps/api/src/registration/registration.service.ts @@ -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('TOKEN_SECRET'); } + + private async notifyUserManagers(registration: RegistrationRequest): Promise { + const groupName = this.config.get('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'; + } } diff --git a/apps/web/src/app/pages/admin-oidc-clients.component.ts b/apps/web/src/app/pages/admin-oidc-clients.component.ts index 04285e0..f6d1254 100644 --- a/apps/web/src/app/pages/admin-oidc-clients.component.ts +++ b/apps/web/src/app/pages/admin-oidc-clients.component.ts @@ -61,7 +61,7 @@ interface CreatedOidcClient extends OidcClient {