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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 22:09:58 +01:00
parent effa57fd61
commit f7df8e97aa
14 changed files with 700 additions and 68 deletions

View file

@ -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;
}
}