generated from bastian/boilerplate
Initial commit
This commit is contained in:
69
apps/backend/src/auth/auth.controller.ts
Normal file
69
apps/backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Controller, Get, Query, Redirect, Req, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthenticatedRequest } from './authenticated-request';
|
||||
import { Public } from './guards/public.decorator';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: AppConfigService,
|
||||
) {}
|
||||
|
||||
@Get('login')
|
||||
@Public()
|
||||
@SensitiveRateLimit()
|
||||
@Redirect()
|
||||
async login() {
|
||||
return { url: await this.auth.createLoginUrl() };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@Public()
|
||||
@SensitiveRateLimit()
|
||||
async callback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { session, csrfToken } = await this.auth.completeLogin(
|
||||
code,
|
||||
state,
|
||||
req.get('user-agent'),
|
||||
req.ip,
|
||||
);
|
||||
res.cookie(this.config.session.cookieName, session.id, {
|
||||
httpOnly: true,
|
||||
signed: true,
|
||||
secure: this.config.isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: session.absoluteExpiresAt,
|
||||
});
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: this.config.isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: session.absoluteExpiresAt,
|
||||
});
|
||||
res.redirect(this.config.frontendBaseUrl);
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
@Public()
|
||||
@SensitiveRateLimit()
|
||||
async logout(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||
const sessionId = req.signedCookies?.[this.config.session.cookieName] as
|
||||
| string
|
||||
| undefined;
|
||||
const logoutUrl = await this.auth.logout(sessionId);
|
||||
res.clearCookie(this.config.session.cookieName, { path: '/' });
|
||||
res.clearCookie('csrf_token', { path: '/' });
|
||||
res.redirect(logoutUrl);
|
||||
}
|
||||
}
|
||||
27
apps/backend/src/auth/auth.module.ts
Normal file
27
apps/backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExternalHttpClient } from '../common/http/external-http-client';
|
||||
import { RolesModule } from '../roles/roles.module';
|
||||
import { SessionsModule } from '../sessions/sessions.module';
|
||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
OidcLoginStateEntity,
|
||||
UserEntity,
|
||||
UserSettingsEntity,
|
||||
]),
|
||||
RolesModule,
|
||||
SessionsModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, ExternalHttpClient, UsersRepository],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import type { ExternalHttpClient } from '../common/http/external-http-client';
|
||||
import type { AppConfigService } from '../config/config.service';
|
||||
import type { RolesService } from '../roles/roles.service';
|
||||
import type { SessionsService } from '../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../users/repositories/users.repository';
|
||||
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||
Promise.resolve('id-token'),
|
||||
);
|
||||
const service = new AuthService(
|
||||
{
|
||||
frontendBaseUrl: 'https://app.example.test',
|
||||
appBaseUrl: 'https://app.example.test',
|
||||
oidc: {
|
||||
issuer: 'https://idp.example.test',
|
||||
clientId: 'business-app',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid profile email',
|
||||
allowedAlgorithms: ['RS256'],
|
||||
httpTimeoutMs: 5000,
|
||||
logoutUrl: 'https://idp.example.test/logout',
|
||||
},
|
||||
} as AppConfigService,
|
||||
{} as ExternalHttpClient,
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||
{} as DataSource,
|
||||
{} as Repository<OidcLoginStateEntity>,
|
||||
);
|
||||
|
||||
const url = new URL(await service.logout('session-1'));
|
||||
|
||||
expect(revoke).toHaveBeenCalledWith('session-1');
|
||||
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
|
||||
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
|
||||
expect(url.searchParams.get('client_id')).toBe('business-app');
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||
'https://app.example.test',
|
||||
);
|
||||
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
|
||||
});
|
||||
});
|
||||
328
apps/backend/src/auth/auth.service.ts
Normal file
328
apps/backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, LessThan, Repository } from 'typeorm';
|
||||
import { ExternalHttpClient } from '../common/http/external-http-client';
|
||||
import { ApiError } from '../common/errors/api-error';
|
||||
import { ErrorCode } from '../common/errors/error-codes';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { RolesService } from '../roles/roles.service';
|
||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import { SessionsService } from '../sessions/sessions.service';
|
||||
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import type {
|
||||
OidcDiscovery,
|
||||
OidcTokenResponse,
|
||||
OidcUserInfo,
|
||||
} from './oidc.types';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly http: ExternalHttpClient,
|
||||
private readonly roles: RolesService,
|
||||
private readonly users: UsersRepository,
|
||||
private readonly sessions: SessionsService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@InjectRepository(OidcLoginStateEntity)
|
||||
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||
) {}
|
||||
|
||||
async createLoginUrl(): Promise<string> {
|
||||
const discovery = await this.discovery();
|
||||
const state = randomBytes(32).toString('hex');
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
const codeVerifier = randomBytes(48).toString('base64url');
|
||||
const challenge = createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
await this.loginStates.delete({ expiresAt: LessThan(new Date()) });
|
||||
const loginState = new OidcLoginStateEntity();
|
||||
loginState.state = state;
|
||||
loginState.codeVerifier = codeVerifier;
|
||||
loginState.nonce = nonce;
|
||||
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
await this.loginStates.save(loginState);
|
||||
|
||||
const url = new URL(discovery.authorization_endpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', this.config.oidc.clientId);
|
||||
url.searchParams.set('redirect_uri', this.callbackUrl);
|
||||
url.searchParams.set('scope', this.config.oidc.scopes);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async completeLogin(
|
||||
code: string,
|
||||
state: string,
|
||||
userAgent: string | undefined,
|
||||
ip: string | undefined,
|
||||
) {
|
||||
const loginState = await this.loginStates.findOneBy({ state });
|
||||
if (!loginState || loginState.expiresAt <= new Date()) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'Die Anmeldung ist abgelaufen.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
await this.loginStates.delete({ state });
|
||||
|
||||
const discovery = await this.discovery();
|
||||
const tokens = await this.exchangeCode(
|
||||
discovery,
|
||||
code,
|
||||
loginState.codeVerifier,
|
||||
);
|
||||
const profile = await this.verifyAndLoadProfile(
|
||||
discovery,
|
||||
tokens,
|
||||
loginState.nonce,
|
||||
);
|
||||
const user = await this.upsertLocalUser(discovery.issuer, profile);
|
||||
if (!user.active) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserDisabled,
|
||||
'Dieser Benutzer ist deaktiviert.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return this.sessions.createSession(
|
||||
user,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
||||
idToken: tokens.id_token,
|
||||
accessTokenExpiresAt: new Date(
|
||||
Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
),
|
||||
},
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(sessionId: string | undefined): Promise<string> {
|
||||
let idToken: string | undefined;
|
||||
if (sessionId) {
|
||||
idToken = await this.readLogoutIdToken(sessionId);
|
||||
await this.sessions.revoke(sessionId);
|
||||
}
|
||||
return this.createLogoutUrl(idToken);
|
||||
}
|
||||
|
||||
private async upsertLocalUser(
|
||||
issuer: string,
|
||||
profile: OidcUserInfo,
|
||||
): Promise<UserEntity> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
|
||||
try {
|
||||
const { admin, user: userRole } =
|
||||
await this.roles.ensureSystemRoles(manager);
|
||||
let user = await manager.getRepository(UserEntity).findOne({
|
||||
where: { issuer, subject: profile.sub },
|
||||
relations: { roles: true, settings: true },
|
||||
});
|
||||
if (!user) {
|
||||
user = new UserEntity();
|
||||
user.issuer = issuer;
|
||||
user.subject = profile.sub;
|
||||
user.active = true;
|
||||
user.roles = [userRole];
|
||||
const userCount = await manager.getRepository(UserEntity).count();
|
||||
if (userCount === 0) {
|
||||
user.roles = [userRole, admin];
|
||||
}
|
||||
}
|
||||
user.name = profile.name ?? profile.email ?? profile.sub;
|
||||
user.email = profile.email ?? null;
|
||||
user.lastLoginAt = new Date();
|
||||
const savedUser = await manager.getRepository(UserEntity).save(user);
|
||||
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||
return savedUser;
|
||||
} finally {
|
||||
await manager.query("SELECT RELEASE_LOCK('business_app_first_admin')");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureUserSettings(
|
||||
manager: EntityManager,
|
||||
user: UserEntity,
|
||||
): Promise<UserSettingsEntity> {
|
||||
const settingsRepository = manager.getRepository(UserSettingsEntity);
|
||||
const existingSettings = await settingsRepository.findOne({
|
||||
where: { user: { id: user.id } },
|
||||
});
|
||||
if (existingSettings) {
|
||||
return existingSettings;
|
||||
}
|
||||
|
||||
const settings = new UserSettingsEntity();
|
||||
settings.user = user;
|
||||
return settingsRepository.save(settings);
|
||||
}
|
||||
|
||||
private async discovery(): Promise<OidcDiscovery> {
|
||||
const discoveryUrl = new URL(
|
||||
'/.well-known/openid-configuration',
|
||||
this.config.oidc.issuer,
|
||||
);
|
||||
const discovery = await this.http.requestJson<OidcDiscovery>(
|
||||
discoveryUrl.toString(),
|
||||
);
|
||||
if (discovery.issuer !== this.config.oidc.issuer) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'OIDC-Issuer ist ungueltig.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
return discovery;
|
||||
}
|
||||
|
||||
private async readLogoutIdToken(
|
||||
sessionId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await this.sessions.getIdTokenForLogout(sessionId);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async createLogoutUrl(idToken: string | undefined): Promise<string> {
|
||||
const endpoint = await this.resolveLogoutEndpoint();
|
||||
if (!endpoint) {
|
||||
return this.config.frontendBaseUrl;
|
||||
}
|
||||
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set('client_id', this.config.oidc.clientId);
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
this.config.frontendBaseUrl,
|
||||
);
|
||||
if (idToken) {
|
||||
url.searchParams.set('id_token_hint', idToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async resolveLogoutEndpoint(): Promise<string | undefined> {
|
||||
if (this.config.oidc.logoutUrl) {
|
||||
return this.config.oidc.logoutUrl;
|
||||
}
|
||||
try {
|
||||
return (await this.discovery()).end_session_endpoint;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async exchangeCode(
|
||||
discovery: OidcDiscovery,
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
): Promise<OidcTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.callbackUrl,
|
||||
client_id: this.config.oidc.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
const basic = Buffer.from(
|
||||
`${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`,
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
return this.http.requestJson<OidcTokenResponse>(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyAndLoadProfile(
|
||||
discovery: OidcDiscovery,
|
||||
tokens: OidcTokenResponse,
|
||||
nonce: string,
|
||||
): Promise<OidcUserInfo> {
|
||||
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
|
||||
await import('jose');
|
||||
const protectedHeader = decodeProtectedHeader(tokens.id_token);
|
||||
if (!this.isAllowedOidcAlgorithm(protectedHeader.alg)) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'OIDC-Signaturalgorithmus ist ungueltig.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(
|
||||
tokens.id_token,
|
||||
createRemoteJWKSet(new URL(discovery.jwks_uri)),
|
||||
{
|
||||
issuer: discovery.issuer,
|
||||
audience: this.config.oidc.clientId,
|
||||
algorithms: this.config.oidc.allowedAlgorithms,
|
||||
},
|
||||
);
|
||||
if (payload['nonce'] !== nonce || typeof payload.sub !== 'string') {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'OIDC-Token ist ungueltig.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (!discovery.userinfo_endpoint) {
|
||||
const fallback: OidcUserInfo = { sub: payload.sub };
|
||||
if (typeof payload['name'] === 'string') {
|
||||
fallback.name = payload['name'];
|
||||
}
|
||||
if (typeof payload['email'] === 'string') {
|
||||
fallback.email = payload['email'];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||
discovery.userinfo_endpoint,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
},
|
||||
);
|
||||
if (userInfo.sub !== payload.sub) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'OIDC-UserInfo ist ungueltig.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
|
||||
return (
|
||||
typeof algorithm === 'string' &&
|
||||
algorithm.toLowerCase() !== 'none' &&
|
||||
this.config.oidc.allowedAlgorithms.includes(algorithm)
|
||||
);
|
||||
}
|
||||
|
||||
private get callbackUrl(): string {
|
||||
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
|
||||
}
|
||||
}
|
||||
12
apps/backend/src/auth/authenticated-request.ts
Normal file
12
apps/backend/src/auth/authenticated-request.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Request } from 'express';
|
||||
import type { Permission } from '../roles/permissions';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
19
apps/backend/src/auth/entities/oidc-login-state.entity.ts
Normal file
19
apps/backend/src/auth/entities/oidc-login-state.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('oidc_login_states')
|
||||
export class OidcLoginStateEntity {
|
||||
@PrimaryColumn({ type: 'char', length: 64 })
|
||||
state!: string;
|
||||
|
||||
@Column({ name: 'code_verifier', type: 'varchar', length: 160 })
|
||||
codeVerifier!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
nonce!: string;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
|
||||
expiresAt!: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||
createdAt!: Date;
|
||||
}
|
||||
49
apps/backend/src/auth/guards/csrf.guard.ts
Normal file
49
apps/backend/src/auth/guards/csrf.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ErrorCode } from '../../common/errors/error-codes';
|
||||
import { AppConfigService } from '../../config/config.service';
|
||||
import { SessionsService } from '../../sessions/sessions.service';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import type { AuthenticatedRequest } from '../authenticated-request';
|
||||
|
||||
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
@Injectable()
|
||||
export class CsrfGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly config: AppConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
if (!unsafeMethods.has(request.method)) {
|
||||
return true;
|
||||
}
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
const sessionId = request.signedCookies?.[
|
||||
this.config.session.cookieName
|
||||
] as string | undefined;
|
||||
const csrfToken = request.header(this.config.csrfHeaderName);
|
||||
if (
|
||||
!sessionId ||
|
||||
!csrfToken ||
|
||||
!(await this.sessions.verifyCsrfToken(sessionId, csrfToken))
|
||||
) {
|
||||
throw new ApiError(
|
||||
ErrorCode.CsrfInvalid,
|
||||
'Das Sicherheits-Token ist ungueltig.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
68
apps/backend/src/auth/guards/permissions.guard.ts
Normal file
68
apps/backend/src/auth/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ErrorCode } from '../../common/errors/error-codes';
|
||||
import { AppConfigService } from '../../config/config.service';
|
||||
import { SessionsService } from '../../sessions/sessions.service';
|
||||
import type { Permission } from '../../roles/permissions';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from './require-permissions.decorator';
|
||||
import type { AuthenticatedRequest } from '../authenticated-request';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly config: AppConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const required = this.reflector.getAllAndOverride<Permission[]>(
|
||||
REQUIRED_PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (isPublic || !required?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const sessionId = request.signedCookies?.[
|
||||
this.config.session.cookieName
|
||||
] as string | undefined;
|
||||
if (!sessionId) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'Bitte melden Sie sich an.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await this.sessions.resolveSession(
|
||||
sessionId,
|
||||
request.ip,
|
||||
request.get('user-agent'),
|
||||
);
|
||||
request.user = {
|
||||
id: session.user.id,
|
||||
sessionId,
|
||||
permissions: session.permissions,
|
||||
};
|
||||
|
||||
const allowed = required.every((permission) =>
|
||||
session.permissions.includes(permission),
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new ApiError(
|
||||
ErrorCode.PermissionDenied,
|
||||
'Keine Berechtigung fuer diese Aktion.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
4
apps/backend/src/auth/guards/public.decorator.ts
Normal file
4
apps/backend/src/auth/guards/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { Permission } from '../../roles/permissions';
|
||||
|
||||
export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
|
||||
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||
23
apps/backend/src/auth/oidc.types.ts
Normal file
23
apps/backend/src/auth/oidc.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface OidcDiscovery {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
revocation_endpoint?: string;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OidcTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
id_token: string;
|
||||
expires_in?: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface OidcUserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user