import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as crypto from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from './permissions/permission-resolver.service'; import { MfaService } from './mfa/mfa.service'; import { AccountGuardService } from './mfa/account-guard.service'; import { OneTimeTokenService } from './one-time-token.service'; import { parseDurationToMs } from './util/duration'; import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims'; interface AuthTokens { accessToken: string; refreshToken: string; } export type LoginResult = | ({ mfaRequired: false } & AuthTokens) | { mfaRequired: true; mfaToken: string }; export interface CurrentUserProfile { id: string; email: string; name: string; roleKey: string; roleName: string; permissions: string[]; mfaEnabled: boolean; hasAvatar: boolean; } const REFRESH_TOKEN_BYTES = 40; // Hard cap from the moment of login, however often the session is refreshed. const DEFAULT_SESSION_MAX_AGE = '7d'; // Two tabs refreshing with the same token at the same moment is normal; // only a reuse after this window is treated as theft. const ROTATION_GRACE_MS = 10_000; // Same answer for unknown email, wrong password, deactivated or locked // account - nothing in the response tells which one it was. const INVALID_CREDENTIALS = 'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.'; // Hash of a random value nobody knows, at the same cost as real password // hashes; compared against when there is no real hash, so every login // costs the same time. const DUMMY_PASSWORD_HASH = bcrypt.hashSync( crypto.randomBytes(32).toString('hex'), 12, ); @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private readonly usersService: UsersService, private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, private readonly mfaService: MfaService, private readonly accountGuard: AccountGuardService, private readonly oneTimeTokens: OneTimeTokenService, ) {} async getCurrentUserProfile(userId: string): Promise { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, include: { role: { select: { key: true, name: true } } }, }); const permissions = await this.permissionResolver.getPermissions(userId); return { id: user.id, email: user.email, name: user.name, roleKey: user.role.key, roleName: user.role.name, permissions: Array.from(permissions), mfaEnabled: user.mfaEnabled, hasAvatar: user.avatarPath !== null, }; } private hashRefreshToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } private signAccessToken(user: { id: string; email: string }): string { const expiresInSeconds = Math.floor( parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000, ); return this.jwtService.sign( { sub: user.id, email: user.email }, jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds), ); } private async issueRefreshToken( userId: string, sessionStartedAt: Date = new Date(), ): Promise { const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex'); const slidingExpiry = Date.now() + parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'); const absoluteExpiry = sessionStartedAt.getTime() + parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE); await this.prisma.refreshToken.create({ data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)), sessionStartedAt, }, }); return token; } async login(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); // bcrypt runs on every path - unknown, deactivated, locked or real - so // the response time does not reveal which accounts exist. const passwordMatches = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user) { this.logger.warn('Login failed: unknown email'); throw new UnauthorizedException(INVALID_CREDENTIALS); } if (!user.isActive || this.accountGuard.isLocked(user)) { this.logger.warn(`Login refused: user ${user.id} inactive or locked`); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN_FAILED', userId: user.id, }); throw new UnauthorizedException(INVALID_CREDENTIALS); } if (!passwordMatches) { this.logger.warn(`Login failed: wrong password for user ${user.id}`); await this.accountGuard.recordFailure(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN_FAILED', userId: user.id, }); throw new UnauthorizedException(INVALID_CREDENTIALS); } if (user.mfaEnabled) { return { mfaRequired: true, mfaToken: this.mfaService.signChallengeToken(user.id), }; } const authenticatedUser = { id: user.id, email: user.email, }; const accessToken = this.signAccessToken(authenticatedUser); const refreshToken = await this.issueRefreshToken(user.id); await this.accountGuard.recordSuccess(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN', userId: user.id, }); return { mfaRequired: false, accessToken, refreshToken }; } /** * Issues our own access/refresh token pair for a user already * authenticated via SSO. Local MFA is still enforced here, same as * password login: SSO establishes identity, not the second factor, so a * user who has explicitly enabled TOTP must complete it here too rather * than getting it silently skipped by signing in via Microsoft instead. */ async issueTokensForSsoUser(userId: string): Promise { const user = await this.usersService.findById(userId); if (!user.isActive) { throw new UnauthorizedException('Invalid credentials'); } if (user.mfaEnabled) { return { mfaRequired: true, mfaToken: this.mfaService.signChallengeToken(user.id), }; } const accessToken = this.signAccessToken({ id: user.id, email: user.email, }); const refreshToken = await this.issueRefreshToken(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN', userId: user.id, }); return { mfaRequired: false, accessToken, refreshToken }; } async verifyMfaAndLogin(mfaToken: string, code: string): Promise { const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken); const user = await this.usersService.findById(userId); if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) { throw new UnauthorizedException('MFA nicht verfügbar'); } if (this.accountGuard.isLocked(user)) { throw new UnauthorizedException(INVALID_CREDENTIALS); } const isValid = await this.accountGuard.consumeTotp(user, code); if (!isValid) { this.logger.warn( `MFA verification failed: wrong or reused code for user ${user.id}`, ); await this.accountGuard.recordFailure(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN_FAILED', userId: user.id, }); throw new UnauthorizedException('Code ungültig'); } // A challenge that already opened a session cannot open another one. await this.oneTimeTokens.consume(claims); await this.accountGuard.recordSuccess(user.id); const authenticatedUser = { id: user.id, email: user.email, }; const accessToken = this.signAccessToken(authenticatedUser); const refreshToken = await this.issueRefreshToken(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN', userId: user.id, }); return { accessToken, refreshToken }; } async refresh(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, }); if (!stored) { throw new UnauthorizedException('Invalid refresh token'); } if ( stored.revokedAt && Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS ) { // Lost a race with a parallel refresh (second tab): no new session, // but no alarm either - that tab simply uses the other's cookie. throw new UnauthorizedException('Invalid refresh token'); } if (stored.revokedAt) { // Reuse of an already-rotated-out refresh token is a strong signal // it was stolen (a legitimate client only ever presents the newest // token) - revoke every other active session for this user rather // than trusting rotation alone to have contained the leak. await this.prisma.refreshToken.updateMany({ where: { userId: stored.userId, revokedAt: null }, data: { revokedAt: new Date() }, }); this.permissionResolver.invalidateUser(stored.userId); this.logger.warn( `Refresh token reuse detected for user ${stored.userId}; all sessions revoked`, ); throw new UnauthorizedException('Invalid refresh token'); } if (stored.expiresAt < new Date()) { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.usersService.findById(stored.userId); if (!user.isActive) { throw new UnauthorizedException('Invalid refresh token'); } // Atomic: of two requests presenting the same token, exactly one wins // and gets a successor - one stolen token can never fork into two live // sessions. const claimed = await this.prisma.refreshToken.updateMany({ where: { id: stored.id, revokedAt: null }, data: { revokedAt: new Date() }, }); if (claimed.count !== 1) { throw new UnauthorizedException('Invalid refresh token'); } const authenticatedUser = { id: user.id, email: user.email, }; const accessToken = this.signAccessToken(authenticatedUser); const newRefreshToken = await this.issueRefreshToken( user.id, stored.sessionStartedAt, ); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'TOKEN_REFRESHED', userId: user.id, }); return { accessToken, refreshToken: newRefreshToken }; } async logout(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, }); if (!stored || stored.revokedAt) { return; } await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, }); await this.auditLog.record({ entityType: 'AuthSession', entityId: stored.userId, action: 'LOGOUT', userId: stored.userId, }); } }