mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
9814480a96
middleware/auth.ts exported seven things. Two of them — extractToken and verifyJwtAndLoadUser — are the canonical session check every auth surface goes through. The other five (authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock) are Express middlewares with no production caller left; the guards in nest/auth replaced them and only comments still mention them. The two live functions move to nest/auth/jwt-verify.ts and the file goes, along with middleware/validate.ts (nothing but its own test imported it) and middleware/idempotency.ts (its only caller was `authenticate`, and IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go with them — src/typings/express.d.ts is what types req.user now. Eight importers repoint: the three guards, auth/files/plugins-proxy services, platform.routes and the global MFA policy. Two deliberate departures from the plan for this package. JWT_SECRET does not move to app-config. It is not an env value: it is generated into data/.jwt_secret, and the admin panel rotates it at runtime through updateJwtSecret. `export let` is precisely what makes a rotation take effect in-process — a registerAs token would freeze the boot value and silently break rotation. The verify also keeps reading the db singleton rather than DatabaseService. That change is not a one-line swap: it gives the three guards a constructor dependency, which means registering them as providers everywhere they are used. Measured: 38 directories use those guards and 21 of them do not import AuthModule, on top of 34 e2e tests that each build a container around a single domain module. That is its own PR, not a rider on one that deletes dead files. tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply deleted. Its extractToken cases and the five verify cases it drove through `authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting verifyJwtAndLoadUser directly. Four cases are new: the password_version gate — the whole reason this function exists — had no direct coverage at all. Its three adminOnly cases are dropped because AdminGuard, their successor, is covered by three e2e suites.
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
|
import { readEnv } from '../app-config';
|
|
import { db } from '../db/database';
|
|
import { extractToken, verifyJwtAndLoadUser } from '../nest/auth/jwt-verify';
|
|
import { DEMO_EMAILS } from '../nest/common/demo';
|
|
|
|
/** Paths that never require MFA (public or pre-auth). */
|
|
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
|
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
|
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
|
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
|
// Unauthenticated passkey (primary) login ceremony.
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/options') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/verify') return true;
|
|
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
|
export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
|
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
|
// Allow enrolling a passkey as the second factor (a user-verified passkey
|
|
// satisfies require_mfa), so a fresh user under the policy isn't stuck.
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/options') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/verify') return true;
|
|
if (method === 'GET' && pathNoQuery === '/api/auth/passkey/credentials') return true;
|
|
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* When app_settings.require_mfa is true, block API access for users without MFA enabled,
|
|
* except for public routes and MFA setup endpoints.
|
|
*/
|
|
export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
|
|
const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
|
|
|
|
if (!pathNoQuery.startsWith('/api')) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (isPublicApiPath(req.method, pathNoQuery)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Accept both the httpOnly session cookie (regular SPA users) and the
|
|
// Authorization header (MCP / API clients). Previously this only looked
|
|
// at the header so every normal cookie-authenticated session sailed
|
|
// past `require_mfa` unchecked.
|
|
const token = extractToken(req);
|
|
if (!token) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Use the shared verify helper so the `password_version` gate applies
|
|
// here too — a JWT stolen before a password reset would otherwise
|
|
// continue to satisfy this middleware until its natural 24h expiry.
|
|
const verified = verifyJwtAndLoadUser(token);
|
|
if (!verified) {
|
|
next();
|
|
return;
|
|
}
|
|
const userId = verified.id;
|
|
|
|
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
|
if (requireRow?.value !== 'true') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (readEnv().demo.enabled && verified.email && DEMO_EMAILS.has(verified.email)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
|
|
| { mfa_enabled: number | boolean }
|
|
| undefined;
|
|
if (!row) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// A user-verified passkey is phishing-resistant and inherently two-factor, so
|
|
// owning at least one satisfies the require_mfa policy exactly like TOTP does.
|
|
// (All stored passkeys were registered with userVerification required.)
|
|
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
|
const passkeyOk = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
|
if (mfaOk || passkeyOk) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
res.status(403).json({
|
|
error: 'Two-factor authentication is required. Complete setup in Settings.',
|
|
code: 'MFA_REQUIRED',
|
|
});
|
|
}
|