import { Injectable, UnauthorizedException, ConflictException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { PrismaService } from '../../database/prisma.service'; import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto'; export interface JwtPayload { sub: string; email: string; roles: string[]; permissions: string[]; tenantId?: string; } interface UserWithRoles { id: string; email: string; password: string; firstName: string | null; lastName: string | null; isActive: boolean; tenantId: string | null; roles: Array<{ role: { name: string; permissions: Array<{ permission: { name: string; }; }>; }; }>; } @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) {} /** * Register a new user */ async register(dto: RegisterDto): Promise { // Check if email already exists const existingUser = await this.prisma.user.findUnique({ where: { email: dto.email }, }); if (existingUser) { throw new ConflictException('EMAIL_ALREADY_EXISTS'); } // Hash password const hashedPassword = await this.hashPassword(dto.password); // Create user with default role const user = await this.prisma.user.create({ data: { email: dto.email, password: hashedPassword, firstName: dto.firstName, lastName: dto.lastName, roles: { create: { role: { connectOrCreate: { where: { name: 'user' }, create: { name: 'user', description: 'Default user role' }, }, }, }, }, }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }); return this.generateTokens(user as unknown as UserWithRoles); } /** * Login with email and password */ async login(dto: LoginDto): Promise { // Find user by email const user = await this.prisma.user.findUnique({ where: { email: dto.email }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!user) { throw new UnauthorizedException('INVALID_CREDENTIALS'); } // Verify password const isPasswordValid = await this.comparePassword( dto.password, user.password, ); if (!isPasswordValid) { throw new UnauthorizedException('INVALID_CREDENTIALS'); } if (!user.isActive) { throw new UnauthorizedException('ACCOUNT_DISABLED'); } return this.generateTokens(user as unknown as UserWithRoles); } /** * Refresh access token using refresh token */ async refreshToken(refreshToken: string): Promise { // Find refresh token const storedToken = await this.prisma.refreshToken.findUnique({ where: { token: refreshToken }, include: { user: { include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }, }, }); if (!storedToken) { throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); } if (storedToken.expiresAt < new Date()) { // Delete expired token await this.prisma.refreshToken.delete({ where: { id: storedToken.id }, }); throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); } // Delete old refresh token await this.prisma.refreshToken.delete({ where: { id: storedToken.id }, }); return this.generateTokens(storedToken.user as unknown as UserWithRoles); } /** * Logout - invalidate refresh token */ async logout(refreshToken: string): Promise { await this.prisma.refreshToken.deleteMany({ where: { token: refreshToken }, }); } /** * Validate user by ID (used by JWT strategy) */ async validateUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { roles: { include: { role: { include: { permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!user || !user.isActive) { return null; } // Remove password from user object // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _, ...result } = user; return result; } /** * Generate access and refresh tokens */ private async generateTokens(user: UserWithRoles): Promise { // Extract roles and permissions const roles = user.roles.map((ur) => ur.role.name); const permissions = user.roles.flatMap((ur) => ur.role.permissions.map((rp) => rp.permission.name), ); const payload: JwtPayload = { sub: user.id, email: user.email, roles, permissions, tenantId: user.tenantId || undefined, }; // Generate access token const accessToken = this.jwtService.sign(payload, { expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), }); // Generate refresh token const refreshTokenValue = crypto.randomUUID(); const refreshExpiration = this.parseExpiration( this.configService.get('JWT_REFRESH_EXPIRATION', '7d'), ); // Store refresh token await this.prisma.refreshToken.create({ data: { token: refreshTokenValue, userId: user.id, expiresAt: new Date(Date.now() + refreshExpiration), }, }); return { accessToken, refreshToken: refreshTokenValue, expiresIn: this.parseExpiration( this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), ) / 1000, // Convert to seconds user: { id: user.id, email: user.email, firstName: user.firstName || undefined, lastName: user.lastName || undefined, roles, }, }; } /** * Hash password using bcrypt */ private async hashPassword(password: string): Promise { const saltRounds = 12; return bcrypt.hash(password, saltRounds); } /** * Compare password with hash */ private async comparePassword( password: string, hashedPassword: string, ): Promise { return bcrypt.compare(password, hashedPassword); } /** * Parse expiration string to milliseconds */ private parseExpiration(expiration: string): number { const match = expiration.match(/^(\d+)([smhd])$/); if (!match) { return 15 * 60 * 1000; // Default 15 minutes } const value = parseInt(match[1], 10); const unit = match[2]; switch (unit) { case 's': return value * 1000; case 'm': return value * 60 * 1000; case 'h': return value * 60 * 60 * 1000; case 'd': return value * 24 * 60 * 60 * 1000; default: return 15 * 60 * 1000; } } }