import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Repository } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import { assertPasswordPolicy } from '../common/password-policy'; import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util'; import { LldapService } from '../lldap/lldap.service'; import { PortalMailService } from '../mail/portal-mail.service'; import { RegisterDto } from './dto/register.dto'; import { EmailToken } from './email-token.entity'; import { RegistrationRequest } from './registration-request.entity'; @Injectable() export class RegistrationService { constructor( @InjectRepository(RegistrationRequest) private readonly registrations: Repository, @InjectRepository(EmailToken) private readonly emailTokens: Repository, private readonly config: ConfigService, private readonly lldap: LldapService, private readonly mail: PortalMailService, private readonly audit: AuditService, ) {} async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) { assertPasswordPolicy(dto.password); const email = dto.email.toLowerCase(); const existingLdapUser = await this.lldap.findUserByUsername(email).catch(() => null); if (existingLdapUser) { throw new ConflictException('Diese E-Mail-Adresse ist bereits registriert.'); } const pending = await this.registrations.findOne({ where: { username: email, status: 'pending_email' }, }); if (pending) { throw new ConflictException('Fuer diese E-Mail-Adresse existiert bereits eine offene Registrierung.'); } const registration = await this.registrations.save( this.registrations.create({ username: email, email, displayName: dto.displayName, encryptedPassword: encryptSecret(dto.password, this.tokenSecret), status: 'pending_email', expiresAt: new Date(Date.now() + 24 * 60 * 60_000), }), ); const token = randomToken(); await this.emailTokens.save( this.emailTokens.create({ registrationId: registration.id, tokenHash: hashToken(token, this.tokenSecret), expiresAt: new Date(Date.now() + 24 * 60 * 60_000), }), ); await this.mail.sendVerificationMail(registration.email, token); await this.audit.record({ type: 'registration.started', username: registration.username, ipAddress, userAgent, }); return { message: 'Bitte pruefe dein E-Mail-Postfach, um die Registrierung abzuschliessen.' }; } async verify(token: string, ipAddress?: string, userAgent?: string) { const tokenHash = hashToken(token, this.tokenSecret); const tokenRecord = await this.emailTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } }); if (!tokenRecord || tokenRecord.expiresAt.getTime() < Date.now()) { throw new BadRequestException('Der Bestaetigungslink ist ungültig oder abgelaufen.'); } const registration = await this.registrations.findOneByOrFail({ id: tokenRecord.registrationId }); if (registration.status !== 'pending_email' || registration.expiresAt.getTime() < Date.now()) { throw new BadRequestException('Diese Registrierung ist nicht mehr gueltig.'); } tokenRecord.consumedAt = new Date(); registration.status = 'pending_approval'; registration.verifiedAt = new Date(); await this.emailTokens.save(tokenRecord); await this.registrations.save(registration); await this.audit.record({ type: 'registration.email_verified', username: registration.username, ipAddress, userAgent, }); return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' }; } async list() { return this.registrations.find({ where: { status: In(['pending_email', 'pending_approval']) }, order: { createdAt: 'DESC' }, }); } async approve(id: string, reviewer: string, ipAddress?: string, userAgent?: string) { const registration = await this.registrations.findOneByOrFail({ id }); if (registration.status !== 'pending_approval') { throw new BadRequestException('Diese Registrierung wartet nicht auf Freigabe.'); } await this.lldap.createUser({ username: registration.username, email: registration.email, displayName: registration.displayName, password: decryptSecret(registration.encryptedPassword, this.tokenSecret), }); registration.status = 'approved'; registration.reviewedAt = new Date(); registration.reviewedBy = reviewer; await this.registrations.save(registration); await this.audit.record({ type: 'registration.approved', username: registration.username, ipAddress, userAgent, metadata: { reviewer }, }); return registration; } async reject(id: string, reviewer: string, reason?: string, ipAddress?: string, userAgent?: string) { const registration = await this.registrations.findOneByOrFail({ id }); if (registration.status !== 'pending_approval') { throw new BadRequestException('Diese Registrierung wartet nicht auf Freigabe.'); } registration.status = 'rejected'; registration.reviewedAt = new Date(); registration.reviewedBy = reviewer; registration.rejectionReason = reason; await this.registrations.save(registration); await this.audit.record({ type: 'registration.rejected', username: registration.username, ipAddress, userAgent, metadata: { reviewer, reason }, }); return registration; } private get tokenSecret(): string { return this.config.getOrThrow('TOKEN_SECRET'); } }