Files
2026-07-13 13:32:57 +08:00

158 lines
7.0 KiB
TypeScript

import { redirect } from "@remix-run/node";
import { getUserById } from "~/models/user.server";
import { sanitizeRedirectPath } from "~/utils";
import { extractClientIp } from "~/utils/extractClientIp.server";
import { authenticator } from "./auth.server";
import { getImpersonationId } from "./impersonation.server";
import { logger } from "./logger.server";
import { revalidateSsoSession } from "./ssoSessionRevalidation.server";
/**
* Logs the user out when their session has lived past `User.nextSessionEnd`.
*
* The deadline is written at login (and any time the effective duration is
* recomputed — see `commitAuthenticatedSession`) and shortened in bulk when
* an admin lowers an org cap (see the admin `session-duration` route).
* Non-impersonation `requireUserId`/`getUserId` paths do NOT enforce —
* enforcement happens at the next page navigation (root.tsx calls `getUser`),
* which matches HIPAA auto-logoff semantics: terminate at the navigation
* boundary, not on every polling fetch. Impersonation paths DO enforce in
* `getUserId` (against the admin's deadline) so a `requireUserId`-only route
* can't keep operating as the impersonation target after the admin's session
* has expired or their admin role was revoked.
*
* `nextSessionEnd === null` means "no enforced deadline" — applies to legacy
* sessions from before this feature shipped. The default `User.sessionDuration`
* is 1 year (matching the cookie's `Max-Age`), so a null deadline is
* functionally identical to "natural cookie expiry" for users with default
* settings. Every path that produces a sub-default effective duration —
* fresh login, user setting change, admin cap change — also writes
* `nextSessionEnd`, so there is no realistic state where an unenforced null
* masks a tighter cap.
*/
function maybeAutoLogout(
request: Request,
user: { id: string; nextSessionEnd: Date | null },
impersonatedUserId: string | null = null
): void {
if (user.nextSessionEnd === null) return;
if (Date.now() <= user.nextSessionEnd.getTime()) return;
// Don't redirect to /logout when we're already on /logout — root.tsx's
// loader runs for every route (including /logout itself), so without this
// guard an expired session would redirect to /logout in a loop and the
// route's own loader (which destroys the session cookie) would never run.
if (new URL(request.url).pathname === "/logout") return;
// HIPAA audit trail: structured log lands in CloudWatch via stdout. Use
// the stable `event` field to filter/aggregate auto-logout events.
// `sourceIp` uses ALB's appended (last) X-Forwarded-For element, not the
// first one, since the leading element is client-supplied and spoofable.
logger.info("Auto-logout: session exceeded effective duration", {
event: "session.auto_logout",
userId: user.id,
impersonatedUserId,
nextSessionEnd: user.nextSessionEnd.toISOString(),
requestPath: new URL(request.url).pathname,
sourceIp: extractClientIp(request.headers.get("x-forwarded-for")),
});
throw redirect("/logout");
}
export async function getUserId(request: Request): Promise<string | undefined> {
const impersonatedUserId = await getImpersonationId(request);
if (impersonatedUserId) {
// Impersonating: verify the real user (the admin) is still an admin and
// enforce against their deadline (the cap belongs to the cookie, not the
// target). If admin status was revoked, fall through to operate as the
// admin themselves — otherwise a `requireUserId`-only route would keep
// letting them act as the impersonation target after losing the role.
const authUser = await authenticator.isAuthenticated(request);
if (!authUser?.userId) return undefined;
const realUser = await getUserById(authUser.userId);
if (!realUser) return authUser.userId;
if (realUser.admin) {
maybeAutoLogout(request, realUser, impersonatedUserId);
return impersonatedUserId;
}
maybeAutoLogout(request, realUser);
return authUser.userId;
}
// Non-impersonation fast path: zero DB queries. Auto-logout enforcement
// for this path happens in `getUser`, where we already pay for the User
// row fetch. `requireUserId` callers stay cookie-only.
const authUser = await authenticator.isAuthenticated(request);
// SSO session re-validation runs here so it covers both navigation
// (getUser) and API fetches (requireUserId). It's single-flight and
// throttled, so most requests do nothing; only SSO-marked sessions
// touch Redis. Throws redirect("/logout") if the IdP says invalid.
await revalidateSsoSession(request, authUser);
return authUser?.userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const user = await getUserById(userId);
if (!user) {
// Same loop-guard as in maybeAutoLogout: if /logout's loader is what
// matched, let it destroy the cookie itself rather than redirecting in
// circles via root.tsx.
if (new URL(request.url).pathname === "/logout") return null;
throw await logout(request);
}
// Auto-logout for the non-impersonation path. The impersonation path was
// already enforced inside `getUserId` against the admin's deadline, so
// skip re-checking against the (impersonation target's) row here.
const impersonationId = await getImpersonationId(request);
if (!impersonationId) maybeAutoLogout(request, user);
return user;
}
export async function requireUserId(request: Request, redirectTo?: string) {
const userId = await getUserId(request);
if (!userId) {
const url = new URL(request.url);
// Only propagate the originating URL when it's a real user-navigable page.
// Fetcher endpoints (e.g. /resources/*) and auth callbacks would render
// blank or loop if used as a post-login destination.
const finalRedirectTo = sanitizeRedirectPath(redirectTo ?? `${url.pathname}${url.search}`);
const searchParams = new URLSearchParams([["redirectTo", finalRedirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export type UserFromSession = Awaited<ReturnType<typeof requireUser>>;
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) {
const url = new URL(request.url);
const finalRedirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`);
const searchParams = new URLSearchParams([["redirectTo", finalRedirectTo]]);
throw redirect(`/login?${searchParams}`);
}
const impersonationId = await getImpersonationId(request);
return {
id: user.id,
email: user.email,
name: user.name,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
admin: user.admin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
dashboardPreferences: user.dashboardPreferences,
confirmedBasicDetails: user.confirmedBasicDetails,
mfaEnabledAt: user.mfaEnabledAt,
isImpersonating: !!impersonationId && impersonationId === user.id,
};
}
export async function logout(request: Request) {
return redirect("/logout");
}