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 ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
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/assets', assetsRouter);
|
||||
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 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) {
|
||||
if (!MUTATING.has(req.method)) return next();
|
||||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||
// browsers and can't be drive-by'd from another origin.
|
||||
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();
|
||||
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); }
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) {
|
|||
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
||||
});
|
||||
if (!res.ok) throw new Error('EDL export failed: ' + res.status);
|
||||
const blob = await res.blob();
|
||||
|
|
|
|||
|
|
@ -2063,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
|||
await new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
|
||||
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
|
||||
xhr.withCredentials = true;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
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.open('POST', url);
|
||||
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue