feat: v0.94 — session token hashing, geolocation opt-in privacy setting
This commit is contained in:
parent
840620efe2
commit
9a2a7ecdee
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = ?"
|
||||
|
|
|
|||
Loading…
Reference in New Issue