154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
} |