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 licenseRepo from '../licenses/repository.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import * as jwt from './jwt.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 {
|
import {
|
||||||
LoginSchema,
|
LoginSchema,
|
||||||
RegisterSchema,
|
RegisterSchema,
|
||||||
@ -112,6 +117,49 @@ class AccountLockedError extends ServiceError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
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
|
// Login
|
||||||
app.post('/auth/login', async req => {
|
app.post('/auth/login', async req => {
|
||||||
const parsed = LoginSchema.safeParse(req.body);
|
const parsed = LoginSchema.safeParse(req.body);
|
||||||
@ -161,6 +209,17 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
await repo.incrementFailedLogin(user.id, lockedUntil);
|
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');
|
throw new UnauthorizedError('AUTH_INVALID_CREDENTIALS');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,8 +227,45 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
if (user.failedLoginAttempts && user.failedLoginAttempts > 0) {
|
if (user.failedLoginAttempts && user.failedLoginAttempts > 0) {
|
||||||
await repo.resetFailedLogin(user.id);
|
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);
|
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({
|
const accessToken = await jwt.createAccessToken({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user