Fix reset-admin.js: honor recovery token in requireAuth

scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.

Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
  decoded.recovery === true and synthesize a req.user record in
  memory (role: admin, plan_id: enterprise). The synthetic user is
  never persisted, so FK-constrained writes that expect a real
  user (creating devices, etc.) will still fail — which is fine,
  recovery is only meant to let the operator reset a password or
  create a fresh admin via the Settings UI.

Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-21 19:08:49 -05:00
parent 8da0e60c20
commit 772ead28a2
2 changed files with 22 additions and 2 deletions

View file

@ -13,7 +13,7 @@ const crypto = require('crypto');
const nonce = crypto.randomBytes(8).toString('hex'); const nonce = crypto.randomBytes(8).toString('hex');
const token = jwt.sign( const token = jwt.sign(
{ id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin' }, { id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin', recovery: true },
config.jwtSecret, config.jwtSecret,
{ expiresIn: '1h' } { expiresIn: '1h' }
); );

View file

@ -14,6 +14,20 @@ function verifyToken(token) {
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }); return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
} }
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
// persisted; only exists for the lifetime of the request.
function recoveryUser(decoded) {
return {
id: decoded.id,
email: decoded.email || 'admin@localhost',
name: 'Recovery Admin',
role: decoded.role || 'admin',
auth_provider: 'recovery',
avatar_url: null,
plan_id: 'enterprise'
};
}
// Express middleware - requires valid JWT // Express middleware - requires valid JWT
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@ -24,6 +38,10 @@ function requireAuth(req, res, next) {
try { try {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
const decoded = verifyToken(token); const decoded = verifyToken(token);
if (decoded.recovery) {
req.user = recoveryUser(decoded);
return next();
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' }); if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user; req.user = user;
@ -40,7 +58,9 @@ function optionalAuth(req, res, next) {
try { try {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
const decoded = verifyToken(token); const decoded = verifyToken(token);
req.user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); req.user = decoded.recovery
? recoveryUser(decoded)
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
} catch (err) { } catch (err) {
// Token invalid, continue without user // Token invalid, continue without user
} }