design
This commit is contained in:
208
README.md
208
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.
|
||||
|
||||
@@ -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!) {
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ interface CreatedOidcClient extends OidcClient {
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" formControlName="firstParty">
|
||||
First-Party Client
|
||||
First-Party Client (Consent ueberspringen)
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" formControlName="includeGroups">
|
||||
@@ -76,6 +76,7 @@ interface CreatedOidcClient extends OidcClient {
|
||||
<div class="secret-box">
|
||||
<strong>Client Secret</strong>
|
||||
<code>{{ createdSecret() }}</code>
|
||||
<small>Dieses Secret wird nur einmal angezeigt.</small>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
@@ -128,7 +129,7 @@ interface CreatedOidcClient extends OidcClient {
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" formControlName="firstParty">
|
||||
First-Party Client
|
||||
First-Party Client (Consent ueberspringen)
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" formControlName="includeGroups">
|
||||
@@ -162,6 +163,11 @@ interface CreatedOidcClient extends OidcClient {
|
||||
<button type="button" class="secondary-action" (click)="toggle(client)">
|
||||
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
|
||||
</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>
|
||||
</div>
|
||||
}
|
||||
@@ -252,6 +258,39 @@ export class AdminOidcClientsComponent implements OnInit {
|
||||
.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 {
|
||||
this.failed.set(false);
|
||||
this.message.set('');
|
||||
|
||||
@@ -383,6 +383,14 @@ code {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted-action {
|
||||
align-items: center;
|
||||
color: #637083;
|
||||
display: inline-flex;
|
||||
font-size: 0.9rem;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user