import { BadRequestException, ConflictException, Injectable, Optional, UnauthorizedException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { AuthTokenResponse, AuthTokens, JwtTokenPayload, PublicUser, PublicUserSearchResult, } from './auth.types'; import { AppEvents } from '../events/app-events'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserEntity } from './user.entity'; @Injectable() export class AuthService { private readonly accessTokenExpiresIn = '15m'; private readonly refreshTokenExpiresIn = '7d'; private readonly accessTokenSecret = process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret'; private readonly refreshTokenSecret = process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret'; constructor( private readonly eventEmitter: EventEmitter2, private readonly jwtService: JwtService, @InjectRepository(UserEntity) private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) private readonly refreshTokensRepository: Repository, @Optional() private readonly auditLogService?: AuditLogService, ) {} async register( registerDto: RegisterDto, ): Promise<{ message: string; user: PublicUser }> { const email = this.normalizeEmail(registerDto.email); const password = this.requirePassword(registerDto.password); const name = this.normalizeName(registerDto.name); const existingUser = await this.usersRepository.findOne({ where: { email }, }); if (existingUser) { throw new ConflictException('Email is already registered.'); } const verificationToken = this.createToken(); const user = this.usersRepository.create({ id: randomUUID(), email, name, passwordHash: this.hashPassword(password), verificationToken, verified: false, }); const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.registered', entityType: 'user', entityId: savedUser.id, metadata: { verified: savedUser.verified }, }); this.eventEmitter.emit(AppEvents.UserRegistered, { email, verificationUrl: this.createVerificationUrl(verificationToken), }); return { message: 'Registration successful. Please verify your email address.', user: this.toPublicUser(savedUser), }; } async verifyEmail( token?: string, ): Promise<{ message: string; user: PublicUser }> { if (!token) { throw new BadRequestException('Verification token is required.'); } const user = await this.usersRepository.findOne({ where: { verificationToken: token }, }); if (!user) { throw new BadRequestException('Verification token is invalid.'); } user.verified = true; user.verificationToken = null; try { const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.email_verified', entityType: 'user', entityId: savedUser.id, }); return { message: 'Email verified successfully.', user: this.toPublicUser(savedUser), }; } catch { throw new BadRequestException('user not saved.') } } async resendVerificationEmail( resendVerificationDto: ResendVerificationDto, ): Promise<{ message: string }> { const email = this.normalizeEmail(resendVerificationDto.email); const message = 'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.'; const user = await this.usersRepository.findOne({ where: { email } }); if (!user || user.verified) { return { message }; } user.verificationToken = this.createToken(); const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.verification_resent', entityType: 'user', entityId: savedUser.id, }); this.eventEmitter.emit(AppEvents.UserRegistered, { email: savedUser.email, verificationUrl: this.createVerificationUrl(savedUser.verificationToken!), }); return { message }; } async login(loginDto: LoginDto): Promise { const email = this.normalizeEmail(loginDto.email); const password = this.requirePassword(loginDto.password); const user = await this.usersRepository.findOne({ where: { email } }); if (!user || !this.passwordMatches(password, user.passwordHash)) { await this.auditLogService?.record({ actorEmail: email, action: 'user.login_failed', entityType: 'user', entityId: user?.id, metadata: { reason: 'invalid_credentials' }, }); throw new UnauthorizedException('Invalid email or password.'); } if (!user.verified) { await this.auditLogService?.record({ actorUserId: user.id, actorEmail: user.email, action: 'user.login_failed', entityType: 'user', entityId: user.id, metadata: { reason: 'email_not_verified' }, }); throw new UnauthorizedException('Please verify your email before login.'); } const response = { ...(await this.createAuthTokens(user)), user: this.toPublicUser(user), }; await this.auditLogService?.record({ actorUserId: user.id, actorEmail: user.email, action: 'user.login_succeeded', entityType: 'user', entityId: user.id, }); return response; } async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise { const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken); const payload = this.verifyRefreshToken(refreshToken); const tokenRecord = await this.refreshTokensRepository.findOne({ where: { jti: payload.jti }, }); if ( !tokenRecord || tokenRecord.userId !== payload.sub || tokenRecord.expiresAt.getTime() <= Date.now() || !this.tokenMatches(refreshToken, tokenRecord.tokenHash) ) { throw new UnauthorizedException('Refresh token is invalid.'); } const user = await this.usersRepository.findOne({ where: { id: payload.sub }, }); if (!user || !user.verified) { throw new UnauthorizedException('Refresh token is invalid.'); } await this.refreshTokensRepository.delete({ jti: payload.jti }); const response = { ...(await this.createAuthTokens(user)), user: this.toPublicUser(user), }; await this.auditLogService?.record({ actorUserId: user.id, actorEmail: user.email, action: 'user.token_refreshed', entityType: 'user', entityId: user.id, }); return response; } async verifyAccessToken(accessToken: string): Promise { try { const payload = this.jwtService.verify(accessToken, { secret: this.accessTokenSecret, }); if (payload.type !== 'access') { throw new UnauthorizedException('Access token is invalid.'); } const user = await this.usersRepository.findOne({ where: { id: payload.sub }, }); if (!user || !user.verified) { throw new UnauthorizedException('Access token is invalid.'); } return payload; } catch { throw new UnauthorizedException('Access token is invalid.'); } } async getUserDisplayName(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } return user.name || user.email; } async getPublicUser(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } return this.toPublicUser(user); } async searchUsers( actorUserId: string, query?: string, ): Promise { const normalizedQuery = query?.trim(); if (!normalizedQuery || normalizedQuery.length < 2) { return []; } const pattern = `%${normalizedQuery}%`; const users = await this.usersRepository.find({ where: [ { verified: true, email: Like(pattern) }, { verified: true, name: Like(pattern) }, ], order: { email: 'ASC' }, take: 10, }); return users .filter((user) => user.id !== actorUserId) .filter( (user, index, allUsers) => allUsers.findIndex((existingUser) => existingUser.id === user.id) === index, ) .slice(0, 10) .map((user) => ({ id: user.id, email: user.email, name: user.name ?? undefined, })); } async updateOnboardingCompleted( userId: string, completed: boolean, ): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, }); if (!user) { throw new UnauthorizedException('Authenticated user is required.'); } user.onboardingCompleted = completed; const savedUser = await this.usersRepository.save(user); await this.auditLogService?.record({ actorUserId: savedUser.id, actorEmail: savedUser.email, action: 'user.onboarding_updated', entityType: 'user', entityId: savedUser.id, metadata: { completed }, }); return this.toPublicUser(savedUser); } private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); if ( !normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail) ) { throw new BadRequestException('A valid email is required.'); } return normalizedEmail; } private normalizeName(name?: string): string | undefined { const normalizedName = name?.trim(); return normalizedName || undefined; } private requirePassword(password?: string): string { if (!password || password.length < 8) { throw new BadRequestException( 'Password must contain at least 8 characters.', ); } return password; } private hashPassword(password: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 64).toString('hex'); return `${salt}:${hash}`; } private passwordMatches(password: string, passwordHash: string): boolean { const [salt, storedHash] = passwordHash.split(':'); if (!salt || !storedHash) { return false; } const attemptedHash = scryptSync(password, salt, 64); const storedHashBuffer = Buffer.from(storedHash, 'hex'); return ( storedHashBuffer.length === attemptedHash.length && timingSafeEqual(storedHashBuffer, attemptedHash) ); } private async createAuthTokens(user: UserEntity): Promise { const refreshTokenJti = randomUUID(); const accessTokenPayload: JwtTokenPayload = { sub: user.id, email: user.email, type: 'access', }; const refreshTokenPayload: JwtTokenPayload = { sub: user.id, email: user.email, type: 'refresh', jti: refreshTokenJti, }; const accessToken = this.jwtService.sign(accessTokenPayload, { expiresIn: this.accessTokenExpiresIn, secret: this.accessTokenSecret, }); const refreshToken = this.jwtService.sign(refreshTokenPayload, { expiresIn: this.refreshTokenExpiresIn, secret: this.refreshTokenSecret, }); await this.refreshTokensRepository.save( this.refreshTokensRepository.create({ jti: refreshTokenJti, userId: user.id, tokenHash: this.hashToken(refreshToken), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }), ); return { accessToken, refreshToken }; } private verifyRefreshToken(refreshToken: string): JwtTokenPayload & { jti: string; } { try { const payload = this.jwtService.verify(refreshToken, { secret: this.refreshTokenSecret, }); if (payload.type !== 'refresh' || !payload.jti) { throw new UnauthorizedException('Refresh token is invalid.'); } return { ...payload, jti: payload.jti }; } catch { throw new UnauthorizedException('Refresh token is invalid.'); } } private requireRefreshToken(refreshToken?: string): string { if (!refreshToken) { throw new BadRequestException('Refresh token is required.'); } return refreshToken; } private hashToken(token: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(token, salt, 64).toString('hex'); return `${salt}:${hash}`; } private tokenMatches(token: string, tokenHash: string): boolean { return this.passwordMatches(token, tokenHash); } private createToken(): string { return randomBytes(32).toString('hex'); } private createVerificationUrl(token: string): string { const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200'; return `${clientUrl}/verify-email?token=${token}`; } private toPublicUser(user: UserEntity): PublicUser { return { id: user.id, email: user.email, name: user.name ?? undefined, verified: user.verified, onboardingCompleted: user.onboardingCompleted === true, }; } }