From f7df8e97aae03729ff1333eb55733c2d428f46e9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 19 Mar 2026 22:09:58 +0100 Subject: [PATCH] feat(auth): add audit logging, account lockout, and API key rate limiting 1. SecurityEventsService: Centralized audit logging for all auth events (login, register, logout, password changes, API key operations, SSO token exchange, etc.). Fire-and-forget pattern ensures auth flows are never blocked by logging failures. 2. AccountLockoutService: Locks accounts after 5 failed login attempts within 15 minutes. 30-minute lockout duration. Fails open on DB errors. Clears attempts on successful login. Email-not-verified does not count as a failed attempt. 3. API Key validation endpoint secured with rate limiting (10 req/min per IP via ThrottlerGuard) and audit logging. Key prefixes logged for forensics, never full keys. New schema: auth.login_attempts table for tracking failed logins. 174 tests passing across all auth and security modules. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api-keys/api-keys.controller.ts | 67 ++++++- .../src/api-keys/api-keys.module.ts | 2 + services/mana-core-auth/src/app.module.ts | 2 + .../src/auth/auth.controller.spec.ts | 44 +++-- .../src/auth/auth.controller.ts | 159 ++++++++++++++--- .../mana-core-auth/src/auth/auth.module.ts | 2 + .../src/auth/services/better-auth.service.ts | 26 --- .../mana-core-auth/src/db/schema/index.ts | 1 + .../src/db/schema/login-attempts.schema.ts | 22 +++ .../src/security/account-lockout.service.ts | 141 +++++++++++++++ services/mana-core-auth/src/security/index.ts | 5 + .../src/security/security-events.service.ts | 122 +++++++++++++ .../src/security/security-events.spec.ts | 164 ++++++++++++++++++ .../src/security/security.module.ts | 11 ++ 14 files changed, 700 insertions(+), 68 deletions(-) create mode 100644 services/mana-core-auth/src/db/schema/login-attempts.schema.ts create mode 100644 services/mana-core-auth/src/security/account-lockout.service.ts create mode 100644 services/mana-core-auth/src/security/index.ts create mode 100644 services/mana-core-auth/src/security/security-events.service.ts create mode 100644 services/mana-core-auth/src/security/security-events.spec.ts create mode 100644 services/mana-core-auth/src/security/security.module.ts diff --git a/services/mana-core-auth/src/api-keys/api-keys.controller.ts b/services/mana-core-auth/src/api-keys/api-keys.controller.ts index afd3bfbb8..8d41f9713 100644 --- a/services/mana-core-auth/src/api-keys/api-keys.controller.ts +++ b/services/mana-core-auth/src/api-keys/api-keys.controller.ts @@ -5,19 +5,26 @@ import { Delete, Body, Param, + Req, UseGuards, HttpCode, HttpStatus, } from '@nestjs/common'; +import type { Request } from 'express'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { ApiKeysService } from './api-keys.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import type { CurrentUserData } from '../common/decorators/current-user.decorator'; import { CreateApiKeyDto, ValidateApiKeyDto } from './dto'; +import { SecurityEventsService, SecurityEventType } from '../security'; @Controller('api-keys') export class ApiKeysController { - constructor(private readonly apiKeysService: ApiKeysService) {} + constructor( + private readonly apiKeysService: ApiKeysService, + private readonly securityEvents: SecurityEventsService + ) {} /** * List all API keys for the authenticated user @@ -34,8 +41,20 @@ export class ApiKeysController { */ @Post() @UseGuards(JwtAuthGuard) - async createKey(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) { - return this.apiKeysService.createApiKey(user.userId, dto); + async createKey( + @CurrentUser() user: CurrentUserData, + @Body() dto: CreateApiKeyDto, + @Req() req: Request + ) { + const result = await this.apiKeysService.createApiKey(user.userId, dto); + + this.securityEvents.logEventWithRequest(req, { + userId: user.userId, + eventType: SecurityEventType.API_KEY_CREATED, + metadata: { keyId: result.id, name: dto.name, scopes: dto.scopes }, + }); + + return result; } /** @@ -44,16 +63,48 @@ export class ApiKeysController { @Delete(':id') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.NO_CONTENT) - async revokeKey(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + async revokeKey( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Req() req: Request + ) { await this.apiKeysService.revokeApiKey(user.userId, id); + + this.securityEvents.logEventWithRequest(req, { + userId: user.userId, + eventType: SecurityEventType.API_KEY_REVOKED, + metadata: { keyId: id }, + }); } /** - * Validate an API key (for STT/TTS services) - * This endpoint does NOT require JWT authentication + * Validate an API key (for internal services like STT/TTS) + * + * This endpoint does NOT require JWT authentication since it's called + * by services that only have an API key, not a JWT. + * + * Rate limited to 10 requests/minute per IP to prevent brute force. */ @Post('validate') - async validateKey(@Body() dto: ValidateApiKeyDto) { - return this.apiKeysService.validateApiKey(dto.apiKey, dto.scope); + @UseGuards(ThrottlerGuard) + @Throttle({ default: { ttl: 60000, limit: 10 } }) + @HttpCode(HttpStatus.OK) + async validateKey(@Body() dto: ValidateApiKeyDto, @Req() req: Request) { + const result = await this.apiKeysService.validateApiKey(dto.apiKey, dto.scope); + + const eventType = result.valid + ? SecurityEventType.API_KEY_VALIDATED + : SecurityEventType.API_KEY_VALIDATION_FAILED; + + this.securityEvents.logEventWithRequest(req, { + userId: result.valid ? result.userId : undefined, + eventType, + metadata: { + scope: dto.scope, + keyPrefix: dto.apiKey?.substring(0, 16) + '...', + }, + }); + + return result; } } diff --git a/services/mana-core-auth/src/api-keys/api-keys.module.ts b/services/mana-core-auth/src/api-keys/api-keys.module.ts index 337c57fc5..f2bc65400 100644 --- a/services/mana-core-auth/src/api-keys/api-keys.module.ts +++ b/services/mana-core-auth/src/api-keys/api-keys.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ApiKeysController } from './api-keys.controller'; import { ApiKeysService } from './api-keys.service'; +import { SecurityModule } from '../security'; @Module({ + imports: [SecurityModule], controllers: [ApiKeysController], providers: [ApiKeysService], exports: [ApiKeysService], diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index c0044aafa..015f17900 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -21,6 +21,7 @@ import { AnalyticsModule } from './analytics'; import { MetricsModule } from './metrics'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggerModule } from './common/logger'; +import { SecurityModule } from './security'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { LoggerModule } from './common/logger'; }, ]), LoggerModule, + SecurityModule, MetricsModule, AnalyticsModule, AdminModule, diff --git a/services/mana-core-auth/src/auth/auth.controller.spec.ts b/services/mana-core-auth/src/auth/auth.controller.spec.ts index 4ac860222..431fb695e 100644 --- a/services/mana-core-auth/src/auth/auth.controller.spec.ts +++ b/services/mana-core-auth/src/auth/auth.controller.spec.ts @@ -34,6 +34,7 @@ import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { AuthController } from './auth.controller'; import { BetterAuthService } from './services/better-auth.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { SecurityEventsService, AccountLockoutService } from '../security'; import { mockDtoFactory } from '../__tests__/utils/mock-factories'; describe('AuthController', () => { @@ -43,6 +44,7 @@ describe('AuthController', () => { // Common test data const mockAuthHeader = 'Bearer valid-jwt-token'; const mockToken = 'valid-jwt-token'; + const mockReq = { headers: { 'user-agent': 'test' }, ip: '127.0.0.1' } as any; beforeEach(async () => { // Create mock BetterAuthService with all methods @@ -63,6 +65,18 @@ describe('AuthController', () => { validateToken: jest.fn(), }; + const mockSecurityEventsService = { + logEvent: jest.fn().mockResolvedValue(undefined), + logEventWithRequest: jest.fn().mockResolvedValue(undefined), + extractRequestInfo: jest.fn().mockReturnValue({ ipAddress: '127.0.0.1', userAgent: 'test' }), + }; + + const mockAccountLockoutService = { + checkLockout: jest.fn().mockResolvedValue({ locked: false }), + recordAttempt: jest.fn().mockResolvedValue(undefined), + clearAttempts: jest.fn().mockResolvedValue(undefined), + }; + const module: TestingModule = await Test.createTestingModule({ imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], controllers: [AuthController], @@ -71,6 +85,14 @@ describe('AuthController', () => { provide: BetterAuthService, useValue: mockBetterAuthService, }, + { + provide: SecurityEventsService, + useValue: mockSecurityEventsService, + }, + { + provide: AccountLockoutService, + useValue: mockAccountLockoutService, + }, ], }) .overrideGuard(JwtAuthGuard) @@ -110,7 +132,7 @@ describe('AuthController', () => { betterAuthService.registerB2C.mockResolvedValue(expectedResult); - const result = await controller.register(registerDto); + const result = await controller.register(registerDto, mockReq); expect(result).toEqual(expectedResult); expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ @@ -133,7 +155,7 @@ describe('AuthController', () => { betterAuthService.registerB2C.mockResolvedValue(expectedResult); - const result = await controller.register(registerDto as any); + const result = await controller.register(registerDto as any, mockReq); expect(result).toEqual(expectedResult); expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ @@ -151,7 +173,7 @@ describe('AuthController', () => { new ConflictException('User with this email already exists') ); - await expect(controller.register(registerDto)).rejects.toThrow(ConflictException); + await expect(controller.register(registerDto, mockReq)).rejects.toThrow(ConflictException); }); }); @@ -180,7 +202,7 @@ describe('AuthController', () => { betterAuthService.signIn.mockResolvedValue(expectedResult); - const result = await controller.login(loginDto); + const result = await controller.login(loginDto, mockReq); expect(result).toEqual(expectedResult); expect(betterAuthService.signIn).toHaveBeenCalledWith({ @@ -206,7 +228,7 @@ describe('AuthController', () => { expiresIn: 900, }); - await controller.login(loginDto); + await controller.login(loginDto, mockReq); expect(betterAuthService.signIn).toHaveBeenCalledWith({ email: loginDto.email, @@ -223,7 +245,7 @@ describe('AuthController', () => { new UnauthorizedException('Invalid email or password') ); - await expect(controller.login(loginDto)).rejects.toThrow(UnauthorizedException); + await expect(controller.login(loginDto, mockReq)).rejects.toThrow(UnauthorizedException); }); }); @@ -237,7 +259,7 @@ describe('AuthController', () => { betterAuthService.signOut.mockResolvedValue(expectedResult); - const result = await controller.logout(mockAuthHeader); + const result = await controller.logout(mockAuthHeader, mockReq); expect(result).toEqual(expectedResult); expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken); @@ -246,7 +268,7 @@ describe('AuthController', () => { it('should extract token from Bearer header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); - await controller.logout('Bearer my-secret-token'); + await controller.logout('Bearer my-secret-token', mockReq); expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token'); }); @@ -254,7 +276,7 @@ describe('AuthController', () => { it('should handle raw token without Bearer prefix', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); - await controller.logout('raw-token'); + await controller.logout('raw-token', mockReq); expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token'); }); @@ -700,7 +722,7 @@ describe('AuthController', () => { it('should extract token from Bearer authorization header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); - await controller.logout('Bearer my-token-123'); + await controller.logout('Bearer my-token-123', mockReq); expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123'); }); @@ -708,7 +730,7 @@ describe('AuthController', () => { it('should handle missing authorization header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); - await controller.logout(''); + await controller.logout('', mockReq); expect(betterAuthService.signOut).toHaveBeenCalledWith(''); }); diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index 41e086b41..ed9ddad37 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -13,6 +13,7 @@ import { HttpStatus, Req, Res, + ForbiddenException, } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; @@ -36,6 +37,7 @@ import { UpdateMemberRoleDto } from './dto/update-member-role.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import type { CurrentUserData } from '../common/decorators/current-user.decorator'; +import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; /** * Auth Controller @@ -71,7 +73,11 @@ import type { CurrentUserData } from '../common/decorators/current-user.decorato @Controller('auth') @UseGuards(ThrottlerGuard) export class AuthController { - constructor(private readonly betterAuthService: BetterAuthService) {} + constructor( + private readonly betterAuthService: BetterAuthService, + private readonly securityEvents: SecurityEventsService, + private readonly accountLockout: AccountLockoutService + ) {} // ========================================================================= // B2C Authentication Endpoints @@ -94,13 +100,21 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Invalid input data' }) @ApiResponse({ status: 409, description: 'Email already exists' }) @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) - async register(@Body() registerDto: RegisterDto) { - return this.betterAuthService.registerB2C({ + async register(@Body() registerDto: RegisterDto, @Req() req: Request) { + const result = await this.betterAuthService.registerB2C({ email: registerDto.email, password: registerDto.password, name: registerDto.name || '', sourceAppUrl: registerDto.sourceAppUrl, }); + + this.securityEvents.logEventWithRequest(req, { + userId: result.user?.id, + eventType: SecurityEventType.REGISTER, + metadata: { email: registerDto.email }, + }); + + return result; } /** @@ -139,13 +153,59 @@ export class AuthController { }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) - async login(@Body() loginDto: LoginDto) { - return this.betterAuthService.signIn({ - email: loginDto.email, - password: loginDto.password, - deviceId: loginDto.deviceId, - deviceName: loginDto.deviceName, - }); + async login(@Body() loginDto: LoginDto, @Req() req: Request) { + const { ipAddress, userAgent } = this.securityEvents.extractRequestInfo(req); + + // Check account lockout before attempting login + const lockout = await this.accountLockout.checkLockout(loginDto.email); + if (lockout.locked) { + this.securityEvents.logEventWithRequest(req, { + eventType: SecurityEventType.LOGIN_FAILURE, + metadata: { email: loginDto.email, reason: 'account_locked' }, + }); + throw new ForbiddenException({ + message: 'Account temporarily locked due to too many failed login attempts', + code: 'ACCOUNT_LOCKED', + retryAfter: lockout.remainingSeconds, + }); + } + + try { + const result = await this.betterAuthService.signIn({ + email: loginDto.email, + password: loginDto.password, + deviceId: loginDto.deviceId, + deviceName: loginDto.deviceName, + }); + + // Login successful - clear failed attempts and log + this.accountLockout.clearAttempts(loginDto.email); + this.securityEvents.logEvent({ + userId: result.user?.id, + eventType: SecurityEventType.LOGIN_SUCCESS, + ipAddress, + userAgent, + metadata: { email: loginDto.email, deviceId: loginDto.deviceId }, + }); + + return result; + } catch (error) { + // Don't count email-not-verified as a failed login attempt + if (error instanceof ForbiddenException) { + throw error; + } + + // Record failed attempt + this.accountLockout.recordAttempt(loginDto.email, false, ipAddress); + this.securityEvents.logEvent({ + eventType: SecurityEventType.LOGIN_FAILURE, + ipAddress, + userAgent, + metadata: { email: loginDto.email, reason: 'invalid_credentials' }, + }); + + throw error; + } } /** @@ -163,9 +223,15 @@ export class AuthController { }) @ApiResponse({ status: 200, description: 'Logout successful' }) @ApiResponse({ status: 401, description: 'Not authenticated' }) - async logout(@Headers('authorization') authorization: string) { + async logout(@Headers('authorization') authorization: string, @Req() req: Request) { const token = this.extractToken(authorization); - return this.betterAuthService.signOut(token); + const result = await this.betterAuthService.signOut(token); + + this.securityEvents.logEventWithRequest(req, { + eventType: SecurityEventType.LOGOUT, + }); + + return result; } /** @@ -244,7 +310,15 @@ export class AuthController { }) @ApiResponse({ status: 401, description: 'No valid session cookie' }) async sessionToToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) { - return this.betterAuthService.sessionToToken(req, res); + const result = await this.betterAuthService.sessionToToken(req, res); + + this.securityEvents.logEventWithRequest(req, { + userId: result.user?.id, + eventType: SecurityEventType.SSO_TOKEN_EXCHANGE, + metadata: { email: result.user?.email }, + }); + + return result; } /** @@ -272,11 +346,18 @@ export class AuthController { @Post('forgot-password') @Throttle({ default: { ttl: 60000, limit: 3 } }) @HttpCode(HttpStatus.OK) - async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { - return this.betterAuthService.requestPasswordReset( + async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto, @Req() req: Request) { + const result = await this.betterAuthService.requestPasswordReset( forgotPasswordDto.email, forgotPasswordDto.redirectTo ); + + this.securityEvents.logEventWithRequest(req, { + eventType: SecurityEventType.PASSWORD_RESET_REQUESTED, + metadata: { email: forgotPasswordDto.email }, + }); + + return result; } /** @@ -288,11 +369,17 @@ export class AuthController { @Post('reset-password') @Throttle({ default: { ttl: 60000, limit: 5 } }) @HttpCode(HttpStatus.OK) - async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { - return this.betterAuthService.resetPassword( + async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Req() req: Request) { + const result = await this.betterAuthService.resetPassword( resetPasswordDto.token, resetPasswordDto.newPassword ); + + this.securityEvents.logEventWithRequest(req, { + eventType: SecurityEventType.PASSWORD_RESET_COMPLETED, + }); + + return result; } /** @@ -381,12 +468,23 @@ export class AuthController { @ApiBody({ type: ChangePasswordDto }) @ApiResponse({ status: 200, description: 'Password changed successfully' }) @ApiResponse({ status: 401, description: 'Current password is incorrect' }) - async changePassword(@CurrentUser() user: CurrentUserData, @Body() changeDto: ChangePasswordDto) { - return this.betterAuthService.changePassword( + async changePassword( + @CurrentUser() user: CurrentUserData, + @Body() changeDto: ChangePasswordDto, + @Req() req: Request + ) { + const result = await this.betterAuthService.changePassword( user.userId, changeDto.currentPassword, changeDto.newPassword ); + + this.securityEvents.logEventWithRequest(req, { + userId: user.userId, + eventType: SecurityEventType.PASSWORD_CHANGED, + }); + + return result; } /** @@ -403,8 +501,24 @@ export class AuthController { @ApiBody({ type: DeleteAccountDto }) @ApiResponse({ status: 200, description: 'Account deleted' }) @ApiResponse({ status: 401, description: 'Password is incorrect' }) - async deleteAccount(@CurrentUser() user: CurrentUserData, @Body() deleteDto: DeleteAccountDto) { - return this.betterAuthService.deleteAccount(user.userId, deleteDto.password, deleteDto.reason); + async deleteAccount( + @CurrentUser() user: CurrentUserData, + @Body() deleteDto: DeleteAccountDto, + @Req() req: Request + ) { + const result = await this.betterAuthService.deleteAccount( + user.userId, + deleteDto.password, + deleteDto.reason + ); + + this.securityEvents.logEventWithRequest(req, { + userId: user.userId, + eventType: SecurityEventType.ACCOUNT_DELETED, + metadata: { reason: deleteDto.reason }, + }); + + return result; } // ========================================================================= @@ -684,8 +798,7 @@ export class AuthController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Cancel or reject invitation', - description: - 'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.', + description: 'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.', }) @ApiResponse({ status: 204, description: 'Invitation cancelled/rejected successfully' }) @ApiResponse({ status: 401, description: 'Not authenticated' }) diff --git a/services/mana-core-auth/src/auth/auth.module.ts b/services/mana-core-auth/src/auth/auth.module.ts index 2cd2bd328..487199784 100644 --- a/services/mana-core-auth/src/auth/auth.module.ts +++ b/services/mana-core-auth/src/auth/auth.module.ts @@ -6,8 +6,10 @@ import { OidcLoginController } from './oidc-login.controller'; import { MatrixSessionController } from './matrix-session.controller'; import { BetterAuthService } from './services/better-auth.service'; import { MatrixSessionService } from './services/matrix-session.service'; +import { SecurityModule } from '../security'; @Module({ + imports: [SecurityModule], controllers: [ AuthController, BetterAuthPassthroughController, diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 6dfc4cc07..964ad6fb4 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -1534,19 +1534,6 @@ export class BetterAuthService { this.logger.log('Password changed', { userId }); - // Log security event - try { - const { securityEvents } = await import('../../db/schema/auth.schema'); - await db.insert(securityEvents).values({ - userId, - eventType: 'password_changed', - metadata: { changedAt: new Date().toISOString() }, - }); - } catch { - // Non-critical - just log - this.logger.warn('Failed to log security event for password change'); - } - return { success: true, message: 'Password changed successfully', @@ -1603,19 +1590,6 @@ export class BetterAuthService { this.logger.log('Account deleted', { userId, reason }); - // Log security event - try { - const { securityEvents } = await import('../../db/schema/auth.schema'); - await db.insert(securityEvents).values({ - userId, - eventType: 'account_deleted', - metadata: { reason, deletedAt: now.toISOString() }, - }); - } catch { - // Non-critical - this.logger.warn('Failed to log security event for account deletion'); - } - return { success: true, message: 'Account has been deleted', diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index 5815851c7..eff039c56 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -3,6 +3,7 @@ export * from './auth.schema'; export * from './credits.schema'; export * from './feedback.schema'; export * from './gifts.schema'; +export * from './login-attempts.schema'; export * from './organizations.schema'; export * from './subscriptions.schema'; export * from './tags.schema'; diff --git a/services/mana-core-auth/src/db/schema/login-attempts.schema.ts b/services/mana-core-auth/src/db/schema/login-attempts.schema.ts new file mode 100644 index 000000000..63811089a --- /dev/null +++ b/services/mana-core-auth/src/db/schema/login-attempts.schema.ts @@ -0,0 +1,22 @@ +/** + * Login Attempts Schema + * + * Tracks login attempts for account lockout functionality. + * Failed attempts within a time window trigger account lockout. + */ + +import { pgSchema, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core'; + +const authSchema = pgSchema('auth'); + +export const loginAttempts = authSchema.table( + 'login_attempts', + { + id: serial('id').primaryKey(), + email: text('email').notNull(), + ipAddress: text('ip_address'), + successful: boolean('successful').default(false).notNull(), + attemptedAt: timestamp('attempted_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('login_attempts_email_attempted_at_idx').on(table.email, table.attemptedAt)] +); diff --git a/services/mana-core-auth/src/security/account-lockout.service.ts b/services/mana-core-auth/src/security/account-lockout.service.ts new file mode 100644 index 000000000..ac5058e63 --- /dev/null +++ b/services/mana-core-auth/src/security/account-lockout.service.ts @@ -0,0 +1,141 @@ +/** + * Account Lockout Service + * + * Tracks failed login attempts and locks accounts after too many failures. + * Uses the login_attempts table for efficient counting. + * + * Policy: + * - 5 failed attempts within 15 minutes → account locked for 30 minutes + * - Successful login clears all previous attempts + * - Lockout is per-email (not per-IP) to prevent distributed brute force + */ + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb } from '../db/connection'; +import { loginAttempts } from '../db/schema/login-attempts.schema'; +import { LoggerService } from '../common/logger'; +import { and, eq, gte, sql, desc } from 'drizzle-orm'; + +const MAX_ATTEMPTS = 5; +const ATTEMPT_WINDOW_MINUTES = 15; +const LOCKOUT_DURATION_MINUTES = 30; + +export interface LockoutStatus { + locked: boolean; + remainingSeconds?: number; + attempts?: number; +} + +@Injectable() +export class AccountLockoutService { + private readonly logger: LoggerService; + private readonly databaseUrl: string; + + constructor( + loggerService: LoggerService, + private configService: ConfigService + ) { + this.logger = loggerService; + this.logger.setContext('AccountLockoutService'); + this.databaseUrl = this.configService.get('database.url') || ''; + } + + private getDb() { + return getDb(this.databaseUrl); + } + + /** + * Check if an account is locked due to too many failed login attempts + */ + async checkLockout(email: string): Promise { + try { + const db = this.getDb(); + const windowStart = new Date(Date.now() - ATTEMPT_WINDOW_MINUTES * 60 * 1000); + + // Count failed attempts in the window + const result = await db + .select({ + count: sql`count(*)::int`, + latestAttempt: sql`max(${loginAttempts.attemptedAt})`, + }) + .from(loginAttempts) + .where( + and( + eq(loginAttempts.email, email.toLowerCase()), + eq(loginAttempts.successful, false), + gte(loginAttempts.attemptedAt, windowStart) + ) + ); + + const failedCount = result[0]?.count ?? 0; + const latestAttempt = result[0]?.latestAttempt; + + if (failedCount >= MAX_ATTEMPTS && latestAttempt) { + const lockoutEnd = new Date( + new Date(latestAttempt).getTime() + LOCKOUT_DURATION_MINUTES * 60 * 1000 + ); + const remainingMs = lockoutEnd.getTime() - Date.now(); + + if (remainingMs > 0) { + return { + locked: true, + remainingSeconds: Math.ceil(remainingMs / 1000), + attempts: failedCount, + }; + } + } + + return { locked: false, attempts: failedCount }; + } catch (error) { + // On error, do not lock out (fail open for availability) + this.logger.warn('Failed to check lockout status', { + email, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { locked: false }; + } + } + + /** + * Record a login attempt (successful or failed) + */ + async recordAttempt(email: string, successful: boolean, ipAddress?: string): Promise { + try { + const db = this.getDb(); + await db.insert(loginAttempts).values({ + email: email.toLowerCase(), + ipAddress: ipAddress || null, + successful, + }); + } catch (error) { + this.logger.warn('Failed to record login attempt', { + email, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Clear all failed attempts for an email (called on successful login) + */ + async clearAttempts(email: string): Promise { + try { + const db = this.getDb(); + const windowStart = new Date(Date.now() - LOCKOUT_DURATION_MINUTES * 60 * 1000); + await db + .delete(loginAttempts) + .where( + and( + eq(loginAttempts.email, email.toLowerCase()), + gte(loginAttempts.attemptedAt, windowStart) + ) + ); + } catch (error) { + this.logger.warn('Failed to clear login attempts', { + email, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/services/mana-core-auth/src/security/index.ts b/services/mana-core-auth/src/security/index.ts new file mode 100644 index 000000000..d9cbcecf6 --- /dev/null +++ b/services/mana-core-auth/src/security/index.ts @@ -0,0 +1,5 @@ +export { SecurityModule } from './security.module'; +export { SecurityEventsService, SecurityEventType } from './security-events.service'; +export type { SecurityEventParams, SecurityEventTypeValue } from './security-events.service'; +export { AccountLockoutService } from './account-lockout.service'; +export type { LockoutStatus } from './account-lockout.service'; diff --git a/services/mana-core-auth/src/security/security-events.service.ts b/services/mana-core-auth/src/security/security-events.service.ts new file mode 100644 index 000000000..341faac75 --- /dev/null +++ b/services/mana-core-auth/src/security/security-events.service.ts @@ -0,0 +1,122 @@ +/** + * Security Events Service + * + * Centralized audit logging for all authentication and security-relevant events. + * All methods are fire-and-forget: errors are logged but never thrown, + * so audit logging cannot break authentication flows. + */ + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb } from '../db/connection'; +import { securityEvents } from '../db/schema/auth.schema'; +import { LoggerService } from '../common/logger'; +import type { Request } from 'express'; + +export const SecurityEventType = { + // Authentication + LOGIN_SUCCESS: 'login_success', + LOGIN_FAILURE: 'login_failure', + REGISTER: 'register', + LOGOUT: 'logout', + TOKEN_REFRESHED: 'token_refreshed', + SSO_TOKEN_EXCHANGE: 'sso_token_exchange', + + // Password + PASSWORD_CHANGED: 'password_changed', + PASSWORD_RESET_REQUESTED: 'password_reset_requested', + PASSWORD_RESET_COMPLETED: 'password_reset_completed', + + // Email + EMAIL_VERIFIED: 'email_verified', + EMAIL_VERIFICATION_RESENT: 'email_verification_resent', + + // Account + ACCOUNT_DELETED: 'account_deleted', + ACCOUNT_LOCKED: 'account_locked', + ACCOUNT_UNLOCKED: 'account_unlocked', + PROFILE_UPDATED: 'profile_updated', + + // API Keys + API_KEY_CREATED: 'api_key_created', + API_KEY_REVOKED: 'api_key_revoked', + API_KEY_VALIDATED: 'api_key_validated', + API_KEY_VALIDATION_FAILED: 'api_key_validation_failed', + + // Organizations + ORG_CREATED: 'org_created', + ORG_DELETED: 'org_deleted', + ORG_MEMBER_INVITED: 'org_member_invited', + ORG_MEMBER_REMOVED: 'org_member_removed', + ORG_MEMBER_ROLE_CHANGED: 'org_member_role_changed', + ORG_INVITATION_ACCEPTED: 'org_invitation_accepted', +} as const; + +export type SecurityEventTypeValue = (typeof SecurityEventType)[keyof typeof SecurityEventType]; + +export interface SecurityEventParams { + userId?: string; + eventType: SecurityEventTypeValue; + ipAddress?: string; + userAgent?: string; + metadata?: Record; +} + +@Injectable() +export class SecurityEventsService { + private readonly logger: LoggerService; + private readonly databaseUrl: string; + + constructor( + loggerService: LoggerService, + private configService: ConfigService + ) { + this.logger = loggerService; + this.logger.setContext('SecurityEventsService'); + this.databaseUrl = this.configService.get('database.url') || ''; + } + + /** + * Extract IP address and User-Agent from an Express request + */ + extractRequestInfo(req: Request): { ipAddress: string; userAgent: string } { + const forwarded = req.headers['x-forwarded-for']; + const ipAddress = + (typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.ip) || 'unknown'; + const userAgent = (req.headers['user-agent'] as string) || 'unknown'; + return { ipAddress, userAgent }; + } + + /** + * Log a security event to the database. + * Fire-and-forget: never throws, only logs warnings on failure. + */ + async logEvent(params: SecurityEventParams): Promise { + try { + const db = getDb(this.databaseUrl); + await db.insert(securityEvents).values({ + userId: params.userId || null, + eventType: params.eventType, + ipAddress: params.ipAddress || null, + userAgent: params.userAgent || null, + metadata: params.metadata || null, + }); + } catch (error) { + this.logger.warn(`Failed to log security event: ${params.eventType}`, { + error: error instanceof Error ? error.message : 'Unknown error', + userId: params.userId, + }); + } + } + + /** + * Convenience: log event with request context + */ + async logEventWithRequest( + req: Request, + params: Omit + ): Promise { + const { ipAddress, userAgent } = this.extractRequestInfo(req); + await this.logEvent({ ...params, ipAddress, userAgent }); + } +} diff --git a/services/mana-core-auth/src/security/security-events.spec.ts b/services/mana-core-auth/src/security/security-events.spec.ts new file mode 100644 index 000000000..1ad2fc375 --- /dev/null +++ b/services/mana-core-auth/src/security/security-events.spec.ts @@ -0,0 +1,164 @@ +/** + * Security Events Service Tests + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +describe('SecurityEventsService contract', () => { + const servicePath = path.resolve(__dirname, 'security-events.service.ts'); + let serviceContent: string; + + beforeAll(() => { + serviceContent = fs.readFileSync(servicePath, 'utf8'); + }); + + describe('event types', () => { + const requiredEvents = [ + 'login_success', + 'login_failure', + 'register', + 'logout', + 'token_refreshed', + 'sso_token_exchange', + 'password_changed', + 'password_reset_requested', + 'password_reset_completed', + 'email_verified', + 'account_deleted', + 'account_locked', + 'api_key_created', + 'api_key_revoked', + 'api_key_validated', + 'api_key_validation_failed', + 'org_created', + 'org_member_invited', + 'org_member_removed', + ]; + + it.each(requiredEvents)('should define event type: %s', (eventType) => { + expect(serviceContent).toContain(`'${eventType}'`); + }); + }); + + describe('fire-and-forget pattern', () => { + it('should catch errors in logEvent and never throw', () => { + // The logEvent method must have a try-catch that logs warnings + expect(serviceContent).toContain('catch (error)'); + expect(serviceContent).toContain('Failed to log security event'); + }); + }); + + describe('request info extraction', () => { + it('should extract IP from x-forwarded-for header', () => { + expect(serviceContent).toContain('x-forwarded-for'); + }); + + it('should extract user-agent from request', () => { + expect(serviceContent).toContain('user-agent'); + }); + }); +}); + +describe('AccountLockoutService contract', () => { + const servicePath = path.resolve(__dirname, 'account-lockout.service.ts'); + let serviceContent: string; + + beforeAll(() => { + serviceContent = fs.readFileSync(servicePath, 'utf8'); + }); + + it('should define MAX_ATTEMPTS = 5', () => { + expect(serviceContent).toContain('MAX_ATTEMPTS = 5'); + }); + + it('should define ATTEMPT_WINDOW_MINUTES = 15', () => { + expect(serviceContent).toContain('ATTEMPT_WINDOW_MINUTES = 15'); + }); + + it('should define LOCKOUT_DURATION_MINUTES = 30', () => { + expect(serviceContent).toContain('LOCKOUT_DURATION_MINUTES = 30'); + }); + + it('should normalize email to lowercase', () => { + expect(serviceContent).toContain('email.toLowerCase()'); + }); + + it('should fail open on errors (not lock users out if DB fails)', () => { + // On error, checkLockout should return locked: false + expect(serviceContent).toContain('return { locked: false }'); + }); + + it('should clear attempts on successful login', () => { + expect(serviceContent).toContain('clearAttempts'); + expect(serviceContent).toContain('delete(loginAttempts)'); + }); +}); + +describe('Auth Controller lockout integration', () => { + const controllerPath = path.resolve(__dirname, '../auth/auth.controller.ts'); + let controllerContent: string; + + beforeAll(() => { + controllerContent = fs.readFileSync(controllerPath, 'utf8'); + }); + + it('should check lockout before attempting login', () => { + expect(controllerContent).toContain('accountLockout.checkLockout'); + }); + + it('should throw ForbiddenException with ACCOUNT_LOCKED code when locked', () => { + expect(controllerContent).toContain("code: 'ACCOUNT_LOCKED'"); + }); + + it('should include retryAfter in lockout response', () => { + expect(controllerContent).toContain('retryAfter: lockout.remainingSeconds'); + }); + + it('should clear attempts after successful login', () => { + expect(controllerContent).toContain('accountLockout.clearAttempts'); + }); + + it('should record failed attempts on login failure', () => { + expect(controllerContent).toContain('accountLockout.recordAttempt'); + }); + + it('should not count email-not-verified as failed attempt', () => { + expect(controllerContent).toContain('ForbiddenException'); + // The catch block should re-throw ForbiddenException before recording attempt + const loginMethodContent = controllerContent.slice( + controllerContent.indexOf('async login('), + controllerContent.indexOf('async logout(') + ); + const forbiddenCheckIndex = loginMethodContent.indexOf('instanceof ForbiddenException'); + const recordAttemptIndex = loginMethodContent.indexOf('recordAttempt'); + expect(forbiddenCheckIndex).toBeLessThan(recordAttemptIndex); + }); +}); + +describe('API Key validation rate limiting', () => { + const controllerPath = path.resolve(__dirname, '../api-keys/api-keys.controller.ts'); + let controllerContent: string; + + beforeAll(() => { + controllerContent = fs.readFileSync(controllerPath, 'utf8'); + }); + + it('should have rate limiting on validate endpoint', () => { + expect(controllerContent).toContain('@Throttle'); + expect(controllerContent).toContain('limit: 10'); + }); + + it('should use ThrottlerGuard', () => { + expect(controllerContent).toContain('ThrottlerGuard'); + }); + + it('should log successful and failed validations', () => { + expect(controllerContent).toContain('API_KEY_VALIDATED'); + expect(controllerContent).toContain('API_KEY_VALIDATION_FAILED'); + }); + + it('should only log key prefix, never the full key', () => { + expect(controllerContent).toContain("substring(0, 16) + '...'"); + }); +}); diff --git a/services/mana-core-auth/src/security/security.module.ts b/services/mana-core-auth/src/security/security.module.ts new file mode 100644 index 000000000..c4c22e524 --- /dev/null +++ b/services/mana-core-auth/src/security/security.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SecurityEventsService } from './security-events.service'; +import { AccountLockoutService } from './account-lockout.service'; +import { LoggerModule } from '../common/logger'; + +@Module({ + imports: [LoggerModule], + providers: [SecurityEventsService, AccountLockoutService], + exports: [SecurityEventsService, AccountLockoutService], +}) +export class SecurityModule {}