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

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

View file

@ -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)]
);