diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts index d8cdaf26..77a1eb0e 100644 --- a/services/platform-service/src/modules/auth/routes.ts +++ b/services/platform-service/src/modules/auth/routes.ts @@ -41,6 +41,11 @@ import * as subscriptionRepo from '../subscriptions/repository.js'; import * as licenseRepo from '../licenses/repository.js'; import * as repo from './repository.js'; import * as jwt from './jwt.js'; +import * as loginEventRepo from './login-events/repository.js'; +import { scoreLoginRisk } from './login-events/risk-scorer.js'; +import * as mfaRepo from './mfa/repository.js'; +import { createChallenge } from './mfa/challenge-store.js'; +import * as deviceRepo from './devices/repository.js'; import { LoginSchema, RegisterSchema, @@ -112,6 +117,49 @@ class AccountLockedError extends ServiceError { } export async function authRoutes(app: FastifyInstance) { + // ── Login event recording helper ────────────────────────── + async function recordLoginEvent( + userId: string, + productId: string, + result: 'success' | 'failed' | 'locked' | 'mfa_required' | 'mfa_failed', + method: 'password' | 'oauth_google' | 'oauth_microsoft' | 'oauth_apple' | 'passkey' | 'refresh', + ip: string, + userAgent?: string, + fingerprint?: string + ): Promise { + try { + const device = fingerprint ? await deviceRepo.getByFingerprint(userId, fingerprint) : null; + const recentFailures = await loginEventRepo.countRecentFailures(userId, 15 * 60 * 1000); + const risk = scoreLoginRisk({ + ip, + userAgent, + fingerprint, + isNewIp: true, // simplified — full impl would check IP history + isNewDevice: !device, + isDeviceTrusted: deviceRepo.isDeviceTrusted(device), + recentFailures, + method, + hourOfDay: new Date().getHours(), + }); + await loginEventRepo.record({ + id: `le_${crypto.randomUUID()}`, + userId, + productId, + result, + method, + riskLevel: risk.level, + riskScore: risk.score, + ip, + userAgent, + fingerprint, + riskFlags: risk.flags, + createdAt: new Date().toISOString(), + }); + } catch { + // best-effort — never block login + } + } + // Login app.post('/auth/login', async req => { const parsed = LoginSchema.safeParse(req.body); @@ -161,6 +209,17 @@ export async function authRoutes(app: FastifyInstance) { .catch(() => {}); } await repo.incrementFailedLogin(user.id, lockedUntil); + + // Record failed login event (best-effort) + recordLoginEvent( + user.id, + productId, + 'failed', + 'password', + ip, + req.headers['user-agent'] as string | undefined + ).catch(() => {}); + throw new UnauthorizedError('AUTH_INVALID_CREDENTIALS'); } @@ -168,8 +227,45 @@ export async function authRoutes(app: FastifyInstance) { if (user.failedLoginAttempts && user.failedLoginAttempts > 0) { await repo.resetFailedLogin(user.id); } + + // Check if MFA is enabled — issue challenge instead of tokens + if (user.mfaEnabled) { + const mfaDoc = await mfaRepo.getByUserId(user.id); + if (mfaDoc?.verified) { + const challengeToken = createChallenge({ + userId: user.id, + productId, + email: user.email, + role: user.role, + plan: user.plan, + }); + + // Record MFA-required login event (best-effort) + recordLoginEvent( + user.id, + productId, + 'mfa_required', + 'password', + ip, + req.headers['user-agent'] as string | undefined + ).catch(() => {}); + + return { mfaRequired: true, challengeToken }; + } + } + await repo.updateLastLogin(user.id); + // Record successful login event (best-effort) + recordLoginEvent( + user.id, + productId, + 'success', + 'password', + ip, + req.headers['user-agent'] as string | undefined + ).catch(() => {}); + const accessToken = await jwt.createAccessToken({ sub: user.id, email: user.email,