fix(auth): final-review integration fixes — Users page alias + PATCH, CSRF on uploads + heartbeat, drop .bak
Final-review findings: - Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the existing SPA Users page works; add PATCH /:id for inline edits (display_name, role, password). - Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader 403s before reaching the route. - Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent heartbeats keep working when NODE_TOKEN is unset. - Remove stale auth.js.bak.
This commit is contained in:
parent
8ede44ae87
commit
03d0d098f5
6 changed files with 35 additions and 0 deletions
|
|
@ -111,6 +111,7 @@ app.use('/api/v1', (req, res, next) => {
|
||||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||||
app.use('/api/v1/auth', authRouter);
|
app.use('/api/v1/auth', authRouter);
|
||||||
app.use('/api/v1/auth/users', usersRouter);
|
app.use('/api/v1/auth/users', usersRouter);
|
||||||
|
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
|
||||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||||
app.use('/api/v1/assets', assetsRouter);
|
app.use('/api/v1/assets', assetsRouter);
|
||||||
app.use('/api/v1/projects', projectsRouter);
|
app.use('/api/v1/projects', projectsRouter);
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,18 @@ export async function requireAuth(req, res, next) {
|
||||||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
const REQUIRED_HEADER = 'dragonflight-ui';
|
const REQUIRED_HEADER = 'dragonflight-ui';
|
||||||
|
|
||||||
|
// Paths exempt from the CSRF header check. Must match the SERVICE_PATHS set
|
||||||
|
// in index.js — these are non-browser service-to-service calls (node-agent
|
||||||
|
// heartbeat) where the CSRF protection doesn't apply.
|
||||||
|
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
||||||
|
|
||||||
export function requireUiHeader(req, res, next) {
|
export function requireUiHeader(req, res, next) {
|
||||||
if (!MUTATING.has(req.method)) return next();
|
if (!MUTATING.has(req.method)) return next();
|
||||||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||||
// browsers and can't be drive-by'd from another origin.
|
// browsers and can't be drive-by'd from another origin.
|
||||||
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||||||
|
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
|
||||||
|
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
|
||||||
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
||||||
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,28 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /:id { display_name?, role?, password? } — generic update.
|
||||||
|
// password update goes through hashPassword; other fields are passed through.
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
||||||
|
const sets = []; const vals = [];
|
||||||
|
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
||||||
|
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
|
||||||
|
if (typeof req.body?.password === 'string') {
|
||||||
|
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||||||
|
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
||||||
|
vals.push(await hashPassword(req.body.password));
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return bad(res, 'nothing to update');
|
||||||
|
vals.push(req.params.id);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`,
|
||||||
|
vals
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'user not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) {
|
||||||
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
|
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('EDL export failed: ' + res.status);
|
if (!res.ok) throw new Error('EDL export failed: ' + res.status);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|
|
||||||
|
|
@ -2063,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
|
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
|
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ function _xhrPost(url, formData, onProgress) {
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new Error('Network error'));
|
xhr.onerror = () => reject(new Error('Network error'));
|
||||||
xhr.open('POST', url);
|
xhr.open('POST', url);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue