This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { BadRequestException, Injectable, 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 { assertPasswordPolicy } from '../common/password-policy';
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 { 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,
) {}
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();
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),
}),
);
await this.mail.sendPasswordResetMail(user.email, token);
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 ungueltig 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');
}
}