feat(auth): wire login events + MFA challenge into login handler

- Add recordLoginEvent() helper with risk scoring via scoreLoginRisk()
- On failed login: record 'failed' event with risk assessment
- On MFA-required: issue challenge token, record 'mfa_required' event
- On success: record 'success' event with risk assessment
- Import login-events repo, risk-scorer, mfa repo, challenge store, device repo
This commit is contained in:
saravanakumardb1 2026-03-12 11:17:23 -07:00
parent 10494ae0e4
commit 82d7f157d9

View File

@ -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<void> {
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,