Files
mpm/apps/platform-backend/src/auth/auth.service.ts

154 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<LoginResult> {
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<void> {
await this.sessionService.delete(sessionId);
await this.auditService.record({
userId: user.id,
username: user.username,
action: 'LOGOUT',
ipAddress,
});
}
}