Files
listify/listify-api/src/auth/auth.service.ts
Bastian Wagner e1cc78ca27 collab
2026-06-10 15:44:18 +02:00

499 lines
14 KiB
TypeScript

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<UserEntity>,
@InjectRepository(RefreshTokenEntity)
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
@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<AuthTokenResponse> {
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<AuthTokenResponse> {
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<JwtTokenPayload> {
try {
const payload = this.jwtService.verify<JwtTokenPayload>(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<string> {
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<PublicUser> {
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<PublicUserSearchResult[]> {
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<PublicUser> {
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<AuthTokens> {
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<JwtTokenPayload>(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,
};
}
}