refactor: simplify admin code — extract shared helpers, fix quality issues
- Extract KEBAB_RE, SESSION_COOKIE, SESSION_MAX_AGE_MS, COOKIE_MAX_AGE,
authRequest(), and sessionResponse() to auth.ts — eliminating 4 copies
of the auth() wrapper and 3 copies of KEBAB_RE across admin API routes
- Remove timing side-channel: drop a.length !== b.length early-return
before timingSafeEqual in verifySession (SHA-256 HMAC always 64 chars)
- Fix YOLO localStorage write-before-read race using initialization guard
- Fix admin layout /api/ bypass to return {} instead of {authenticated:true}
- Extract extractError() helper in admin page — removes 4 inline patterns
- Parallel Promise.all in admin loadData() — halves round-trip latency
- Parallel Promise.all in moveCheckedUnits() + surface per-request errors
- Hoist Big(inputValue) above .map() in converter — one alloc per recompute
- O(n) single-pass orderedResults using Map instead of O(n*groups) filters
- Fix expandedGroups.add() mutation — use Set assignment for consistency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { parse } from 'cookie';
|
||||
import { parse, serialize } from 'cookie';
|
||||
import type { AppConfig } from './data';
|
||||
import { loadConfig } from './data';
|
||||
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
/** Session cookie name — single source of truth. */
|
||||
export const SESSION_COOKIE = 'hu_session';
|
||||
|
||||
/** Session lifetime in milliseconds (7 days). Used by both HMAC age-check and cookie max-age. */
|
||||
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Cookie max-age in seconds (derived from SESSION_MAX_AGE_MS). */
|
||||
export const COOKIE_MAX_AGE = SESSION_MAX_AGE_MS / 1000;
|
||||
|
||||
/** Kebab-case ID validation regex — shared by all admin API routes. */
|
||||
export const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export async function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, BCRYPT_COST);
|
||||
}
|
||||
@@ -26,25 +39,23 @@ export function createSession(secret: string): string {
|
||||
|
||||
/**
|
||||
* Verifies an HMAC-signed session token.
|
||||
* Returns false if format is wrong, HMAC doesn't match, or token is >7 days old.
|
||||
* Returns false if format is wrong, HMAC doesn't match, or token is expired.
|
||||
*/
|
||||
export function verifySession(token: string, secret: string): boolean {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const [timestamp, providedHmac] = parts;
|
||||
|
||||
// Check age (7 days)
|
||||
// Check age
|
||||
const ts = parseInt(timestamp, 10);
|
||||
if (isNaN(ts)) return false;
|
||||
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) return false;
|
||||
if (Date.now() - ts > SESSION_MAX_AGE_MS) return false;
|
||||
|
||||
// Verify HMAC using timing-safe comparison
|
||||
// Verify HMAC using timing-safe comparison — no early-return on length to
|
||||
// avoid leaking timing information through a branch before timingSafeEqual.
|
||||
const expectedHmac = createHmac('sha256', secret).update(timestamp).digest('hex');
|
||||
try {
|
||||
const a = Buffer.from(providedHmac, 'hex');
|
||||
const b = Buffer.from(expectedHmac, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
return timingSafeEqual(Buffer.from(providedHmac, 'hex'), Buffer.from(expectedHmac, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -57,7 +68,33 @@ export function verifySession(token: string, secret: string): boolean {
|
||||
export function requireAuth(request: Request, config: AppConfig): boolean {
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
const cookies = parse(cookieHeader);
|
||||
const token = cookies['hu_session'];
|
||||
const token = cookies[SESSION_COOKIE];
|
||||
if (!token) return false;
|
||||
return verifySession(token, config.sessionSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: check auth using the live config from disk.
|
||||
* Avoids repeating `requireAuth(request, loadConfig())` in every route.
|
||||
*/
|
||||
export function authRequest(request: Request): boolean {
|
||||
return requireAuth(request, loadConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Set-Cookie Response for session operations.
|
||||
* Pass token='' and maxAge=0 to clear the cookie (logout).
|
||||
*/
|
||||
export function sessionResponse(token: string, secure: boolean, maxAge: number): Response {
|
||||
const cookie = serialize(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Set-Cookie': cookie }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user