From d0600ab6ac07670a1cc4210a86b0fb2bf64a4ec3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 15 Jul 2026 17:08:17 +0200 Subject: [PATCH] login --- apps/api/src/account/account.controller.ts | 2 +- apps/api/src/auth/auth.service.ts | 2 +- apps/api/src/auth/jwt-auth.guard.ts | 2 +- .../src/oidc/oidc-interaction.controller.ts | 56 ++++-- apps/api/src/oidc/oidc-provider.service.ts | 6 +- apps/api/src/password/password.service.ts | 2 +- .../src/registration/registration.service.ts | 2 +- .../app/pages/admin-oidc-clients.component.ts | 160 +++++++++++++++--- apps/web/src/styles.css | 17 ++ 9 files changed, 206 insertions(+), 43 deletions(-) diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index 65052fd..6f493e2 100644 --- a/apps/api/src/account/account.controller.ts +++ b/apps/api/src/account/account.controller.ts @@ -83,7 +83,7 @@ export class AccountController { const tokenHash = hashToken(dto.token, this.tokenSecret); const record = await this.emailChanges.findOne({ where: { tokenHash, consumedAt: IsNull() } }); if (!record || record.expiresAt.getTime() < Date.now() || record.username !== request.user.username) { - return { message: 'Der Bestaetigungslink ist ungueltig oder abgelaufen.' }; + return { message: 'Der Bestaetigungslink ist ungültig oder abgelaufen.' }; } await this.lldap.updateUser(request.user.username, { email: record.newEmail }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index eb651d6..d2addfd 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -15,7 +15,7 @@ export class AuthService { const valid = await this.ldapAuth.verifyPassword(username, password); if (!valid) { await this.audit.record({ type: 'auth.login_failed', username, ipAddress, userAgent }); - throw new UnauthorizedException('Ungueltige Zugangsdaten.'); + throw new UnauthorizedException('Ungültige Zugangsdaten.'); } await this.audit.record({ type: 'auth.login_success', username, ipAddress, userAgent }); diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts index 12c62ef..519ff8f 100644 --- a/apps/api/src/auth/jwt-auth.guard.ts +++ b/apps/api/src/auth/jwt-auth.guard.ts @@ -18,7 +18,7 @@ export class JwtAuthGuard implements CanActivate { request.user = await this.jwt.verifyAsync(token); return true; } catch { - throw new UnauthorizedException('Session ist ungueltig oder abgelaufen.'); + throw new UnauthorizedException('Session ist ungültig oder abgelaufen.'); } } diff --git a/apps/api/src/oidc/oidc-interaction.controller.ts b/apps/api/src/oidc/oidc-interaction.controller.ts index 4bfea26..55d672c 100644 --- a/apps/api/src/oidc/oidc-interaction.controller.ts +++ b/apps/api/src/oidc/oidc-interaction.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common'; import { Request, Response } from 'express'; import { OidcProviderService } from './oidc-provider.service'; @@ -10,7 +10,7 @@ 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('Ungueltige Anfrage', '

Die OIDC-Interaktion ist ungueltig.

')); + response.status(400).send(this.page('Ungültige Anfrage', '

Die OIDC-Interaktion ist ungültig.

')); return; } @@ -18,16 +18,7 @@ export class OidcInteractionController { response.send( this.page( 'Anmelden', - ` -
- - - -
-
- -
- `, + this.loginForm(uid), ), ); return; @@ -38,7 +29,7 @@ export class OidcInteractionController { this.page( 'Zugriff erlauben', ` -

Client ${this.escape(String(details.params.client_id ?? ''))} moechte Zugriff auf folgende Scopes:

+

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

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

@@ -62,7 +53,19 @@ export class OidcInteractionController { @Req() request: Request, @Res() response: Response, ) { - await this.oidc.finishLogin(request, response, uid, body.username ?? '', body.password ?? ''); + const username = body.username ?? ''; + try { + await this.oidc.finishLogin(request, response, uid, username, body.password ?? ''); + } catch (error) { + if (this.isInvalidCredentialsError(error)) { + response + .status(401) + .send(this.page('Anmelden', this.loginForm(uid, username, 'Ungültige Zugangsdaten.'))); + return; + } + + throw error; + } } @Post(':uid/confirm') @@ -75,6 +78,25 @@ export class OidcInteractionController { await this.oidc.abortInteraction(request, response); } + private loginForm(uid: string, username = '', errorMessage = ''): string { + const encodedUid = encodeURIComponent(uid); + const error = errorMessage + ? `` + : ''; + + return ` + ${error} + + + + +
+
+ +
+ `; + } + private page(title: string, body: string): string { return ` @@ -91,6 +113,8 @@ export class OidcInteractionController { input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; } button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; } button.secondary { background: white; color: #0f6b6e; } + .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; } @@ -106,4 +130,8 @@ export class OidcInteractionController { .replaceAll('"', '"') .replaceAll("'", '''); } + + private isInvalidCredentialsError(error: unknown): boolean { + return error instanceof UnauthorizedException && error.message === 'Ungültige Zugangsdaten.'; + } } diff --git a/apps/api/src/oidc/oidc-provider.service.ts b/apps/api/src/oidc/oidc-provider.service.ts index 3962c2c..f6595f1 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('Ungueltige OIDC-Interaktion.'); + throw new UnauthorizedException('Ungültige 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('Ungueltige Zugangsdaten.'); + throw new UnauthorizedException('Ungültige Zugangsdaten.'); } const account = await this.lldap.getAccount(username); @@ -96,7 +96,7 @@ export class OidcProviderService implements OnModuleInit { async finishConsent(request: Request, response: Response, uid: string): Promise { const details = await this.interactionDetails(request, response); if (details.uid !== uid || details.prompt.name !== 'consent') { - throw new UnauthorizedException('Ungueltige OIDC-Interaktion.'); + throw new UnauthorizedException('Ungültige OIDC-Interaktion.'); } const clientId = String(details.params.client_id ?? ''); diff --git a/apps/api/src/password/password.service.ts b/apps/api/src/password/password.service.ts index 0c53414..b8db2d0 100644 --- a/apps/api/src/password/password.service.ts +++ b/apps/api/src/password/password.service.ts @@ -86,7 +86,7 @@ export class PasswordService { const tokenHash = hashToken(token, this.tokenSecret); const record = await this.resetTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } }); if (!record || record.expiresAt.getTime() < Date.now()) { - throw new BadRequestException('Der Reset-Link ist ungueltig oder abgelaufen.'); + throw new BadRequestException('Der Reset-Link ist ungültig oder abgelaufen.'); } await this.lldap.setPassword(record.username, newPassword); diff --git a/apps/api/src/registration/registration.service.ts b/apps/api/src/registration/registration.service.ts index 9707e70..e328793 100644 --- a/apps/api/src/registration/registration.service.ts +++ b/apps/api/src/registration/registration.service.ts @@ -75,7 +75,7 @@ export class RegistrationService { const tokenHash = hashToken(token, this.tokenSecret); const tokenRecord = await this.emailTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } }); if (!tokenRecord || tokenRecord.expiresAt.getTime() < Date.now()) { - throw new BadRequestException('Der Bestaetigungslink ist ungueltig oder abgelaufen.'); + throw new BadRequestException('Der Bestaetigungslink ist ungültig oder abgelaufen.'); } const registration = await this.registrations.findOneByOrFail({ id: tokenRecord.registrationId }); 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 61a6052..04285e0 100644 --- a/apps/web/src/app/pages/admin-oidc-clients.component.ts +++ b/apps/web/src/app/pages/admin-oidc-clients.component.ts @@ -98,26 +98,73 @@ interface CreatedOidcClient extends OidcClient {
@for (client of clients(); track client.id) {
-
- {{ client.clientName }} - {{ client.clientId }} -
-
- {{ client.enabled ? 'aktiv' : 'deaktiviert' }} - {{ client.tokenEndpointAuthMethod }} - @if (client.firstParty) { first-party } - @if (client.includeGroups) { groups } -
-
-
Redirect URIs
{{ client.redirectUris.join(', ') }}
-
Scopes
{{ client.scope }}
-
-
- - -
+ @if (editingClientId() === client.id) { +
+
+
+ Client bearbeiten + {{ client.clientId }} +
+
+ {{ client.enabled ? 'aktiv' : 'deaktiviert' }} + {{ client.tokenEndpointAuthMethod }} +
+
+ + + + + + +
+ + +
+
+ } @else { +
+
+ {{ client.clientName }} + {{ client.clientId }} +
+
+ {{ client.enabled ? 'aktiv' : 'deaktiviert' }} + {{ client.tokenEndpointAuthMethod }} + @if (client.firstParty) { first-party } + @if (client.includeGroups) { groups } +
+
+
+
Redirect URIs
{{ client.redirectUris.join(', ') }}
+
Logout Redirect URIs
{{ client.postLogoutRedirectUris.join(', ') || '-' }}
+
Scopes
{{ client.scope }}
+
+
+ + + +
+ }
}
@@ -134,8 +181,10 @@ export class AdminOidcClientsComponent implements OnInit { readonly failed = signal(false); readonly message = signal(''); readonly createdSecret = signal(''); + readonly editingClientId = signal(null); readonly oidcBaseUrl: string; readonly form; + readonly editForm; constructor( private readonly fb: FormBuilder, @@ -152,6 +201,14 @@ export class AdminOidcClientsComponent implements OnInit { firstParty: [false], includeGroups: [true], }); + this.editForm = this.fb.nonNullable.group({ + clientName: ['', Validators.required], + redirectUris: ['', Validators.required], + postLogoutRedirectUris: [''], + scope: ['openid profile email groups'], + firstParty: [false], + includeGroups: [true], + }); } ngOnInit(): void { @@ -195,10 +252,67 @@ export class AdminOidcClientsComponent implements OnInit { .subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) }); } + startEdit(client: OidcClient): void { + this.failed.set(false); + this.message.set(''); + this.createdSecret.set(''); + this.editingClientId.set(client.id); + this.editForm.setValue({ + clientName: client.clientName, + redirectUris: this.multiline(client.redirectUris), + postLogoutRedirectUris: this.multiline(client.postLogoutRedirectUris), + scope: client.scope, + firstParty: client.firstParty, + includeGroups: client.includeGroups, + }); + } + + cancelEdit(): void { + this.editingClientId.set(null); + } + + save(client: OidcClient): void { + if (this.editForm.invalid) { + return; + } + + this.loading.set(true); + this.failed.set(false); + this.message.set(''); + const value = this.editForm.getRawValue(); + this.http + .patch(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`, { + ...value, + redirectUris: this.lines(value.redirectUris), + postLogoutRedirectUris: this.lines(value.postLogoutRedirectUris), + }) + .subscribe({ + next: () => { + this.editingClientId.set(null); + this.message.set('Client wurde gespeichert.'); + this.load(); + }, + error: (error) => { + this.failed.set(true); + this.message.set(apiErrorMessage(error)); + this.loading.set(false); + }, + complete: () => this.loading.set(false), + }); + } + delete(client: OidcClient): void { this.http .delete(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`) - .subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) }); + .subscribe({ + next: () => { + if (this.editingClientId() === client.id) { + this.editingClientId.set(null); + } + this.load(); + }, + error: (error) => this.message.set(apiErrorMessage(error)), + }); } private load(): void { @@ -217,4 +331,8 @@ export class AdminOidcClientsComponent implements OnInit { .map((line) => line.trim()) .filter(Boolean); } + + private multiline(values: string[]): string { + return values.join('\n'); + } } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 26d839d..84d1de7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -293,6 +293,23 @@ dd { gap: 12px; } +.client-heading { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + +.client-heading > div:first-child { + display: grid; + gap: 6px; +} + +.client-edit-form { + gap: 14px; +} + .group-item span, .group-item small, .attribute-row small,