import bcrypt from 'bcryptjs'; import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'; import { getDb } from './db/index.js'; const SESSION_DAYS = 30; const DEVICE_DAYS = 365; const COOKIE_NAME = 'oradio_sid'; const DEVICE_COOKIE = 'oradio_device'; export function hashPassword(plain) { return bcrypt.hashSync(plain, 10); } export function verifyPassword(plain, hash) { return bcrypt.compareSync(plain, hash); } export function createSession(userId) { const token = randomBytes(32).toString('hex'); const expires = new Date(Date.now() + SESSION_DAYS * 86400e3).toISOString(); getDb().prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)') .run(token, userId, expires); return { token, expires }; } export function destroySession(token) { if (token) getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token); } export function getUserBySession(token) { if (!token) return null; return getDb().prepare(` SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now') `).get(token); } export function readSessionToken(req) { const raw = req.headers.cookie || ''; for (const part of raw.split(';')) { const [k, v] = part.trim().split('='); if (k === COOKIE_NAME) return decodeURIComponent(v || ''); } return null; } export function setSessionCookie(res, token, expires) { const attrs = [ `${COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Expires=${new Date(expires).toUTCString()}` ]; appendSetCookieRaw(res, attrs.join('; ')); } export function clearSessionCookie(res) { appendSetCookieRaw(res, `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } function appendSetCookieRaw(res, value) { const prev = res.getHeader('Set-Cookie'); if (!prev) res.setHeader('Set-Cookie', value); else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]); else res.setHeader('Set-Cookie', [prev, value]); } // --------------------------------------------------------------------------- // API key auth // --------------------------------------------------------------------------- function hashApiKey(key) { return createHash('sha256').update(key).digest('hex'); } export function createApiKey(userId, label = '') { const key = 'oradio_' + randomBytes(32).toString('hex'); getDb().prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)') .run(hashApiKey(key), label, userId); return key; // shown exactly once — never stored in plain text } export function getUserByApiKey(key) { if (!key || typeof key !== 'string') return null; // Constant-time-safe: derive the hash first, then look it up by hash. const hash = hashApiKey(key); const row = getDb().prepare(` SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji, ak.id AS ak_id FROM api_keys ak JOIN users u ON u.id = ak.user_id WHERE ak.key_hash = ? `).get(hash); if (!row) return null; // Bump last_used_at asynchronously so auth stays fast setImmediate(() => { getDb().prepare('UPDATE api_keys SET last_used_at = datetime(\'now\') WHERE id = ?') .run(row.ak_id); }); const { ak_id: _drop, ...user } = row; return user; } export function listApiKeys() { return getDb().prepare( 'SELECT ak.id, ak.label, ak.created_at, ak.last_used_at, u.username ' + 'FROM api_keys ak JOIN users u ON u.id = ak.user_id ORDER BY ak.id' ).all(); } export function revokeApiKey(id) { getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id); } // Registers a raw key from the environment (e.g. ORADIO_API_KEY) so it can // be used immediately without going through the admin UI. Idempotent. export function ensureBootstrapApiKey(rawKey) { if (!rawKey) return; const db = getDb(); const hash = hashApiKey(rawKey); if (db.prepare('SELECT 1 FROM api_keys WHERE key_hash = ?').get(hash)) return; const main = db.prepare('SELECT id FROM users WHERE is_main = 1').get() || db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get(); if (!main) return; db.prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)') .run(hash, 'env-bootstrap', main.id); console.log('[auth] bootstrap API key registered from ORADIO_API_KEY'); } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- export function authMiddleware(req, _res, next) { // 1. API key: X-Api-Key header or Authorization: Bearer const rawAuth = req.headers.authorization || ''; const apiKey = req.headers['x-api-key'] || (rawAuth.startsWith('Bearer ') ? rawAuth.slice(7) : null); if (apiKey) { req.session = {}; req.user = getUserByApiKey(apiKey); return next(); } // 2. Session cookie (browser UI) const token = readSessionToken(req); req.session = { token }; req.user = getUserBySession(token); next(); } export function requireUser(req, res, next) { if (!req.user) return res.status(401).json({ error: 'auth required' }); next(); } export function requireAdmin(req, res, next) { if (!req.user) return res.status(401).json({ error: 'auth required' }); if (req.user.role !== 'admin') return res.status(403).json({ error: 'admin only' }); next(); } export function ensureBootstrapAdmin({ username, password }) { if (!username || !password) return; const db = getDb(); const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existing) return; const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') .run(username, hashPassword(password), 'admin'); db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)') .run(info.lastInsertRowid, username); console.log(`[auth] bootstrap admin '${username}' created`); } /** * Ensure exactly one user is flagged `is_main = 1`. Resolution order: * 1. The user named `mainUsername` (if it exists) * 2. The current main user (if any) * 3. The first admin * 4. The first user * Idempotent. */ export function ensureMainUser(mainUsername) { const db = getDb(); let main = null; if (mainUsername) { main = db.prepare('SELECT id, username FROM users WHERE username = ?').get(mainUsername); } if (!main) main = db.prepare('SELECT id, username FROM users WHERE is_main = 1').get(); if (!main) main = db.prepare("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); if (!main) main = db.prepare('SELECT id, username FROM users ORDER BY id LIMIT 1').get(); if (!main) return null; db.transaction(() => { db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(main.id); db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(main.id); })(); console.log(`[auth] main user is '${main.username}' (id=${main.id})`); return main; } /** Read the public-shape main user, or null. */ export function getMainUser() { const row = getDb().prepare( 'SELECT id, username, avatar_color, avatar_emoji FROM users WHERE is_main = 1' ).get(); return row || null; } // ---------- Trusted devices (fast user switching) ---------- export function readDeviceToken(req) { const raw = req.headers.cookie || ''; for (const part of raw.split(';')) { const [k, v] = part.trim().split('='); if (k === DEVICE_COOKIE) return decodeURIComponent(v || ''); } return null; } export function setDeviceCookie(res, token) { const expires = new Date(Date.now() + DEVICE_DAYS * 86400e3); const attrs = [ `${DEVICE_COOKIE}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Expires=${expires.toUTCString()}` ]; appendSetCookieRaw(res, attrs.join('; ')); } function appendSetCookie(res, value) { const prev = res.getHeader('Set-Cookie'); if (!prev) res.setHeader('Set-Cookie', value); else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]); else res.setHeader('Set-Cookie', [prev, value]); } /** Return the trusted device row matching the request's device cookie, or null. */ export function getTrustedDevice(req) { const token = readDeviceToken(req); if (!token) return null; const db = getDb(); const dev = db.prepare('SELECT * FROM kiosk_devices WHERE token = ?').get(token); if (!dev) return null; db.prepare("UPDATE kiosk_devices SET last_seen_at = datetime('now') WHERE id = ?").run(dev.id); return dev; } /** Return the list of users this device is allowed to fast-switch to. */ export function listDeviceUsers(deviceId) { return getDb().prepare(` SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji FROM kiosk_device_users kdu JOIN users u ON u.id = kdu.user_id WHERE kdu.device_id = ? ORDER BY u.is_main DESC, u.username COLLATE NOCASE `).all(deviceId); } /** Create (or replace) a trusted device with the given user whitelist. */ export function trustDevice({ label, userIds }) { const db = getDb(); const token = randomBytes(24).toString('hex'); return db.transaction(() => { const info = db.prepare('INSERT INTO kiosk_devices (token, label) VALUES (?, ?)') .run(token, label || null); const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)'); for (const uid of userIds || []) stmt.run(info.lastInsertRowid, uid); return { id: info.lastInsertRowid, token }; })(); } export function isUserAllowedOnDevice(deviceId, userId) { return !!getDb().prepare( 'SELECT 1 FROM kiosk_device_users WHERE device_id = ? AND user_id = ?' ).get(deviceId, userId); }