Files
ldap-identidy/apps/api/src/password/password.service.ts
Bastian Wagner 201c4e03f8 logging
2026-07-17 10:50:10 +02:00

157 lines
5.6 KiB
TypeScript

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<PasswordResetToken>,
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<string>('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<string>('TOKEN_SECRET');
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error';
}
}