Files
TREK/server/src/routes/auth.ts
T
Maurice 2d0414b4a3 security: internal audit — batch 1
Fixes the critical + high + medium findings from our internal security
review. Bundled into one PR because the changes overlap heavily (JWT
verification unifies across three call sites; backup-code hashing and
demo-email handling cross-cut several services); splitting them out
would mean redundant reviews of the same files.

Critical
- CI-C1 — .github/workflows/test.yml: restore actions/{checkout,setup-
  node,upload-artifact} to @v4. The @v6 refs don't exist, so the test
  workflow was errorring before a single test ran.
- SEC-C1 — mfaPolicy now extracts the token via extractToken() (cookie-
  first, Bearer fallback). Previously it only read Authorization, so
  every cookie-authenticated SPA session bypassed require_mfa entirely.
- SEC-C2/C4/C6 — all JWT verification paths (MCP bearer, file download,
  photo route) now go through the shared verifyJwtAndLoadUser that
  checks password_version. resetPassword additionally deletes every
  mcp_tokens row and marks outstanding oauth_tokens revoked, so a
  password reset invalidates ALL credential classes — not just the
  cookie JWT.

High
- SEC-H2 — reset email URL is built from server-side APP_URL /
  ALLOWED_ORIGINS (via existing getAppUrl()), not request headers.
  Closes the host-header-injection vector into reset links.
- SEC-H3 — OIDC findOrCreateUser wraps the invite-redemption UPDATE +
  user INSERT in a transaction. The UPDATE is the capacity check; if
  a concurrent callback takes the last slot, the whole transaction
  aborts with registration_disabled instead of double-creating users.
- SEC-H4 — new verifyIdToken() performs full JWT signature
  verification via the provider's JWKS (Node's crypto.createPublicKey
  accepts JWK directly — no extra dependency), plus iss/aud/exp
  checks. The callback also rejects the login when userinfo.sub does
  not match id_token.sub.
- SEC-H5 — OAuth DCR now validates redirect_uris against an allowlist
  of schemes: https, http-loopback, or a private custom scheme. Plain
  http://non-loopback is rejected.
- SEC-H6 — oauthService audience defaults to mcpResource when the
  `resource` parameter is missing, so tokens are always audience-bound
  to /mcp instead of being issued with audience=null.
- SEC-H7 — HSTS is enabled any time NODE_ENV=production (previously
  required FORCE_HTTPS=true), includeSubDomains defaults on and can
  be disabled with HSTS_INCLUDE_SUBDOMAINS=false.
- SEC-H8 — trek_session cookie Secure flag is also driven by
  req.secure (which Express resolves from X-Forwarded-Proto once
  trust proxy is set), so instances behind a TLS-terminating proxy
  get Secure cookies without needing FORCE_HTTPS.

Medium
- SEC-M1 — permanentDeleteFile / emptyTrash / avatar unlink now use
  fs.promises.rm with { force: true } (one async op vs the previous
  existsSync + unlinkSync pair per file).
- SEC-M2 — invalidatePermissionsCache() is called inside restoreFromZip
  so a restored DB with different permission rows is honoured
  immediately.
- SEC-M3 + C1 — idempotency store bounds the key at 128 chars, caches
  only responses ≤ 256 KiB, and scopes the lookup by (key, user_id,
  method, path) rather than (key, user_id). Same key replayed against
  a different endpoint no longer returns a stale unrelated body.
- SEC-M4 — share_tokens gets an expires_at column; new tokens default
  to 90-day TTL, expired tokens are denied at lookup. Existing tokens
  stay NULL = no expiry so already-published links don't break.
- SEC-M5 — /uploads/photos/:filename now resolves the photo to its
  trip_id and requires the share token to cover THAT trip. Previously
  any share token for any trip would unlock any photo filename.
- SEC-M6 — BLOCKED_EXTENSIONS is the single source of truth shared
  between fileService and collab uploads. The '*' allowed_file_types
  wildcard now still rejects executables/scripts.
- SEC-M7 — single DEMO_EMAILS constant (services/demo.ts) used by
  demoUploadBlock, mfaPolicy, and every demo-mode guard in
  authService. The old demoUploadBlock only matched 'demo@nomad.app'
  so the seed 'demo@trek.app' could in fact upload in demo mode.
- SEC-M8 — MFA backup codes are now bcrypt-hashed at rest
  (hashBackupCodeBcrypt). matchBackupCode accepts both bcrypt and
  legacy SHA-256 hex hashes, so existing installs keep working until
  the user regenerates codes via enableMfa.
- SEC-M9 — document the "security via UUID v4 filename" model for
  /uploads/avatars|covers|journey. Requires no code change but
  captures the decision so future reviewers don't re-flag it.
- SEC-M10 — already covered by the resetPassword revocation logic
  above: mcp_tokens DELETE + oauth_tokens UPDATE … SET revoked_at.

Performance
- PERF-H1 — new migration adds the indexes flagged in the audit:
  trips(user_id), trips(created_at DESC), photos(day_id),
  photos(place_id), reservations(day_id), share_tokens(token), plus
  conditional day_accommodations and notifications indexes depending
  on which columns are present.

Tests
- tests/integration/oidc.test.ts now mocks verifyIdToken and passes
  an id_token in the exchangeCodeForToken stub for the three flows
  that exercise a successful callback. The three remaining failures
  tests pointed out were all pre-existing (file-upload flakes +
  notificationPreferences event_types count drift), none introduced
  by this PR.
2026-04-20 20:36:52 +02:00

408 lines
17 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
import {
getAppConfig,
demoLogin,
validateInviteToken,
registerUser,
loginUser,
getCurrentUser,
changePassword,
deleteAccount,
updateMapsKey,
updateApiKeys,
updateSettings,
getSettings,
saveAvatar,
deleteAvatar,
listUsers,
validateKeys,
getAppSettings,
updateAppSettings,
getTravelStats,
setupMfa,
enableMfa,
disableMfa,
verifyMfaLogin,
listMcpTokens,
createMcpToken,
deleteMcpToken,
createWsToken,
createResourceToken,
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
const router = express.Router();
// ---------------------------------------------------------------------------
// Avatar upload (multer config stays in route — middleware concern)
// ---------------------------------------------------------------------------
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, avatarDir),
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
});
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: MAX_AVATAR_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
},
});
// ---------------------------------------------------------------------------
// Rate limiter (middleware concern — stays in route)
// ---------------------------------------------------------------------------
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
const forgotAttempts = new Map<string, { count: number; first: number }>();
const resetAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
}
for (const [key, record] of mfaAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
}
for (const [key, record] of forgotAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
}
for (const [key, record] of resetAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
const user = (req as OptionalAuthRequest).user;
res.json(getAppConfig(user));
});
router.post('/demo-login', (req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
const result = validateInviteToken(req.params.token);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
});
router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!, req);
res.status(201).json({ token: result.token, user: result.user });
});
router.post('/login', authLimiter, (req: Request, res: Response) => {
const result = loginUser(req.body);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
// ---------------------------------------------------------------------------
// Password reset (forgot / complete)
// ---------------------------------------------------------------------------
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot handler so a "no such user"
// path does not complete noticeably faster than a real reset.
const FORGOT_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
const ip = getClientIp(req);
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the server-side canonical APP_URL (or
// first ALLOWED_ORIGINS entry) — never from request headers. A
// crafted `Origin` / `Host` / `Referer` would otherwise put an
// attacker-controlled domain into the emailed reset link while the
// token itself is still legitimate.
const origin = getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
// Pad the response so timing doesn't reveal outcome.
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
}
res.json(GENERIC_FORGOT_RESPONSE);
});
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const ip = getClientIp(req);
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
// Purposefully do NOT auto-login — the user just demonstrated they
// have email+password access; asking them to sign in fresh is the
// standard, safer UX.
res.json({ success: true });
});
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = getCurrentUser(authReq.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
});
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res, req);
res.json({ success: true });
});
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
res.json({ success: true });
});
router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
res.json({ success: true });
});
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key));
});
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateApiKeys(authReq.user.id, req.body));
});
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: result.success, user: result.user });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
});
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(await deleteAvatar(authReq.user.id));
});
router.get('/users', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ users: listUsers(authReq.user.id) });
});
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await validateKeys(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
});
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getAppSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json(result.data);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateAppSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: result.auditSummary,
debugDetails: result.auditDebugDetails,
});
res.json({ success: true });
});
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getTravelStats(authReq.user.id));
});
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = setupMfa(authReq.user.id, authReq.user.email);
if (result.error) return res.status(result.status!).json({ error: result.error });
result.qrPromise!
.then((qr_svg: string) => {
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
})
.catch((err: unknown) => {
console.error('[MFA] QR code generation error:', err);
res.status(500).json({ error: 'Could not generate QR code' });
});
});
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = enableMfa(authReq.user.id, req.body.code);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled });
});
// --- MCP Token Management ---
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ tokens: listMcpTokens(authReq.user.id) });
});
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createMcpToken(authReq.user.id, req.body.name);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.status(201).json({ token: result.token });
});
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteMcpToken(authReq.user.id, req.params.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// Short-lived single-use token for WebSocket connections
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createWsToken(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ token: result.token });
});
// Short-lived single-use token for direct resource URLs
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const token = createResourceToken(authReq.user.id, req.body.purpose);
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json(token);
});
export default router;
// Exported for test resets only — do not use in production code
export { loginAttempts, mfaAttempts };