feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren - Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze - Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix) - Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt - Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand) - Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten - Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck - Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2684
-289
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||
import { SsoService } from './sso.service';
|
||||
|
||||
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
|
||||
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
// Scoped to /complete only - the completion endpoint is the only place that
|
||||
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
|
||||
const SSO_CODE_COOKIE = 'oidcSsoCode';
|
||||
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
|
||||
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
|
||||
|
||||
interface PkceCookiePayload {
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth/sso')
|
||||
export class SsoController {
|
||||
constructor(
|
||||
private readonly ssoService: SsoService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
private frontendLoginUrl(): string {
|
||||
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
|
||||
}
|
||||
|
||||
private cookieOptions(path: string, maxAge: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
sameSite: 'lax' as const,
|
||||
path,
|
||||
maxAge,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('status')
|
||||
status(): { enabled: boolean } {
|
||||
return { enabled: this.ssoService.isConfigured() };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('login')
|
||||
async login(@Res() res: Response): Promise<void> {
|
||||
const { redirectUrl, codeVerifier, state } =
|
||||
await this.ssoService.buildAuthorizationRequest();
|
||||
const payload: PkceCookiePayload = { codeVerifier, state };
|
||||
res.cookie(
|
||||
PKCE_COOKIE,
|
||||
JSON.stringify(payload),
|
||||
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
|
||||
);
|
||||
res.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('callback')
|
||||
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
|
||||
PKCE_COOKIE
|
||||
];
|
||||
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
|
||||
|
||||
const pkce = this.parsePkceCookie(rawCookie);
|
||||
if (!pkce) {
|
||||
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(
|
||||
req.originalUrl,
|
||||
`${req.protocol}://${req.get('host')}`,
|
||||
);
|
||||
const result = await this.ssoService.handleCallback(
|
||||
callbackUrl,
|
||||
pkce.codeVerifier,
|
||||
pkce.state,
|
||||
);
|
||||
if (result.status === 'error') {
|
||||
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// The one-time completion code never touches the URL (history, server
|
||||
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
|
||||
// to the /complete endpoint, which the frontend calls right after.
|
||||
res.cookie(
|
||||
SSO_CODE_COOKIE,
|
||||
result.ssoCode,
|
||||
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
|
||||
);
|
||||
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async complete(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto> {
|
||||
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||
SSO_CODE_COOKIE
|
||||
];
|
||||
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
|
||||
if (!ssoCode) {
|
||||
throw new UnauthorizedException(
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||
const { accessToken, refreshToken } =
|
||||
await this.authService.issueTokensForSsoUser(userId);
|
||||
setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
|
||||
if (
|
||||
typeof parsed.codeVerifier !== 'string' ||
|
||||
typeof parsed.state !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user