refactor(auth): single digest registry, extracted 2FA and login-log, dep bumps
- password.ts: derive plain and salted digest detection from one DIGEST_SCHEMES table instead of parallel hardcoded lists, so adding a family is one row. - auth.ts: move 2FA challenge verification into twofactor-verification.ts and the website login-log insert into website-login-log.ts, slimming the NextAuth provider to orchestration only. - deps: bump @formatjs/icu-messageformat-parser, @tanstack/react-query, jszip, lucide-react, motion (patch/minor only). @types/react stay pinned per pnpm-workspace.yaml; next-auth is already at the newest available (v5 beta).
This commit is contained in:
1 parent
5d7c9fccdc
commit
2e25b39364
6 files changed
+157
-122
No files matched your search
+5
-66
@@ -14,59 +14,12 @@ import {
|
||||
|
||||
export { invalidateLoginCache };
|
||||
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { db, User, WebsiteLoginLogs } from "@/lib/db";
|
||||
import { verify2faChallenge } from "@/lib/auth/twofactor-verification";
|
||||
import { recordWebsiteLogin } from "@/lib/auth/website-login-log";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
const [user] = await db
|
||||
.select({
|
||||
twoFactorSecret: User.twoFactorSecret,
|
||||
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user?.twoFactorSecret) return false;
|
||||
|
||||
// Try TOTP first
|
||||
try {
|
||||
const appKey = env.APP_KEY;
|
||||
if (!appKey) throw new Error("APP_KEY not configured");
|
||||
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return true;
|
||||
} catch {
|
||||
logger.warn(
|
||||
"2FA TOTP verification failed, falling through to recovery codes",
|
||||
);
|
||||
}
|
||||
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
logger.warn("Failed to parse 2FA recovery codes JSON");
|
||||
return false;
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||
await db
|
||||
.update(User)
|
||||
.set({ twoFactorRecoveryCodes: remaining })
|
||||
.where(eq(User.id, userId));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const { handlers, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: env.AUTH_SECRET,
|
||||
@@ -130,25 +83,11 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
// even when the attacker rotates IPs or knows the password.
|
||||
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
||||
|
||||
if (!(await verify2faCode(user.id, code))) return null;
|
||||
if (!(await verify2faChallenge(user.id, code))) return null;
|
||||
}
|
||||
|
||||
// Record the successful login for the user's "session logs" page.
|
||||
// Best-effort — never let logging block or fail the sign-in.
|
||||
try {
|
||||
const { headers } = await import("next/headers");
|
||||
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
||||
await db.insert(WebsiteLoginLogs).values({
|
||||
userId: user.id,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
logger.warn("Failed to record login log for user", {
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
await recordWebsiteLogin(user.id, ip);
|
||||
|
||||
const jwtVersion = await getCachedJwtVersion(user.id);
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user