277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
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 <key>
|
|
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);
|
|
}
|