import { BadRequestException, Injectable, ServiceUnavailableException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; import { markErrorAsLogged } from '../application-error-log/logged-error-marker'; import { assertPasswordPolicy } from '../common/password-policy'; import { RequestContextService } from '../common/request-context.service'; import { hashToken, randomToken } from '../common/token.util'; import { LdapAuthService } from '../lldap/ldap-auth.service'; import { LldapService } from '../lldap/lldap.service'; import { PortalMailService } from '../mail/portal-mail.service'; import { maskEmail } from '../application-error-log/application-error-sanitizer'; import { PasswordResetToken } from './password-reset-token.entity'; @Injectable() export class PasswordService { constructor( @InjectRepository(PasswordResetToken) private readonly resetTokens: Repository, private readonly config: ConfigService, private readonly ldapAuth: LdapAuthService, private readonly lldap: LldapService, private readonly mail: PortalMailService, private readonly audit: AuditService, private readonly applicationErrorLogger: ApplicationErrorLoggerService, private readonly requestContext: RequestContextService, ) {} async changePassword( username: string, currentPassword: string, newPassword: string, ipAddress?: string, userAgent?: string, ) { assertPasswordPolicy(newPassword); const valid = await this.ldapAuth.verifyPassword(username, currentPassword); if (!valid) { await this.audit.record({ type: 'password.change_failed', username, ipAddress, userAgent }); throw new UnauthorizedException('Das aktuelle Passwort ist nicht korrekt.'); } await this.lldap.setPassword(username, newPassword); await this.audit.record({ type: 'password.changed', username, ipAddress, userAgent }); return { message: 'Das Passwort wurde geaendert.' }; } async requestReset(email: string, ipAddress?: string, userAgent?: string) { const normalizedEmail = email.toLowerCase(); const neutral = { message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link versendet.', }; const user = await this.lldap.findUserByEmail(normalizedEmail).catch(() => null); if (!user?.email) { await this.audit.record({ type: 'password.reset_requested_unknown', ipAddress, userAgent, metadata: { email: normalizedEmail }, }); return neutral; } const token = randomToken(); const resetToken = await this.resetTokens.save( this.resetTokens.create({ username: user.id, email: user.email, tokenHash: hashToken(token, this.tokenSecret), expiresAt: new Date(Date.now() + 60 * 60_000), }), ); try { await this.mail.sendPasswordResetMail(user.email, token); } catch (error) { await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined); const currentRequestContext = this.requestContext.get(); await this.applicationErrorLogger.log({ error, category: ApplicationErrorCategory.EMAIL, code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED, module: 'PasswordModule', service: PasswordService.name, operation: 'sendPasswordResetEmail', requestContext: { ...currentRequestContext, userId: user.id, }, context: { maskedRecipient: maskEmail(user.email), mailProvider: this.config.get('SMTP_HOST') ?? 'smtp', }, handled: true, }); await this.audit .record({ type: 'password.reset_mail_failed', username: user.id, ipAddress, userAgent, metadata: { error: this.errorMessage(error) }, }) .catch(() => undefined); const exception = new ServiceUnavailableException( 'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.', ); markErrorAsLogged(exception); throw exception; } await this.audit.record({ type: 'password.reset_requested', username: user.id, ipAddress, userAgent, }); return neutral; } async confirmReset(token: string, newPassword: string, ipAddress?: string, userAgent?: string) { assertPasswordPolicy(newPassword); const tokenHash = hashToken(token, this.tokenSecret); const record = await this.resetTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } }); if (!record || record.expiresAt.getTime() < Date.now()) { throw new BadRequestException('Der Reset-Link ist ungültig oder abgelaufen.'); } await this.lldap.setPassword(record.username, newPassword); record.consumedAt = new Date(); await this.resetTokens.save(record); await this.audit.record({ type: 'password.reset_completed', username: record.username, ipAddress, userAgent, }); return { message: 'Das Passwort wurde zurueckgesetzt.' }; } private get tokenSecret(): string { return this.config.getOrThrow('TOKEN_SECRET'); } private errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'unknown error'; } }