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) {