157 lines
5.6 KiB
TypeScript
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';
|
|
}
|
|
}
|