feat: TOTP 2FA for login & profile setup flow

This commit is contained in:
null 2026-06-04 04:10:14 -05:00
parent a6b2e8bb87
commit 653dd72e12
10 changed files with 856 additions and 18 deletions

View File

@ -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.

View File

@ -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'),

View File

@ -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() {
/>
</div>
{/* 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 && (
<div className="surface-elevated p-8 space-y-5">
<div>
<h1 className="text-lg font-semibold">Two-factor authentication</h1>
<p className="text-sm text-muted-foreground mt-1">
{useRecovery ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.'}
</p>
</div>
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="totp-code">{useRecovery ? 'Recovery code' : 'Authenticator code'}</Label>
<Input
id="totp-code"
value={totpCode}
onChange={e => 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
/>
</div>
{totpError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{totpError}
</div>
)}
<Button type="submit" className="w-full" disabled={totpLoading || !totpCode.trim()}>
{totpLoading ? 'Verifying…' : 'Verify'}
</Button>
</form>
<div className="text-center space-y-2">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => { setUseRecovery(v => !v); setTotpCode(''); setTotpError(''); }}
>
{useRecovery ? 'Use authenticator app instead' : "Can't access your app? Use a recovery code"}
</button>
<br />
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => { setTotpChallenge(null); setTotpCode(''); setTotpError(''); }}
>
Back to sign in
</button>
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
{!totpChallenge && (authMode === null ? (
<div className="surface-elevated p-8 flex items-center justify-center min-h-[120px]">
<span className="text-sm text-muted-foreground">Loading</span>
</div>
@ -243,7 +333,7 @@ export default function LoginPage() {
</Link>
</div>
</div>
)} {/* end authMode !== null */}
))} {/* end !totpChallenge + authMode check */}
</div>
{/* Change Password Dialog */}

View File

@ -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 (
<>
<TotpSection />
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form.">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-3">
<div className="space-y-1.5">
@ -656,6 +658,189 @@ function ChangePassword() {
</Button>
</form>
</SectionCard>
</>
);
}
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button type="button" onClick={copy} className="ml-1.5 inline-flex items-center text-muted-foreground hover:text-foreground transition-colors">
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
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 (
<SectionCard title="Two-Factor Authentication" icon={ScanLine} subtitle="Require an authenticator app code on every sign-in.">
<div className="px-6 py-5 space-y-5">
{/* Idle — enabled status */}
{step === 'idle' && (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className={`h-2.5 w-2.5 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
<span className="text-sm">{enabled ? 'Authenticator app is active' : 'Not configured'}</span>
</div>
{enabled
? <Button variant="outline" size="sm" onClick={() => { setCode(''); setStep('disable'); }}>Remove</Button>
: <Button size="sm" onClick={startSetup} disabled={saving}>{saving ? 'Loading…' : 'Set up'}</Button>
}
</div>
)}
{/* Setup — show QR code */}
{step === 'setup' && setupData && (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
Scan the QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app. Then enter the 6-digit code to confirm.
</p>
<div className="flex flex-col sm:flex-row gap-6 items-start">
<img src={setupData.qr_data_url} alt="TOTP QR code" className="rounded-lg border border-border/60 w-40 h-40 shrink-0" />
<div className="space-y-3 min-w-0">
<p className="text-xs text-muted-foreground">Can't scan? Enter this key manually:</p>
<div className="flex items-center gap-1 font-mono text-sm bg-muted/30 border border-border/60 rounded px-3 py-2 break-all">
{setupData.secret}
<CopyButton text={setupData.secret} />
</div>
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
<Input
value={code}
onChange={e => 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
/>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={saving || !code.trim()}>{saving ? 'Verifying…' : 'Confirm & Enable'}</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setStep('idle'); setSetupData(null); }}>Cancel</Button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Recovery codes — shown once after enabling */}
{step === 'recovery' && (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<TriangleAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-600 dark:text-amber-400">
Save these recovery codes somewhere safe. Each code works once. If you lose your phone, use one of these to sign in.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{recoveryCodes.map(c => (
<div key={c} className="flex items-center justify-between gap-1 font-mono text-xs bg-muted/30 border border-border/60 rounded px-2.5 py-1.5">
{c} <CopyButton text={c} />
</div>
))}
</div>
<Button size="sm" onClick={() => { setStep('idle'); setRecoveryCodes([]); }}>Done I've saved these</Button>
</div>
)}
{/* Disable — requires TOTP code */}
{step === 'disable' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Enter the current code from your authenticator app to remove 2FA.</p>
<form onSubmit={confirmDisable} className="flex items-end gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Authenticator code</label>
<Input
value={code}
onChange={e => 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
/>
</div>
<Button type="submit" variant="destructive" size="sm" disabled={saving || !code.trim()}>{saving ? 'Removing…' : 'Remove 2FA'}</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setStep('idle'); setCode(''); }}>Cancel</Button>
</form>
</div>
)}
</div>
</SectionCard>
);
}

View File

@ -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');
}
}
];

311
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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()

View File

@ -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);

113
services/totpService.js Normal file
View File

@ -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,
};