feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users. - Added functionality for admins to edit the shared "main" user's favorites. - Created a one-shot DB smoke test script for verifying multi-user kiosk migrations. - Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
119
server/auth.js
119
server/auth.js
@@ -3,7 +3,9 @@ import { randomBytes } 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);
|
||||
@@ -27,7 +29,7 @@ export function destroySession(token) {
|
||||
export function getUserBySession(token) {
|
||||
if (!token) return null;
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, u.role
|
||||
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);
|
||||
@@ -50,11 +52,18 @@ export function setSessionCookie(res, token, expires) {
|
||||
'SameSite=Lax',
|
||||
`Expires=${new Date(expires).toUTCString()}`
|
||||
];
|
||||
res.setHeader('Set-Cookie', attrs.join('; '));
|
||||
appendSetCookieRaw(res, attrs.join('; '));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
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]);
|
||||
}
|
||||
|
||||
export function authMiddleware(req, _res, next) {
|
||||
@@ -86,3 +95,107 @@ export function ensureBootstrapAdmin({ username, password }) {
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user