mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth, each repeating a slightly different subset of its checks. Introduce resolveSessionUser() in middleware/auth.js as the single definition of "this token is a usable session, and here is whose it is", and route all of them through it: the three /api/status token routes, the screenshot route, the content-reference gate, and the /dashboard socket handshake. requireAuth is now a thin wrapper over the same helper, so the two cannot drift. Also: - Give the pre-TOTP token a distinct audience so it is redeemable only through verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any token carrying an audience, so a token minted for one purpose cannot be redeemed on another path. - The dashboard socket handshake now takes userId/userRole from the live users row rather than from the token claim, so role changes take effect on the next connection instead of riding the token's remaining lifetime. - Add test/session-token-resolution.test.js covering all six surfaces, including the socket handshake. Every call site keeps the status code and error body it returned before. Net query cost: the content-reference gate and the socket handshake each gain one users-by-id lookup (the same one requireAuth already does per request); the other four are unchanged or replace an equivalent lookup. In-flight pre-TOTP tokens are invalidated by the audience change; they live 5 minutes, so the window is a re-login at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3c7282bb7
commit
c4b5a8679e
|
|
@ -2,6 +2,26 @@ const jwt = require('jsonwebtoken');
|
|||
const config = require('../config');
|
||||
const { db } = require('../db/database');
|
||||
|
||||
// Audience marker for the pre-TOTP token minted by generateMfaPendingToken below.
|
||||
// Session tokens (generateToken, and the recovery token from scripts/reset-admin.js)
|
||||
// carry NO `aud`, so verifyToken can refuse anything that does. That way a token minted
|
||||
// for one narrow purpose can only be redeemed through its own accessor, and a future
|
||||
// hand-rolled verify site that forgets a check fails CLOSED instead of accepting a
|
||||
// half-authenticated token.
|
||||
const MFA_TOKEN_AUDIENCE = 'st:mfa';
|
||||
|
||||
// Raised when a token is cryptographically valid but is not a usable session: the TOTP
|
||||
// step is outstanding, the user row is gone, or a forced password change is pending.
|
||||
// `code` lets each caller map the outcome onto the status/body it already returned, so
|
||||
// no existing response shape changes.
|
||||
class SessionError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message || code);
|
||||
this.name = 'SessionError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.1: JWT now optionally carries the user's current workspace_id so
|
||||
// the tenancy middleware can resolve scope without an extra DB lookup on
|
||||
// every request. Callers that don't know the workspace yet (legacy paths,
|
||||
|
|
@ -17,19 +37,44 @@ function generateToken(user, currentWorkspaceId) {
|
|||
|
||||
// #100: issued after password verification but BEFORE the TOTP step, so the client
|
||||
// can complete MFA. It is NOT a session token - it carries mfa_pending:true and is
|
||||
// accepted ONLY by POST /api/auth/totp/verify. requireAuth/optionalAuth reject it
|
||||
// (see below) - otherwise password-alone would yield a usable token and TOTP would
|
||||
// be decorative. Short-lived.
|
||||
// accepted ONLY by POST /api/auth/totp/verify (via verifyMfaPendingToken) - otherwise
|
||||
// password-alone would yield a usable token and TOTP would be decorative. Short-lived.
|
||||
// Two independent guards keep it off session paths: the mfa_pending check in
|
||||
// resolveSessionUser, and the audience below (which verifyToken refuses outright, so
|
||||
// even a caller that skips resolveSessionUser cannot accept this token).
|
||||
function generateMfaPendingToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, mfa_pending: true },
|
||||
config.jwtSecret,
|
||||
{ algorithm: 'HS256', expiresIn: '5m' }
|
||||
{ algorithm: 'HS256', expiresIn: '5m', audience: MFA_TOKEN_AUDIENCE }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify a SESSION token. Rejects any token carrying an audience: those are minted for a
|
||||
// single narrower purpose and must go through their own accessor (verifyMfaPendingToken),
|
||||
// never through a session path.
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||
const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||
if (decoded && decoded.aud !== undefined) {
|
||||
// The pre-TOTP audience is a KNOWN narrow purpose: report it as such so callers can
|
||||
// still tell the client to complete MFA rather than "your token is broken". Any other
|
||||
// audience is unrecognised here and refused generically - the fail-closed default.
|
||||
const aud = Array.isArray(decoded.aud) ? decoded.aud : [decoded.aud];
|
||||
if (aud.includes(MFA_TOKEN_AUDIENCE)) throw new SessionError('mfa_required');
|
||||
throw new SessionError('invalid_audience', 'token is scoped to another purpose');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// The ONLY accepted path for a pre-TOTP token: POST /api/auth/totp/verify. Requires the
|
||||
// audience, so a session token can't be presented here either.
|
||||
function verifyMfaPendingToken(token) {
|
||||
const decoded = jwt.verify(token, config.jwtSecret, {
|
||||
algorithms: ['HS256'],
|
||||
audience: MFA_TOKEN_AUDIENCE,
|
||||
});
|
||||
if (!decoded.mfa_pending) throw new SessionError('invalid_token', 'not a pre-TOTP token');
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
|
||||
|
|
@ -46,6 +91,38 @@ function recoveryUser(decoded) {
|
|||
};
|
||||
}
|
||||
|
||||
// THE single definition of "this token is a usable session, and here is whose it is".
|
||||
// requireAuth below is a thin wrapper over it, and every site that verifies a JWT by
|
||||
// hand calls it too (the status backup/export/import routes, the screenshot + content
|
||||
// gates, the dashboard socket handshake) - so those checks cannot drift apart from
|
||||
// requireAuth's again.
|
||||
//
|
||||
// Returns { user, decoded, viaRecovery }. Throws:
|
||||
// - a jsonwebtoken error bad signature / expired / malformed
|
||||
// - SessionError 'invalid_audience' token minted for a narrower purpose (pre-TOTP)
|
||||
// - SessionError 'mfa_required' password accepted, TOTP step NOT completed. #100
|
||||
// (tightening #1): if this check is missing, password-alone yields a working session
|
||||
// and TOTP is decorative.
|
||||
// - SessionError 'user_not_found' the token's user id no longer exists
|
||||
// - SessionError 'password_change_required' #7: forced first-login change outstanding,
|
||||
// enforced SERVER-SIDE so a provisioned temp password doesn't work indefinitely.
|
||||
//
|
||||
// allowPasswordChange lets requireAuth keep its two exempt endpoints (the change itself,
|
||||
// PUT /api/auth/me, and logout) while every other caller stays hard-denied.
|
||||
function resolveSessionUser(token, { allowPasswordChange = false } = {}) {
|
||||
const decoded = verifyToken(token);
|
||||
// Recovery identities are synthetic (scripts/reset-admin.js) and have no users row, so
|
||||
// they skip the lookup. Callers that must not honour break-glass check viaRecovery.
|
||||
if (decoded.recovery) return { user: recoveryUser(decoded), decoded, viaRecovery: true };
|
||||
if (decoded.mfa_pending) throw new SessionError('mfa_required');
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user) throw new SessionError('user_not_found');
|
||||
if (user.must_change_password && !allowPasswordChange) {
|
||||
throw new SessionError('password_change_required');
|
||||
}
|
||||
return { user, decoded, viaRecovery: false };
|
||||
}
|
||||
|
||||
// Express middleware - requires valid JWT
|
||||
function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
|
@ -53,38 +130,24 @@ function requireAuth(req, res, next) {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// #7: while must_change_password is set, allow only reading/updating one's own profile
|
||||
// (PUT /api/auth/me clears the flag) and logout; block everything else.
|
||||
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
|
||||
const allowPasswordChange = url === '/api/auth/me' || url === '/api/auth/logout';
|
||||
|
||||
let session;
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
if (decoded.recovery) {
|
||||
req.user = recoveryUser(decoded);
|
||||
req.jwtWorkspaceId = null;
|
||||
return next();
|
||||
}
|
||||
// #100 (tightening #1): an mfa_pending token has cleared the password but NOT the
|
||||
// TOTP step. It must never authorize a protected route - only /api/auth/totp/verify
|
||||
// accepts it. If this check is removed, password-alone yields a working session and
|
||||
// TOTP is bypassed. (Covered by the mfa_pending bite-test.)
|
||||
if (decoded.mfa_pending) return res.status(401).json({ error: 'mfa_required' });
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||
req.user = user;
|
||||
// Tenancy middleware reads this on the resolver step.
|
||||
req.jwtWorkspaceId = decoded.current_workspace_id || null;
|
||||
// #7: enforce the forced first-login password change SERVER-SIDE (was a
|
||||
// frontend-only redirect, so a provisioned temp password worked indefinitely
|
||||
// via the API). While the flag is set, allow only reading/updating one's own
|
||||
// profile (the password change is PUT /api/auth/me, which clears the flag)
|
||||
// and logout; block everything else.
|
||||
if (user.must_change_password) {
|
||||
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
|
||||
const allowed = url === '/api/auth/me' || url === '/api/auth/logout';
|
||||
if (!allowed) return res.status(403).json({ error: 'password_change_required' });
|
||||
}
|
||||
next();
|
||||
session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange });
|
||||
} catch (err) {
|
||||
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
|
||||
if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' });
|
||||
if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
req.user = session.user;
|
||||
// Tenancy middleware reads this on the resolver step.
|
||||
req.jwtWorkspaceId = session.viaRecovery ? null : (session.decoded.current_workspace_id || null);
|
||||
next();
|
||||
}
|
||||
|
||||
// Optional auth - sets req.user if token present, continues either way
|
||||
|
|
@ -163,4 +226,4 @@ function requireSuperAdmin(req, res, next) {
|
|||
// Preferred alias for new code.
|
||||
const requirePlatformAdmin = requireSuperAdmin;
|
||||
|
||||
module.exports = { generateToken, generateMfaPendingToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };
|
||||
module.exports = { generateToken, generateMfaPendingToken, verifyToken, verifyMfaPendingToken, resolveSessionUser, SessionError, MFA_TOKEN_AUDIENCE, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const https = require('https');
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
const { db } = require('../db/database');
|
||||
const { generateToken, generateMfaPendingToken, verifyToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
|
||||
const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
const totp = require('../lib/totp');
|
||||
|
|
@ -351,7 +351,9 @@ router.post('/totp/verify', (req, res) => {
|
|||
const { mfa_token, code } = req.body;
|
||||
if (!mfa_token || !code) return res.status(400).json({ error: 'mfa_token and code required' });
|
||||
let decoded;
|
||||
try { decoded = verifyToken(mfa_token); } catch { return res.status(401).json({ error: 'mfa session expired' }); }
|
||||
// verifyMfaPendingToken is the ONLY accessor that accepts the pre-TOTP audience; a full
|
||||
// session token presented here is rejected by it (audience mismatch).
|
||||
try { decoded = verifyMfaPendingToken(mfa_token); } catch { return res.status(401).json({ error: 'mfa session expired' }); }
|
||||
if (!decoded.mfa_pending || !decoded.id) return res.status(401).json({ error: 'invalid mfa token' });
|
||||
if (totpLockout.isLocked(decoded.id)) return res.status(429).json({ error: 'Too many invalid codes. Try again later.' });
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const path = require('path');
|
|||
const fs = require('fs');
|
||||
const config = require('../config');
|
||||
const VERSION = require('../version');
|
||||
const { PLATFORM_ROLES } = require('../middleware/auth');
|
||||
const { PLATFORM_ROLES, resolveSessionUser } = require('../middleware/auth');
|
||||
const loopLag = require('../services/loop-lag');
|
||||
// #146 P3.8: soak observability — internal limiter/maintenance states.
|
||||
const flapLimiter = require('../lib/flap-limiter');
|
||||
|
|
@ -61,19 +61,35 @@ function formatUptime(seconds) {
|
|||
return `${m}m`;
|
||||
}
|
||||
|
||||
// These three routes take the session token from the query string / Authorization header
|
||||
// and resolve it themselves rather than sitting behind requireAuth. resolveSessionUser is
|
||||
// the SAME resolver requireAuth uses, so they inherit every check it makes (pre-TOTP
|
||||
// refusal, live user row, forced password change). Each keeps the exact status/body it
|
||||
// returned before for the invalid-token case.
|
||||
function denySession(res, err) {
|
||||
if (err && err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
|
||||
if (err && err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Full database backup (superadmin only)
|
||||
router.get('/backup', (req, res) => {
|
||||
const token = req.query.token;
|
||||
if (!token) return res.status(401).json({ error: 'Token required' });
|
||||
|
||||
let session;
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
const decoded = jwt.verify(token, config.jwtSecret);
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' });
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
session = resolveSessionUser(token);
|
||||
} catch (err) {
|
||||
// An unknown user id stays indistinguishable from "not a platform admin" (as before),
|
||||
// so this endpoint never confirms whether a given id exists.
|
||||
if (err.code === 'user_not_found') return res.status(403).json({ error: 'Platform admin only' });
|
||||
return denySession(res, err);
|
||||
}
|
||||
// A break-glass identity has no users row, so it could never pass the role check here
|
||||
// before; keep it that way rather than letting the synthetic role claim decide.
|
||||
if (session.viaRecovery || !PLATFORM_ROLES.includes(session.user.role)) {
|
||||
return res.status(403).json({ error: 'Platform admin only' });
|
||||
}
|
||||
|
||||
const dbPath = require('../config').dbPath;
|
||||
|
|
@ -88,16 +104,19 @@ router.get('/export', (req, res) => {
|
|||
let userId;
|
||||
let workspaceId;
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
const decoded = jwt.verify(token, config.jwtSecret);
|
||||
userId = decoded.id;
|
||||
workspaceId = decoded.current_workspace_id || null;
|
||||
const session = resolveSessionUser(token);
|
||||
// For a break-glass identity this is the synthetic recovery id, which has no users
|
||||
// row - the lookup below then 404s exactly as the inline verify did before.
|
||||
userId = session.user.id;
|
||||
workspaceId = session.decoded.current_workspace_id || null;
|
||||
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
} catch (err) {
|
||||
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
|
||||
return denySession(res, err);
|
||||
}
|
||||
|
||||
// Re-read with the export's own column list (it needs created_at, which the session
|
||||
// resolver doesn't select).
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
|
|
@ -219,19 +238,18 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
|||
let userId;
|
||||
let workspaceId;
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const jwtConfig = require('../config');
|
||||
const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret);
|
||||
userId = decoded.id;
|
||||
workspaceId = decoded.current_workspace_id || null;
|
||||
const session = resolveSessionUser(authHeader.split(' ')[1]);
|
||||
// A break-glass identity has no users row: the lookup this replaced returned nothing
|
||||
// for it, so the route 404'd. Preserve that.
|
||||
if (session.viaRecovery) return res.status(404).json({ error: 'User not found' });
|
||||
userId = session.user.id;
|
||||
workspaceId = session.decoded.current_workspace_id || null;
|
||||
if (!userId) return res.status(401).json({ error: 'Invalid token' });
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
} catch (err) {
|
||||
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
|
||||
return denySession(res, err);
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Phase 2.2b: imports stamp workspace_id on devices and content so the
|
||||
// rows are visible to the workspace-filtered list endpoints. Fall back to
|
||||
// the importer's first accessible workspace if the JWT didn't carry one.
|
||||
|
|
|
|||
|
|
@ -410,19 +410,26 @@ app.use('/api/stripe', stripeRouter);
|
|||
|
||||
|
||||
// Screenshot route (before protected routes - needs custom auth for img tags)
|
||||
const { verifyToken } = require('./middleware/auth');
|
||||
const { resolveSessionUser } = require('./middleware/auth');
|
||||
app.get('/api/devices/:id/screenshot', (req, res) => {
|
||||
let user = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
const tokenParam = req.query.token;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : tokenParam;
|
||||
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||
// resolveSessionUser is the same resolver requireAuth uses, so this route inherits the
|
||||
// pre-TOTP refusal, the live-user check and the forced-password-change gate.
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
const { db } = require('./db/database');
|
||||
user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||
} catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
|
||||
const session = resolveSessionUser(token);
|
||||
// Break-glass has no users row; the lookup this replaced returned nothing for it.
|
||||
if (session.viaRecovery) return res.status(401).json({ error: 'User not found' });
|
||||
user = session.user;
|
||||
} catch (err) {
|
||||
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
|
||||
if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
|
||||
if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' });
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
const { db: sdb } = require('./db/database');
|
||||
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
|
|
@ -453,13 +460,17 @@ function requesterCanAccessContent(req, content) {
|
|||
try {
|
||||
const m = (req.headers.authorization || '').match(/^Bearer (.+)$/);
|
||||
if (!m) return false;
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.verify(m[1], config.jwtSecret, { algorithms: ['HS256'] });
|
||||
if (!decoded || !decoded.id) return false;
|
||||
if (decoded.role === 'platform_admin') return true;
|
||||
// Same resolver as requireAuth: a pre-TOTP token, a deleted user or an outstanding
|
||||
// forced password change all throw here and fall through to false.
|
||||
const session = resolveSessionUser(m[1]);
|
||||
if (session.viaRecovery) return false; // no workspace membership; unchanged behaviour
|
||||
const user = session.user;
|
||||
if (!user || !user.id) return false;
|
||||
// Role from the LIVE users row, not the token claim, so a demotion takes effect at once.
|
||||
if (user.role === 'platform_admin') return true;
|
||||
const { db } = require('./db/database');
|
||||
return !!db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
|
||||
.get(content.workspace_id, decoded.id);
|
||||
.get(content.workspace_id, user.id);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
|
|
|||
263
server/test/session-token-resolution.test.js
Normal file
263
server/test/session-token-resolution.test.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
'use strict';
|
||||
|
||||
// Six surfaces resolve a session token THEMSELVES instead of sitting behind requireAuth:
|
||||
// the three /api/status token routes, the screenshot route, the content-reference gate,
|
||||
// and the /dashboard socket handshake. They all now call middleware/auth.resolveSessionUser,
|
||||
// the same resolver requireAuth uses, so they must inherit its checks. This suite walks
|
||||
// every one of them and asserts, per surface:
|
||||
//
|
||||
// 1. a PRE-TOTP token (mfa_pending, i.e. password accepted but the TOTP step not
|
||||
// completed) is refused,
|
||||
// 2. a normal full session token is still accepted, and
|
||||
// 3. a recovery token behaves as it did before this refactor - accepted by
|
||||
// requireAuth-gated routes, refused by these six (it has no users row, so the
|
||||
// lookups these replaced already denied it).
|
||||
//
|
||||
// The socket handshake is included deliberately: it is the one surface with no HTTP
|
||||
// equivalent, so a suite that covered only the routes would not hold it to the contract.
|
||||
//
|
||||
// Boots the REAL server.js as a subprocess against an isolated DB (same convention as
|
||||
// api.test.js / totp.test.js). Node built-ins + socket.io-client (devDep) only.
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const ioClient = require('socket.io-client');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const { freePort } = require('./helpers/free-port');
|
||||
let PORT, BASE;
|
||||
const SECRET = 'test-secret-session-resolution-' + crypto.randomBytes(4).toString('hex');
|
||||
const DATA_DIR = path.join(os.tmpdir(), 'st-session-test-' + crypto.randomBytes(4).toString('hex'));
|
||||
const LOG = path.join(os.tmpdir(), 'st-session-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||
let proc;
|
||||
const S = {}; // shared fixtures populated in before()
|
||||
|
||||
const PW = 'Passw0rd123';
|
||||
|
||||
async function jfetch(p, opts = {}) {
|
||||
const res = await fetch(BASE + p, opts);
|
||||
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
const auth = (tok, extra = {}) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json', ...extra } });
|
||||
const post = (tok, obj, extra) => ({ method: 'POST', ...auth(tok, extra), body: JSON.stringify(obj || {}) });
|
||||
|
||||
// A 1x1 PNG - enough for the ingest path to produce a real file + thumbnail, so the
|
||||
// content-reference gate is actually reachable.
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8AAAwAB/gGVUvXhAAAAAElFTkSuQmCC',
|
||||
'base64'
|
||||
);
|
||||
|
||||
// Connect to the /dashboard namespace and report whether the handshake was accepted.
|
||||
function connectDashboard(token) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = ioClient(`${BASE}/dashboard`, {
|
||||
auth: { token }, transports: ['websocket'], reconnection: false, forceNew: true,
|
||||
});
|
||||
let settled = false;
|
||||
const done = (r) => { if (settled) return; settled = true; try { sock.close(); } catch { /* */ } resolve(r); };
|
||||
sock.on('connect', () => done({ connected: true, message: null }));
|
||||
sock.on('connect_error', (e) => done({ connected: false, message: e.message }));
|
||||
setTimeout(() => done({ connected: false, message: 'timeout' }), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
PORT = await freePort();
|
||||
BASE = `http://127.0.0.1:${PORT}`;
|
||||
const logFd = fs.openSync(LOG, 'w');
|
||||
proc = spawn('node', ['server.js'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', JWT_SECRET: SECRET },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
let up = false;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||
|
||||
// First registered user becomes platform_admin - needed so the /backup happy path is
|
||||
// actually authorized, which is what makes its pre-TOTP refusal meaningful.
|
||||
S.adminEmail = 'admin' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
||||
const reg = await jfetch('/api/auth/register', post(null, { email: S.adminEmail, password: PW }));
|
||||
S.adminToken = reg.body.token;
|
||||
assert.equal(reg.body.user.role, 'platform_admin', 'first user is platform_admin');
|
||||
|
||||
// Enroll TOTP, then log in again to obtain a genuine mfa_pending token. The pre-existing
|
||||
// session token stays valid across enrollment (see totp.test.js), so S.adminToken is
|
||||
// still our "full session" fixture for the same account.
|
||||
const setup = await jfetch('/api/auth/totp/setup', post(S.adminToken, {}));
|
||||
await jfetch('/api/auth/totp/enable', post(S.adminToken, { code: authenticator.generate(setup.body.secret) }));
|
||||
const login = await jfetch('/api/auth/login', post(null, { email: S.adminEmail, password: PW }));
|
||||
assert.equal(login.body.mfa_required, true, 'login now stops at the TOTP step');
|
||||
assert.equal(login.body.token, undefined, 'no full session token before TOTP');
|
||||
S.mfaToken = login.body.mfa_token;
|
||||
assert.ok(S.mfaToken, 'got a pre-TOTP token');
|
||||
|
||||
// Recovery token, minted exactly as scripts/reset-admin.js does it.
|
||||
S.recoveryToken = jwt.sign(
|
||||
{ id: 'recovery-' + crypto.randomBytes(8).toString('hex'), email: 'admin@localhost', role: 'admin', recovery: true },
|
||||
SECRET, { expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Content with a real file + thumbnail, not referenced by any playlist, so
|
||||
// /api/content/:id/{file,thumbnail} falls through to the requester gate.
|
||||
const fd = new FormData();
|
||||
fd.append('file', new Blob([PNG_1X1], { type: 'image/png' }), 'px.png');
|
||||
const up2 = await fetch(BASE + '/api/content', { method: 'POST', headers: { Authorization: 'Bearer ' + S.adminToken }, body: fd });
|
||||
const created = await up2.json();
|
||||
S.contentId = created.id;
|
||||
assert.ok(S.contentId, 'uploaded content for the reference-gate tests');
|
||||
});
|
||||
|
||||
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. GET /api/status/backup (?token=) - full database download
|
||||
// ---------------------------------------------------------------------------
|
||||
test('status/backup: pre-TOTP token refused, full admin session accepted, recovery refused', async () => {
|
||||
const mfa = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.mfaToken)}`);
|
||||
assert.equal(mfa.status, 401, 'pre-TOTP token must not reach the database backup');
|
||||
assert.equal(mfa.body.error, 'mfa_required');
|
||||
|
||||
// Happy path returns the DB file itself, so consume it rather than parsing JSON.
|
||||
const ok = await fetch(`${BASE}/api/status/backup?token=${encodeURIComponent(S.adminToken)}`);
|
||||
await ok.arrayBuffer();
|
||||
assert.equal(ok.status, 200, 'a full platform-admin session still downloads the backup');
|
||||
|
||||
const rec = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.recoveryToken)}`);
|
||||
assert.equal(rec.status, 403, 'recovery identity is not a platform admin here (unchanged)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. GET /api/status/export (?token=)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('status/export: pre-TOTP token refused, full session accepted, recovery refused', async () => {
|
||||
const mfa = await jfetch(`/api/status/export?token=${encodeURIComponent(S.mfaToken)}`);
|
||||
assert.equal(mfa.status, 401, 'pre-TOTP token must not export account data');
|
||||
assert.equal(mfa.body.error, 'mfa_required');
|
||||
|
||||
const ok = await jfetch(`/api/status/export?token=${encodeURIComponent(S.adminToken)}`);
|
||||
assert.equal(ok.status, 200, 'a full session still exports');
|
||||
|
||||
const rec = await jfetch(`/api/status/export?token=${encodeURIComponent(S.recoveryToken)}`);
|
||||
assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. POST /api/status/import (Authorization header)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('status/import: pre-TOTP token refused, full session reaches the handler, recovery refused', async () => {
|
||||
const mfa = await jfetch('/api/status/import', post(S.mfaToken, { format: 'screentinker-export-v2' }));
|
||||
assert.equal(mfa.status, 401, 'pre-TOTP token must not import into a workspace');
|
||||
assert.equal(mfa.body.error, 'mfa_required');
|
||||
|
||||
// 400 here is the post-auth validation failing on a body with no payload - i.e. the
|
||||
// token WAS accepted. Asserting "not 401/403/404" is the auth boundary we care about.
|
||||
const ok = await jfetch('/api/status/import', post(S.adminToken, { nope: true }));
|
||||
assert.equal(ok.status, 400, 'a full session passes auth and fails validation instead');
|
||||
|
||||
const rec = await jfetch('/api/status/import', post(S.recoveryToken, { nope: true }));
|
||||
assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. GET /api/devices/:id/screenshot (?token= and header)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('screenshot: pre-TOTP token refused before the device lookup; full session gets past auth', async () => {
|
||||
const id = crypto.randomUUID();
|
||||
const mfa = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.mfaToken)}`);
|
||||
assert.equal(mfa.status, 401, 'pre-TOTP token must not read device screenshots');
|
||||
assert.equal(mfa.body.error, 'mfa_required');
|
||||
|
||||
// No device exists, so 404 "Device not found" is the proof that auth was accepted and
|
||||
// the handler moved on to its (deliberately untouched) ownership logic.
|
||||
const ok = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.adminToken)}`);
|
||||
assert.equal(ok.status, 404, 'a full session clears auth and reaches the device lookup');
|
||||
|
||||
const rec = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.recoveryToken)}`);
|
||||
assert.equal(rec.status, 401, 'recovery identity has no users row (unchanged)');
|
||||
|
||||
// Same outcome via the Authorization header, not just the query parameter.
|
||||
const hdr = await jfetch(`/api/devices/${id}/screenshot`, auth(S.mfaToken));
|
||||
assert.equal(hdr.status, 401, 'header path refuses the pre-TOTP token too');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. requesterCanAccessContent -> /api/content/:id/{file,thumbnail}
|
||||
// ---------------------------------------------------------------------------
|
||||
test('content gate: pre-TOTP token gets no more than an anonymous caller; full session reads', async () => {
|
||||
// Baseline: unreferenced content is not public.
|
||||
const anon = await jfetch(`/api/content/${S.contentId}/thumbnail`);
|
||||
assert.equal(anon.status, 403, 'unreferenced content is not anonymously readable');
|
||||
|
||||
// The gate returns a boolean, so a refused token surfaces as the same 403 an anonymous
|
||||
// caller gets - the point is that it grants NOTHING extra.
|
||||
const mfa = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.mfaToken));
|
||||
assert.equal(mfa.status, 403, 'pre-TOTP token unlocks no content');
|
||||
const mfaFile = await jfetch(`/api/content/${S.contentId}/file`, auth(S.mfaToken));
|
||||
assert.equal(mfaFile.status, 403, 'pre-TOTP token unlocks no file either');
|
||||
|
||||
const okThumb = await fetch(`${BASE}/api/content/${S.contentId}/thumbnail`, auth(S.adminToken));
|
||||
await okThumb.arrayBuffer();
|
||||
assert.equal(okThumb.status, 200, 'a workspace member with a full session still reads the thumbnail');
|
||||
const okFile = await fetch(`${BASE}/api/content/${S.contentId}/file`, auth(S.adminToken));
|
||||
await okFile.arrayBuffer();
|
||||
assert.equal(okFile.status, 200, 'and the file');
|
||||
|
||||
const rec = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.recoveryToken));
|
||||
assert.equal(rec.status, 403, 'recovery identity has no workspace membership (unchanged)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. /dashboard socket handshake
|
||||
// ---------------------------------------------------------------------------
|
||||
test('dashboard socket: pre-TOTP token cannot open the namespace; full session can', async () => {
|
||||
const mfa = await connectDashboard(S.mfaToken);
|
||||
assert.equal(mfa.connected, false, 'pre-TOTP token must not reach the device-command channel');
|
||||
assert.equal(mfa.message, 'mfa_required');
|
||||
|
||||
const ok = await connectDashboard(S.adminToken);
|
||||
assert.equal(ok.connected, true, 'a full session still connects');
|
||||
|
||||
const rec = await connectDashboard(S.recoveryToken);
|
||||
assert.equal(rec.connected, false, 'recovery identity is refused the handshake');
|
||||
|
||||
const bogus = await connectDashboard('not-a-jwt');
|
||||
assert.equal(bogus.connected, false, 'garbage token still refused');
|
||||
assert.equal(bogus.message, 'Invalid token');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-checks: the audience split, and requireAuth non-regression.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('audience split: the pre-TOTP token works ONLY at /totp/verify, and a session token does not', async () => {
|
||||
// A full session token presented as an mfa_token is rejected (no pre-TOTP audience).
|
||||
const wrongWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.adminToken, code: '000000' }));
|
||||
assert.equal(wrongWay.status, 401, 'a session token is not accepted as an mfa_token');
|
||||
assert.equal(wrongWay.body.error, 'mfa session expired');
|
||||
|
||||
// And the pre-TOTP token is still accepted there: a wrong code gets past token
|
||||
// validation to the code check (401 "Invalid code"), not rejected as a bad token.
|
||||
const rightWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.mfaToken, code: '000000' }));
|
||||
assert.equal(rightWay.status, 401);
|
||||
assert.equal(rightWay.body.error, 'Invalid code', 'pre-TOTP token is still redeemable at /totp/verify');
|
||||
});
|
||||
|
||||
test('requireAuth non-regression: full session works, pre-TOTP 401s, recovery still reaches a gated route', async () => {
|
||||
assert.equal((await jfetch('/api/auth/me', auth(S.adminToken))).status, 200, 'full session');
|
||||
const mfa = await jfetch('/api/auth/me', auth(S.mfaToken));
|
||||
assert.equal(mfa.status, 401, 'pre-TOTP token');
|
||||
assert.equal(mfa.body.error, 'mfa_required');
|
||||
// Break-glass must keep working where it worked before - a requireAuth-gated route.
|
||||
assert.equal((await jfetch('/api/devices', auth(S.recoveryToken))).status, 200, 'recovery still authenticates');
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
const heartbeat = require('../services/heartbeat');
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const { resolveSessionUser } = require('../middleware/auth');
|
||||
const { db } = require('../db/database');
|
||||
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
|
||||
const { workspaceRoom } = require('../lib/socket-rooms');
|
||||
|
|
@ -38,14 +38,25 @@ module.exports = function setupDashboardSocket(io) {
|
|||
dashboardNs.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token;
|
||||
if (!token) return next(new Error('Authentication required'));
|
||||
let session;
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
socket.userId = decoded.id;
|
||||
socket.userRole = decoded.role;
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('Invalid token'));
|
||||
// Same resolver as requireAuth, so the socket inherits the pre-TOTP refusal and the
|
||||
// forced-password-change gate that the HTTP surface enforces.
|
||||
session = resolveSessionUser(token);
|
||||
} catch (err) {
|
||||
if (err.code === 'mfa_required') return next(new Error('mfa_required'));
|
||||
if (err.code === 'password_change_required') return next(new Error('password_change_required'));
|
||||
return next(new Error('Invalid token'));
|
||||
}
|
||||
// Break-glass identities have no users row and no workspace membership, so
|
||||
// canActOnDevice -> accessContext already denied them every command. Refuse the
|
||||
// handshake rather than hold open a socket that can do nothing.
|
||||
if (session.viaRecovery) return next(new Error('Invalid token'));
|
||||
socket.userId = session.user.id;
|
||||
// Role + existence come from the LIVE users row, not the token claim: a deleted or
|
||||
// demoted user no longer keeps fleet control for the remainder of a 7-day JWT.
|
||||
socket.userRole = session.user.role;
|
||||
next();
|
||||
});
|
||||
|
||||
dashboardNs.on('connection', (socket) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue