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

@ -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<string>('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<LockoutStatus> {
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<number>`count(*)::int`,
latestAttempt: sql<Date>`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<void> {
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<void> {
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',
});
}
}
}

View file

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

View file

@ -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<string, unknown>;
}
@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<string>('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<void> {
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<SecurityEventParams, 'ipAddress' | 'userAgent'>
): Promise<void> {
const { ipAddress, userAgent } = this.extractRequestInfo(req);
await this.logEvent({ ...params, ipAddress, userAgent });
}
}

View file

@ -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) + '...'");
});
});

View file

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