From 653dd72e12a3af36ee2bf7e465d99c6b1a232313 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 4 Jun 2026 04:10:14 -0500 Subject: [PATCH] feat: TOTP 2FA for login & profile setup flow --- HISTORY.md | 4 + client/api.js | 5 + client/pages/LoginPage.jsx | 96 ++++++++++- client/pages/ProfilePage.jsx | 189 ++++++++++++++++++++- db/database.js | 24 +++ package-lock.json | 311 +++++++++++++++++++++++++++++++++-- package.json | 2 + routes/auth.js | 123 +++++++++++++- services/authService.js | 7 + services/totpService.js | 113 +++++++++++++ 10 files changed, 856 insertions(+), 18 deletions(-) create mode 100644 services/totpService.js diff --git a/HISTORY.md b/HISTORY.md index 67e1cc5..6a97745 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -8,6 +8,10 @@ - **Login history: encrypted at rest + geolocation + new device alerts + failed attempt tracking + session detection + 10 records** — `user_login_history` now stores `ip_address` and `user_agent` encrypted with AES-256-GCM (same `encryptionService` used for SMTP, OIDC, and bank tokens). Migration v0.84 retroactively encrypts any existing plaintext rows and adds four new location columns (`location_city`, `location_country`, `location_region`, `location_isp`). On each new login, a non-blocking fire-and-forget request to `ip-api.com` resolves the IP to a city/region/country/ISP and stores it encrypted. Private and loopback IPs are skipped for geolocation. The login-history API decrypts all fields server-side before returning them — only the authenticated user can see their own data. History limit increased from 3 to 10 records. The Profile page now shows city, region, country, and ISP below each login entry. Migration v0.85 adds `success` (1 = successful login, 0 = failed attempt) and `session_fingerprint` (SHA-256 of the session ID) columns. Failed login attempts with wrong passwords are now recorded and displayed in the history modal with a red "Failed attempt" badge. The current active session is marked with a "This session" badge by comparing the session cookie fingerprint against stored values. New device logins (device fingerprint not seen in previous 10 logins) trigger a push notification via the user's configured channel (ntfy, Gotify, Discord, or Telegram). The summary card on the Profile page now shows location and always reflects the most recent successful login, not a failed attempt. +- **Login history for single-user mode** — In single-user mode the app bypasses the session system entirely, so `recordLogin` was never called and login history was always empty. Fixed by issuing a `bt_single_session` presence cookie (httpOnly, 30-day, same security flags as the regular session cookie) on the first request from each new browser. `recordLogin` fires once per new cookie via `setImmediate` so it never delays page load. Return visits reuse the cookie and don't create duplicate entries. The login-history route now uses `bt_single_session` when `req.singleUserMode` is set so the "This session" fingerprint comparison works correctly without a real session cookie. OIDC logins were also missing the `sessionId` parameter passed to `recordLogin`, so "This session" never matched for OIDC sessions; fixed by passing `session.sessionId` through the OIDC callback. + +- **No Login mode — admin UI redesign** — The `LoginModeCard` in the Admin page is now a clean radio-group selector with two first-class options: **Require Login** (multi-user, shows the OIDC/local-login settings card below) and **No Login — Single User** (hides the auth methods card, shows a user picker and an amber security warning). An inline confirmation dialog is shown before enabling No Login mode. The `AuthMethodsCard` is conditionally hidden when single-user mode is active since OIDC and local-login settings are irrelevant when there is no login screen. The backend lockout validation (which previously blocked saving when all login methods were disabled) now skips entirely when `auth_mode === 'single'`. The `LoginPage` no longer flashes the sign-in form in single-user mode — it renders a neutral loading state while the auth-mode check is in-flight and redirects before the form ever appears. + - **`payments.js` SQL fragment renamed for clarity** — `const LIVE = 'deleted_at IS NULL'` was renamed to `const SQL_NOT_DELETED` and given a 4-line comment explaining why SQL fragment interpolation is safe here, why parameterisation is not applicable to SQL fragments (only values can be bound, not column conditions), and explicitly warning future developers not to replace the pattern with dynamic input. - **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema. diff --git a/client/api.js b/client/api.js index 12e759a..d07813c 100644 --- a/client/api.js +++ b/client/api.js @@ -64,6 +64,11 @@ export const api = { acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), acknowledgeVersion: () => post('/auth/acknowledge-version'), loginHistory: () => get('/auth/login-history'), + totpStatus: () => get('/auth/totp/status'), + totpSetup: () => get('/auth/totp/setup'), + totpEnable: (data) => post('/auth/totp/enable', data), + totpDisable: (data) => post('/auth/totp/disable', data), + totpChallenge: (data) => post('/auth/totp/challenge', data), // Admin hasUsers: () => get('/admin/has-users'), diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx index 505a01e..3275e77 100644 --- a/client/pages/LoginPage.jsx +++ b/client/pages/LoginPage.jsx @@ -28,6 +28,13 @@ export default function LoginPage() { const [showChangePw, setShowChangePw] = useState(false); const [showPrivacy, setShowPrivacy] = useState(false); + // TOTP challenge state + const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string + const [totpCode, setTotpCode] = useState(''); + const [totpError, setTotpError] = useState(''); + const [totpLoading, setTotpLoading] = useState(false); + const [useRecovery, setUseRecovery] = useState(false);; + const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); const [pwLoading, setPwLoading] = useState(false); @@ -70,6 +77,13 @@ export default function LoginPage() { setLoading(true); try { const data = await api.login({ username, password }); + if (data.requires_totp) { + setTotpChallenge(data.challenge_token); + setTotpCode(''); + setTotpError(''); + setUseRecovery(false); + return; + } handlePostLogin(data.user); } catch (err) { setError(err.message || 'Login failed.'); @@ -78,6 +92,24 @@ export default function LoginPage() { } }; + const handleTotpSubmit = async (e) => { + e.preventDefault(); + setTotpError(''); + setTotpLoading(true); + try { + const payload = { challenge_token: totpChallenge }; + if (useRecovery) payload.recovery_code = totpCode; + else payload.code = totpCode; + const data = await api.totpChallenge(payload); + handlePostLogin(data.user); + } catch (err) { + setTotpError(err.message || 'Invalid code.'); + setTotpCode(''); + } finally { + setTotpLoading(false); + } + }; + const localEnabled = authMode?.local_enabled !== false; const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url; const providerName = authMode?.oidc_provider_name || 'authentik'; @@ -139,8 +171,66 @@ export default function LoginPage() { /> - {/* Card — hidden while auth mode is still resolving to avoid flash in single-user mode */} - {authMode === null ? ( + {/* TOTP step — shown after password is accepted */} + {totpChallenge && ( +
+
+

Two-factor authentication

+

+ {useRecovery ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.'} +

+
+ +
+
+ + setTotpCode(e.target.value)} + placeholder={useRecovery ? 'XXXXX-XXXXX' : '000 000'} + autoComplete="one-time-code" + autoFocus + disabled={totpLoading} + maxLength={useRecovery ? 11 : 7} + className="text-center tracking-widest text-lg font-mono" + required + /> +
+ + {totpError && ( +
+ {totpError} +
+ )} + + +
+ +
+ +
+ +
+
+ )} + + {/* Sign-in card — hidden while auth mode resolves or during TOTP step */} + {!totpChallenge && (authMode === null ? (
Loading…
@@ -243,7 +333,7 @@ export default function LoginPage() { - )} {/* end authMode !== null */} + ))} {/* end !totpChallenge + authMode check */} {/* Change Password Dialog */} diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index 87a9549..edfe554 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { toast } from 'sonner'; import { User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight, - Bell, SendHorizontal, + Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, } from 'lucide-react'; import { api } from '@/api'; import { useAuth } from '@/hooks/useAuth'; @@ -637,6 +637,8 @@ function ChangePassword() { }; return ( + <> +
@@ -656,6 +658,189 @@ function ChangePassword() { + + ); +} + +function CopyButton({ text }) { + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + return ( + + ); +} + +function TotpSection() { + const { singleUserMode } = useAuth(); + const [enabled, setEnabled] = useState(null); // null = loading + const [step, setStep] = useState('idle'); // idle | setup | confirm | recovery | disable + const [setupData, setSetupData] = useState(null); // { secret, qr_data_url } + const [code, setCode] = useState(''); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [saving, setSaving] = useState(false); + + const load = useCallback(() => { + if (singleUserMode) return; + api.totpStatus() + .then(d => setEnabled(d.enabled)) + .catch(() => setEnabled(false)); + }, [singleUserMode]); + + useEffect(() => { load(); }, [load]); + + if (singleUserMode) return null; + if (enabled === null) return null; + + const startSetup = async () => { + setSaving(true); + try { + const d = await api.totpSetup(); + setSetupData(d); + setCode(''); + setStep('setup'); + } catch (err) { + toast.error(err.message || 'Failed to generate setup data.'); + } finally { + setSaving(false); + } + }; + + const confirmEnable = async (e) => { + e.preventDefault(); + setSaving(true); + try { + const d = await api.totpEnable({ secret: setupData.secret, code }); + setRecoveryCodes(d.recovery_codes); + setEnabled(true); + setStep('recovery'); + } catch (err) { + toast.error(err.message || 'Invalid code. Try again.'); + } finally { + setSaving(false); + } + }; + + const confirmDisable = async (e) => { + e.preventDefault(); + setSaving(true); + try { + await api.totpDisable({ code }); + setEnabled(false); + setStep('idle'); + setCode(''); + toast.success('Authenticator app removed.'); + } catch (err) { + toast.error(err.message || 'Invalid code.'); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + {/* Idle — enabled status */} + {step === 'idle' && ( +
+
+
+ {enabled ? 'Authenticator app is active' : 'Not configured'} +
+ {enabled + ? + : + } +
+ )} + + {/* Setup — show QR code */} + {step === 'setup' && setupData && ( +
+

+ Scan the QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app. Then enter the 6-digit code to confirm. +

+
+ TOTP QR code +
+

Can't scan? Enter this key manually:

+
+ {setupData.secret} + +
+
+ setCode(e.target.value)} + placeholder="000 000" + autoComplete="one-time-code" + maxLength={7} + className="text-center tracking-widest font-mono text-lg max-w-[140px]" + autoFocus + required + /> +
+ + +
+
+
+
+
+ )} + + {/* Recovery codes — shown once after enabling */} + {step === 'recovery' && ( +
+
+ +

+ Save these recovery codes somewhere safe. Each code works once. If you lose your phone, use one of these to sign in. +

+
+
+ {recoveryCodes.map(c => ( +
+ {c} +
+ ))} +
+ +
+ )} + + {/* Disable — requires TOTP code */} + {step === 'disable' && ( +
+

Enter the current code from your authenticator app to remove 2FA.

+
+
+ + setCode(e.target.value)} + placeholder="000 000" + autoComplete="one-time-code" + maxLength={7} + className="text-center tracking-widest font-mono text-lg max-w-[140px]" + autoFocus + required + /> +
+ + +
+
+ )} +
+ ); } diff --git a/db/database.js b/db/database.js index 1c12dff..c7753da 100644 --- a/db/database.js +++ b/db/database.js @@ -2761,6 +2761,30 @@ function runMigrations() { } console.log('[v0.85] user_login_history: success + session_fingerprint columns added'); } + }, + { + version: 'v0.86', + description: 'users: TOTP/authenticator 2FA columns + totp_challenges table', + dependsOn: ['v0.85'], + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('totp_enabled')) + db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0'); + if (!cols.includes('totp_secret')) + db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT'); + if (!cols.includes('totp_recovery_codes')) + db.exec('ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT'); + + db.exec(` + CREATE TABLE IF NOT EXISTS totp_challenges ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + console.log('[v0.86] users: TOTP columns + totp_challenges table'); + } } ]; diff --git a/package-lock.json b/package-lock.json index f8ffcd3..d6f9330 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bill-tracker", - "version": "0.28.4.4", + "version": "0.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bill-tracker", - "version": "0.28.4.4", + "version": "0.36.0", "license": "ISC", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", @@ -35,6 +35,8 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.9", "openid-client": "^5.7.1", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -2155,6 +2157,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2190,6 +2204,62 @@ "node": ">= 8" } }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3783,6 +3853,15 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@tanstack/query-core": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", @@ -4058,7 +4137,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4068,7 +4146,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4575,6 +4652,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4789,7 +4875,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4802,7 +4887,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/comma-separated-tokens": { @@ -5073,6 +5157,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -5218,6 +5311,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5271,7 +5370,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -5794,6 +5892,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5968,7 +6079,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6600,7 +6710,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7053,6 +7162,18 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8337,6 +8458,20 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8355,6 +8490,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8396,6 +8567,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8481,6 +8661,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8730,6 +8919,89 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -9188,7 +9460,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9204,6 +9475,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9477,6 +9754,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9820,7 +10103,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9951,7 +10233,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10962,6 +11243,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", diff --git a/package.json b/package.json index a5f1c49..0b30064 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.9", "openid-client": "^5.7.1", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/routes/auth.js b/routes/auth.js index 70f3879..a7566fc 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -52,6 +52,11 @@ router.post('/login', (req, res, next) => { return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR')); } + // TOTP required — don't create a session yet + if (result.requires_totp) { + return res.json({ requires_totp: true, challenge_token: result.challenge_token }); + } + logAudit({ user_id: result.user.id, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') }); recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId); @@ -153,7 +158,123 @@ router.get('/login-history', requireAuth, (req, res) => { res.json({ history }); }); -// POST /api/auth/acknowledge-version — user has seen the release notes +// ── TOTP / Authenticator App ───────────────────────────────────────────────── + +const { + generateSecret, generateQrCode, verifyToken, verifyTokenRaw, + generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, + createChallenge, consumeChallenge, +} = require('../services/totpService'); +const { encryptSecret: encTotpSecret } = require('../services/encryptionService'); + +// POST /api/auth/totp/challenge — second step of login when TOTP is enabled. +// Takes challenge_token (from first login step) + totp_code, creates a session. +router.post('/totp/challenge', async (req, res) => { + req.csrfSkip = true; + const { challenge_token, code, recovery_code } = req.body || {}; + if (!challenge_token) return res.status(400).json(standardizeError('challenge_token is required', 'VALIDATION_ERROR')); + + const db = getDb(); + const userId = consumeChallenge(db, challenge_token); + if (!userId) return res.status(401).json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR')); + + const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId); + if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); + + let verified = false; + if (recovery_code) { + const result = consumeRecoveryCode(db, userId, recovery_code); + verified = result.used; + if (verified && result.remaining === 0) { + // Warn but don't block — they're in, but should regenerate codes + } + } else if (code) { + verified = verifyToken(user.totp_secret, code); + } + + if (!verified) { + logAudit({ user_id: userId, action: 'totp.failure', ip_address: req.ip, user_agent: req.get('user-agent') }); + return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR')); + } + + try { + const { createSession } = require('../services/authService'); + const session = await createSession(userId); + if (!session) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); + + logAudit({ user_id: userId, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') }); + recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId); + + res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req)); + res.json({ user: session.user }); + } catch (err) { + console.error('[totp/challenge]', err); + res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR')); + } +}); + +// GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user. +// The secret is NOT saved yet; the user must confirm a valid code via /totp/enable. +router.get('/totp/setup', requireAuth, async (req, res) => { + if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR')); + try { + const secret = generateSecret(); + const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id); + const { uri, qr_data_url } = await generateQrCode(secret, user.username); + res.json({ secret, uri, qr_data_url }); + } catch (err) { + console.error('[totp/setup]', err); + res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR')); + } +}); + +// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP. +router.post('/totp/enable', requireAuth, (req, res) => { + if (req.singleUserMode) return res.status(400).json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR')); + const { secret, code } = req.body || {}; + if (!secret || !code) return res.status(400).json(standardizeError('secret and code are required', 'VALIDATION_ERROR')); + if (!verifyTokenRaw(secret, code)) return res.status(400).json(standardizeError('Invalid authenticator code. Check your app and try again.', 'VALIDATION_ERROR', 'code')); + + const plainCodes = generateRecoveryCodes(); + const hashedCodes = plainCodes.map(hashRecoveryCode); + const db = getDb(); + db.prepare(` + UPDATE users SET totp_enabled=1, totp_secret=?, totp_recovery_codes=?, updated_at=datetime('now') + WHERE id=? + `).run(encTotpSecret(secret), encTotpSecret(JSON.stringify(hashedCodes)), req.user.id); + + logAudit({ user_id: req.user.id, action: 'totp.enabled', ip_address: req.ip, user_agent: req.get('user-agent') }); + res.json({ enabled: true, recovery_codes: plainCodes }); +}); + +// POST /api/auth/totp/disable — disable TOTP. Requires a valid TOTP code or recovery code. +router.post('/totp/disable', requireAuth, (req, res) => { + const { code, recovery_code } = req.body || {}; + const db = getDb(); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + if (!user?.totp_enabled) return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR')); + + let verified = false; + if (recovery_code) { + verified = consumeRecoveryCode(db, req.user.id, recovery_code).used; + } else if (code) { + verified = verifyToken(user.totp_secret, code); + } + if (!verified) return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code')); + + db.prepare(`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`) + .run(req.user.id); + logAudit({ user_id: req.user.id, action: 'totp.disabled', ip_address: req.ip, user_agent: req.get('user-agent') }); + res.json({ enabled: false }); +}); + +// GET /api/auth/totp/status — is TOTP enabled for the current user? +router.get('/totp/status', requireAuth, (req, res) => { + const user = getDb().prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id); + res.json({ enabled: !!user?.totp_enabled }); +}); + +// POST /api/auth/totp/acknowledge-version — user has seen the release notes router.post('/acknowledge-version', requireAuth, (req, res) => { const currentVersion = getAppVersion(); getDb() diff --git a/services/authService.js b/services/authService.js index 341fe65..e9d171d 100644 --- a/services/authService.js +++ b/services/authService.js @@ -61,6 +61,13 @@ async function login(username, password) { // The external response is identical either way — no user enumeration. if (!valid) return { error: 'bad_password', userId: user.id }; + // TOTP is enabled — don't create a session yet; issue a short-lived challenge instead + if (user.totp_enabled) { + const { createChallenge } = require('./totpService'); + const challengeToken = createChallenge(getDb(), user.id); + return { requires_totp: true, challenge_token: challengeToken }; + } + // Clean up expired sessions for this user before creating new session try { db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id); diff --git a/services/totpService.js b/services/totpService.js new file mode 100644 index 0000000..93550d1 --- /dev/null +++ b/services/totpService.js @@ -0,0 +1,113 @@ +'use strict'; + +const crypto = require('crypto'); +const { authenticator } = require('otplib'); +const QRCode = require('qrcode'); +const { encryptSecret, decryptSecret } = require('./encryptionService'); + +const APP_NAME = 'Bill Tracker'; +const RECOVERY_CODE_COUNT = 8; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +authenticator.options = { window: 1 }; // accept ±1 time step for clock drift + +function generateSecret() { + return authenticator.generateSecret(20); +} + +async function generateQrCode(secret, username) { + const uri = authenticator.keyuri(username, APP_NAME, secret); + const dataUrl = await QRCode.toDataURL(uri, { width: 200, margin: 2 }); + return { uri, qr_data_url: dataUrl }; +} + +function verifyToken(encryptedSecret, token) { + if (!encryptedSecret || !token) return false; + try { + const secret = decryptSecret(encryptedSecret); + return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); + } catch { + return false; + } +} + +function verifyTokenRaw(secret, token) { + if (!secret || !token) return false; + try { + return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret }); + } catch { + return false; + } +} + +function generateRecoveryCodes() { + return Array.from({ length: RECOVERY_CODE_COUNT }, () => { + const bytes = crypto.randomBytes(5); + const hex = bytes.toString('hex').toUpperCase(); + return `${hex.slice(0, 5)}-${hex.slice(5)}`; + }); +} + +function hashRecoveryCode(code) { + return crypto.createHash('sha256').update(code.replace(/-/g, '').toUpperCase()).digest('hex'); +} + +// Returns { used: true } if a matching unused recovery code was found and consumed. +function consumeRecoveryCode(db, userId, code) { + const normalized = code.replace(/[-\s]/g, '').toUpperCase(); + const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId); + if (!user?.totp_recovery_codes) return { used: false }; + + let stored; + try { + stored = JSON.parse(decryptSecret(user.totp_recovery_codes)); + } catch { + return { used: false }; + } + + const incomingHash = crypto.createHash('sha256').update(normalized).digest('hex'); + const idx = stored.findIndex(h => h === incomingHash); + if (idx === -1) return { used: false }; + + stored.splice(idx, 1); + db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?') + .run(encryptSecret(JSON.stringify(stored)), userId); + + return { used: true, remaining: stored.length }; +} + +// Short-lived challenge token issued after password passes, before TOTP is verified. +function createChallenge(db, userId) { + const id = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS) + .toISOString().slice(0, 19).replace('T', ' '); + db.prepare('DELETE FROM totp_challenges WHERE user_id = ?').run(userId); + db.prepare('INSERT INTO totp_challenges (id, user_id, expires_at) VALUES (?, ?, ?)').run(id, userId, expiresAt); + return id; +} + +function consumeChallenge(db, challengeId) { + const row = db.prepare( + "SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')" + ).get(challengeId); + if (!row) return null; + db.prepare('DELETE FROM totp_challenges WHERE id = ?').run(challengeId); + return row.user_id; +} + +function pruneExpiredChallenges(db) { + db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run(); +} + +module.exports = { + generateSecret, + generateQrCode, + verifyToken, + verifyTokenRaw, + generateRecoveryCodes, + hashRecoveryCode, + consumeRecoveryCode, + createChallenge, + consumeChallenge, + pruneExpiredChallenges, +};