mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
1f5deeba6c
* fix: clean up dangling FK references before deleting a user Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id and DELETE /api/auth/me when the target user had rows in trip_members.invited_by, share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id, journey_entries.author_id, journey_contributors.user_id, or journey_share_tokens.created_by — none of which had ON DELETE clauses. Introduces deleteUserCompletely() in userCleanupService.ts which wraps all cleanup and the final DELETE FROM users in a single transaction. Both adminService.deleteUser and authService.deleteAccount now call it instead of the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types including notification sender/recipient and notice dismissals. * test: extend FK deletion tests to cover journeys, files, and photos ADMIN-005b and AUTH-040 now also seed and assert: - owned journey with entries (cascade-deleted via journeys.user_id cleanup) - trip_files.uploaded_by (SET NULL — file survives, attribution cleared) - trek_photos.owner_id (SET NULL — photo record survives, owner cleared) - trip_photos.user_id (CASCADE — photo association removed) * test: extend user deletion tests to cover all FK relationships ADMIN-005b and AUTH-040 now seed and assert every user FK relationship: CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens, oauth_consents, vacay_plans, vacay_plan_members, bucket_list, visited_countries, visited_regions, packing_templates, invite_tokens, collab_notes, settings, password_reset_tokens, notification_channel_preferences SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id, packing_bags, audit_log Caught and fixed: notification_preferences was dropped in migration 72; correct table is notification_channel_preferences. * fix: preserve URL hash and OIDC redirect target through login flow - Include location.hash in redirect param at all three producer sites (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so hash fragments survive the login bounce - Stash redirectTarget in sessionStorage before any OIDC provider redirect and restore it after the code exchange, since the IdP strips the original ?redirect= param during the roundtrip - Clear sessionStorage on OIDC error to avoid stale state - Add tests covering sessionStorage stash on mount, navigate to saved redirect after OIDC exchange, fallback to /dashboard, and cleanup on error * fix: use day position instead of ID for accommodation date range clamping Math.min/Math.max over raw day IDs breaks the start/end picker when a trip's day IDs are non-monotonic relative to day_number (normal after repeated generateDays extend/shrink cycles). Replaced with findIndex lookups so clamping is always based on positional order. Closes #889 * fix: normalize env var comparisons to be case-insensitive All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like 'Production' or 'True' behave identically to their lowercase forms. Also adds APP_VERSION to the startup banner. * fix: delete surplus days when shortening a trip When shrinking a trip's date range, surplus days are now deleted along with their assignments, notes, and accommodations (cascade). Places remain in the trip pool; reservations keep their day reference nulled by the existing ON DELETE SET NULL constraint (issue #909). Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016 as a regression test for the empty-day case. * fix: auto-backup retention deletes itself and manual backups on Docker Two bugs in cleanupOldBackups: 1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too. Now restricted to auto-backup-* prefix. 2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs (Docker default), making every backup appear epoch-old and get deleted immediately. Age is now parsed from the filename timestamp and falls back to mtimeMs (reliable on overlayfs). Also converts inline require('./services/auditLog') calls to a static import throughout scheduler.ts, and adds 8 unit tests covering the fixed retention logic including the overlayfs regression case. * test: update TRIP-024 to match delete behavior on trip shrink * feat: add bypass-branch-check label to skip branch enforcement
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import { db } from '../db/database';
|
|
import { JWT_SECRET } from '../config';
|
|
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
|
import { applyIdempotency } from './idempotency';
|
|
import { isDemoEmail } from '../services/demo';
|
|
|
|
export function extractToken(req: Request): string | null {
|
|
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
|
const cookieToken = (req as any).cookies?.trek_session;
|
|
if (cookieToken) return cookieToken;
|
|
const authHeader = req.headers['authorization'];
|
|
return (authHeader && authHeader.split(' ')[1]) || null;
|
|
}
|
|
|
|
/**
|
|
* Verify a JWT and load its user, enforcing the password_version gate.
|
|
*
|
|
* Exported so every auth surface in the codebase (MCP bearer tokens,
|
|
* file download query tokens, the photo-serving route) goes through the
|
|
* same check. A password reset bumps `users.password_version`, which
|
|
* invalidates every JWT that embedded the prior value — but only if
|
|
* every verify path actually compares the claim. Previously several
|
|
* paths called `jwt.verify` directly and skipped the DB lookup, so a
|
|
* stolen token kept working after the victim reset.
|
|
*/
|
|
export function verifyJwtAndLoadUser(token: string): User | null {
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
|
|
const row = db.prepare(
|
|
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
|
|
).get(decoded.id) as (User & { password_version?: number }) | undefined;
|
|
if (!row) return null;
|
|
// Session invalidation: any token whose embedded password_version
|
|
// predates the user's current one is rejected. Tokens issued before
|
|
// the `pv` claim existed (decoded.pv === undefined) are treated as
|
|
// version 0 so legacy sessions keep working until the user resets.
|
|
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
|
|
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
|
|
if (tokenPv !== currentPv) return null;
|
|
// Don't leak password_version beyond the middleware.
|
|
const { password_version: _pv, ...user } = row;
|
|
return user as User;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
|
|
const token = extractToken(req);
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
|
return;
|
|
}
|
|
|
|
const user = verifyJwtAndLoadUser(token);
|
|
if (!user) {
|
|
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
|
return;
|
|
}
|
|
(req as AuthRequest).user = user;
|
|
applyIdempotency(req, res, next, user.id);
|
|
};
|
|
|
|
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
|
|
* Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
|
|
* to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
|
|
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
|
|
const cookieToken = (req as any).cookies?.trek_session;
|
|
if (!cookieToken) {
|
|
res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
|
|
return;
|
|
}
|
|
const user = verifyJwtAndLoadUser(cookieToken);
|
|
if (!user) {
|
|
res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
|
|
return;
|
|
}
|
|
(req as AuthRequest).user = user;
|
|
next();
|
|
};
|
|
|
|
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
|
|
const token = extractToken(req);
|
|
|
|
if (!token) {
|
|
(req as OptionalAuthRequest).user = null;
|
|
return next();
|
|
}
|
|
|
|
(req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
|
|
next();
|
|
};
|
|
|
|
const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
|
|
const authReq = req as AuthRequest;
|
|
if (!authReq.user || authReq.user.role !== 'admin') {
|
|
res.status(403).json({ error: 'Admin access required' });
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
|
|
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
|
|
const authReq = req as AuthRequest;
|
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) {
|
|
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
|
|
export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };
|