Add persistent mobile tokens with idle sliding expiration and device revocation

This commit is contained in:
Chris 2026-11-01 12:35:50 -05:00
parent e4b71fc5e4
commit 633eb4e63c
3 changed files with 311 additions and 6 deletions

View file

@ -53,6 +53,8 @@ const elements = {
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
settingsStatusMsg: document.getElementById('settings-status-msg'), settingsStatusMsg: document.getElementById('settings-status-msg'),
settingsStatusText: document.getElementById('settings-status-text'), settingsStatusText: document.getElementById('settings-status-text'),
linkedDevicesList: document.getElementById('linked-devices-list'),
btnRefreshDevices: document.getElementById('btn-refresh-devices'),
scanImageInput: document.getElementById('scan-image-input'), scanImageInput: document.getElementById('scan-image-input'),
btnScanImage: document.getElementById('btn-scan-image'), btnScanImage: document.getElementById('btn-scan-image'),
recipesGrid: document.getElementById('recipes-grid'), recipesGrid: document.getElementById('recipes-grid'),
@ -290,6 +292,9 @@ function registerEvents() {
if (elements.btnDiscoverModels) { if (elements.btnDiscoverModels) {
elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true)); elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true));
} }
if (elements.btnRefreshDevices) {
elements.btnRefreshDevices.addEventListener('click', loadLinkedDevices);
}
// Image Scanning Actions // Image Scanning Actions
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
@ -2395,6 +2400,7 @@ async function openSettingsModal() {
} }
openModal('settings-modal'); openModal('settings-modal');
loadLinkedDevices();
try { try {
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
@ -2504,6 +2510,99 @@ function toggleSettingsKeyVisibility() {
lucide.createIcons(); lucide.createIcons();
} }
async function loadLinkedDevices() {
const container = document.getElementById('linked-devices-list');
if (!container) return;
container.innerHTML = '<div style="font-size: 0.85rem; color: var(--text-muted); font-style: italic; padding: 6px 0;">Loading linked devices...</div>';
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 = '<div style="font-size: 0.85rem; color: var(--text-muted); padding: 8px 0;">No mobile devices currently linked.</div>';
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 = '<div style="font-size: 0.85rem; color: #ef4444; padding: 6px 0;">Could not load linked devices.</div>';
}
}
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() { function showScanLoading() {
elements.loadingOverlay.classList.add('active'); elements.loadingOverlay.classList.add('active');

View file

@ -1116,7 +1116,26 @@
</div> </div>
</div> </div>
<!-- SECTION 3: CULINARY OPTIONS (COMING SOON) --> <!-- SECTION 3: LINKED MOBILE DEVICES -->
<div class="settings-section" style="margin-bottom: 24px;">
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
<div style="display: flex; align-items: center; gap: 8px;">
<i data-lucide="smartphone" style="color: var(--primary); width: 18px; height: 18px;"></i>
<span>Linked Mobile Devices</span>
</div>
<button type="button" id="btn-refresh-devices" class="btn btn-secondary btn-sm" style="font-size: 0.75rem; padding: 2px 10px; height: 28px; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;" title="Refresh list of linked devices">
<i data-lucide="refresh-cw" style="width: 13px; height: 13px;"></i>
</button>
</div>
<p style="font-size: 0.85rem; line-height: 1.4; color: var(--text-muted); margin-bottom: 12px;">
Mobile apps authorized with persistent tokens. Devices inactive for 90 days expire automatically.
</p>
<div id="linked-devices-list" style="display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 0.85rem; color: var(--text-muted); font-style: italic; padding: 8px 0;">Loading linked devices...</div>
</div>
</div>
<!-- SECTION 4: CULINARY OPTIONS (COMING SOON) -->
<div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;"> <div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;">
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);"> <div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">

197
server.js
View file

@ -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(); 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) {} } 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 // Initialize single settings record if empty
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
if (settingsCount === 0) { if (settingsCount === 0) {
@ -457,19 +473,70 @@ function hashPassword(password, salt) {
return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex'); 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) { function requireAuth(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization required. Please log in.' }); return res.status(401).json({ error: 'Authorization required. Please log in.' });
} }
const token = authHeader.substring(7); const token = authHeader.substring(7);
// 1. Check in-memory web session first
const session = activeSessions.get(token); const session = activeSessions.get(token);
if (!session) { if (session) {
return res.status(401).json({ error: 'Session expired. Please log in again.' }); 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 // 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) // Admin API: List all users (Requires Admin session)
app.get('/api/admin/users', requireAuth, (req, res) => { app.get('/api/admin/users', requireAuth, (req, res) => {
if (!req.user.isAdmin) { if (!req.user.isAdmin) {