import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { FastifyReply } from 'fastify'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { CreateUserDto } from '../users/dto/create-user.dto'; import { JwtPayload } from './types/jwt-payload.type'; import { LoginDto } from './dto/login.dto'; const REFRESH_COOKIE = 'refresh_token'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly usersService: UsersService, ) { } async register(dto: CreateUserDto, reply: FastifyReply) { const existingUser = await this.usersService.findByEmail(dto.email); if (existingUser) { throw new BadRequestException('Email already registered'); } const hashedPassword = await bcrypt.hash(dto.password, 10); const user = await this.usersService.createWithCompany(dto, hashedPassword); const tokens = await this.issueTokens(user); await this.updateRefreshToken(user.id, tokens.refreshToken); this.attachRefreshTokenCookie(reply, tokens.refreshToken); return { accessToken: tokens.accessToken }; } async login(dto: LoginDto, reply: FastifyReply) { const user = await this.usersService.findByEmailWithCompany(dto.email); if (!user) { throw new UnauthorizedException('Invalid credentials'); } const passwordMatches = await bcrypt.compare(dto.password, user.password); if (!passwordMatches) { throw new UnauthorizedException('Invalid credentials'); } const tokens = await this.issueTokens(user); await this.updateRefreshToken(user.id, tokens.refreshToken); this.attachRefreshTokenCookie(reply, tokens.refreshToken); return { accessToken: tokens.accessToken }; } async refreshTokens(userId: string, refreshToken: string, reply: FastifyReply) { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { company: true }, }); if (!user || !user.refreshToken) { throw new UnauthorizedException('Refresh token not found'); } const refreshMatches = await bcrypt.compare(refreshToken, user.refreshToken); if (!refreshMatches) { throw new UnauthorizedException('Refresh token invalid'); } const tokens = await this.issueTokens(user); await this.updateRefreshToken(user.id, tokens.refreshToken); this.attachRefreshTokenCookie(reply, tokens.refreshToken); return { accessToken: tokens.accessToken }; } async logout(userId: string, reply: FastifyReply) { await this.prisma.user.update({ where: { id: userId }, data: { refreshToken: null }, }); reply.clearCookie(REFRESH_COOKIE, { path: '/' }); return { success: true }; } private async issueTokens(user: any) { const payload: JwtPayload = { sub: user.id, email: user.email, companyId: user.companyId, companyStatus: user.company?.status, role: user.role, }; const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(payload, { secret: this.configService.get('JWT_ACCESS_SECRET') || 'access-secret', expiresIn: this.configService.get('JWT_ACCESS_EXPIRES', '15m'), }), this.jwtService.signAsync(payload, { secret: this.configService.get('JWT_REFRESH_SECRET') || 'refresh-secret', expiresIn: this.configService.get('JWT_REFRESH_EXPIRES', '7d'), }), ]); return { accessToken, refreshToken }; } private async updateRefreshToken(userId: string, refreshToken: string) { const hashedRefresh = await bcrypt.hash(refreshToken, 10); await this.prisma.user.update({ where: { id: userId }, data: { refreshToken: hashedRefresh }, }); } attachRefreshTokenCookie(reply: FastifyReply, token: string) { reply.setCookie(REFRESH_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: this.configService.get('NODE_ENV') === 'production', path: '/', maxAge: 60 * 60 * 24 * 7, // 7 days }); } }