158 lines
7.0 KiB
TypeScript
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");
|
|
}
|