mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 00:26:41 +02:00
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:
parent
effa57fd61
commit
f7df8e97aa
14 changed files with 700 additions and 68 deletions
141
services/mana-core-auth/src/security/account-lockout.service.ts
Normal file
141
services/mana-core-auth/src/security/account-lockout.service.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
5
services/mana-core-auth/src/security/index.ts
Normal file
5
services/mana-core-auth/src/security/index.ts
Normal 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';
|
||||
122
services/mana-core-auth/src/security/security-events.service.ts
Normal file
122
services/mana-core-auth/src/security/security-events.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
164
services/mana-core-auth/src/security/security-events.spec.ts
Normal file
164
services/mana-core-auth/src/security/security-events.spec.ts
Normal 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) + '...'");
|
||||
});
|
||||
});
|
||||
11
services/mana-core-auth/src/security/security.module.ts
Normal file
11
services/mana-core-auth/src/security/security.module.ts
Normal 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 {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue