BillTracker/middleware/requireAuth.cts

102 lines
3.2 KiB
TypeScript
Raw Permalink Normal View History

import type { Req, Res, Next } from '../types/http';
const crypto = require('crypto');
const {
getSessionUser,
COOKIE_NAME,
SINGLE_COOKIE_NAME,
cookieOpts,
publicUser,
recordLogin,
} = require('../services/authService.cts');
const { getDb, getSetting } = require('../db/database.cts');
const { standardizeError } = require('./errorFormatter.cts');
2026-05-03 19:51:57 -05:00
function getSingleModeUser(): any {
2026-05-03 19:51:57 -05:00
if (getSetting('auth_mode') !== 'single') return null;
const userId = getSetting('default_user_id');
if (!userId) return null;
// Single-user mode: validate only that the configured user exists, is active,
// and has role 'user'. Sessions are not relevant here.
const row = getDb()
.prepare(
`
SELECT id, username, display_name, role, must_change_password, first_login,
2026-05-14 21:00:07 -05:00
active, is_default_admin, last_seen_version
FROM users
WHERE id = ? AND role = 'user' AND active = 1
`,
)
.get(userId);
2026-05-03 19:51:57 -05:00
return row ? publicUser(row) : null;
}
function requireAuth(req: Req, res: Res, next: Next): any {
2026-05-03 19:51:57 -05:00
// Single-user mode: bypass session entirely, auto-attach the default user
const singleUser = getSingleModeUser();
if (singleUser) {
req.user = singleUser;
req.singleUserMode = true;
// Track logins via a presence cookie so login history works without a real session.
const existing = req.cookies?.[SINGLE_COOKIE_NAME];
if (existing) {
req.singleSessionId = existing;
} else {
const sessionId = crypto.randomUUID();
res.cookie(SINGLE_COOKIE_NAME, sessionId, {
httpOnly: true,
sameSite: 'strict',
secure: cookieOpts(req).secure,
maxAge: 30 * 86400 * 1000, // 30 days
path: '/',
});
req.singleSessionId = sessionId;
// Non-blocking — don't delay the first request
setImmediate(() => {
try {
recordLogin(singleUser.id, req.ip, req.get('user-agent'), sessionId);
} catch {}
});
}
2026-05-03 19:51:57 -05:00
return next();
}
const user = getSessionUser(req.cookies?.[COOKIE_NAME]);
fix: four review-confirmed regressions in auth + OIDC flows All found by the multi-angle /code-review over the branch and verified against the installed libraries: - 401 discriminator: the session-expiry redirect keyed off a path prefix (!/auth/*) and falsely logged users out on /profile/change-password with a wrong current password. requireAuth's no-session 401 now carries the distinct AUTH_REQUIRED code — the only signal api.ts dispatches auth:expired on; wrong-credential 401s keep AUTH_ERROR on any path. Test updated to pin the code-based contract on both directions. - OIDC RFC 9207: exchangeAndVerifyTokens rebuilt the callback URL with only code+state, dropping the iss parameter openid-client v6 validates when the provider advertises support (authentik does) — every OIDC login would fail post-upgrade. The full callback query is now forwarded. - OIDC http issuers: v6 enforces HTTPS on discovery/endpoints (v5 didn't) — breaking internal http:// providers on Docker networks. OIDC_ALLOW_INSECURE_HTTP=true opts back in (documented in .env.example). - 2FA downgrade: authService's webauthn fallback caught ANY challenge error and silently downgraded to password-only login; now only the stale-flag 'No registered WebAuthn credentials' case falls through, everything else rethrows. - Burned-challenge retry: the server consumes the single-use 2FA challenge before verifying (anti-replay), so after a server rejection a retry can only ever return 'Challenge expired'. LoginPage now resets to sign-in on server failure (WebAuthn AND the pre-existing TOTP trap); a browser-prompt cancel keeps retry (token still live). Also lazy-loads @simplewebauthn/browser out of the entry bundle, matching ProfilePage. Server 252/252, client 52/52, OIDC smoke 44/44, probe 33/33, e2e 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:07 -05:00
// AUTH_REQUIRED (not AUTH_ERROR): the one code that means "no valid session".
// The client keys its session-expired redirect off this code — wrong-credential
// 401s elsewhere (change-password, TOTP disable, …) use AUTH_ERROR and must
// NOT log the user out.
if (!user) return res.status(401).json(standardizeError('Not authenticated', 'AUTH_REQUIRED'));
2026-05-03 19:51:57 -05:00
req.user = user;
next();
}
function requireUser(req: Req, res: Res, next: Next): any {
2026-05-04 23:34:24 -05:00
if (req.user?.is_default_admin) {
return res
.status(403)
.json(standardizeError('Default admin account does not have tracker access', 'FORBIDDEN'));
2026-05-04 23:34:24 -05:00
}
2026-05-03 20:40:48 -05:00
if (!['user', 'admin'].includes(req.user?.role)) {
return res
.status(403)
.json(standardizeError('Access denied: user account required', 'FORBIDDEN'));
2026-05-03 19:51:57 -05:00
}
next();
}
function requireAdmin(req: Req, res: Res, next: Next): any {
2026-05-03 19:51:57 -05:00
// In single-user mode the auto-attached user is never admin,
// so admin routes naturally stay protected by session.
if (req.user?.role !== 'admin') {
return res
.status(403)
.json(standardizeError('Access denied: admin account required', 'FORBIDDEN'));
2026-05-03 19:51:57 -05:00
}
next();
}
module.exports = { requireAuth, requireUser, requireAdmin };