161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
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<RegistrationRequest>,
|
|
@InjectRepository(EmailToken)
|
|
private readonly emailTokens: Repository<EmailToken>,
|
|
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<string>('TOKEN_SECRET');
|
|
}
|
|
}
|