mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 21:36:39 +02:00
feat: add Microsoft Entra ID (Azure AD) SSO integration
Backend-driven Authorization Code Flow with @azure/msal-node: - EntraIdService: MSAL ConfidentialClientApplication, auth URL generation, token exchange - SsoController: /auth/sso/microsoft (initiate) + /auth/sso/microsoft/callback (callback) - AuthService.loginViaSso(): User provisioning (find by OID, auto-link by email, or create new) - CSRF protection via state parameter stored in Redis - SSO status endpoint for frontend feature detection Frontend: - "Mit Microsoft anmelden" button on login page (shown only when SSO is configured) - SsoCallbackPage: handles redirect from backend, sets token, loads user profile - AuthContext.loginWithToken(): new method for SSO token handling Configuration: - AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_REDIRECT_URI env vars - docker-compose.yml updated to pass Azure vars to core service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8efaa49930
commit
45cf644f81
14 changed files with 766 additions and 6 deletions
|
|
@ -66,8 +66,12 @@ SMTP_FROM=noreply@xinion.de
|
||||||
GRAFANA_ADMIN_USER=admin
|
GRAFANA_ADMIN_USER=admin
|
||||||
GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen!
|
GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen!
|
||||||
|
|
||||||
# --- MS SSO (Beta - noch nicht aktiv) ---
|
# --- Microsoft Entra ID (Azure AD) SSO ---
|
||||||
# MS_SSO_CLIENT_ENCRYPTION_KEY= # AES-256 Key fuer Client Secret Verschluesselung
|
# Azure App Registration: https://portal.azure.com → App registrations
|
||||||
|
AZURE_TENANT_ID= # Directory (Tenant) ID
|
||||||
|
AZURE_CLIENT_ID= # Application (Client) ID
|
||||||
|
AZURE_CLIENT_SECRET= # Client Secret Value
|
||||||
|
AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback
|
||||||
|
|
||||||
# --- KI-Hilfe-Chat (optional) ---
|
# --- KI-Hilfe-Chat (optional) ---
|
||||||
# ANTHROPIC_API_KEY= # Claude API Key
|
# ANTHROPIC_API_KEY= # Claude API Key
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,11 @@ services:
|
||||||
BCRYPT_COST: ${BCRYPT_COST:-12}
|
BCRYPT_COST: ${BCRYPT_COST:-12}
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://172.20.10.59}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://172.20.10.59}
|
||||||
|
# Microsoft Entra ID (Azure AD) SSO
|
||||||
|
AZURE_TENANT_ID: ${AZURE_TENANT_ID:-}
|
||||||
|
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-}
|
||||||
|
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-}
|
||||||
|
AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI:-}
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
THROTTLE_TTL: ${THROTTLE_TTL:-60000}
|
THROTTLE_TTL: ${THROTTLE_TTL:-60000}
|
||||||
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}
|
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}
|
||||||
|
|
|
||||||
33
packages/core-service/package-lock.json
generated
33
packages/core-service/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-node": "^5.0.6",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/config": "^3.2.0",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
|
|
@ -227,6 +228,38 @@
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@azure/msal-common": {
|
||||||
|
"version": "16.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz",
|
||||||
|
"integrity": "sha512-ge0nGzTLmEE5lg7tSCbTBrYqMGkpFQeQEtqfcKPuGJn/FPFf8Xz51uDfZsm5xpstNZGMYPhHvnYbL8OeNp/aLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-node": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-vwGXndrTkf/5Nu0xjobrFXW1AVlrbp2IrTdmJumSERfHXMsBQC+5YqIvLxCqT2+Rn+sBvzRpGaUqHCA8CKAyjg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-common": "16.2.0",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"uuid": "^8.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-node/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"prisma:seed": "ts-node prisma/seed.ts"
|
"prisma:seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-node": "^5.0.6",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/config": "^3.2.0",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,23 @@ class EnvironmentVariables {
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
THROTTLE_LIMIT = 200;
|
THROTTLE_LIMIT = 200;
|
||||||
|
|
||||||
|
// Microsoft Entra ID (Azure AD) SSO - optional
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
AZURE_TENANT_ID?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
AZURE_CLIENT_ID?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
AZURE_CLIENT_SECRET?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
AZURE_REDIRECT_URI?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConfig(
|
export function validateConfig(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { TotpService } from './totp.service';
|
import { TotpService } from './totp.service';
|
||||||
|
import { EntraIdService } from './sso/entra-id.service';
|
||||||
|
import { SsoController } from './sso/sso.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -40,8 +42,8 @@ import { TotpService } from './totp.service';
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, SsoController],
|
||||||
providers: [AuthService, JwtStrategy, TotpService],
|
providers: [AuthService, JwtStrategy, TotpService, EntraIdService],
|
||||||
exports: [AuthService, JwtModule],
|
exports: [AuthService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,156 @@ export class AuthService {
|
||||||
this.logger.log(`2FA deaktiviert für User ${userId}`);
|
this.logger.log(`2FA deaktiviert für User ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO-Login: User via Microsoft Entra ID (Azure AD) anmelden.
|
||||||
|
*
|
||||||
|
* Logik:
|
||||||
|
* 1. User via AuthProvider(MS_SSO, providerId=oid) suchen
|
||||||
|
* 2. Falls nicht gefunden: User via E-Mail suchen
|
||||||
|
* → Falls gefunden: MS_SSO AuthProvider verknuepfen (Auto-Link)
|
||||||
|
* → Falls nicht gefunden: Neuen User + MS_SSO AuthProvider anlegen
|
||||||
|
* 3. User muss isActive sein
|
||||||
|
* 4. JWT-Tokens generieren (wie bei normalem Login)
|
||||||
|
*/
|
||||||
|
async loginViaSso(msUser: {
|
||||||
|
oid: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}): Promise<LoginResponse & { refreshToken: string }> {
|
||||||
|
// 1. Bestehenden MS_SSO AuthProvider suchen (Provider ID = MS Object ID)
|
||||||
|
const existingAuth = await this.prisma.authProvider.findFirst({
|
||||||
|
where: {
|
||||||
|
provider: 'MS_SSO',
|
||||||
|
providerId: msUser.oid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
tenantMemberships: {
|
||||||
|
include: { tenant: true },
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
tenantMemberships?: Array<{
|
||||||
|
tenant: { id: string; slug: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingAuth) {
|
||||||
|
// Bekannter SSO-User
|
||||||
|
user = existingAuth.user;
|
||||||
|
this.logger.log(`SSO Login: Bekannter User ${user.email}`);
|
||||||
|
} else {
|
||||||
|
// 2. User via E-Mail suchen
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { email: msUser.email },
|
||||||
|
include: {
|
||||||
|
authProvider: true,
|
||||||
|
tenantMemberships: {
|
||||||
|
include: { tenant: true },
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// MS_SSO AuthProvider verknuepfen (Auto-Link)
|
||||||
|
await this.prisma.authProvider.create({
|
||||||
|
data: {
|
||||||
|
userId: existingUser.id,
|
||||||
|
provider: 'MS_SSO',
|
||||||
|
providerId: msUser.oid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
user = existingUser;
|
||||||
|
this.logger.log(
|
||||||
|
`SSO Auto-Link: MS_SSO Provider fuer ${user.email} verknuepft`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 3. Neuen User + MS_SSO AuthProvider anlegen
|
||||||
|
const newUser = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: msUser.email,
|
||||||
|
firstName: msUser.firstName || 'SSO',
|
||||||
|
lastName: msUser.lastName || 'User',
|
||||||
|
role: 'USER',
|
||||||
|
isActive: true,
|
||||||
|
authProvider: {
|
||||||
|
create: {
|
||||||
|
provider: 'MS_SSO',
|
||||||
|
providerId: msUser.oid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenantMemberships: {
|
||||||
|
include: { tenant: true },
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
user = newUser;
|
||||||
|
this.logger.log(`SSO Neuer User angelegt: ${user.email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User muss aktiv sein
|
||||||
|
if (!('isActive' in user) || !(user as { isActive: boolean }).isActive) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Ihr Benutzerkonto ist deaktiviert. Bitte wenden Sie sich an den Administrator.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLogin aktualisieren
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
lastLogin: new Date(),
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tenant-Info
|
||||||
|
const primaryMembership = user.tenantMemberships?.[0];
|
||||||
|
|
||||||
|
// Tokens generieren
|
||||||
|
const tokens = await this.generateTokenPair({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenantId: primaryMembership?.tenant.id,
|
||||||
|
tenantSlug: primaryMembership?.tenant.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
twoFactorEnabled: user.twoFactorEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-Paar generieren (Access + Refresh).
|
* Token-Paar generieren (Access + Refresh).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
166
packages/core-service/src/core/auth/sso/entra-id.service.ts
Normal file
166
packages/core-service/src/core/auth/sso/entra-id.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
ConfidentialClientApplication,
|
||||||
|
type Configuration,
|
||||||
|
type AuthorizationUrlRequest,
|
||||||
|
type AuthorizationCodeRequest,
|
||||||
|
type AuthenticationResult,
|
||||||
|
} from '@azure/msal-node';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informationen aus dem Microsoft ID-Token.
|
||||||
|
*/
|
||||||
|
export interface MsUserInfo {
|
||||||
|
/** Microsoft Object ID (eindeutig pro Tenant) */
|
||||||
|
oid: string;
|
||||||
|
/** E-Mail-Adresse */
|
||||||
|
email: string;
|
||||||
|
/** Vorname */
|
||||||
|
firstName: string;
|
||||||
|
/** Nachname */
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EntraIdService - Microsoft Entra ID (Azure AD) Integration.
|
||||||
|
*
|
||||||
|
* Nutzt MSAL ConfidentialClientApplication fuer den
|
||||||
|
* Authorization Code Flow (Server-seitig).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EntraIdService {
|
||||||
|
private readonly logger = new Logger(EntraIdService.name);
|
||||||
|
private msalClient: ConfidentialClientApplication | null = null;
|
||||||
|
private readonly redirectUri: string;
|
||||||
|
private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const clientId = this.config.get<string>('AZURE_CLIENT_ID');
|
||||||
|
const tenantId = this.config.get<string>('AZURE_TENANT_ID');
|
||||||
|
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
|
||||||
|
this.redirectUri = this.config.get<string>(
|
||||||
|
'AZURE_REDIRECT_URI',
|
||||||
|
'http://localhost/api/v1/auth/sso/microsoft/callback',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientId && tenantId && clientSecret) {
|
||||||
|
const msalConfig: Configuration = {
|
||||||
|
auth: {
|
||||||
|
clientId,
|
||||||
|
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||||
|
clientSecret,
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
loggerOptions: {
|
||||||
|
loggerCallback: (level, message) => {
|
||||||
|
this.logger.debug(`MSAL [${level}]: ${message}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.msalClient = new ConfidentialClientApplication(msalConfig);
|
||||||
|
this.logger.log('Microsoft Entra ID SSO konfiguriert');
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
'Microsoft Entra ID SSO nicht konfiguriert (AZURE_CLIENT_ID, AZURE_TENANT_ID oder AZURE_CLIENT_SECRET fehlt)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist Entra ID SSO konfiguriert?
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return !!this.msalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization-URL fuer den OAuth2 Flow generieren.
|
||||||
|
* @param state CSRF-Token (wird in Redis gespeichert)
|
||||||
|
*/
|
||||||
|
async getAuthUrl(state: string): Promise<string> {
|
||||||
|
if (!this.msalClient) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Microsoft SSO ist nicht konfiguriert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrlRequest: AuthorizationUrlRequest = {
|
||||||
|
scopes: this.scopes,
|
||||||
|
redirectUri: this.redirectUri,
|
||||||
|
state,
|
||||||
|
prompt: 'select_account',
|
||||||
|
};
|
||||||
|
|
||||||
|
const authUrl = await this.msalClient.getAuthCodeUrl(authUrlRequest);
|
||||||
|
this.logger.debug('Authorization URL generiert');
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization Code gegen Tokens tauschen und User-Info extrahieren.
|
||||||
|
* @param code Authorization Code von Microsoft
|
||||||
|
*/
|
||||||
|
async handleCallback(code: string): Promise<MsUserInfo> {
|
||||||
|
if (!this.msalClient) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Microsoft SSO ist nicht konfiguriert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRequest: AuthorizationCodeRequest = {
|
||||||
|
code,
|
||||||
|
scopes: this.scopes,
|
||||||
|
redirectUri: this.redirectUri,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: AuthenticationResult =
|
||||||
|
await this.msalClient.acquireTokenByCode(tokenRequest);
|
||||||
|
|
||||||
|
this.logger.debug('Token erfolgreich erhalten');
|
||||||
|
|
||||||
|
// User-Informationen aus ID-Token Claims extrahieren
|
||||||
|
const claims = response.idTokenClaims as Record<string, unknown>;
|
||||||
|
|
||||||
|
const oid = (claims.oid as string) || (claims.sub as string);
|
||||||
|
if (!oid) {
|
||||||
|
throw new Error('Keine Object ID (oid) im ID-Token gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail: preferred_username, email, oder upn
|
||||||
|
const email =
|
||||||
|
(claims.preferred_username as string) ||
|
||||||
|
(claims.email as string) ||
|
||||||
|
(claims.upn as string) ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('Keine E-Mail-Adresse im ID-Token gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namen: given_name + family_name, oder name splitten
|
||||||
|
let firstName = (claims.given_name as string) || '';
|
||||||
|
let lastName = (claims.family_name as string) || '';
|
||||||
|
|
||||||
|
if (!firstName && !lastName && claims.name) {
|
||||||
|
const parts = (claims.name as string).split(' ');
|
||||||
|
firstName = parts[0] || '';
|
||||||
|
lastName = parts.slice(1).join(' ') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`MS SSO User: ${email} (OID: ${oid})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oid,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
165
packages/core-service/src/core/auth/sso/sso.controller.ts
Normal file
165
packages/core-service/src/core/auth/sso/sso.controller.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Public } from '../../../common/decorators/public.decorator';
|
||||||
|
import { RedisService } from '../../../redis/redis.service';
|
||||||
|
import { EntraIdService } from './entra-id.service';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SsoController - Microsoft Entra ID SSO Endpoints.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. GET /auth/sso/microsoft → Redirect zu Microsoft Login
|
||||||
|
* 2. GET /auth/sso/microsoft/callback → Callback von Microsoft, User anlegen/verknuepfen, JWT generieren
|
||||||
|
*/
|
||||||
|
@ApiTags('SSO')
|
||||||
|
@Controller('auth/sso')
|
||||||
|
export class SsoController {
|
||||||
|
private readonly logger = new Logger(SsoController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly entraIdService: EntraIdService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/auth/sso/microsoft
|
||||||
|
* Initiiert den OAuth2 Authorization Code Flow.
|
||||||
|
* Redirectet den Browser zur Microsoft-Login-Seite.
|
||||||
|
*/
|
||||||
|
@Get('microsoft')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'Microsoft SSO Login starten' })
|
||||||
|
async initiate(@Res() res: Response): Promise<void> {
|
||||||
|
if (!this.entraIdService.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Microsoft SSO ist nicht konfiguriert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF-State generieren und in Redis speichern (5 Minuten TTL)
|
||||||
|
const state = uuidv4();
|
||||||
|
await this.redis.set(`sso_state:${state}`, '1', 300);
|
||||||
|
|
||||||
|
// Authorization URL von MSAL holen
|
||||||
|
const authUrl = await this.entraIdService.getAuthUrl(state);
|
||||||
|
|
||||||
|
this.logger.log('SSO Flow gestartet, Redirect zu Microsoft');
|
||||||
|
res.redirect(authUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/auth/sso/microsoft/callback
|
||||||
|
* Callback von Microsoft nach erfolgreicher Authentifizierung.
|
||||||
|
* Erstellt/verknuepft User, generiert JWT, redirectet zum Frontend.
|
||||||
|
*/
|
||||||
|
@Get('microsoft/callback')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'Microsoft SSO Callback' })
|
||||||
|
async callback(
|
||||||
|
@Query('code') code: string,
|
||||||
|
@Query('state') state: string,
|
||||||
|
@Query('error') error: string,
|
||||||
|
@Query('error_description') errorDescription: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const frontendUrl = this.config.get<string>(
|
||||||
|
'FRONTEND_URL',
|
||||||
|
'http://172.20.10.59',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fehler von Microsoft
|
||||||
|
if (error) {
|
||||||
|
this.logger.warn(`SSO Fehler von Microsoft: ${error} - ${errorDescription}`);
|
||||||
|
res.redirect(
|
||||||
|
`${frontendUrl}/login?sso_error=${encodeURIComponent(errorDescription || error)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code und State validieren
|
||||||
|
if (!code || !state) {
|
||||||
|
this.logger.warn('SSO Callback ohne Code oder State');
|
||||||
|
res.redirect(`${frontendUrl}/login?sso_error=Ungültige SSO-Antwort`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF-State aus Redis validieren
|
||||||
|
const storedState = await this.redis.get(`sso_state:${state}`);
|
||||||
|
if (!storedState) {
|
||||||
|
this.logger.warn('SSO State ungültig oder abgelaufen');
|
||||||
|
res.redirect(
|
||||||
|
`${frontendUrl}/login?sso_error=SSO-Sitzung abgelaufen. Bitte erneut versuchen.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State verbrauchen (einmalig verwendbar)
|
||||||
|
await this.redis.del(`sso_state:${state}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Code gegen Tokens tauschen und User-Info extrahieren
|
||||||
|
const msUser = await this.entraIdService.handleCallback(code);
|
||||||
|
|
||||||
|
// User finden oder anlegen + JWT generieren
|
||||||
|
const loginResult = await this.authService.loginViaSso(msUser);
|
||||||
|
|
||||||
|
// Refresh-Token als HttpOnly Cookie setzen (wie beim normalen Login)
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
res.cookie('refresh_token', loginResult.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: isProduction ? 'strict' : 'lax',
|
||||||
|
path: '/api/v1/auth',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect zum Frontend mit Access-Token
|
||||||
|
this.logger.log(`SSO Login erfolgreich: ${msUser.email}`);
|
||||||
|
res.redirect(
|
||||||
|
`${frontendUrl}/auth/sso/callback?token=${loginResult.accessToken}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`SSO Callback Fehler: ${(err as Error).message}`);
|
||||||
|
|
||||||
|
if (err instanceof UnauthorizedException) {
|
||||||
|
res.redirect(
|
||||||
|
`${frontendUrl}/login?sso_error=${encodeURIComponent((err as Error).message)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(
|
||||||
|
`${frontendUrl}/login?sso_error=SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/auth/sso/status
|
||||||
|
* Pruefen ob Microsoft SSO konfiguriert ist.
|
||||||
|
* Wird vom Frontend genutzt um den SSO-Button anzuzeigen.
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'SSO Status abfragen' })
|
||||||
|
async getStatus() {
|
||||||
|
return {
|
||||||
|
microsoft: this.entraIdService.isConfigured(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
|
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
|
||||||
|
loginWithToken: (accessToken: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +104,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO-Login: Access-Token direkt setzen und User-Profil laden.
|
||||||
|
* Wird von SsoCallbackPage aufgerufen.
|
||||||
|
*/
|
||||||
|
const loginWithToken = useCallback(async (token: string) => {
|
||||||
|
setAccessToken(token);
|
||||||
|
const { data } = await api.get<User>('/users/me');
|
||||||
|
setUser(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<User>('/users/me');
|
const { data } = await api.get<User>('/users/me');
|
||||||
|
|
@ -130,6 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
isLoading,
|
isLoading,
|
||||||
login,
|
login,
|
||||||
|
loginWithToken,
|
||||||
logout,
|
logout,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,51 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider span {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssoButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssoButton:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssoButton svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, useEffect, type FormEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '../api/client';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import styles from './LoginPage.module.css';
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
@ -14,6 +17,25 @@ export function LoginPage() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// SSO-Fehler aus URL-Parameter lesen (Redirect von Backend)
|
||||||
|
useEffect(() => {
|
||||||
|
const ssoError = searchParams.get('sso_error');
|
||||||
|
if (ssoError) {
|
||||||
|
setError(ssoError);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// SSO-Status abfragen: Ist Microsoft SSO konfiguriert?
|
||||||
|
const { data: ssoStatus } = useQuery<{ microsoft: boolean }>({
|
||||||
|
queryKey: ['sso', 'status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<{ microsoft: boolean }>('/auth/sso/status');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 Minuten cachen
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -42,6 +64,11 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMicrosoftLogin = () => {
|
||||||
|
// Direkt zum Backend SSO-Endpoint redirecten
|
||||||
|
window.location.href = '/api/v1/auth/sso/microsoft';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|
@ -107,6 +134,33 @@ export function LoginPage() {
|
||||||
{isSubmitting ? 'Anmeldung...' : 'Anmelden'}
|
{isSubmitting ? 'Anmeldung...' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Microsoft SSO Button */}
|
||||||
|
{ssoStatus?.microsoft && (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<span>oder</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.ssoButton}
|
||||||
|
onClick={handleMicrosoftLogin}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 21 21"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||||
|
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||||
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||||
|
</svg>
|
||||||
|
Mit Microsoft anmelden
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
101
packages/frontend/src/auth/SsoCallbackPage.tsx
Normal file
101
packages/frontend/src/auth/SsoCallbackPage.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { setAccessToken } from '../api/client';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SsoCallbackPage - Verarbeitet den SSO-Callback vom Backend.
|
||||||
|
*
|
||||||
|
* Das Backend redirectet hierher mit dem Access-Token als Query-Parameter:
|
||||||
|
* /auth/sso/callback?token=eyJhbGci...
|
||||||
|
*
|
||||||
|
* Diese Seite:
|
||||||
|
* 1. Liest den Token aus der URL
|
||||||
|
* 2. Setzt ihn im AuthContext (loginWithToken)
|
||||||
|
* 3. Laedt das User-Profil
|
||||||
|
* 4. Navigiert zum Dashboard
|
||||||
|
*/
|
||||||
|
export function SsoCallbackPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { loginWithToken } = useAuth();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Kein Token in der SSO-Antwort gefunden.');
|
||||||
|
setTimeout(() => navigate('/login'), 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Token setzen und User-Profil laden
|
||||||
|
await loginWithToken(token);
|
||||||
|
|
||||||
|
// Zum Dashboard navigieren
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch {
|
||||||
|
setError('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
||||||
|
setAccessToken(null);
|
||||||
|
setTimeout(() => navigate('/login'), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, navigate, loginWithToken]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #1a56db 0%, #1e3a5f 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
padding: '2.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#dc2626', fontSize: '0.9375rem', marginBottom: '0.5rem' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#6b7280', fontSize: '0.8125rem' }}>
|
||||||
|
Sie werden zur Login-Seite weitergeleitet...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: '3px solid #e5e7eb',
|
||||||
|
borderTopColor: '#1a56db',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 0.8s linear infinite',
|
||||||
|
margin: '0 auto 1rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p style={{ color: '#374151', fontSize: '0.9375rem' }}>
|
||||||
|
Anmeldung wird abgeschlossen...
|
||||||
|
</p>
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { LoginPage } from '../auth/LoginPage';
|
import { LoginPage } from '../auth/LoginPage';
|
||||||
|
import { SsoCallbackPage } from '../auth/SsoCallbackPage';
|
||||||
import { AppLayout } from './AppLayout';
|
import { AppLayout } from './AppLayout';
|
||||||
import { DashboardPage } from './DashboardPage';
|
import { DashboardPage } from './DashboardPage';
|
||||||
import { AdminUsersPage } from '../admin/AdminUsersPage';
|
import { AdminUsersPage } from '../admin/AdminUsersPage';
|
||||||
|
|
@ -30,6 +31,7 @@ export function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Öffentliche Routen */}
|
{/* Öffentliche Routen */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
|
||||||
|
|
||||||
{/* Geschützte Routen */}
|
{/* Geschützte Routen */}
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue