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, '''); } /** * 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 { // 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 { 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 = { 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 = `

Nouveau message de contact

${company ? `` : ''} ${phone ? `` : ''}
Nom ${firstName} ${lastName}
Email ${email}
Entreprise${company}
Téléphone${phone}
Sujet ${subjectLabel}

Message :

${message}

Xpeditis — Formulaire de contact

`; 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); } }