xpeditis2.0/apps/backend/src/application/auth/auth.service.ts

584 lines
19 KiB
TypeScript

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<PasswordResetTokenOrmEntity>,
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<JwtPayload>(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<boolean> {
try {
return (await this.cache.get<boolean>(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<void> {
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<void> {
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<User | null> {
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<string>('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<string> {
// 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'
);
}
}