import { Injectable, UnauthorizedException } from '@nestjs/common'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { Inject } from '@nestjs/common'; import { AuditService } from '../audit/audit.service'; import { PasswordHasher } from '../users/password-hasher'; import { UserRepository } from '../users/user.repository'; import type { AuthUser } from '../users/user.types'; import { RateLimiterService } from './rate-limiter.service'; import { SessionService, type SessionData } from './session.service'; /** Ergebnis eines erfolgreichen Logins. */ export interface LoginResult { readonly user: AuthUser; readonly sessionToken: string; readonly session: SessionData; } /** Generische Meldung – verhindert User-Enumeration. */ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch'; /** * Authentifizierungs-Logik (Domain/Application): * Login mit Rate Limiting, Account Lockout, Argon2id-Verifikation, * Session-Erstellung und Audit-Logging. */ @Injectable() export class AuthService { constructor( private readonly userRepository: UserRepository, private readonly passwordHasher: PasswordHasher, private readonly sessionService: SessionService, private readonly rateLimiter: RateLimiterService, private readonly auditService: AuditService, @Inject(APP_CONFIG) private readonly config: AppConfig, ) {} async login(input: { username: string; password: string; ipAddress: string | null; }): Promise { const { security } = this.config; const rateLimitKey = `login:${input.ipAddress ?? 'unknown'}`; if (!this.rateLimiter.isAllowed( rateLimitKey, security.loginRateLimitAttempts, security.loginRateLimitWindowMinutes, )) { await this.auditService.record({ userId: null, username: input.username, action: 'LOGIN_FAILED', details: { reason: 'RATE_LIMITED' }, ipAddress: input.ipAddress, }); throw new UnauthorizedException('Zu viele Anmeldeversuche. Bitte später erneut versuchen.'); } const user = await this.userRepository.findByUsername(input.username); // Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort" // (keine User-Enumeration). if (!user) { await this.auditService.record({ userId: null, username: input.username, action: 'LOGIN_FAILED', details: { reason: 'UNKNOWN_USER' }, ipAddress: input.ipAddress, }); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); } if (user.lockedUntil && user.lockedUntil > new Date()) { await this.auditService.record({ userId: user.id, username: user.username, action: 'LOGIN_FAILED', details: { reason: 'ACCOUNT_LOCKED' }, ipAddress: input.ipAddress, }); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); } const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password); if (!passwordValid) { const attempts = user.failedLoginAttempts + 1; const shouldLock = attempts >= security.loginMaxAttempts; await this.userRepository.updateLoginFailure( user.id, attempts, shouldLock, security.loginLockoutMinutes, ); await this.auditService.record({ userId: user.id, username: user.username, action: shouldLock ? 'LOGIN_FAILED_LOCKED' : 'LOGIN_FAILED', details: { reason: 'INVALID_PASSWORD', attempts }, ipAddress: input.ipAddress, }); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); } if (!user.isActive) { await this.auditService.record({ userId: user.id, username: user.username, action: 'LOGIN_FAILED', details: { reason: 'ACCOUNT_INACTIVE' }, ipAddress: input.ipAddress, }); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); } await this.userRepository.updateLoginSuccess(user.id); this.rateLimiter.reset(rateLimitKey); const { token, data } = await this.sessionService.create( user.id, security.sessionTtlMinutes, ); await this.auditService.record({ userId: user.id, username: user.username, action: 'LOGIN_SUCCESS', ipAddress: input.ipAddress, }); return { user: { id: user.id, username: user.username, email: user.email, displayName: user.displayName, role: user.role, }, sessionToken: token, session: data, }; } async logout(sessionId: string, user: AuthUser, ipAddress: string | null): Promise { await this.sessionService.delete(sessionId); await this.auditService.record({ userId: user.id, username: user.username, action: 'LOGOUT', ipAddress, }); } }