diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index e044f9f..2647bb5 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -22,6 +22,7 @@ import { InvitationService } from '../services/invitation.service'; import { InvitationsController } from '../controllers/invitations.controller'; import { EmailModule } from '../../infrastructure/email/email.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { AuditModule } from '../audit/audit.module'; @Module({ imports: [ @@ -53,6 +54,9 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; // Subscriptions module for license checks SubscriptionsModule, + + // Audit module for login/logout tracking + AuditModule, ], controllers: [AuthController, InvitationsController], providers: [ diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 1808174..040ff1b 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -235,9 +235,11 @@ export class AuthService { * 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 { + async logout( + refreshToken?: string + ): Promise<{ userId: string; email: string; organizationId: string } | null> { if (!refreshToken) { - return; + return null; } try { @@ -248,9 +250,19 @@ export class AuthService { 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; } } diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 75e99fe..2735571 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -34,6 +34,8 @@ 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, @@ -72,9 +74,26 @@ export class AuthController { 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 @@ -208,18 +227,59 @@ export class AuthController { }) async login( @Body() dto: LoginDto, + @Req() req: Request, @Res({ passthrough: true }) res: Response ): Promise { const rememberMe = dto.rememberMe === true; - const result = await this.authService.login(dto.email, dto.password, rememberMe); + const { ipAddress, userAgent } = this.getClientInfo(req); - this.setAuthCookies(res, result, rememberMe); + try { + const result = await this.authService.login(dto.email, dto.password, rememberMe); - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - user: result.user, - }; + 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; + } } /** @@ -302,9 +362,22 @@ export class AuthController { ): Promise<{ message: string }> { const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken]; - await this.authService.logout(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' }; }