550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Body,
|
|
HttpCode,
|
|
HttpStatus,
|
|
UseGuards,
|
|
Get,
|
|
Inject,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
Req,
|
|
Res,
|
|
} from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { AuthService } from '../auth/auth.service';
|
|
import {
|
|
LoginDto,
|
|
RegisterDto,
|
|
AuthResponseDto,
|
|
RefreshTokenDto,
|
|
ForgotPasswordDto,
|
|
ResetPasswordDto,
|
|
ContactFormDto,
|
|
} from '../dto/auth-login.dto';
|
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
import { Public } from '../decorators/public.decorator';
|
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
import { UserMapper } from '../mappers/user.mapper';
|
|
import { InvitationService } from '../services/invitation.service';
|
|
import { AuditService } from '../services/audit.service';
|
|
import { AuditAction } from '@domain/entities/audit-log.entity';
|
|
import {
|
|
AUTH_COOKIE_NAMES,
|
|
authCookieOptions,
|
|
} from '../../infrastructure/security/security.config';
|
|
|
|
const REFRESH_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
/**
|
|
* Escape user-provided text before interpolating it into HTML emails
|
|
*/
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Authentication Controller
|
|
*
|
|
* Handles user authentication endpoints:
|
|
* - POST /auth/register - User registration
|
|
* - POST /auth/login - User login
|
|
* - POST /auth/refresh - Token refresh
|
|
* - POST /auth/logout - User logout (placeholder)
|
|
* - GET /auth/me - Get current user profile
|
|
*/
|
|
@ApiTags('Authentication')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
private readonly logger = new Logger(AuthController.name);
|
|
|
|
constructor(
|
|
private readonly authService: AuthService,
|
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
|
private readonly invitationService: InvitationService,
|
|
private readonly auditService: AuditService,
|
|
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
|
) {}
|
|
|
|
/**
|
|
* Extract the client IP and user agent from the request so we can record
|
|
* *who* connected and *from where* in the audit trail.
|
|
*/
|
|
private getClientInfo(req: Request): { ipAddress?: string; userAgent?: string } {
|
|
const forwardedFor = req.headers['x-forwarded-for'];
|
|
const ipAddress =
|
|
(Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0]?.trim()) ||
|
|
req.ip ||
|
|
req.socket?.remoteAddress;
|
|
return {
|
|
ipAddress,
|
|
userAgent: req.headers['user-agent'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Deliver tokens as httpOnly cookies so they are out of reach of XSS.
|
|
* When rememberMe is false the cookies are session-scoped (cleared when
|
|
* the browser closes); otherwise they persist for the refresh window.
|
|
*/
|
|
private setAuthCookies(
|
|
res: Response,
|
|
tokens: { accessToken: string; refreshToken: string },
|
|
rememberMe: boolean
|
|
): void {
|
|
const maxAgeMs = rememberMe ? REFRESH_COOKIE_MAX_AGE_MS : undefined;
|
|
res.cookie(AUTH_COOKIE_NAMES.accessToken, tokens.accessToken, authCookieOptions({ maxAgeMs }));
|
|
res.cookie(
|
|
AUTH_COOKIE_NAMES.refreshToken,
|
|
tokens.refreshToken,
|
|
authCookieOptions({ maxAgeMs })
|
|
);
|
|
// Readable flag (no token inside) so the frontend knows a session exists
|
|
res.cookie(AUTH_COOKIE_NAMES.session, '1', authCookieOptions({ maxAgeMs, httpOnly: false }));
|
|
}
|
|
|
|
private clearAuthCookies(res: Response): void {
|
|
res.clearCookie(AUTH_COOKIE_NAMES.accessToken, authCookieOptions());
|
|
res.clearCookie(AUTH_COOKIE_NAMES.refreshToken, authCookieOptions());
|
|
res.clearCookie(AUTH_COOKIE_NAMES.session, authCookieOptions({ httpOnly: false }));
|
|
}
|
|
|
|
/**
|
|
* Register a new user
|
|
*
|
|
* Creates a new user account and returns access + refresh tokens.
|
|
*
|
|
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
|
|
* @returns Access token, refresh token, and user info
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
@Post('register')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
@ApiOperation({
|
|
summary: 'Register new user',
|
|
description: 'Create a new user account with email and password. Returns JWT tokens.',
|
|
})
|
|
@ApiResponse({
|
|
status: 201,
|
|
description: 'User successfully registered',
|
|
type: AuthResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 409,
|
|
description: 'User with this email already exists',
|
|
})
|
|
@ApiResponse({
|
|
status: 400,
|
|
description: 'Validation error (invalid email, weak password, etc.)',
|
|
})
|
|
async register(
|
|
@Body() dto: RegisterDto,
|
|
@Res({ passthrough: true }) res: Response
|
|
): Promise<AuthResponseDto> {
|
|
// If invitation token is provided, verify and use it
|
|
let invitationOrganizationId: string | undefined;
|
|
let invitationRole: string | undefined;
|
|
|
|
if (dto.invitationToken) {
|
|
const invitation = await this.invitationService.verifyInvitation(dto.invitationToken);
|
|
|
|
// Verify email matches invitation
|
|
if (invitation.email.toLowerCase() !== dto.email.toLowerCase()) {
|
|
throw new NotFoundException('Invitation email does not match registration email');
|
|
}
|
|
|
|
invitationOrganizationId = invitation.organizationId;
|
|
invitationRole = invitation.role;
|
|
|
|
// Override firstName/lastName from invitation if not provided
|
|
dto.firstName = dto.firstName || invitation.firstName;
|
|
dto.lastName = dto.lastName || invitation.lastName;
|
|
}
|
|
|
|
// Joining an existing organization is only allowed through a verified
|
|
// invitation token — never from a caller-supplied organization ID.
|
|
const result = await this.authService.register(
|
|
dto.email,
|
|
dto.password,
|
|
dto.firstName,
|
|
dto.lastName,
|
|
invitationOrganizationId,
|
|
dto.organization,
|
|
invitationRole
|
|
);
|
|
|
|
// Mark invitation as used if provided
|
|
if (dto.invitationToken) {
|
|
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
|
|
}
|
|
|
|
this.setAuthCookies(res, result, false);
|
|
|
|
return {
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken,
|
|
user: result.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Login with email and password
|
|
*
|
|
* Authenticates a user and returns access + refresh tokens.
|
|
*
|
|
* @param dto - Login credentials (email, password)
|
|
* @returns Access token, refresh token, and user info
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
@Post('login')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'User login',
|
|
description: 'Authenticate with email and password. Returns JWT tokens.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Login successful',
|
|
type: AuthResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Invalid credentials or inactive account',
|
|
})
|
|
async login(
|
|
@Body() dto: LoginDto,
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response
|
|
): Promise<AuthResponseDto> {
|
|
const rememberMe = dto.rememberMe === true;
|
|
const { ipAddress, userAgent } = this.getClientInfo(req);
|
|
|
|
try {
|
|
const result = await this.authService.login(dto.email, dto.password, rememberMe);
|
|
|
|
this.setAuthCookies(res, result, rememberMe);
|
|
|
|
// Audit log: record who logged in, when and from where
|
|
await this.auditService.logSuccess(
|
|
AuditAction.USER_LOGIN,
|
|
result.user.id,
|
|
result.user.email,
|
|
result.user.organizationId,
|
|
{
|
|
resourceType: 'user',
|
|
resourceId: result.user.id,
|
|
ipAddress,
|
|
userAgent,
|
|
metadata: { rememberMe },
|
|
}
|
|
);
|
|
|
|
this.logger.log(`Login success: ${result.user.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
|
|
return {
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken,
|
|
user: result.user,
|
|
};
|
|
} catch (error: any) {
|
|
// Audit log: record failed login attempts (the attempted email is the
|
|
// only identity we have — the credentials did not match a valid user)
|
|
await this.auditService.logFailure(
|
|
AuditAction.USER_LOGIN,
|
|
'unknown',
|
|
dto.email,
|
|
'unknown',
|
|
error?.message || 'Invalid credentials',
|
|
{
|
|
resourceType: 'user',
|
|
ipAddress,
|
|
userAgent,
|
|
}
|
|
);
|
|
|
|
this.logger.warn(`Login failed for ${dto.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh access token
|
|
*
|
|
* Obtains a new access token using a valid refresh token.
|
|
*
|
|
* @param dto - Refresh token
|
|
* @returns New access token
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
|
@Post('refresh')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Refresh access token',
|
|
description:
|
|
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Token refreshed successfully — new tokens are set as httpOnly cookies',
|
|
schema: {
|
|
properties: {
|
|
success: { type: 'boolean', example: true },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Invalid or expired refresh token',
|
|
})
|
|
async refresh(
|
|
@Body() dto: RefreshTokenDto,
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response
|
|
): Promise<{ success: boolean }> {
|
|
// Prefer the httpOnly cookie; fall back to the body for legacy clients
|
|
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken] || dto.refreshToken;
|
|
|
|
if (!refreshToken) {
|
|
this.clearAuthCookies(res);
|
|
throw new UnauthorizedException('No refresh token provided');
|
|
}
|
|
|
|
const result = await this.authService.refreshAccessToken(refreshToken);
|
|
|
|
this.setAuthCookies(res, result, result.rememberMe);
|
|
|
|
// Tokens are intentionally NOT returned in the body: an XSS payload could
|
|
// otherwise call this endpoint and exfiltrate a fresh access token.
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Logout
|
|
*
|
|
* Revokes the refresh token (Redis blacklist) and clears the auth cookies.
|
|
* The access token naturally expires within 15 minutes.
|
|
*/
|
|
@Public()
|
|
@Post('logout')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Logout',
|
|
description: 'Revoke the refresh token and clear authentication cookies.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Logout successful',
|
|
schema: {
|
|
properties: {
|
|
message: { type: 'string', example: 'Logout successful' },
|
|
},
|
|
},
|
|
})
|
|
async logout(
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response
|
|
): Promise<{ message: string }> {
|
|
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
|
|
|
|
const loggedOutUser = await this.authService.logout(refreshToken);
|
|
this.clearAuthCookies(res);
|
|
|
|
// Audit log: record who logged out and when
|
|
if (loggedOutUser) {
|
|
const { ipAddress, userAgent } = this.getClientInfo(req);
|
|
await this.auditService.logSuccess(
|
|
AuditAction.USER_LOGOUT,
|
|
loggedOutUser.userId,
|
|
loggedOutUser.email,
|
|
loggedOutUser.organizationId,
|
|
{ resourceType: 'user', resourceId: loggedOutUser.userId, ipAddress, userAgent }
|
|
);
|
|
this.logger.log(`Logout: ${loggedOutUser.email} from ${ipAddress ?? 'unknown IP'}`);
|
|
}
|
|
|
|
return { message: 'Logout successful' };
|
|
}
|
|
|
|
/**
|
|
* Contact form — forwards message to contact@xpeditis.com
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
|
@Post('contact')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Contact form',
|
|
description: 'Send a contact message to the Xpeditis team.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Message sent successfully' })
|
|
async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> {
|
|
const subjectLabels: Record<string, string> = {
|
|
demo: 'Demande de démonstration',
|
|
pricing: 'Questions sur les tarifs',
|
|
partnership: 'Partenariat',
|
|
support: 'Support technique',
|
|
press: 'Relations presse',
|
|
careers: 'Recrutement',
|
|
other: 'Autre',
|
|
};
|
|
|
|
const subjectLabel = escapeHtml(subjectLabels[dto.subject] || dto.subject);
|
|
const firstName = escapeHtml(dto.firstName);
|
|
const lastName = escapeHtml(dto.lastName);
|
|
const email = escapeHtml(dto.email);
|
|
const company = dto.company ? escapeHtml(dto.company) : undefined;
|
|
const phone = dto.phone ? escapeHtml(dto.phone) : undefined;
|
|
const message = escapeHtml(dto.message);
|
|
|
|
const html = `
|
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
<div style="background: #10183A; padding: 24px; border-radius: 8px 8px 0 0;">
|
|
<h1 style="color: #34CCCD; margin: 0; font-size: 20px;">Nouveau message de contact</h1>
|
|
</div>
|
|
<div style="background: #f9f9f9; padding: 24px; border: 1px solid #e0e0e0;">
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
|
|
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${firstName} ${lastName}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
|
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${email}" style="color: #34CCCD;">${email}</a></td>
|
|
</tr>
|
|
${company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${company}</td></tr>` : ''}
|
|
${phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${phone}</td></tr>` : ''}
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
|
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
|
</tr>
|
|
</table>
|
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
|
|
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
|
|
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${message}</p>
|
|
</div>
|
|
</div>
|
|
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
|
<p style="color: #999; font-size: 12px; margin: 0;">Xpeditis — Formulaire de contact</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
await this.emailService.send({
|
|
to: 'contact@xpeditis.com',
|
|
replyTo: dto.email,
|
|
subject: `[Contact] ${subjectLabels[dto.subject] || dto.subject} — ${dto.firstName} ${dto.lastName}`,
|
|
html,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send contact email: ${error}`);
|
|
throw new InternalServerErrorException(
|
|
"Erreur lors de l'envoi du message. Veuillez réessayer."
|
|
);
|
|
}
|
|
|
|
return { message: 'Message envoyé avec succès.' };
|
|
}
|
|
|
|
/**
|
|
* Forgot password — sends reset email
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
|
@Post('forgot-password')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Forgot password',
|
|
description: 'Send a password reset email. Always returns 200 to avoid user enumeration.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Reset email sent (if account exists)' })
|
|
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> {
|
|
await this.authService.forgotPassword(dto.email);
|
|
return {
|
|
message: 'Si un compte existe avec cet email, vous recevrez un lien de réinitialisation.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset password using token from email
|
|
*/
|
|
@Public()
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
@Post('reset-password')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Reset password',
|
|
description: 'Reset user password using the token received by email.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
|
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
|
|
async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> {
|
|
await this.authService.resetPassword(dto.token, dto.newPassword);
|
|
return { message: 'Mot de passe réinitialisé avec succès.' };
|
|
}
|
|
|
|
/**
|
|
* Get current user profile
|
|
*
|
|
* Returns the profile of the currently authenticated user with complete details.
|
|
*
|
|
* @param user - Current user from JWT token
|
|
* @returns User profile with firstName, lastName, etc.
|
|
*/
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('me')
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Get current user profile',
|
|
description: 'Returns the complete profile of the authenticated user.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'User profile retrieved successfully',
|
|
schema: {
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
email: { type: 'string', format: 'email' },
|
|
firstName: { type: 'string' },
|
|
lastName: { type: 'string' },
|
|
role: { type: 'string', enum: ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] },
|
|
organizationId: { type: 'string', format: 'uuid' },
|
|
isActive: { type: 'boolean' },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - invalid or missing token',
|
|
})
|
|
async getProfile(@CurrentUser() user: UserPayload) {
|
|
// Fetch complete user details from database
|
|
const fullUser = await this.userRepository.findById(user.id);
|
|
|
|
if (!fullUser) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
// Return complete user data with firstName and lastName
|
|
return UserMapper.toDto(fullUser);
|
|
}
|
|
}
|