feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)

This commit is contained in:
MPM Dev
2026-10-06 14:22:11 +02:00
commit a7e1c421f2
85 changed files with 17701 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
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,
});
}
}