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"; import { User, UserRole } from "@prisma/client"; export interface JwtPayload { sub: string; email: string; role: string; tenantId?: 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, passwordHash: hashedPassword, firstName: dto.firstName, lastName: dto.lastName, role: UserRole.user, }, }); // Create usage limit for user await this.prisma.usageLimit.create({ data: { userId: user.id, analysisCount: 0, couponCount: 0, lastResetDate: new Date(), }, }); return this.generateTokens(user); } /** * 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 }, }); if (!user) { throw new UnauthorizedException("INVALID_CREDENTIALS"); } // Verify password const isPasswordValid = await this.comparePassword( dto.password, user.passwordHash, ); if (!isPasswordValid) { throw new UnauthorizedException("INVALID_CREDENTIALS"); } if (!user.isActive) { throw new UnauthorizedException("ACCOUNT_DISABLED"); } return this.generateTokens(user); } /** * 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: 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); } /** * 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 }, }); if (!user || !user.isActive) { return null; } // Remove password from user object const { passwordHash: _, ...result } = user; return result; } /** * Generate access and refresh tokens */ private async generateTokens(user: User): Promise { const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role, 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: [user.role], // Single role as array for backwards compatibility }, }; } /** * 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; } } }