diff --git a/HISTORY.md b/HISTORY.md index 4f9974f..67e1cc5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,8 @@ - **Bump** — `0.35.1` → `0.36.0` +- **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. + - **`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. diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index e9e5264..87a9549 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -125,7 +125,7 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } Login History - Your last 3 sign-in events + Your last 10 sign-in events @@ -137,21 +137,35 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } ) : history.length === 0 ? (

No login history recorded.

) : history.map((entry, i) => { + const isFailed = entry.success === false; const parsed = parseUserAgent(entry.user_agent); const browser = entry.browser || parsed.browser; const os = entry.os || parsed.os; const deviceType = entry.device_type || (parsed.mobile ? 'mobile' : 'desktop'); const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor; + const isFirstSuccess = !isFailed && history.slice(0, i).every(e => e.success === false); return (
- + className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${ + isFailed ? 'border-destructive/30 bg-destructive/5' : 'border-border/50 bg-muted/20' + }`}> +
-

+

{formatDateTime(entry.logged_in_at)} - {i === 0 && ( - - most recent + {isFailed && ( + + Failed attempt + + )} + {entry.is_current_session && ( + + This session + + )} + {!entry.is_current_session && isFirstSuccess && ( + + Most recent )}

@@ -160,7 +174,17 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded } {entry.ip_address && ( {entry.ip_address} )} + {(entry.location_city || entry.location_country) && ( + + — {[entry.location_city, entry.location_region, entry.location_country].filter(Boolean).join(', ')} + + )}

+ {entry.location_isp && ( +

+ {entry.location_isp} +

+ )} {entry.device_fingerprint && (

Device ID {entry.device_fingerprint} @@ -173,10 +197,10 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }

- Showing up to 3 most recent sign-ins. Device ID is a short privacy-preserving identifier. + Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.

- This information is shown only to you here. It is not shared with admins in the app UI. + This information is shown only to you and is encrypted at rest. It is not shared with admins.

@@ -213,8 +237,13 @@ function LoginSummaryCard({ latestLogin, loading, onOpen }) {

{deviceLabel(deviceType)} · {browser} on {os}

+ {(latestLogin.location_city || latestLogin.location_country) && ( +

+ {[latestLogin.location_city, latestLogin.location_region, latestLogin.location_country].filter(Boolean).join(', ')} +

+ )} {latestLogin.ip_address && ( -

+

{latestLogin.ip_address}

)} @@ -260,7 +289,8 @@ function ProfileSummary({ profile, loading }) { ); } - const latestLogin = loginHistory[0] || null; + // Show the most recent SUCCESSFUL login in the summary card (not a failed attempt) + const latestLogin = loginHistory.find(l => l.success !== false) || null; return ( <> diff --git a/db/database.js b/db/database.js index 60e485e..1c12dff 100644 --- a/db/database.js +++ b/db/database.js @@ -2717,6 +2717,50 @@ function runMigrations() { console.log('[v0.83] bill_merchant_rules.auto_attribute_late added'); } } + }, + { + version: 'v0.84', + description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records', + dependsOn: ['v0.83'], + run: function() { + const { encryptSecret, decryptSecret } = require('../services/encryptionService'); + const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); + + // Add location columns + const newCols = ['location_city', 'location_country', 'location_region', 'location_isp']; + for (const col of newCols) { + if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`); + } + + // Encrypt existing plaintext ip_address and user_agent rows + const rows = db.prepare('SELECT id, ip_address, user_agent FROM user_login_history').all(); + const updIp = db.prepare("UPDATE user_login_history SET ip_address=? WHERE id=?"); + const updUa = db.prepare("UPDATE user_login_history SET user_agent=? WHERE id=?"); + for (const row of rows) { + if (row.ip_address && !row.ip_address.startsWith('v2:')) { + try { updIp.run(encryptSecret(row.ip_address), row.id); } catch {} + } + if (row.user_agent && !row.user_agent.startsWith('v2:')) { + try { updUa.run(encryptSecret(row.user_agent), row.id); } catch {} + } + } + console.log(`[v0.84] login history: location columns added, ${rows.length} rows encrypted`); + } + }, + { + version: 'v0.85', + description: 'user_login_history: failed attempt tracking + session fingerprint for current-session detection', + dependsOn: ['v0.84'], + run: function() { + const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); + if (!cols.includes('success')) { + db.exec('ALTER TABLE user_login_history ADD COLUMN success INTEGER NOT NULL DEFAULT 1'); + } + if (!cols.includes('session_fingerprint')) { + db.exec('ALTER TABLE user_login_history ADD COLUMN session_fingerprint TEXT'); + } + console.log('[v0.85] user_login_history: success + session_fingerprint columns added'); + } } ]; diff --git a/routes/auth.js b/routes/auth.js index 5ea1760..a6da000 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -10,7 +10,8 @@ function getAppVersion() { } const { getDb, getSetting, setSetting } = require('../db/database'); -const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin } = require('../services/authService'); +const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService'); +const { decryptSecret } = require('../services/encryptionService'); const { getCsrfToken } = require('../middleware/csrf'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { getPublicOidcInfo } = require('../services/oidcService'); @@ -42,13 +43,17 @@ router.post('/login', (req, res, next) => { try { const result = await login(username, password); - if (!result) { + if (!result || result.error) { logAudit({ user_id: null, action: 'login.failure', details: { username }, ip_address: req.ip, user_agent: req.get('user-agent') }); + // Track failed attempt against known accounts (wrong password only — not unknown usernames) + if (result?.error === 'bad_password') { + recordFailedLogin(result.userId, req.ip, req.get('user-agent')); + } return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR')); } 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')); + recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId); res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req)); res.json({ user: result.user }); @@ -101,17 +106,47 @@ router.get('/me', requireAuth, (req, res) => { }); }); -// GET /api/auth/login-history — last 3 logins for the authenticated user +// GET /api/auth/login-history — last 10 logins for the authenticated user (encrypted at rest, decrypted here) router.get('/login-history', requireAuth, (req, res) => { const db = getDb(); - const history = db.prepare(` + const rows = db.prepare(` SELECT id, logged_in_at, ip_address, user_agent, - browser, os, device_type, device_fingerprint + browser, os, device_type, device_fingerprint, session_fingerprint, + success, location_city, location_country, location_region, location_isp FROM user_login_history WHERE user_id = ? ORDER BY logged_in_at DESC - LIMIT 3 + LIMIT 10 `).all(req.user.id); + + const safeDecrypt = v => { + if (!v) return null; + try { return decryptSecret(v); } catch { return null; } + }; + + // Compute fingerprint of the current session cookie to mark "this session" + const currentCookie = req.cookies?.[COOKIE_NAME]; + const currentFingerprint = currentCookie + ? require('crypto').createHash('sha256').update(currentCookie).digest('hex').slice(0, 32) + : null; + + const history = rows.map(r => ({ + id: r.id, + logged_in_at: r.logged_in_at, + ip_address: safeDecrypt(r.ip_address), + user_agent: safeDecrypt(r.user_agent), + browser: r.browser, + os: r.os, + device_type: r.device_type, + device_fingerprint: r.device_fingerprint, + success: r.success !== 0, + is_current_session: !!(currentFingerprint && r.session_fingerprint === currentFingerprint), + location_city: safeDecrypt(r.location_city), + location_country: safeDecrypt(r.location_country), + location_region: safeDecrypt(r.location_region), + location_isp: safeDecrypt(r.location_isp), + })); + res.json({ history }); }); diff --git a/services/authService.js b/services/authService.js index f23570e..7e68377 100644 --- a/services/authService.js +++ b/services/authService.js @@ -2,6 +2,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { getDb } = require('../db/database'); const { buildDeviceFingerprint } = require('./loginFingerprint'); +const { encryptSecret, decryptSecret } = require('./encryptionService'); const COOKIE_NAME = 'bt_session'; const SESSION_DAYS = 7; @@ -55,7 +56,9 @@ async function login(username, password) { } const valid = await bcrypt.compare(password, user.password_hash); - if (!valid) return null; + // Return userId so the route can log the failed attempt against the known account. + // The external response is identical either way — no user enumeration. + if (!valid) return { error: 'bad_password', userId: user.id }; // Clean up expired sessions for this user before creating new session try { @@ -172,40 +175,155 @@ function publicUser(u) { } /** - * Records a successful login and prunes older entries so each user - * keeps at most 3 login history rows. + * Records a successful login with encrypted IP/UA and prunes older entries + * so each user keeps at most 10 login history rows. + * Session fingerprint is stored for current-session detection. + * New device alerts and geolocation are resolved asynchronously. */ -function recordLogin(userId, ipAddress, userAgent) { +function recordLogin(userId, ipAddress, userAgent, sessionId) { const db = getDb(); const device = buildDeviceFingerprint({ userAgent, ipAddress }); + + const encIp = ipAddress ? encryptSecret(ipAddress) : null; + const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null; + const sessionFingerprint = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32) + : null; + + // Collect prior fingerprints before insert to detect new devices + let priorFingerprints; + try { + priorFingerprints = db.prepare(` + SELECT device_fingerprint FROM user_login_history + WHERE user_id = ? AND success = 1 + ORDER BY logged_in_at DESC, id DESC LIMIT 10 + `).all(userId).map(r => r.device_fingerprint); + } catch { + priorFingerprints = []; + } + + let insertedId; db.transaction(() => { - db.prepare(` + const result = db.prepare(` INSERT INTO user_login_history ( user_id, logged_in_at, ip_address, user_agent, - browser, os, device_type, device_fingerprint + browser, os, device_type, device_fingerprint, session_fingerprint, success ) - VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?) + VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, 1) `).run( userId, - ipAddress ?? null, - userAgent ? userAgent.slice(0, 500) : null, + encIp, + encUa, device.browser, device.os, device.device_type, device.device_fingerprint, + sessionFingerprint, ); + insertedId = result.lastInsertRowid; - // Keep only the 3 most recent rows for this user + // Keep only the 10 most recent rows for this user db.prepare(` DELETE FROM user_login_history WHERE user_id = ? AND id NOT IN ( SELECT id FROM user_login_history WHERE user_id = ? ORDER BY logged_in_at DESC, id DESC - LIMIT 3 + LIMIT 10 ) `).run(userId, userId); })(); + + // Background tasks: geolocation + new device push alert + if (insertedId) { + setImmediate(() => { + // Geolocation — skip private/loopback IPs + if (ipAddress) { + 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`) + .then(r => r.json()) + .then(data => { + if (data.status !== 'success') return; + const updDb = getDb(); + updDb.prepare(` + UPDATE user_login_history + SET location_city=?, location_country=?, location_region=?, location_isp=? + WHERE id=? + `).run( + encryptSecret(data.city || ''), + encryptSecret(data.country || ''), + encryptSecret(data.regionName || ''), + encryptSecret(data.isp || ''), + insertedId, + ); + }) + .catch(() => {}); + } + } + + // New device alert via push notification + const isNewDevice = device.device_fingerprint && + !priorFingerprints.includes(device.device_fingerprint); + if (isNewDevice) { + try { + const { sendPushToUser } = require('./notificationService')._push; + const alertUser = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId); + if (alertUser?.notify_push_enabled && alertUser?.push_channel) { + const deviceDesc = [device.browser, device.os, device.device_type !== 'desktop' ? device.device_type : null] + .filter(Boolean).join(' / '); + const ipDesc = ipAddress || 'unknown IP'; + sendPushToUser( + alertUser, + 'New device sign-in — Bill Tracker', + `A sign-in was detected from a new device: ${deviceDesc} (${ipDesc}).`, + 'today', + ).catch(() => {}); + } + } catch { /* push is best-effort */ } + } + }); + } +} + +/** + * Records a failed login attempt (wrong password for a known account). + * Stored with success=0 so it shows in login history but doesn't count as + * a real session. Only called when the username maps to a real user — + * unknown usernames are not tracked to prevent user-enumeration side-channels. + */ +function recordFailedLogin(userId, ipAddress, userAgent) { + if (!userId) return; + try { + const db = getDb(); + const device = buildDeviceFingerprint({ userAgent, ipAddress }); + const encIp = ipAddress ? encryptSecret(ipAddress) : null; + const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null; + + db.transaction(() => { + db.prepare(` + INSERT INTO user_login_history ( + user_id, logged_in_at, ip_address, user_agent, + browser, os, device_type, device_fingerprint, success + ) + VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, 0) + `).run( + userId, encIp, encUa, + device.browser, device.os, device.device_type, device.device_fingerprint, + ); + + // Prune: keep 10 most recent (success or fail) per user + db.prepare(` + DELETE FROM user_login_history + WHERE user_id = ? AND id NOT IN ( + SELECT id FROM user_login_history + WHERE user_id = ? + ORDER BY logged_in_at DESC, id DESC + LIMIT 10 + ) + `).run(userId, userId); + })(); + } catch { /* never fail a request because of history logging */ } } // Prune expired sessions — called by daily worker @@ -240,4 +358,4 @@ function invalidateOtherSessions(userId, keepSessionId) { return result; } -module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin }; +module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin }; diff --git a/services/notificationService.js b/services/notificationService.js index 88e5ca1..62b9898 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -92,7 +92,7 @@ async function sendTestPush(user) { ); } -module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, encryptSecret }; +module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret }; // ── SMTP transport ────────────────────────────────────────────────────────────