mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Add persistent mobile tokens with idle sliding expiration and device revocation
This commit is contained in:
parent
e4b71fc5e4
commit
633eb4e63c
3 changed files with 311 additions and 6 deletions
|
|
@ -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 = '<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() {
|
||||
elements.loadingOverlay.classList.add('active');
|
||||
|
||||
|
|
|
|||
|
|
@ -1116,7 +1116,26 @@
|
|||
</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-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;">
|
||||
|
|
|
|||
195
server.js
195
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.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue