Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { WebConfig } from "@toolctrl/config";
|
||||
import { getPublicAuthProviders, getServerAuthProviders } from "./authProviders.server";
|
||||
|
||||
const baseWebConfig: WebConfig = {
|
||||
appName: "Test App",
|
||||
nodeEnv: "production",
|
||||
listen: { host: "0.0.0.0", port: 3000 },
|
||||
routing: {
|
||||
appBaseDomain: "example.com",
|
||||
webAppHost: "app.example.com",
|
||||
adminAppHost: "admin.example.com",
|
||||
apiHost: "api.example.com",
|
||||
protocol: "https",
|
||||
},
|
||||
stripe: {},
|
||||
crisp: {},
|
||||
captcha: {},
|
||||
analytics: {},
|
||||
novu: {},
|
||||
mailerSend: {},
|
||||
ipProviders: {},
|
||||
portalServer: {},
|
||||
magicLink: {
|
||||
enabled: true,
|
||||
tokenTtlMinutes: 15,
|
||||
},
|
||||
} as unknown as WebConfig;
|
||||
|
||||
function requestFor(host = "https://app.example.com/dashboard") {
|
||||
return new Request(host);
|
||||
}
|
||||
|
||||
describe("authProviders.server", () => {
|
||||
test("reports magic-link as enabled and configured when enabled in web config", () => {
|
||||
const providers = getServerAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
|
||||
|
||||
expect(providers.magicLink).toEqual({ enabled: true, configured: true });
|
||||
});
|
||||
|
||||
test("public providers only expose the supported magic-link method", () => {
|
||||
const publicProviders = getPublicAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
|
||||
|
||||
expect(Object.keys(publicProviders)).toEqual(["magicLink"]);
|
||||
expect(publicProviders.magicLink).toEqual({ enabled: true, configured: true });
|
||||
});
|
||||
|
||||
test("normalizes disabled magic-link to a safe disabled unconfigured provider", () => {
|
||||
const webConfig = {
|
||||
...baseWebConfig,
|
||||
magicLink: { enabled: false, tokenTtlMinutes: undefined },
|
||||
} as WebConfig;
|
||||
|
||||
const providers = getServerAuthProviders({ request: requestFor(), webConfig });
|
||||
|
||||
expect(providers.magicLink).toEqual({ enabled: false, configured: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { loadRequiredWebConfig, type WebConfig } from "@toolctrl/config";
|
||||
|
||||
type AuthProviderRequestOptions = {
|
||||
request?: Request;
|
||||
webConfig?: WebConfig;
|
||||
};
|
||||
|
||||
// EU-Sterilisation (AD/EU-Stack): social OAuth login (GitHub/Google) is removed.
|
||||
// Authentication is Email+Password, Magic-Link and TOTP only. Keep this provider
|
||||
// registry so additional EU-compatible methods can slot in later.
|
||||
export type PublicAuthProviders = {
|
||||
magicLink: {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServerAuthProviders = {
|
||||
magicLink: {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function getPublicAuthProviders(options: AuthProviderRequestOptions): PublicAuthProviders {
|
||||
const providers = getServerAuthProviders(options);
|
||||
|
||||
return {
|
||||
magicLink: providers.magicLink,
|
||||
};
|
||||
}
|
||||
|
||||
export function getServerAuthProviders({ webConfig = loadRequiredWebConfig() }: AuthProviderRequestOptions): ServerAuthProviders {
|
||||
return {
|
||||
magicLink: {
|
||||
enabled: webConfig.magicLink.enabled,
|
||||
configured: webConfig.magicLink.enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createSharedStore } from "@toolctrl/shared-store";
|
||||
|
||||
import {
|
||||
checkMfaChallengeRateLimit,
|
||||
mfaChallengeRateLimitKey,
|
||||
recordFailedMfaChallengeAttempt,
|
||||
resetMfaChallengeRateLimit,
|
||||
resetMfaChallengeRateLimiterForTests,
|
||||
__resetMfaChallengeRateLimitStoreForTests,
|
||||
__setMfaChallengeRateLimitStoreForTests,
|
||||
} from "./challenge-rate-limit.server";
|
||||
|
||||
beforeEach(() => {
|
||||
__setMfaChallengeRateLimitStoreForTests(createSharedStore({ namespace: "test-mfa-challenge" }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetMfaChallengeRateLimiterForTests();
|
||||
__resetMfaChallengeRateLimitStoreForTests();
|
||||
});
|
||||
|
||||
describe("MFA challenge rate limiter", () => {
|
||||
it("keys attempts by user and client IP", () => {
|
||||
const request = new Request("https://admin.example.test/mfa-challenge", {
|
||||
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" },
|
||||
});
|
||||
|
||||
expect(mfaChallengeRateLimitKey(request, "user_1")).toBe("user_1:203.0.113.5");
|
||||
});
|
||||
|
||||
it("falls back from blank forwarded headers to real-ip and then unknown", () => {
|
||||
expect(
|
||||
mfaChallengeRateLimitKey(
|
||||
new Request("https://admin.example.test/mfa-challenge", {
|
||||
headers: { "x-forwarded-for": " ", "x-real-ip": "198.51.100.7" },
|
||||
}),
|
||||
"user_1",
|
||||
),
|
||||
).toBe("user_1:198.51.100.7");
|
||||
expect(mfaChallengeRateLimitKey(new Request("https://admin.example.test/mfa-challenge"), "user_1")).toBe(
|
||||
"user_1:unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks after repeated failed attempts and can be reset", async () => {
|
||||
const key = "user_1:203.0.113.5";
|
||||
const now = new Date("2026-05-11T12:00:00.000Z");
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
|
||||
await recordFailedMfaChallengeAttempt(key, now);
|
||||
}
|
||||
|
||||
const blocked = await checkMfaChallengeRateLimit(key, now);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
if (!blocked.allowed) {
|
||||
expect(blocked.retryAfterSeconds).toBe(300);
|
||||
}
|
||||
|
||||
await resetMfaChallengeRateLimit(key);
|
||||
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-numeric stored counters as zero remaining attempts", async () => {
|
||||
const store = createSharedStore({ namespace: "test-mfa-challenge-nan" });
|
||||
__setMfaChallengeRateLimitStoreForTests(store);
|
||||
const key = "user_1:203.0.113.5";
|
||||
const now = new Date("2026-05-11T12:00:00.000Z");
|
||||
await store.set(`rl:fixed:mfa-challenge:${key}`, "NaN", { ttlSeconds: 300 });
|
||||
|
||||
await expect(checkMfaChallengeRateLimit(key, now)).resolves.toEqual({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: new Date("2026-05-11T12:05:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("resetMfaChallengeRateLimiterForTests clears cached fixed-window keys", async () => {
|
||||
const key = "user_1:203.0.113.5";
|
||||
const now = new Date("2026-05-11T12:00:00.000Z");
|
||||
await recordFailedMfaChallengeAttempt(key, now);
|
||||
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
|
||||
|
||||
await resetMfaChallengeRateLimiterForTests();
|
||||
|
||||
const freshStore = createSharedStore({ namespace: "test-mfa-challenge" });
|
||||
__setMfaChallengeRateLimitStoreForTests(freshStore);
|
||||
await recordFailedMfaChallengeAttempt(key, now);
|
||||
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the redis adapter when REDIS_URL is set", () => {
|
||||
const originalRedisUrl = process.env.REDIS_URL;
|
||||
try {
|
||||
process.env.REDIS_URL = "redis://does-not-exist:6379";
|
||||
const store = createSharedStore({ namespace: "mfa-redis-selection", redisUrl: process.env.REDIS_URL });
|
||||
expect(store.adapter.constructor.name).toBe("RedisAdapter");
|
||||
void store.close();
|
||||
} finally {
|
||||
if (originalRedisUrl === undefined) delete process.env.REDIS_URL;
|
||||
else process.env.REDIS_URL = originalRedisUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the memory adapter when REDIS_URL is unset", () => {
|
||||
const originalRedisUrl = process.env.REDIS_URL;
|
||||
delete process.env.REDIS_URL;
|
||||
try {
|
||||
const store = createSharedStore({ namespace: "mfa-memory-selection" });
|
||||
expect(store.adapter.constructor.name).toBe("MemoryAdapter");
|
||||
} finally {
|
||||
if (originalRedisUrl !== undefined) process.env.REDIS_URL = originalRedisUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
type FixedWindowRateLimiter,
|
||||
type SharedStore,
|
||||
} from "@toolctrl/shared-store";
|
||||
import { getSharedStore } from "~/shared-store.server";
|
||||
|
||||
const MFA_CHALLENGE_LIMIT = 5;
|
||||
const MFA_CHALLENGE_WINDOW_SECONDS = 5 * 60;
|
||||
|
||||
export type MfaChallengeRateLimitResult =
|
||||
| { allowed: true; remaining: number; resetAt: Date }
|
||||
| { allowed: false; retryAfterSeconds: number; resetAt: Date };
|
||||
|
||||
type Limiter = {
|
||||
limiter: FixedWindowRateLimiter;
|
||||
store: SharedStore;
|
||||
};
|
||||
|
||||
let cached: Limiter | null = null;
|
||||
let storeFactory: () => SharedStore = () => getSharedStore();
|
||||
|
||||
function getLimiter(): Limiter {
|
||||
if (cached !== null) return cached;
|
||||
const store = storeFactory();
|
||||
cached = {
|
||||
store,
|
||||
limiter: createFixedWindowRateLimiter({
|
||||
store,
|
||||
prefix: "mfa-challenge",
|
||||
windowSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
|
||||
max: MFA_CHALLENGE_LIMIT,
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function mfaChallengeRateLimitKey(request: Request, userId: string) {
|
||||
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||
const ip = forwardedFor || request.headers.get("x-real-ip")?.trim() || "unknown";
|
||||
return `${userId}:${ip}`;
|
||||
}
|
||||
|
||||
export async function checkMfaChallengeRateLimit(key: string, now: Date = new Date()): Promise<MfaChallengeRateLimitResult> {
|
||||
const { store } = getLimiter();
|
||||
const suffix = `rl:fixed:mfa-challenge:${key}`;
|
||||
const raw = await store.get(suffix);
|
||||
const count = raw === null ? 0 : Number.parseInt(raw, 10) || 0;
|
||||
if (count >= MFA_CHALLENGE_LIMIT) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
|
||||
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: MFA_CHALLENGE_LIMIT - count,
|
||||
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordFailedMfaChallengeAttempt(key: string, _now: Date = new Date()): Promise<void> {
|
||||
const { limiter } = getLimiter();
|
||||
await limiter.consume(key);
|
||||
}
|
||||
|
||||
export async function resetMfaChallengeRateLimit(key: string): Promise<void> {
|
||||
const { limiter } = getLimiter();
|
||||
await limiter.reset(key);
|
||||
}
|
||||
|
||||
export async function resetMfaChallengeRateLimiterForTests(): Promise<void> {
|
||||
const previous = cached;
|
||||
cached = null;
|
||||
if (previous) {
|
||||
try {
|
||||
for await (const fullKey of previous.store.adapter.scan(
|
||||
`shared-store:${previous.store.namespace}:rl:fixed:mfa-challenge:*`,
|
||||
)) {
|
||||
const prefix = `shared-store:${previous.store.namespace}:`;
|
||||
if (fullKey.startsWith(prefix)) {
|
||||
await previous.store.del(fullKey.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; isolated tests inject a fresh store via the seam.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: inject a custom SharedStore. */
|
||||
export function __setMfaChallengeRateLimitStoreForTests(store: SharedStore): void {
|
||||
cached = null;
|
||||
storeFactory = () => store;
|
||||
}
|
||||
|
||||
/** Test seam: revert to the default web shared-store. */
|
||||
export function __resetMfaChallengeRateLimitStoreForTests(): void {
|
||||
cached = null;
|
||||
storeFactory = () => getSharedStore();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { UserTotpStatus } from "~/utils/db/users.db.server";
|
||||
import type { MfaStepUpCheckResult } from "./step-up.server";
|
||||
import {
|
||||
ADMIN_MFA_CHALLENGE_PATH,
|
||||
enforceAdminMfa,
|
||||
type AdminMfaEnforcementResult,
|
||||
} from "./enforcement.server";
|
||||
|
||||
const enabledTotp: UserTotpStatus = {
|
||||
enabled: true,
|
||||
pending: false,
|
||||
enabledAt: new Date("2026-05-08T14:00:00.000Z"),
|
||||
pendingCreatedAt: null,
|
||||
};
|
||||
|
||||
const pendingOnlyTotp: UserTotpStatus = {
|
||||
enabled: false,
|
||||
pending: true,
|
||||
enabledAt: null,
|
||||
pendingCreatedAt: new Date("2026-05-08T14:00:00.000Z"),
|
||||
};
|
||||
|
||||
const freshStepUp: MfaStepUpCheckResult = {
|
||||
verified: true,
|
||||
state: { userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:01:00.000Z" },
|
||||
ageMs: 1_000,
|
||||
};
|
||||
|
||||
function enforce(overrides: Partial<Parameters<typeof enforceAdminMfa>[0]> = {}): AdminMfaEnforcementResult {
|
||||
return enforceAdminMfa({
|
||||
requestUrl: "https://admin.example.com/users?tenant=tenant-1#section",
|
||||
userId: "admin-1",
|
||||
isAdmin: true,
|
||||
totpStatus: enabledTotp,
|
||||
stepUp: freshStepUp,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("enforceAdminMfa", () => {
|
||||
test("allows admin users with enabled TOTP and fresh step-up", () => {
|
||||
expect(enforce()).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows admins without active TOTP so the profile enrollment path is opt-in and non-blocking", () => {
|
||||
expect(enforce({ totpStatus: null })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
expect(enforce({ totpStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null } })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
expect(enforce({ totpStatus: pendingOnlyTotp })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
});
|
||||
|
||||
test("requires challenge when step-up is missing", () => {
|
||||
expect(enforce({ stepUp: { verified: false, reason: "missing" } })).toEqual({
|
||||
allowed: false,
|
||||
reason: "step-up-missing",
|
||||
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts URL objects when deriving safe redirects and challenge targets", () => {
|
||||
expect(enforce({ requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section") })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
expect(
|
||||
enforce({
|
||||
requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section"),
|
||||
stepUp: { verified: false, reason: "missing" },
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: "step-up-missing",
|
||||
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows the admin MFA challenge path when requestUrl is already a URL object", () => {
|
||||
expect(
|
||||
enforce({
|
||||
requestUrl: new URL("https://admin.example.com/mfa-challenge?redirectTo=%2Fusers"),
|
||||
stepUp: { verified: false, reason: "missing" },
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
|
||||
});
|
||||
});
|
||||
|
||||
test("requires challenge when step-up is stale", () => {
|
||||
expect(enforce({ stepUp: { verified: false, reason: "stale" } })).toEqual({
|
||||
allowed: false,
|
||||
reason: "step-up-stale",
|
||||
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses canonical admin redirects on the admin subdomain", () => {
|
||||
expect(enforce({ stepUp: { verified: false, reason: "missing" }, redirectMode: "host-aware" })).toEqual({
|
||||
allowed: false,
|
||||
reason: "step-up-missing",
|
||||
redirectTo: "/mfa-challenge?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section",
|
||||
});
|
||||
|
||||
expect(enforce({ redirectMode: "host-aware" })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
|
||||
expect(enforce({ totpStatus: null, redirectMode: "host-aware" })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/users?tenant=tenant-1#section",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows the admin MFA enrollment route before TOTP is enabled", () => {
|
||||
expect(enforce({ requestUrl: "https://admin.example.com/settings/profile?mfa=setup", totpStatus: pendingOnlyTotp, redirectMode: "host-aware" })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/settings/profile?mfa=setup",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows the admin MFA challenge route without existing step-up", () => {
|
||||
expect(enforce({ requestUrl: "https://admin.example.com/mfa-challenge?redirectTo=%2Fusers", stepUp: { verified: false, reason: "missing" } })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
|
||||
});
|
||||
});
|
||||
|
||||
test("denies non-admin and anonymous requests before challenge bypass is possible", () => {
|
||||
expect(enforce({ isAdmin: false, stepUp: freshStepUp })).toEqual({
|
||||
allowed: false,
|
||||
reason: "missing-admin",
|
||||
redirectTo: "/",
|
||||
});
|
||||
expect(enforce({ userId: null, isAdmin: true, stepUp: freshStepUp })).toEqual({
|
||||
allowed: false,
|
||||
reason: "missing-user",
|
||||
redirectTo: "/login",
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizes redirect targets derived from external and malformed request URLs", () => {
|
||||
expect(enforce({ requestUrl: "https://evil.example/phish?next=https://admin.example.com/settings" })).toEqual({
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
redirectTo: "/phish?next=https://admin.example.com/settings",
|
||||
});
|
||||
expect(enforce({ requestUrl: "not a url", stepUp: { verified: false, reason: "malformed" } })).toEqual({
|
||||
allowed: false,
|
||||
reason: "step-up-malformed",
|
||||
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2F`,
|
||||
});
|
||||
});
|
||||
|
||||
test("maps malformed and wrong-user step-up states to fail-closed reasons", () => {
|
||||
expect(enforce({ stepUp: { verified: false, reason: "malformed" } })).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "step-up-malformed",
|
||||
});
|
||||
expect(enforce({ stepUp: { verified: false, reason: "wrong-user" } })).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "step-up-wrong-user",
|
||||
});
|
||||
});
|
||||
|
||||
test("result payloads contain no TOTP secrets, recovery codes, hashes, cookies, tokens, or passwords", () => {
|
||||
const result = enforce({
|
||||
requestUrl: "https://admin.example.com/dashboard?tab=secrets",
|
||||
stepUp: { verified: false, reason: "missing" },
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result)).not.toMatch(/totpSecret|recoveryCode|hash|cookie|token|password|encrypted/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { UserTotpStatus } from "~/utils/db/users.db.server";
|
||||
import type { MfaStepUpCheckReason, MfaStepUpCheckResult } from "./step-up.server";
|
||||
export { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH, ADMIN_MFA_ENROLLMENT_PATH } from "./paths";
|
||||
import { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH } from "./paths";
|
||||
|
||||
export type AdminMfaEnforcementFailureReason =
|
||||
| "missing-user"
|
||||
| "missing-admin"
|
||||
| "missing-totp-status"
|
||||
| "totp-not-enabled"
|
||||
| "step-up-missing"
|
||||
| "step-up-malformed"
|
||||
| "step-up-wrong-user"
|
||||
| "step-up-stale";
|
||||
|
||||
export type AdminMfaEnforcementReason = "allowed" | AdminMfaEnforcementFailureReason;
|
||||
|
||||
export type AdminMfaEnforcementResult =
|
||||
| { allowed: true; reason: "allowed"; redirectTo: string }
|
||||
| { allowed: false; reason: AdminMfaEnforcementFailureReason; redirectTo: string };
|
||||
|
||||
export type AdminMfaEnforcementInput = {
|
||||
requestUrl: string | URL;
|
||||
userId?: string | null;
|
||||
isAdmin?: boolean | null;
|
||||
totpStatus?: UserTotpStatus | null;
|
||||
stepUp: MfaStepUpCheckResult;
|
||||
redirectMode?: "host-aware";
|
||||
appFallbackPath?: string;
|
||||
};
|
||||
|
||||
function isAdminMfaChallengePath(requestUrl: string | URL): boolean {
|
||||
try {
|
||||
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
|
||||
return url.pathname === ADMIN_MFA_CHALLENGE_PATH;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safePathFromRequestUrl(requestUrl: string | URL): string {
|
||||
try {
|
||||
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return ADMIN_MFA_DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
function challengeRedirectTo(requestUrl: string | URL): string {
|
||||
const redirectTo = safePathFromRequestUrl(requestUrl);
|
||||
const params = new URLSearchParams({ redirectTo });
|
||||
return `${ADMIN_MFA_CHALLENGE_PATH}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function stepUpReason(reason: MfaStepUpCheckReason): AdminMfaEnforcementFailureReason {
|
||||
switch (reason) {
|
||||
case "missing":
|
||||
return "step-up-missing";
|
||||
case "malformed":
|
||||
return "step-up-malformed";
|
||||
case "wrong-user":
|
||||
return "step-up-wrong-user";
|
||||
case "stale":
|
||||
return "step-up-stale";
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceAdminMfa(input: AdminMfaEnforcementInput): AdminMfaEnforcementResult {
|
||||
if (!input.userId) {
|
||||
return { allowed: false, reason: "missing-user", redirectTo: "/login" };
|
||||
}
|
||||
|
||||
if (input.isAdmin !== true) {
|
||||
return { allowed: false, reason: "missing-admin", redirectTo: input.appFallbackPath ?? "/" };
|
||||
}
|
||||
|
||||
if (!input.totpStatus || !input.totpStatus.enabled) {
|
||||
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
|
||||
}
|
||||
|
||||
if (!input.stepUp.verified && !isAdminMfaChallengePath(input.requestUrl)) {
|
||||
return { allowed: false, reason: stepUpReason(input.stepUp.reason), redirectTo: challengeRedirectTo(input.requestUrl) };
|
||||
}
|
||||
|
||||
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export const ADMIN_MFA_ENROLLMENT_PATH = adminPath("settings/profile", { mode: "host-aware" });
|
||||
export const ADMIN_MFA_CHALLENGE_PATH = adminPath("mfa-challenge", { mode: "host-aware" });
|
||||
export const ADMIN_MFA_DEFAULT_REDIRECT_PATH = adminPath(undefined, { mode: "host-aware" });
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
const codePattern = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}$/;
|
||||
|
||||
describe("recovery-codes.server", () => {
|
||||
beforeEach(() => {
|
||||
process.env.CRYPTO_SECRET = "test-recovery-code-secret";
|
||||
});
|
||||
|
||||
test("generates the default count of unique human-readable codes with hash-only storage entries", async () => {
|
||||
const { DEFAULT_RECOVERY_CODE_COUNT, generateRecoveryCodes } = await import("./recovery-codes.server");
|
||||
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
|
||||
|
||||
const result = generateRecoveryCodes({ now: generatedAt });
|
||||
|
||||
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
|
||||
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
|
||||
expect(result.codes).toEqual(Array.from({ length: DEFAULT_RECOVERY_CODE_COUNT }, () => expect.stringMatching(codePattern)));
|
||||
expect(result.generatedAt).toBe(generatedAt);
|
||||
expect(result.entries).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
|
||||
for (const [index, entry] of result.entries.entries()) {
|
||||
expect(entry).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/), usedAt: null, createdAt: generatedAt });
|
||||
expect(entry.hash).not.toBe(result.codes[index]);
|
||||
expect(JSON.stringify(entry)).not.toContain(result.codes[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes code input and verifies hashes with constant-length safe comparison", async () => {
|
||||
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
|
||||
const code = "2345-6789-ABCD";
|
||||
const hash = hashRecoveryCode(code);
|
||||
|
||||
expect(normalizeRecoveryCode(" 2345 6789 abcd ")).toBe(code);
|
||||
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(verifyRecoveryCodeHash("23456789abcd", hash!)).toBe(true);
|
||||
expect(verifyRecoveryCodeHash("2345-6789-ABCE", hash!)).toBe(false);
|
||||
expect(verifyRecoveryCodeHash("2345-6789-ABCD", "abcd")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects malformed input before hashing or verification", async () => {
|
||||
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
|
||||
|
||||
expect(normalizeRecoveryCode("")).toBeNull();
|
||||
expect(normalizeRecoveryCode("1234-5678-ABCD")).toBeNull();
|
||||
expect(normalizeRecoveryCode("2345-6789-ABC")).toBeNull();
|
||||
expect(normalizeRecoveryCode("2345-6789-ABCI")).toBeNull();
|
||||
expect(hashRecoveryCode("2345-6789-ABCI")).toBeNull();
|
||||
expect(verifyRecoveryCodeHash("2345-6789-ABCI", "00".repeat(32))).toBe(false);
|
||||
});
|
||||
|
||||
test("consumes a valid recovery code once and rejects replay", async () => {
|
||||
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
|
||||
const generated = generateRecoveryCodes({ count: 2, now: new Date("2026-05-08T14:00:00.000Z") });
|
||||
const firstUseAt = new Date("2026-05-08T14:05:00.000Z");
|
||||
const replayAt = new Date("2026-05-08T14:06:00.000Z");
|
||||
|
||||
const firstUse = consumeRecoveryCode(generated.entries, generated.codes[0], { now: firstUseAt });
|
||||
const replay = consumeRecoveryCode(firstUse.entries, generated.codes[0], { now: replayAt });
|
||||
const wrongCode = consumeRecoveryCode(firstUse.entries, "2345-6789-ABCD", { now: replayAt });
|
||||
|
||||
expect(firstUse.matched).toBe(true);
|
||||
expect(firstUse.usedAt).toBe(firstUseAt);
|
||||
expect(firstUse.entries[0].usedAt).toBe(firstUseAt);
|
||||
expect(firstUse.entries[1].usedAt).toBeNull();
|
||||
expect(replay).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
|
||||
expect(wrongCode).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
|
||||
});
|
||||
|
||||
test("generation does not loop indefinitely: succeeds on the first attempt under healthy entropy", async () => {
|
||||
const { generateRecoveryCodes, DEFAULT_RECOVERY_CODE_COUNT } = await import("./recovery-codes.server");
|
||||
|
||||
const result = generateRecoveryCodes();
|
||||
|
||||
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
|
||||
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
|
||||
});
|
||||
|
||||
test("generation completes in bounded time even when asked for the maximum count (no rejection-sampling loop)", async () => {
|
||||
const { generateRecoveryCodes } = await import("./recovery-codes.server");
|
||||
|
||||
// The legacy implementation rebuilt a `Set` with the codes and looped
|
||||
// until uniqueness — pathological under bad entropy. We assert a strict
|
||||
// wall-clock bound to catch any regression that re-introduces unbounded
|
||||
// looping.
|
||||
const start = Date.now();
|
||||
const result = generateRecoveryCodes({ count: 50 });
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result.codes).toHaveLength(50);
|
||||
expect(new Set(result.codes).size).toBe(50);
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test("status helpers redact raw codes and hashes when serialized", async () => {
|
||||
const { consumeRecoveryCode, generateRecoveryCodes, getRecoveryCodeStatus } = await import("./recovery-codes.server");
|
||||
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
|
||||
const usedAt = new Date("2026-05-08T14:10:00.000Z");
|
||||
const generated = generateRecoveryCodes({ count: 3, now: generatedAt });
|
||||
const consumed = consumeRecoveryCode(generated.entries, generated.codes[1], { now: usedAt });
|
||||
|
||||
const status = getRecoveryCodeStatus(consumed.entries);
|
||||
const serialized = JSON.stringify(status);
|
||||
|
||||
expect(status).toEqual({
|
||||
configured: true,
|
||||
totalCount: 3,
|
||||
remainingCount: 2,
|
||||
usedCount: 1,
|
||||
generatedAt,
|
||||
lastUsedAt: usedAt,
|
||||
});
|
||||
for (const code of generated.codes) {
|
||||
expect(serialized).not.toContain(code);
|
||||
}
|
||||
for (const entry of generated.entries) {
|
||||
expect(serialized).not.toContain(entry.hash);
|
||||
}
|
||||
expect(serialized).not.toMatch(/hash|code|secret/i);
|
||||
});
|
||||
|
||||
test("status helper reports an unconfigured state for null or undefined entries", async () => {
|
||||
const { getRecoveryCodeStatus } = await import("./recovery-codes.server");
|
||||
|
||||
expect(getRecoveryCodeStatus(null)).toEqual({
|
||||
configured: false,
|
||||
totalCount: 0,
|
||||
remainingCount: 0,
|
||||
usedCount: 0,
|
||||
generatedAt: null,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
expect(getRecoveryCodeStatus(undefined, { generatedAt: null, lastUsedAt: null })).toEqual({
|
||||
configured: false,
|
||||
totalCount: 0,
|
||||
remainingCount: 0,
|
||||
usedCount: 0,
|
||||
generatedAt: null,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("consumeRecoveryCode rejects malformed codes and uses the current time when no now option is supplied", async () => {
|
||||
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
|
||||
const generated = generateRecoveryCodes({ count: 1, now: new Date("2026-05-08T14:00:00.000Z") });
|
||||
|
||||
expect(consumeRecoveryCode(generated.entries, "bad-code")).toEqual({
|
||||
matched: false,
|
||||
entries: generated.entries,
|
||||
usedAt: null,
|
||||
});
|
||||
|
||||
const consumed = consumeRecoveryCode(generated.entries, generated.codes[0]);
|
||||
expect(consumed.matched).toBe(true);
|
||||
expect(consumed.usedAt).toBeInstanceOf(Date);
|
||||
expect(consumed.entries[0].usedAt).toBe(consumed.usedAt);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
|
||||
export const DEFAULT_RECOVERY_CODE_COUNT = 10;
|
||||
export const DEFAULT_RECOVERY_CODE_SEGMENTS = 3;
|
||||
export const DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH = 4;
|
||||
export const RECOVERY_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
|
||||
export type RecoveryCodeHashEntry = {
|
||||
hash: string;
|
||||
usedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type RecoveryCodeGeneration = {
|
||||
codes: string[];
|
||||
entries: RecoveryCodeHashEntry[];
|
||||
generatedAt: Date;
|
||||
};
|
||||
|
||||
export type RecoveryCodeStatus = {
|
||||
configured: boolean;
|
||||
totalCount: number;
|
||||
remainingCount: number;
|
||||
usedCount: number;
|
||||
generatedAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
};
|
||||
|
||||
type GenerateRecoveryCodesOptions = {
|
||||
count?: number;
|
||||
segments?: number;
|
||||
segmentLength?: number;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
type NormalizeOptions = {
|
||||
segments?: number;
|
||||
segmentLength?: number;
|
||||
};
|
||||
|
||||
function assertPositiveInteger(value: number, name: string) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function getRecoveryCodeSecret() {
|
||||
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
|
||||
const secret = cryptoSecret || sessionSecret;
|
||||
if (!secret) {
|
||||
throw new Error("CRYPTO_SECRET or SESSION_SECRET env variable not set");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function randomAlphabetCharacter() {
|
||||
const byteLimit = Math.floor(256 / RECOVERY_CODE_ALPHABET.length) * RECOVERY_CODE_ALPHABET.length;
|
||||
|
||||
while (true) {
|
||||
const value = randomBytes(1)[0];
|
||||
if (value < byteLimit) {
|
||||
return RECOVERY_CODE_ALPHABET[value % RECOVERY_CODE_ALPHABET.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRecoveryCode(input: string, options: NormalizeOptions = {}) {
|
||||
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
|
||||
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
|
||||
assertPositiveInteger(segments, "segments");
|
||||
assertPositiveInteger(segmentLength, "segmentLength");
|
||||
|
||||
const compact = input.trim().toUpperCase().replace(/[\s-]/g, "");
|
||||
const expectedLength = segments * segmentLength;
|
||||
if (compact.length !== expectedLength || [...compact].some((character) => !RECOVERY_CODE_ALPHABET.includes(character))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from({ length: segments }, (_, index) =>
|
||||
compact.slice(index * segmentLength, (index + 1) * segmentLength)
|
||||
).join("-");
|
||||
}
|
||||
|
||||
export function generateRecoveryCode(options: NormalizeOptions = {}) {
|
||||
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
|
||||
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
|
||||
assertPositiveInteger(segments, "segments");
|
||||
assertPositiveInteger(segmentLength, "segmentLength");
|
||||
|
||||
return Array.from({ length: segments }, () =>
|
||||
Array.from({ length: segmentLength }, () => randomAlphabetCharacter()).join("")
|
||||
).join("-");
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code: string) {
|
||||
const normalized = normalizeRecoveryCode(code);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createHmac("sha256", getRecoveryCodeSecret()).update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
function hashMatches(candidateHash: string, storedHash: string) {
|
||||
const candidateBuffer = Buffer.from(candidateHash, "hex");
|
||||
const storedBuffer = Buffer.from(storedHash, "hex");
|
||||
if (candidateBuffer.length !== storedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(candidateBuffer, storedBuffer);
|
||||
}
|
||||
|
||||
export function verifyRecoveryCodeHash(code: string, storedHash: string) {
|
||||
const candidateHash = hashRecoveryCode(code);
|
||||
if (!candidateHash) {
|
||||
return false;
|
||||
}
|
||||
return hashMatches(candidateHash, storedHash);
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(options: GenerateRecoveryCodesOptions = {}): RecoveryCodeGeneration {
|
||||
const count = options.count ?? DEFAULT_RECOVERY_CODE_COUNT;
|
||||
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
|
||||
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
|
||||
assertPositiveInteger(count, "count");
|
||||
assertPositiveInteger(segments, "segments");
|
||||
assertPositiveInteger(segmentLength, "segmentLength");
|
||||
|
||||
const generatedAt = options.now ?? new Date();
|
||||
// 32^12 (~10^18) code space means in-batch collisions are astronomically
|
||||
// unlikely. Generate `count` codes upfront and only retry once if we ever
|
||||
// see a duplicate (instead of looping forever on every call).
|
||||
const MAX_RETRIES = 2;
|
||||
let codeList: string[] = [];
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
|
||||
const candidates = Array.from({ length: count }, () => generateRecoveryCode({ segments, segmentLength }));
|
||||
if (new Set(candidates).size === count) {
|
||||
codeList = candidates;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (codeList.length !== count) {
|
||||
throw new Error("Failed to generate unique recovery codes after retry; suspected entropy source failure.");
|
||||
}
|
||||
return {
|
||||
codes: codeList,
|
||||
entries: codeList.map((code) => ({
|
||||
hash: hashRecoveryCode(code)!,
|
||||
usedAt: null,
|
||||
createdAt: generatedAt,
|
||||
})),
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeRecoveryCode(
|
||||
entries: RecoveryCodeHashEntry[],
|
||||
code: string,
|
||||
options: { now?: Date } = {}
|
||||
): { matched: boolean; entries: RecoveryCodeHashEntry[]; usedAt: Date | null } {
|
||||
const candidateHash = hashRecoveryCode(code);
|
||||
if (!candidateHash) {
|
||||
return { matched: false, entries, usedAt: null };
|
||||
}
|
||||
|
||||
const usedAt = options.now ?? new Date();
|
||||
let matched = false;
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (entry.usedAt || matched || !hashMatches(candidateHash, entry.hash)) {
|
||||
return entry;
|
||||
}
|
||||
matched = true;
|
||||
return { ...entry, usedAt };
|
||||
});
|
||||
|
||||
return { matched, entries: matched ? nextEntries : entries, usedAt: matched ? usedAt : null };
|
||||
}
|
||||
|
||||
export function getRecoveryCodeStatus(
|
||||
entries: RecoveryCodeHashEntry[] | null | undefined,
|
||||
options: { generatedAt?: Date | null; lastUsedAt?: Date | null } = {}
|
||||
): RecoveryCodeStatus {
|
||||
const configured = Boolean(entries?.length);
|
||||
const totalCount = entries?.length ?? 0;
|
||||
const usedEntries = entries?.filter((entry) => entry.usedAt) ?? [];
|
||||
const remainingCount = entries?.filter((entry) => !entry.usedAt).length ?? 0;
|
||||
const lastEntryUsedAt = usedEntries.reduce<Date | null>((latest, entry) => {
|
||||
if (!entry.usedAt) {
|
||||
return latest;
|
||||
}
|
||||
if (!latest || entry.usedAt.getTime() > latest.getTime()) {
|
||||
return entry.usedAt;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
configured,
|
||||
totalCount,
|
||||
remainingCount,
|
||||
usedCount: usedEntries.length,
|
||||
generatedAt: options.generatedAt ?? (configured ? entries?.[0]?.createdAt ?? null : null),
|
||||
lastUsedAt: options.lastUsedAt ?? lastEntryUsedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/session.server", async () => {
|
||||
const { createCookieSessionStorage } = await import("react-router");
|
||||
return {
|
||||
storage: createCookieSessionStorage({
|
||||
cookie: { name: "test_session", secrets: ["test-session-secret"], path: "/", httpOnly: true, sameSite: "lax" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { storage } from "~/utils/session.server";
|
||||
import {
|
||||
checkMfaStepUpState,
|
||||
clearMfaStepUpState,
|
||||
getMfaStepUpState,
|
||||
MFA_STEP_UP_MAX_AGE_MS,
|
||||
MFA_STEP_UP_SESSION_KEY,
|
||||
setMfaStepUpState,
|
||||
} from "./step-up.server";
|
||||
|
||||
async function createSession() {
|
||||
return storage.getSession();
|
||||
}
|
||||
|
||||
describe("step-up.server", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("records and verifies fresh MFA step-up state for the same user", async () => {
|
||||
const session = await createSession();
|
||||
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
|
||||
|
||||
const state = setMfaStepUpState(session, {
|
||||
userId: "admin-1",
|
||||
method: "totp",
|
||||
redirectTo: "/admin?tab=security",
|
||||
now: verifiedAt,
|
||||
});
|
||||
const result = checkMfaStepUpState(session, "admin-1", { now: new Date(verifiedAt.getTime() + 30_000) });
|
||||
|
||||
expect(state).toEqual({
|
||||
userId: "admin-1",
|
||||
method: "totp",
|
||||
verifiedAt: verifiedAt.toISOString(),
|
||||
redirectTo: "/admin?tab=security",
|
||||
});
|
||||
expect(getMfaStepUpState(session)).toEqual(state);
|
||||
expect(result).toEqual({ verified: true, state, ageMs: 30_000 });
|
||||
});
|
||||
|
||||
test("records step-up state without optional redirect metadata", async () => {
|
||||
const session = await createSession();
|
||||
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
|
||||
|
||||
const state = setMfaStepUpState(session, {
|
||||
userId: "admin-1",
|
||||
method: "recovery-code",
|
||||
now: verifiedAt,
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
userId: "admin-1",
|
||||
method: "recovery-code",
|
||||
verifiedAt: verifiedAt.toISOString(),
|
||||
});
|
||||
expect(getMfaStepUpState(session)).toEqual(state);
|
||||
});
|
||||
|
||||
test("fails closed for stale and future timestamps", async () => {
|
||||
const staleSession = await createSession();
|
||||
const futureSession = await createSession();
|
||||
const now = new Date("2026-05-08T14:10:00.000Z");
|
||||
|
||||
setMfaStepUpState(staleSession, {
|
||||
userId: "admin-1",
|
||||
method: "recovery-code",
|
||||
now: new Date(now.getTime() - MFA_STEP_UP_MAX_AGE_MS - 1),
|
||||
});
|
||||
setMfaStepUpState(futureSession, {
|
||||
userId: "admin-1",
|
||||
method: "totp",
|
||||
now: new Date(now.getTime() + 1),
|
||||
});
|
||||
|
||||
expect(checkMfaStepUpState(staleSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
|
||||
expect(checkMfaStepUpState(futureSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
|
||||
});
|
||||
|
||||
test("does not authorize a different user", async () => {
|
||||
const session = await createSession();
|
||||
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
|
||||
|
||||
expect(checkMfaStepUpState(session, "admin-2", { now: new Date("2026-05-08T14:01:00.000Z") })).toEqual({
|
||||
verified: false,
|
||||
reason: "wrong-user",
|
||||
});
|
||||
});
|
||||
|
||||
test("treats missing and malformed state as unverified without throwing", async () => {
|
||||
const missingSession = await createSession();
|
||||
const malformedPayloads = [
|
||||
{ method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
||||
{ userId: "", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
||||
"not-json-object",
|
||||
{ userId: "admin-1", method: "sms", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
||||
{ userId: "admin-1", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
||||
{ userId: "admin-1", method: "totp", verifiedAt: "not-a-date" },
|
||||
{ userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z", redirectTo: 123 },
|
||||
];
|
||||
|
||||
expect(checkMfaStepUpState(missingSession, "admin-1")).toEqual({ verified: false, reason: "missing" });
|
||||
|
||||
for (const payload of malformedPayloads) {
|
||||
const session = await createSession();
|
||||
session.set(MFA_STEP_UP_SESSION_KEY, payload);
|
||||
expect(() => getMfaStepUpState(session)).not.toThrow();
|
||||
expect(getMfaStepUpState(session)).toBeNull();
|
||||
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "malformed" });
|
||||
}
|
||||
});
|
||||
|
||||
test("clears existing MFA step-up state", async () => {
|
||||
const session = await createSession();
|
||||
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
|
||||
|
||||
clearMfaStepUpState(session);
|
||||
|
||||
expect(getMfaStepUpState(session)).toBeNull();
|
||||
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "missing" });
|
||||
});
|
||||
|
||||
test("serialized step-up state contains no cookies, tokens, hashes, passwords, or factor secrets", async () => {
|
||||
const session = await createSession();
|
||||
const state = setMfaStepUpState(session, {
|
||||
userId: "admin-1",
|
||||
method: "recovery-code",
|
||||
redirectTo: "/admin",
|
||||
now: new Date("2026-05-08T14:00:00.000Z"),
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(state);
|
||||
|
||||
expect(serialized).toContain("admin-1");
|
||||
expect(serialized).toContain("recovery-code");
|
||||
expect(serialized).not.toMatch(/cookie|csrf|token|secret|hash|password|encrypted|totpSecret|recoveryCodes/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Session } from "react-router";
|
||||
|
||||
export const MFA_STEP_UP_SESSION_KEY = "adminMfaStepUp";
|
||||
export const MFA_STEP_UP_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type MfaStepUpMethod = "totp" | "recovery-code";
|
||||
|
||||
export type MfaStepUpState = {
|
||||
userId: string;
|
||||
method: MfaStepUpMethod;
|
||||
verifiedAt: string;
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export type MfaStepUpCheckReason = "missing" | "malformed" | "wrong-user" | "stale";
|
||||
|
||||
export type MfaStepUpCheckResult =
|
||||
| { verified: true; state: MfaStepUpState; ageMs: number }
|
||||
| { verified: false; reason: MfaStepUpCheckReason };
|
||||
|
||||
const MFA_STEP_UP_METHODS = new Set<MfaStepUpMethod>(["totp", "recovery-code"]);
|
||||
|
||||
function isMfaStepUpMethod(method: unknown): method is MfaStepUpMethod {
|
||||
return typeof method === "string" && MFA_STEP_UP_METHODS.has(method as MfaStepUpMethod);
|
||||
}
|
||||
|
||||
function parseStepUpState(value: unknown): MfaStepUpState | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<Record<keyof MfaStepUpState, unknown>>;
|
||||
if (typeof candidate.userId !== "string" || candidate.userId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!isMfaStepUpMethod(candidate.method)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.verifiedAt !== "string" || Number.isNaN(Date.parse(candidate.verifiedAt))) {
|
||||
return null;
|
||||
}
|
||||
if (candidate.redirectTo !== undefined && typeof candidate.redirectTo !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: candidate.userId,
|
||||
method: candidate.method,
|
||||
verifiedAt: candidate.verifiedAt,
|
||||
...(candidate.redirectTo !== undefined ? { redirectTo: candidate.redirectTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMfaStepUpState(session: Session): MfaStepUpState | null {
|
||||
return parseStepUpState(session.get(MFA_STEP_UP_SESSION_KEY));
|
||||
}
|
||||
|
||||
export function setMfaStepUpState(
|
||||
session: Session,
|
||||
input: {
|
||||
userId: string;
|
||||
method: MfaStepUpMethod;
|
||||
redirectTo?: string;
|
||||
now?: Date;
|
||||
},
|
||||
): MfaStepUpState {
|
||||
const state: MfaStepUpState = {
|
||||
userId: input.userId,
|
||||
method: input.method,
|
||||
verifiedAt: (input.now ?? new Date()).toISOString(),
|
||||
...(input.redirectTo !== undefined ? { redirectTo: input.redirectTo } : {}),
|
||||
};
|
||||
|
||||
session.set(MFA_STEP_UP_SESSION_KEY, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function clearMfaStepUpState(session: Session) {
|
||||
session.unset(MFA_STEP_UP_SESSION_KEY);
|
||||
}
|
||||
|
||||
export function checkMfaStepUpState(
|
||||
session: Session,
|
||||
userId: string,
|
||||
options: { now?: Date; maxAgeMs?: number } = {},
|
||||
): MfaStepUpCheckResult {
|
||||
const rawValue = session.get(MFA_STEP_UP_SESSION_KEY);
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return { verified: false, reason: "missing" };
|
||||
}
|
||||
|
||||
const state = parseStepUpState(rawValue);
|
||||
if (!state) {
|
||||
return { verified: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
if (state.userId !== userId) {
|
||||
return { verified: false, reason: "wrong-user" };
|
||||
}
|
||||
|
||||
const nowMs = (options.now ?? new Date()).getTime();
|
||||
const ageMs = nowMs - Date.parse(state.verifiedAt);
|
||||
const maxAgeMs = options.maxAgeMs ?? MFA_STEP_UP_MAX_AGE_MS;
|
||||
if (ageMs < 0 || ageMs > maxAgeMs) {
|
||||
return { verified: false, reason: "stale" };
|
||||
}
|
||||
|
||||
return { verified: true, state, ageMs };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* D1 (Trust-Device) — DB-gestützter, widerrufbarer Geräte-Marker. Das Cookie trägt einen opaken,
|
||||
* signierten Token; Autorität ist die TrustedDevice-Tabelle (Hash, userId, Ablauf, Widerruf).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/config", async () => {
|
||||
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
|
||||
return {
|
||||
...actual,
|
||||
loadRequiredWebConfig: () => ({ nodeEnv: "test", sessionSecret: "test-session-secret-please-change" }),
|
||||
};
|
||||
});
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
trustedDevice: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("~/services/auth/sessionLifecycle.server", () => ({
|
||||
buildSessionDeviceContext: vi.fn().mockResolvedValue({ ipHash: null, userAgent: "UA", approxLocation: "DE" }),
|
||||
}));
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import {
|
||||
__resetTrustedDeviceCookieForTests,
|
||||
clearTrustedDeviceCookie,
|
||||
forgetCurrentTrustedDevice,
|
||||
isTrustedDevice,
|
||||
rememberDeviceForUser,
|
||||
revokeAllTrustedDevices,
|
||||
revokeTrustedDevice,
|
||||
TRUSTED_DEVICE_COOKIE_NAME,
|
||||
} from "./trusted-device.server";
|
||||
|
||||
const create = vi.mocked(db.trustedDevice.create);
|
||||
const findUnique = vi.mocked(db.trustedDevice.findUnique);
|
||||
const updateMany = vi.mocked(db.trustedDevice.updateMany);
|
||||
|
||||
function activeRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "td-1",
|
||||
userId: "user-1",
|
||||
tokenHash: "hash",
|
||||
createdAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||
revokedAt: null,
|
||||
ipHash: null,
|
||||
userAgent: "UA",
|
||||
deviceLabel: null,
|
||||
approxLocation: "DE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function rememberAndGetRequest(userId = "user-1") {
|
||||
create.mockResolvedValue(activeRow() as never);
|
||||
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId });
|
||||
const pair = setCookie.split(";")[0];
|
||||
return new Request("https://app.example.com/login", { headers: { Cookie: pair } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetTrustedDeviceCookieForTests();
|
||||
});
|
||||
afterEach(() => __resetTrustedDeviceCookieForTests());
|
||||
|
||||
describe("trusted-device (DB-backed)", () => {
|
||||
it("remembers a device: writes a row and returns a Set-Cookie", async () => {
|
||||
create.mockResolvedValue(activeRow() as never);
|
||||
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId: "user-1" });
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(setCookie).toContain(TRUSTED_DEVICE_COOKIE_NAME);
|
||||
// Nur der Hash, nie der rohe Token, wird persistiert.
|
||||
const data = (create.mock.calls[0][0] as { data: { tokenHash: string } }).data;
|
||||
expect(data.tokenHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("round-trips: a remembered device is trusted for the same user", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
findUnique.mockResolvedValue(activeRow() as never);
|
||||
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("touches lastUsedAt (throttled) when the marker was last used long ago", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
findUnique.mockResolvedValue(activeRow({ lastUsedAt: new Date(0) }) as never);
|
||||
vi.mocked(db.trustedDevice.update).mockResolvedValue(activeRow() as never);
|
||||
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
|
||||
expect(db.trustedDevice.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a marker that belongs to a different user", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
findUnique.mockResolvedValue(activeRow({ userId: "user-2" }) as never);
|
||||
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an expired marker", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
findUnique.mockResolvedValue(activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never);
|
||||
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a revoked marker", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
findUnique.mockResolvedValue(activeRow({ revokedAt: new Date() }) as never);
|
||||
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false (and skips the DB) without a cookie", async () => {
|
||||
await expect(isTrustedDevice(new Request("https://app.example.com/login"), "user-1")).resolves.toBe(false);
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a tampered cookie (broken signature) without hitting the DB", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
const tampered = new Request("https://app.example.com/login", { headers: { Cookie: `${request.headers.get("Cookie")}TAMPER` } });
|
||||
findUnique.mockClear();
|
||||
await expect(isTrustedDevice(tampered, "user-1")).resolves.toBe(false);
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes a single device scoped to the owner", async () => {
|
||||
updateMany.mockResolvedValue({ count: 1 } as never);
|
||||
await expect(revokeTrustedDevice({ userId: "user-1", deviceId: "td-1" })).resolves.toBe(true);
|
||||
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: "td-1", userId: "user-1", revokedAt: null }) }));
|
||||
});
|
||||
|
||||
it("revokes all devices of a user", async () => {
|
||||
updateMany.mockResolvedValue({ count: 3 } as never);
|
||||
await expect(revokeAllTrustedDevices("user-1")).resolves.toBe(3);
|
||||
});
|
||||
|
||||
it("forgets the current device: revokes its row and clears the cookie", async () => {
|
||||
const request = await rememberAndGetRequest("user-1");
|
||||
updateMany.mockResolvedValue({ count: 1 } as never);
|
||||
const setCookie = await forgetCurrentTrustedDevice(request);
|
||||
expect(updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
|
||||
});
|
||||
|
||||
it("clears the cookie without DB access", async () => {
|
||||
const cleared = await clearTrustedDeviceCookie();
|
||||
expect(cleared).toContain(TRUSTED_DEVICE_COOKIE_NAME);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createCookie } from "react-router";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { buildSessionDeviceContext } from "~/services/auth/sessionLifecycle.server";
|
||||
|
||||
// D1 (Trust-Device) — "diesem Geraet vertrauen" beim 2FA-Login. Der Marker UEBERLEBT den Logout
|
||||
// (deshalb NICHT die per-Session `UserSession.trustedAt`), ist aber WIDERRUFBAR: ein eigenes, langlebiges
|
||||
// Cookie (`RJ_trusted_device`) traegt einen opaken Zufalls-Token, persistiert wird nur dessen SHA-256-Hash
|
||||
// in der Tabelle `TrustedDevice`. Autoritaet ist die Tabelle (Revoke einzeln / "ueberall vergessen" ueber
|
||||
// Settings > Sicherheit) -> erfuellt den DoD-Screen "Trust-Device-Revoke" (R-P2a). Das Cookie ist nur der
|
||||
// Traeger; ohne passende, aktive Zeile gilt das Geraet als NICHT vertrauenswuerdig.
|
||||
|
||||
export const TRUSTED_DEVICE_COOKIE_NAME = "RJ_trusted_device";
|
||||
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage
|
||||
const TRUSTED_DEVICE_TOKEN_BYTES = 32;
|
||||
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastUsedAt hoechstens alle 5min fortschreiben
|
||||
|
||||
export type TrustedDeviceRow = Awaited<ReturnType<typeof db.trustedDevice.create>>;
|
||||
|
||||
let cachedCookie: ReturnType<typeof createCookie> | null = null;
|
||||
|
||||
function getTrustedDeviceCookie() {
|
||||
if (cachedCookie) {
|
||||
return cachedCookie;
|
||||
}
|
||||
const webConfig = loadRequiredWebConfig();
|
||||
cachedCookie = createCookie(TRUSTED_DEVICE_COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: webConfig.nodeEnv === "production",
|
||||
maxAge: TRUSTED_DEVICE_MAX_AGE_SECONDS,
|
||||
secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [],
|
||||
});
|
||||
return cachedCookie;
|
||||
}
|
||||
|
||||
function generateTrustedDeviceToken(): string {
|
||||
return randomBytes(TRUSTED_DEVICE_TOKEN_BYTES).toString("base64url");
|
||||
}
|
||||
|
||||
function hashTrustedDeviceToken(rawToken: string): string {
|
||||
return createHash("sha256").update(rawToken).digest("hex");
|
||||
}
|
||||
|
||||
function isActive(row: Pick<TrustedDeviceRow, "revokedAt" | "expiresAt"> | null, now = Date.now()): boolean {
|
||||
return Boolean(row && row.revokedAt === null && row.expiresAt.getTime() > now);
|
||||
}
|
||||
|
||||
async function readRawTokenFromCookie(request: Request): Promise<string | null> {
|
||||
try {
|
||||
const parsed = await getTrustedDeviceCookie().parse(request.headers.get("Cookie"));
|
||||
return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert das aktuelle Geraet fuer `userId` als vertrauenswuerdig: legt die Tabellen-Zeile mit
|
||||
* (pseudonymisiertem) Geraete-Kontext an und gibt den Set-Cookie-String mit dem rohen Token zurueck.
|
||||
*/
|
||||
export async function rememberDeviceForUser({ request, userId, now = new Date() }: { request: Request; userId: string; now?: Date }): Promise<string> {
|
||||
const rawToken = generateTrustedDeviceToken();
|
||||
const context = await buildSessionDeviceContext(request);
|
||||
await db.trustedDevice.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashTrustedDeviceToken(rawToken),
|
||||
expiresAt: new Date(now.getTime() + TRUSTED_DEVICE_MAX_AGE_SECONDS * 1000),
|
||||
ipHash: context.ipHash,
|
||||
userAgent: context.userAgent,
|
||||
approxLocation: context.approxLocation,
|
||||
},
|
||||
});
|
||||
return getTrustedDeviceCookie().serialize(rawToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob der Request ein vertrauenswuerdiges Geraet fuer `userId` traegt: Cookie-Token -> Hash ->
|
||||
* aktive Tabellen-Zeile mit passender userId. Best-effort: jeder Fehler gilt als "nicht vertraut".
|
||||
* Schreibt lastUsedAt gedrosselt fort.
|
||||
*/
|
||||
export async function isTrustedDevice(request: Request, userId: string, now: Date = new Date()): Promise<boolean> {
|
||||
try {
|
||||
const rawToken = await readRawTokenFromCookie(request);
|
||||
if (!rawToken) {
|
||||
return false;
|
||||
}
|
||||
const row = await db.trustedDevice.findUnique({ where: { tokenHash: hashTrustedDeviceToken(rawToken) } });
|
||||
if (!row || row.userId !== userId || !isActive(row, now.getTime())) {
|
||||
return false;
|
||||
}
|
||||
if (now.getTime() - row.lastUsedAt.getTime() >= TOUCH_THROTTLE_MS) {
|
||||
await db.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: now } }).catch(() => undefined);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aktive vertrauenswuerdige Geraete eines Nutzers (Geraete-Liste in Settings > Sicherheit). */
|
||||
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
|
||||
return db.trustedDevice.findMany({
|
||||
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { lastUsedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Widerruft genau ein eigenes vertrauenswuerdiges Geraet (Eigentuemerschaft ueber userId gesichert). */
|
||||
export async function revokeTrustedDevice({ userId, deviceId }: { userId: string; deviceId: string }): Promise<boolean> {
|
||||
const result = await db.trustedDevice.updateMany({
|
||||
where: { id: deviceId, userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/** "Alle Geraete vergessen": widerruft alle aktiven Trust-Marker des Nutzers. */
|
||||
export async function revokeAllTrustedDevices(userId: string): Promise<number> {
|
||||
const result = await db.trustedDevice.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergisst das aktuelle Geraet: widerruft die zum Cookie gehoerende Zeile (falls vorhanden) und gibt
|
||||
* den Set-Cookie-String zurueck, der den Trust-Marker im Browser loescht.
|
||||
*/
|
||||
export async function forgetCurrentTrustedDevice(request: Request): Promise<string> {
|
||||
const rawToken = await readRawTokenFromCookie(request);
|
||||
if (rawToken) {
|
||||
await db.trustedDevice.updateMany({ where: { tokenHash: hashTrustedDeviceToken(rawToken), revokedAt: null }, data: { revokedAt: new Date() } }).catch(() => undefined);
|
||||
}
|
||||
return clearTrustedDeviceCookie();
|
||||
}
|
||||
|
||||
/** Set-Cookie-String, der den Trust-Marker im Browser loescht (ohne DB-Zugriff). */
|
||||
export async function clearTrustedDeviceCookie(): Promise<string> {
|
||||
return getTrustedDeviceCookie().serialize("", { maxAge: 0, expires: new Date(0) });
|
||||
}
|
||||
|
||||
/** Test-Naht: erzwingt eine Neuauflage des Cookies (z.B. nach geaenderter Config im Test). */
|
||||
export function __resetTrustedDeviceCookieForTests() {
|
||||
cachedCookie = null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment node
|
||||
import argon2 from "argon2";
|
||||
import bcryptjs from "bcryptjs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { hashPassword, isKnownHashFormat, verifyPassword } from "./passwordHasher.server.js";
|
||||
|
||||
const PLAINTEXT = "correct-horse-battery-staple";
|
||||
const WRONG = "incorrect-zebra-cell-paperclip";
|
||||
|
||||
describe("passwordHasher.server", () => {
|
||||
test("hashPassword produces an argon2id hash that round-trips", async () => {
|
||||
const hash = await hashPassword(PLAINTEXT);
|
||||
expect(hash.startsWith("$argon2id$")).toBe(true);
|
||||
expect(await argon2.verify(hash, PLAINTEXT)).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyPassword accepts a valid argon2 hash without proposing a rehash", async () => {
|
||||
const hash = await hashPassword(PLAINTEXT);
|
||||
const result = await verifyPassword(PLAINTEXT, hash);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.rehashedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
test("verifyPassword rejects a wrong password against an argon2 hash", async () => {
|
||||
const hash = await hashPassword(PLAINTEXT);
|
||||
const result = await verifyPassword(WRONG, hash);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.rehashedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
test("verifyPassword accepts a valid bcrypt hash AND proposes an argon2 rehash", async () => {
|
||||
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
|
||||
expect(legacyHash.startsWith("$2")).toBe(true);
|
||||
const result = await verifyPassword(PLAINTEXT, legacyHash);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.rehashedHash).toBeDefined();
|
||||
expect(result.rehashedHash!.startsWith("$argon2id$")).toBe(true);
|
||||
// The proposed rehash must itself verify the same plaintext.
|
||||
expect(await argon2.verify(result.rehashedHash!, PLAINTEXT)).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyPassword rejects a wrong password against a bcrypt hash and does NOT propose a rehash", async () => {
|
||||
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
|
||||
const result = await verifyPassword(WRONG, legacyHash);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.rehashedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
test("verifyPassword rejects unknown / empty hashes without throwing", async () => {
|
||||
await expect(verifyPassword(PLAINTEXT, "")).resolves.toEqual({ valid: false });
|
||||
await expect(verifyPassword(PLAINTEXT, "garbage")).resolves.toEqual({ valid: false });
|
||||
await expect(verifyPassword(PLAINTEXT, "$pbkdf2-sha256$i=29000,l=32$abc$def")).resolves.toEqual({ valid: false });
|
||||
});
|
||||
|
||||
test("verifyPassword swallows decryption errors and reports invalid", async () => {
|
||||
// An argon2-prefixed but structurally invalid hash makes argon2.verify throw.
|
||||
const result = await verifyPassword(PLAINTEXT, "$argon2id$v=19$m=19456,t=2,p=1$THISisNOTaValidSalt$norIsThisAValidHash");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("verifyPassword reports invalid when argon2 verification throws", async () => {
|
||||
const verifySpy = vi.spyOn(argon2, "verify").mockRejectedValueOnce(new Error("argon2 unavailable"));
|
||||
|
||||
await expect(verifyPassword(PLAINTEXT, "$argon2id$valid-looking-prefix")).resolves.toEqual({ valid: false });
|
||||
expect(verifySpy).toHaveBeenCalled();
|
||||
|
||||
verifySpy.mockRestore();
|
||||
});
|
||||
|
||||
test("verifyPassword reports invalid when legacy bcrypt comparison throws", async () => {
|
||||
const compareSpy = vi.spyOn(bcryptjs, "compare").mockRejectedValueOnce(new Error("bcrypt unavailable"));
|
||||
const legacyHash = "$2b$10$abcdefghijklmnopqrstuu5sBKjRarAkPvaemc5yQMk3QHsOJXxUy";
|
||||
|
||||
await expect(verifyPassword(PLAINTEXT, legacyHash)).resolves.toEqual({ valid: false });
|
||||
expect(compareSpy).toHaveBeenCalledWith(PLAINTEXT, legacyHash);
|
||||
|
||||
compareSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("isKnownHashFormat recognises argon2, bcrypt, and unknown shapes", async () => {
|
||||
expect(isKnownHashFormat(await hashPassword(PLAINTEXT))).toBe("argon2");
|
||||
expect(isKnownHashFormat(await bcryptjs.hash(PLAINTEXT, 4))).toBe("bcrypt");
|
||||
expect(isKnownHashFormat("$2a$10$abc")).toBe("bcrypt");
|
||||
expect(isKnownHashFormat("$2y$12$abc")).toBe("bcrypt");
|
||||
expect(isKnownHashFormat("$argon2i$v=19$...")).toBe("argon2");
|
||||
expect(isKnownHashFormat("plaintext")).toBe("unknown");
|
||||
expect(isKnownHashFormat("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Password hashing + verification. Argon2id is the only path for new hashes;
|
||||
// bcrypt remains as a verify-only fallback for legacy rows. A successful
|
||||
// bcrypt verify produces an argon2 rehash that the caller is responsible for
|
||||
// persisting — that's how we drain the bcrypt corpus without a one-shot
|
||||
// bulk-rehash (which isn't possible since bcrypt is one-way).
|
||||
|
||||
import argon2 from "argon2";
|
||||
import bcryptjs from "bcryptjs";
|
||||
|
||||
// OWASP 2024 recommendation for argon2id at modest server cost.
|
||||
// memoryCost is in KiB (19456 = 19 MiB).
|
||||
const ARGON2_OPTIONS = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
const ARGON2_PREFIX_RE = /^\$argon2(?:i|d|id)\$/;
|
||||
const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/;
|
||||
|
||||
export type VerifyPasswordResult = {
|
||||
valid: boolean;
|
||||
/**
|
||||
* Present only when the input was successfully verified against a bcrypt
|
||||
* legacy hash. The caller MUST persist this value back into the user row
|
||||
* (or whatever store the hash came from) so the next login takes the
|
||||
* argon2 path. Absent for argon2 hits and for any failed verify.
|
||||
*/
|
||||
rehashedHash?: string;
|
||||
};
|
||||
|
||||
export async function hashPassword(plain: string): Promise<string> {
|
||||
return await argon2.hash(plain, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify `plain` against an existing hash. Detects bcrypt vs argon2 by hash
|
||||
* prefix; on a successful bcrypt verify, also computes an argon2 rehash for
|
||||
* the caller to persist.
|
||||
*
|
||||
* Always rejects unknown hash formats. Treats decryption errors (e.g.
|
||||
* corrupted hashes) as a failed verify rather than surfacing the underlying
|
||||
* exception, so the call site can show a generic "invalid credentials"
|
||||
* message without leaking format details.
|
||||
*/
|
||||
export async function verifyPassword(plain: string, hash: string | null | undefined): Promise<VerifyPasswordResult> {
|
||||
// Phase 01 / #20 — `User.passwordHash` is nullable for magic-link-only
|
||||
// accounts. Callers that compare a submitted password against the row
|
||||
// hash now pass `string | null`; treat null/empty as "no comparable
|
||||
// hash" → invalid, never throw.
|
||||
if (typeof hash !== "string" || hash.length === 0) {
|
||||
return { valid: false };
|
||||
}
|
||||
if (ARGON2_PREFIX_RE.test(hash)) {
|
||||
try {
|
||||
const valid = await argon2.verify(hash, plain);
|
||||
return { valid };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
if (BCRYPT_PREFIX_RE.test(hash)) {
|
||||
let valid = false;
|
||||
try {
|
||||
valid = await bcryptjs.compare(plain, hash);
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
if (!valid) {
|
||||
return { valid: false };
|
||||
}
|
||||
const rehashedHash = await hashPassword(plain);
|
||||
return { valid: true, rehashedHash };
|
||||
}
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the hash format is something verifyPassword understands. Useful
|
||||
* for migration scripts that want to identify legacy bcrypt rows.
|
||||
*/
|
||||
export function isKnownHashFormat(hash: string): "argon2" | "bcrypt" | "unknown" {
|
||||
if (ARGON2_PREFIX_RE.test(hash)) return "argon2";
|
||||
if (BCRYPT_PREFIX_RE.test(hash)) return "bcrypt";
|
||||
return "unknown";
|
||||
}
|
||||
Reference in new issue
Block a user