feat: v0.94 — session token hashing, geolocation opt-in privacy setting

This commit is contained in:
null 2026-06-06 17:00:22 -05:00
parent 840620efe2
commit 9a2a7ecdee
6 changed files with 129 additions and 15 deletions

View File

@ -388,6 +388,10 @@ export const api = {
bankSyncConfig: () => get('/admin/bank-sync-config'),
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
// Admin — privacy settings
privacySettings: () => get('/admin/privacy'),
setPrivacySettings: (data) => put('/admin/privacy', data),
// User SQLite import
previewUserDbImport: async (file) => {
const csrfToken = await getCsrfToken();

View File

@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { FieldRow, Toggle } from './adminShared';
export default function PrivacyAdminCard() {
const [geoEnabled, setGeoEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.privacySettings()
.then(d => { setGeoEnabled(!!d.geolocation_enabled); })
.catch(() => toast.error('Failed to load privacy settings'))
.finally(() => setLoading(false));
}, []);
async function toggleGeo(next) {
setSaving(true);
try {
const d = await api.setPrivacySettings({ geolocation_enabled: next });
setGeoEnabled(!!d.geolocation_enabled);
toast.success(next ? 'Geolocation enabled' : 'Geolocation disabled');
} catch {
toast.error('Failed to update privacy settings');
} finally {
setSaving(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Privacy</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FieldRow label="Login geolocation">
<div className="flex items-center gap-3">
<Toggle
checked={geoEnabled}
onChange={toggleGeo}
disabled={loading || saving}
label="Enable login geolocation"
/>
<span className="text-sm text-muted-foreground">
{geoEnabled ? 'On' : 'Off (default)'}
</span>
</div>
</FieldRow>
<p className="text-xs text-muted-foreground">
When enabled, new-device logins resolve the login IP to a city/region via{' '}
<span className="font-mono">ip-api.com</span> over plain HTTP. Disable to keep
all login data on-device. Location data is encrypted at rest.
</p>
</CardContent>
</Card>
);
}

View File

@ -11,6 +11,7 @@ import UsersTable from '@/components/admin/UsersTable';
import AddUserCard from '@/components/admin/AddUserCard';
import BackupManagementCard from '@/components/admin/BackupManagementCard';
import CleanupPanel from '@/components/admin/CleanupPanel';
import PrivacyAdminCard from '@/components/admin/PrivacyAdminCard';
export default function AdminPage() {
const navigate = useNavigate();
@ -84,6 +85,7 @@ export default function AdminPage() {
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8 space-y-6">
<EmailNotifCard />
<BankSyncAdminCard />
<PrivacyAdminCard />
<BackupManagementCard />
<CleanupPanel />
<LoginModeCard users={users} onModeChange={setAuthMode} />

View File

@ -3056,7 +3056,22 @@ function runMigrations() {
`);
console.log('[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)');
}
}
},
{
version: 'v0.94',
description: 'security: session token hashing + geolocation opt-in setting',
run(db) {
// Seed the geolocation setting (default off) for existing installations
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('geolocation_enabled', 'false')").run();
console.log('[v0.94] geolocation_enabled setting seeded');
// All existing plaintext session IDs are invalidated so everyone re-authenticates.
// Going forward, sessions.id stores SHA-256(token); the raw token stays in the cookie.
const count = db.prepare('SELECT COUNT(*) as n FROM sessions').get().n;
db.exec('DELETE FROM sessions');
console.log(`[v0.94] sessions: cleared ${count} existing plaintext sessions (re-login required)`);
}
},
];
// ── users: notification columns ───────────────────────────────────────────
@ -3333,6 +3348,8 @@ function seedDefaults() {
['oidc_auto_provision', 'true'],
['oidc_admin_group', ''],
['oidc_default_role', 'user'],
// Privacy settings (v0.94)
['geolocation_enabled', 'false'],
];
const insert = db.prepare(
@ -3399,6 +3416,13 @@ function getDbPath() {
// Rollback SQL definitions
const ROLLBACK_SQL_MAP = {
'v0.94': {
description: 'security: session token hashing + geolocation opt-in setting',
sql: [
"DELETE FROM settings WHERE key = 'geolocation_enabled'",
// Sessions were cleared; rollback cannot restore them
]
},
'v0.93': {
description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key',
sql: [

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { isEnvKeyActive } = require('../services/encryptionService');
@ -457,6 +457,26 @@ router.put('/bank-sync-config', (req, res) => {
}
});
// ── Privacy Settings ──────────────────────────────────────────────────────────
// GET /api/admin/privacy
router.get('/privacy', (req, res) => {
res.json({
geolocation_enabled: getSetting('geolocation_enabled') === 'true',
});
});
// PUT /api/admin/privacy
router.put('/privacy', (req, res) => {
const { geolocation_enabled } = req.body || {};
if (typeof geolocation_enabled === 'boolean') {
setSetting('geolocation_enabled', geolocation_enabled ? 'true' : 'false');
}
res.json({
geolocation_enabled: getSetting('geolocation_enabled') === 'true',
});
});
// ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req, res) => {
const { version } = req.body;

View File

@ -1,9 +1,14 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database');
const { getDb, getSetting } = require('../db/database');
const { buildDeviceFingerprint } = require('./loginFingerprint');
const { encryptSecret, decryptSecret } = require('./encryptionService');
// Store SHA-256(token) in the DB; the raw token stays only in the cookie.
function hashSession(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
const COOKIE_NAME = 'bt_session';
const SINGLE_COOKIE_NAME = 'bt_single_session';
const SESSION_DAYS = 7;
@ -88,7 +93,7 @@ async function login(username, password) {
.toISOString().slice(0, 19).replace('T', ' ');
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
.run(sessionId, user.id, expiresAt);
.run(hashSession(sessionId), user.id, expiresAt);
// Update last_login_at if column exists (added in v0.17 migration)
try {
@ -116,14 +121,14 @@ async function createSession(userId) {
.toISOString().slice(0, 19).replace('T', ' ');
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
.run(sessionId, user.id, expiresAt);
.run(hashSession(sessionId), user.id, expiresAt);
return { sessionId, user: publicUser(user) };
}
function logout(sessionId) {
if (!sessionId) return;
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId));
}
/**
@ -136,22 +141,22 @@ function rotateSessionId(oldSessionId, userId) {
const db = getDb();
// Verify the old session belongs to the user and is valid
const existingSession = db.prepare('SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')').get(oldSessionId);
const existingSession = db.prepare('SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')').get(hashSession(oldSessionId));
if (!existingSession || existingSession.user_id !== userId) {
return null;
}
// Generate new session ID
const newSessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
.toISOString().slice(0, 19).replace('T', ' ');
// Delete old session and create new one atomically
try {
db.transaction(() => {
db.prepare('DELETE FROM sessions WHERE id = ?').run(oldSessionId);
db.prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(oldSessionId));
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
.run(newSessionId, userId, expiresAt);
.run(hashSession(newSessionId), userId, expiresAt);
})();
} catch {
return null;
@ -168,7 +173,7 @@ function getSessionUser(sessionId) {
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
`).get(sessionId);
`).get(hashSession(sessionId));
return row || null;
}
@ -254,8 +259,8 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) {
// Background tasks: geolocation + new device push alert
if (insertedId) {
setImmediate(() => {
// Geolocation — skip private/loopback IPs
if (ipAddress) {
// Geolocation — only when admin has opted in, and skip private/loopback IPs
if (ipAddress && getSetting('geolocation_enabled') === 'true') {
const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress);
if (!isPrivate) {
fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`)
@ -365,7 +370,7 @@ function invalidateOtherSessions(userId, keepSessionId) {
if (keepSessionId) {
result = db.prepare(
"DELETE FROM sessions WHERE user_id = ? AND id != ?"
).run(userId, keepSessionId);
).run(userId, hashSession(keepSessionId));
} else {
result = db.prepare(
"DELETE FROM sessions WHERE user_id = ?"