import { Injectable, UnauthorizedException, ConflictException, Logger, Inject, BadRequestException, NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull } from 'typeorm'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { User, UserRole } from '@domain/entities/user.entity'; import { OrganizationRepository, ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization } from '@domain/entities/organization.entity'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { CachePort, CACHE_PORT } from '@domain/ports/out/cache.port'; import { v4 as uuidv4 } from 'uuid'; import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { SubscriptionService } from '../services/subscription.service'; import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; export interface JwtPayload { sub: string; // user ID email: string; role: string; organizationId: string; plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM) planFeatures?: string[]; // plan feature flags type: 'access' | 'refresh'; credentialVersion?: string; rememberMe?: boolean; // drives auth cookie persistence across refreshes } @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, @Inject(EMAIL_PORT) private readonly emailService: EmailPort, @Inject(CACHE_PORT) private readonly cache: CachePort, @InjectRepository(PasswordResetTokenOrmEntity) private readonly passwordResetTokenRepository: Repository, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly subscriptionService: SubscriptionService ) {} /** * Register a new user */ async register( email: string, password: string, firstName: string, lastName: string, organizationId?: string, organizationData?: RegisterOrganizationDto, invitationRole?: string ): Promise<{ accessToken: string; refreshToken: string; user: any }> { // Emails are stored lowercase (enforced by the chk_users_email DB CHECK // constraint), so normalize before any lookup or insert. Without this, an // address containing uppercase letters fails the user INSERT after the // organization has already been created — leaving an orphaned organization. email = email.trim().toLowerCase(); this.logger.log(`Registering new user: ${email}`); const existingUser = await this.userRepository.findByEmail(email); if (existingUser) { throw new ConflictException('User with this email already exists'); } const passwordHash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, // 64 MB timeCost: 3, parallelism: 4, }); // Determine organization ID: // 1. If organizationId is provided (invited user), use it // 2. If organizationData is provided (new user), create a new organization // 3. Otherwise, use default organization const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData); // Track whether a brand-new organization was created for this registration, // so it can be rolled back if the subsequent user creation fails. const createdNewOrg = !organizationId && !!organizationData; // Determine role: // - If invitation role is provided (invited user), use it // - If organizationData is provided (new organization creator), make them MANAGER // - Otherwise, default to USER let userRole: UserRole; if (invitationRole) { // Invitations can only grant non-admin roles — reject anything else const allowedInvitationRoles: UserRole[] = [UserRole.MANAGER, UserRole.USER, UserRole.VIEWER]; if (!allowedInvitationRoles.includes(invitationRole as UserRole)) { throw new BadRequestException('Invalid invitation role'); } userRole = invitationRole as UserRole; } else if (organizationData) { // User creating a new organization becomes MANAGER userRole = UserRole.MANAGER; } else { // Default to USER for other cases userRole = UserRole.USER; } const user = User.create({ id: uuidv4(), organizationId: finalOrganizationId, email, passwordHash, firstName, lastName, role: userRole, }); let savedUser: User; try { savedUser = await this.userRepository.save(user); } catch (err) { // The organization is created before the user (non-atomic flow). If the // user INSERT fails, roll the organization back so a retry isn't blocked // by an orphaned organization ("name already exists"). if (createdNewOrg) { try { await this.organizationRepository.deleteById(finalOrganizationId); this.logger.warn( `Rolled back orphaned organization ${finalOrganizationId} after failed user creation` ); } catch (cleanupErr) { this.logger.error( `Failed to roll back organization ${finalOrganizationId}`, cleanupErr as Error ); } } throw err; } // Allocate a license for the new user try { await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId); this.logger.log(`License allocated for user: ${email}`); } catch (error) { this.logger.error(`Failed to allocate license for user ${email}:`, error); // Note: We don't throw here because the user is already created. // The license check should happen before invitation. } const tokens = await this.generateTokens(savedUser); this.logger.log(`User registered successfully: ${email}`); return { ...tokens, user: { id: savedUser.id, email: savedUser.email, firstName: savedUser.firstName, lastName: savedUser.lastName, role: savedUser.role, organizationId: savedUser.organizationId, }, }; } /** * Login user with email and password */ async login( email: string, password: string, rememberMe = false ): Promise<{ accessToken: string; refreshToken: string; user: any }> { email = email.trim().toLowerCase(); this.logger.log(`Login attempt for: ${email}`); const user = await this.userRepository.findByEmail(email); if (!user) { throw new UnauthorizedException('Invalid credentials'); } if (!user.isActive) { throw new UnauthorizedException('User account is inactive'); } const isPasswordValid = await argon2.verify(user.passwordHash, password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid credentials'); } // last_login_at n'etait jamais renseigne. L'amorcage de l'administrateur // s'en sert pour ne jamais ecraser le mot de passe d'un compte deja utilise. try { user.recordLogin(); await this.userRepository.save(user); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Could not record last login for ${email}: ${message}`); } const tokens = await this.generateTokens(user, rememberMe); this.logger.log(`User logged in successfully: ${email}`); return { ...tokens, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role, organizationId: user.organizationId, }, }; } /** * Refresh access token using refresh token */ async refreshAccessToken( refreshToken: string ): Promise<{ accessToken: string; refreshToken: string; rememberMe: boolean }> { try { const payload = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.get('JWT_SECRET'), }); if (payload.type !== 'refresh') { throw new UnauthorizedException('Invalid token type'); } if (await this.isRefreshTokenRevoked(refreshToken)) { throw new UnauthorizedException('Refresh token has been revoked'); } const user = await this.validateUser(payload); if (!user || !user.isActive) { throw new UnauthorizedException('User not found or inactive'); } const rememberMe = payload.rememberMe === true; const tokens = await this.generateTokens(user, rememberMe); this.logger.log(`Access token refreshed for user: ${user.email}`); return { ...tokens, rememberMe }; } catch (error: any) { this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`); throw new UnauthorizedException('Invalid or expired refresh token'); } } /** * Logout — revoke the refresh token so it can no longer be used. * The revocation list lives in Redis with a TTL matching the token's * remaining lifetime, so entries clean themselves up. */ async logout( refreshToken?: string ): Promise<{ userId: string; email: string; organizationId: string } | null> { if (!refreshToken) { return null; } try { const payload = this.jwtService.decode(refreshToken) as JwtPayload & { exp?: number }; const remainingSeconds = payload?.exp ? Math.max(payload.exp - Math.floor(Date.now() / 1000), 1) : 7 * 24 * 60 * 60; await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds); this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`); if (payload?.sub) { return { userId: payload.sub, email: payload.email, organizationId: payload.organizationId, }; } return null; } catch (error) { // Never block logout on revocation failures — log and continue this.logger.error(`Failed to revoke refresh token: ${error}`); return null; } } private async isRefreshTokenRevoked(refreshToken: string): Promise { try { return (await this.cache.get(this.revokedTokenKey(refreshToken))) === true; } catch (error) { this.logger.error(`Failed to check refresh token revocation: ${error}`); return false; } } private revokedTokenKey(refreshToken: string): string { const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); return `auth:revoked-refresh:${hash}`; } /** * Initiate password reset — generates token and sends email */ async forgotPassword(email: string): Promise { this.logger.log(`Password reset requested for: ${email}`); const user = await this.userRepository.findByEmail(email); // Silently succeed if user not found (security: don't reveal user existence) if (!user || !user.isActive) { return; } // Invalidate any existing unused tokens for this user await this.passwordResetTokenRepository.update( { userId: user.id, usedAt: IsNull() }, { usedAt: new Date() } ); // Generate a secure random token; only its hash is stored so a database // leak cannot be used to take over accounts via pending reset tokens const token = crypto.randomBytes(32).toString('hex'); const tokenHash = this.hashResetToken(token); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await this.passwordResetTokenRepository.save({ userId: user.id, token: tokenHash, expiresAt, usedAt: null, }); await this.emailService.sendPasswordResetEmail(email, token); this.logger.log(`Password reset email sent to: ${email}`); } /** * Reset password using token from email */ async resetPassword(token: string, newPassword: string): Promise { const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token: this.hashResetToken(token) }, }); if (!resetToken) { throw new BadRequestException('Token de réinitialisation invalide ou expiré'); } if (resetToken.usedAt) { throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); } if (resetToken.expiresAt < new Date()) { throw new BadRequestException( 'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.' ); } const user = await this.userRepository.findById(resetToken.userId); if (!user || !user.isActive) { throw new NotFoundException('Utilisateur introuvable'); } const passwordHash = await argon2.hash(newPassword, { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4, }); // Update password (mutates in place) user.updatePassword(passwordHash); await this.userRepository.save(user); // Mark token as used await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); this.logger.log(`Password reset successfully for user: ${user.email}`); } private hashResetToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } /** * Validate user from JWT payload */ async validateUser(payload: JwtPayload): Promise { const user = await this.userRepository.findById(payload.sub); if (!user || !user.isActive || payload.credentialVersion !== this.credentialVersion(user)) { return null; } return user; } // Bind sessions to the current password hash without exposing the hash in JWTs. // Tokens minted before this binding was introduced require a fresh login. private credentialVersion(user: User): string { return crypto .createHmac('sha256', this.configService.getOrThrow('JWT_SECRET')) .update(JSON.stringify(['credential-version-v1', user.id, user.passwordHash])) .digest('hex'); } /** * Generate access and refresh tokens */ private async generateTokens( user: User, rememberMe = false ): Promise<{ accessToken: string; refreshToken: string }> { // ADMIN users always get PLATINIUM plan with no expiration let plan = 'BRONZE'; let planFeatures: string[] = []; if (user.role === UserRole.ADMIN) { plan = 'PLATINIUM'; planFeatures = [ 'dashboard', 'wiki', 'user_management', 'csv_export', 'api_access', 'custom_interface', 'dedicated_kam', ]; } else { try { const subscription = await this.subscriptionService.getOrCreateSubscription( user.organizationId ); plan = subscription.accessPlan.value; planFeatures = [...subscription.accessPlan.planFeatures]; } catch (error) { this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); } } const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role, organizationId: user.organizationId, plan, planFeatures, type: 'access', credentialVersion: this.credentialVersion(user), }; const refreshPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role, organizationId: user.organizationId, plan, planFeatures, type: 'refresh', credentialVersion: this.credentialVersion(user), rememberMe, }; const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(accessPayload, { expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), }), this.jwtService.signAsync(refreshPayload, { expiresIn: this.configService.get('JWT_REFRESH_EXPIRATION', '7d'), }), ]); return { accessToken, refreshToken }; } /** * Resolve organization ID for registration * 1. If organizationId is provided (invited user), validate and use it * 2. If organizationData is provided (new user), create a new organization * 3. Otherwise, throw an error (both are required) */ private async resolveOrganizationId( organizationId?: string, organizationData?: RegisterOrganizationDto ): Promise { // Case 1: Invited user - organizationId is provided if (organizationId) { this.logger.log(`Using existing organization for invited user: ${organizationId}`); // Validate that the organization exists const organization = await this.organizationRepository.findById(organizationId); if (!organization) { throw new BadRequestException('Invalid organization ID - organization does not exist'); } if (!organization.isActive) { throw new BadRequestException('Organization is not active'); } return organizationId; } // Case 2: New user - create a new organization if (organizationData) { this.logger.log(`Creating new organization for user registration: ${organizationData.name}`); // Check if organization name already exists const existingOrg = await this.organizationRepository.findByName(organizationData.name); if (existingOrg) { throw new ConflictException('An organization with this name already exists'); } // Check if SCAC code already exists (for carriers) if (organizationData.scac) { const existingScac = await this.organizationRepository.findBySCAC(organizationData.scac); if (existingScac) { throw new ConflictException('An organization with this SCAC code already exists'); } } // Create new organization const newOrganization = Organization.create({ id: uuidv4(), name: organizationData.name, type: organizationData.type, scac: organizationData.scac, siren: organizationData.siren, siret: organizationData.siret, address: { street: organizationData.street, city: organizationData.city, state: organizationData.state, postalCode: organizationData.postalCode, country: organizationData.country, }, documents: [], isActive: true, }); const savedOrganization = await this.organizationRepository.save(newOrganization); this.logger.log( `New organization created: ${savedOrganization.id} - ${savedOrganization.name}` ); return savedOrganization.id; } // Case 3: Neither provided - error throw new BadRequestException( 'Either organizationId (for invited users) or organization data (for new users) must be provided' ); } }