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:
parent
10494ae0e4
commit
82d7f157d9
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user