From 9a2a7ecdee7844329a367e8ddd77435feb0b1e8d Mon Sep 17 00:00:00 2001 From: null Date: Sat, 6 Jun 2026 17:00:22 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.94=20=E2=80=94=20session=20token=20h?= =?UTF-8?q?ashing,=20geolocation=20opt-in=20privacy=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/api.js | 4 ++ client/components/admin/PrivacyAdminCard.jsx | 59 ++++++++++++++++++++ client/pages/AdminPage.jsx | 2 + db/database.js | 26 ++++++++- routes/admin.js | 22 +++++++- services/authService.js | 31 +++++----- 6 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 client/components/admin/PrivacyAdminCard.jsx diff --git a/client/api.js b/client/api.js index 6abc49d..1441b7e 100644 --- a/client/api.js +++ b/client/api.js @@ -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(); diff --git a/client/components/admin/PrivacyAdminCard.jsx b/client/components/admin/PrivacyAdminCard.jsx new file mode 100644 index 0000000..2fffc99 --- /dev/null +++ b/client/components/admin/PrivacyAdminCard.jsx @@ -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 ( + + + Privacy + + + +
+ + + {geoEnabled ? 'On' : 'Off (default)'} + +
+
+

+ When enabled, new-device logins resolve the login IP to a city/region via{' '} + ip-api.com over plain HTTP. Disable to keep + all login data on-device. Location data is encrypted at rest. +

+
+
+ ); +} diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index 130cb17..ad45e1b 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -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() {
+ diff --git a/db/database.js b/db/database.js index 66d4af6..0874d8a 100644 --- a/db/database.js +++ b/db/database.js @@ -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: [ diff --git a/routes/admin.js b/routes/admin.js index 165b467..20fe9d6 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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; diff --git a/services/authService.js b/services/authService.js index 541be6f..475f527 100644 --- a/services/authService.js +++ b/services/authService.js @@ -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 = ?"