+ 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 = ?"