All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
454 lines
14 KiB
TypeScript
454 lines
14 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 { 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';
|
|
}
|
|
|
|
@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,
|
|
@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 }> {
|
|
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);
|
|
|
|
// 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) {
|
|
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,
|
|
});
|
|
|
|
const savedUser = await this.userRepository.save(user);
|
|
|
|
// 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
|
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
|
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');
|
|
}
|
|
|
|
const tokens = await this.generateTokens(user);
|
|
|
|
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 }> {
|
|
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');
|
|
}
|
|
|
|
const user = await this.userRepository.findById(payload.sub);
|
|
|
|
if (!user || !user.isActive) {
|
|
throw new UnauthorizedException('User not found or inactive');
|
|
}
|
|
|
|
const tokens = await this.generateTokens(user);
|
|
|
|
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
|
|
|
return tokens;
|
|
} catch (error: any) {
|
|
this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`);
|
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
|
|
await this.passwordResetTokenRepository.save({
|
|
userId: user.id,
|
|
token,
|
|
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 } });
|
|
|
|
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}`);
|
|
}
|
|
|
|
/**
|
|
* Validate user from JWT payload
|
|
*/
|
|
async validateUser(payload: JwtPayload): Promise<User | null> {
|
|
const user = await this.userRepository.findById(payload.sub);
|
|
|
|
if (!user || !user.isActive) {
|
|
return null;
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Generate access and refresh tokens
|
|
*/
|
|
private async generateTokens(user: User): 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.plan.value;
|
|
planFeatures = [...subscription.plan.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',
|
|
};
|
|
|
|
const refreshPayload: JwtPayload = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
organizationId: user.organizationId,
|
|
plan,
|
|
planFeatures,
|
|
type: 'refresh',
|
|
};
|
|
|
|
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'
|
|
);
|
|
}
|
|
}
|