From 633eb4e63c14f45cb737f1c624eefea2cd7764c2 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 12:35:50 -0500 Subject: [PATCH] Add persistent mobile tokens with idle sliding expiration and device revocation --- public/app.js | 99 +++++++++++++++++++++++ public/index.html | 21 ++++- server.js | 197 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 311 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index 64e3080..9356714 100644 --- a/public/app.js +++ b/public/app.js @@ -53,6 +53,8 @@ const elements = { btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), settingsStatusMsg: document.getElementById('settings-status-msg'), settingsStatusText: document.getElementById('settings-status-text'), + linkedDevicesList: document.getElementById('linked-devices-list'), + btnRefreshDevices: document.getElementById('btn-refresh-devices'), scanImageInput: document.getElementById('scan-image-input'), btnScanImage: document.getElementById('btn-scan-image'), recipesGrid: document.getElementById('recipes-grid'), @@ -290,6 +292,9 @@ function registerEvents() { if (elements.btnDiscoverModels) { elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true)); } + if (elements.btnRefreshDevices) { + elements.btnRefreshDevices.addEventListener('click', loadLinkedDevices); + } // Image Scanning Actions elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); @@ -2395,6 +2400,7 @@ async function openSettingsModal() { } openModal('settings-modal'); + loadLinkedDevices(); try { const response = await fetch('/api/settings', { @@ -2504,6 +2510,99 @@ function toggleSettingsKeyVisibility() { lucide.createIcons(); } +async function loadLinkedDevices() { + const container = document.getElementById('linked-devices-list'); + if (!container) return; + + container.innerHTML = '
Loading linked devices...
'; + + try { + const res = await fetch('/api/auth/devices', { + headers: getAuthHeaders() + }); + if (res.status === 401) { + logout(); + closeModal('settings-modal'); + return; + } + if (!res.ok) throw new Error('Failed to fetch devices'); + const devices = await res.json(); + + if (!Array.isArray(devices) || devices.length === 0) { + container.innerHTML = '
No mobile devices currently linked.
'; + return; + } + + container.innerHTML = ''; + devices.forEach(d => { + const item = document.createElement('div'); + item.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: rgba(0,0,0,0.18); border: 1px solid var(--border-color); border-radius: var(--border-radius-md); gap: 12px;'; + + const left = document.createElement('div'); + left.style.cssText = 'display: flex; flex-direction: column; gap: 3px; min-width: 0;'; + + const title = document.createElement('div'); + title.style.cssText = 'font-size: 0.88rem; font-weight: 600; color: var(--text-main); display: flex; align-items: center; gap: 6px;'; + + const icon = document.createElement('i'); + icon.setAttribute('data-lucide', 'smartphone'); + icon.style.cssText = 'width: 14px; height: 14px; color: var(--primary); flex-shrink: 0;'; + title.appendChild(icon); + + const nameSpan = document.createElement('span'); + nameSpan.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap;'; + nameSpan.textContent = d.deviceName || 'Mobile App'; + title.appendChild(nameSpan); + + const meta = document.createElement('div'); + meta.style.cssText = 'font-size: 0.75rem; color: var(--text-muted);'; + const lastActive = d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Never'; + meta.textContent = 'Last active: ' + lastActive; + + left.appendChild(title); + left.appendChild(meta); + + const btnRevoke = document.createElement('button'); + btnRevoke.type = 'button'; + btnRevoke.className = 'btn btn-secondary btn-sm'; + btnRevoke.style.cssText = 'font-size: 0.75rem; padding: 4px 10px; color: #ef4444; border-color: rgba(239, 68, 68, 0.35); flex-shrink: 0; cursor: pointer;'; + btnRevoke.textContent = 'Revoke'; + btnRevoke.onclick = () => revokeDevice(d.id, d.deviceName); + + item.appendChild(left); + item.appendChild(btnRevoke); + container.appendChild(item); + }); + + if (window.lucide && lucide.createIcons) { + lucide.createIcons(); + } + } catch (err) { + console.error('Error loading devices:', err); + container.innerHTML = '
Could not load linked devices.
'; + } +} + +async function revokeDevice(deviceId, deviceName) { + if (!confirm('Are you sure you want to revoke access for "' + (deviceName || 'this device') + '"? The mobile app will be logged out immediately.')) { + return; + } + + try { + const res = await fetch('/api/auth/devices/' + deviceId, { + method: 'DELETE', + headers: getAuthHeaders() + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to revoke device'); + } + await loadLinkedDevices(); + } catch (err) { + alert('Could not revoke device: ' + err.message); + } +} + function showScanLoading() { elements.loadingOverlay.classList.add('active'); diff --git a/public/index.html b/public/index.html index 7c66dd1..38ae3e6 100644 --- a/public/index.html +++ b/public/index.html @@ -1116,7 +1116,26 @@ - + +
+
+
+ + Linked Mobile Devices +
+ +
+

+ Mobile apps authorized with persistent tokens. Devices inactive for 90 days expire automatically. +

+
+
Loading linked devices...
+
+
+ +
diff --git a/server.js b/server.js index 0fda361..83b14a0 100644 --- a/server.js +++ b/server.js @@ -216,6 +216,22 @@ try { db.prepare("UPDATE settings SET geminiModel = 'gemini-2.5-flash' WHERE geminiModel IS NULL OR geminiModel = '' OR geminiModel = 'gemini-2.0-flash'").run(); } catch (e) {} +// Initialize persistent mobile tokens table +db.prepare(` + CREATE TABLE IF NOT EXISTS user_mobile_tokens ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + tokenHash TEXT NOT NULL UNIQUE, + deviceName TEXT NOT NULL, + createdAt TEXT NOT NULL, + lastUsedAt TEXT NOT NULL, + expiresAt TEXT NOT NULL, + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE + ) +`).run(); +db.prepare('CREATE INDEX IF NOT EXISTS idx_user_mobile_tokens_hash ON user_mobile_tokens(tokenHash)').run(); +db.prepare('CREATE INDEX IF NOT EXISTS idx_user_mobile_tokens_user ON user_mobile_tokens(userId)').run(); + // Initialize single settings record if empty const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; if (settingsCount === 0) { @@ -457,19 +473,70 @@ function hashPassword(password, salt) { return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex'); } -// Middleware: Authenticate Session Token +// Middleware: Authenticate Session Token (in-memory web session or persistent mobile token) function requireAuth(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Authorization required. Please log in.' }); } const token = authHeader.substring(7); + + // 1. Check in-memory web session first const session = activeSessions.get(token); - if (!session) { - return res.status(401).json({ error: 'Session expired. Please log in again.' }); + if (session) { + req.user = session; + return next(); + } + + // 2. Check persistent mobile device token + try { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const row = db.prepare(` + SELECT t.id AS tokenId, t.userId, t.deviceName, t.lastUsedAt, t.expiresAt, + u.username, u.isAdmin, u.isRecipeManager, u.defaultConsolidateLists + FROM user_mobile_tokens t + JOIN users u ON t.userId = u.id + WHERE t.tokenHash = ? + `).get(tokenHash); + + if (!row) { + return res.status(401).json({ error: 'Session expired or invalid. Please log in again.' }); + } + + // Check idle expiration + const now = new Date(); + if (new Date(row.expiresAt) < now) { + db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(row.tokenId); + return res.status(401).json({ error: 'Session expired due to inactivity. Please log in again.' }); + } + + // Slide expiration forward (90 days inactivity threshold) + // To minimize write overhead, only update if lastUsedAt was more than 15 minutes ago + const lastUsed = new Date(row.lastUsedAt); + if (now.getTime() - lastUsed.getTime() > 15 * 60 * 1000) { + const newExpiresAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare(` + UPDATE user_mobile_tokens + SET lastUsedAt = ?, expiresAt = ? + WHERE id = ? + `).run(now.toISOString(), newExpiresAt, row.tokenId); + } + + req.user = { + id: row.userId, + username: row.username, + isAdmin: row.isAdmin === 1, + isRecipeManager: row.isRecipeManager === 1, + defaultConsolidateLists: row.defaultConsolidateLists !== 0, + isMobileToken: true, + deviceId: row.tokenId, + deviceName: row.deviceName + }; + next(); + } catch (err) { + log.error('Error verifying mobile token in requireAuth:', err); + return res.status(500).json({ error: 'Internal authentication error.' }); } - req.user = session; - next(); } // Helper: Deserialize JSON columns from SQLite recipe row @@ -598,6 +665,126 @@ app.post('/api/auth/login', (req, res) => { } }); +// Auth API: Mobile App Login (Issues persistent, hashed device token with 90-day idle expiration) +app.post('/api/auth/mobile-login', (req, res) => { + const { username, password, deviceName } = req.body; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required.' }); + } + + const cleanUsername = username.trim().toLowerCase(); + try { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(cleanUsername); + if (!user) { + return res.status(401).json({ error: 'Invalid username or password.' }); + } + + const hash = hashPassword(password, user.salt); + if (hash !== user.passwordHash) { + return res.status(401).json({ error: 'Invalid username or password.' }); + } + + // Generate cryptographic device token and SHA-256 hash + const rawToken = 'ff_m_' + crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + const tokenId = crypto.randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(); + const cleanDeviceName = (deviceName && typeof deviceName === 'string' ? deviceName.trim().slice(0, 80) : 'Mobile Device') || 'Mobile Device'; + + db.prepare(` + INSERT INTO user_mobile_tokens (id, userId, tokenHash, deviceName, createdAt, lastUsedAt, expiresAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(tokenId, user.id, tokenHash, cleanDeviceName, now.toISOString(), now.toISOString(), expiresAt); + + log.info(`[Mobile Auth] Issued persistent token for user '${user.username}' on device '${cleanDeviceName}'`); + + const defaultConsolidateLists = user.defaultConsolidateLists !== 0; + res.json({ + token: rawToken, + expiresAt, + user: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin === 1, + isRecipeManager: user.isRecipeManager === 1, + defaultConsolidateLists + } + }); + } catch (error) { + log.error('Error during mobile login:', error); + res.status(500).json({ error: 'Mobile login failed.' }); + } +}); + +// Auth API: List Linked Mobile Devices +app.get('/api/auth/devices', requireAuth, (req, res) => { + try { + const rows = db.prepare(` + SELECT id, deviceName, createdAt, lastUsedAt, expiresAt + FROM user_mobile_tokens + WHERE userId = ? + ORDER BY lastUsedAt DESC + `).all(req.user.id); + + const devices = rows.map(d => ({ + id: d.id, + deviceName: d.deviceName, + createdAt: d.createdAt, + lastUsedAt: d.lastUsedAt, + expiresAt: d.expiresAt, + isCurrentDevice: req.user.isMobileToken && req.user.deviceId === d.id + })); + + res.json(devices); + } catch (error) { + log.error('Error listing linked mobile devices:', error); + res.status(500).json({ error: 'Failed to retrieve linked devices.' }); + } +}); + +// Auth API: Revoke Linked Mobile Device +app.delete('/api/auth/devices/:id', requireAuth, (req, res) => { + try { + const targetId = req.params.id; + // Allow owner or system admin to revoke + const tokenRecord = db.prepare('SELECT id, userId, deviceName FROM user_mobile_tokens WHERE id = ?').get(targetId); + if (!tokenRecord) { + return res.status(404).json({ error: 'Device record not found.' }); + } + + if (tokenRecord.userId !== req.user.id && !req.user.isAdmin) { + return res.status(403).json({ error: 'Access denied. You cannot revoke another user\'s device.' }); + } + + db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(targetId); + log.info(`[Mobile Auth] Revoked device '${tokenRecord.deviceName}' (${targetId}) by user '${req.user.username}'`); + res.json({ success: true, message: `Device '${tokenRecord.deviceName}' has been revoked.` }); + } catch (error) { + log.error('Error revoking device:', error); + res.status(500).json({ error: 'Failed to revoke device.' }); + } +}); + +// Auth API: Logout +app.post('/api/auth/logout', requireAuth, (req, res) => { + try { + if (req.user.isMobileToken && req.user.deviceId) { + db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(req.user.deviceId); + } else { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + activeSessions.delete(token); + } + } + res.json({ success: true, message: 'Logged out successfully.' }); + } catch (error) { + log.error('Error during logout:', error); + res.status(500).json({ error: 'Logout failed.' }); + } +}); + // Admin API: List all users (Requires Admin session) app.get('/api/admin/users', requireAuth, (req, res) => { if (!req.user.isAdmin) {