/** * D1 — 2FA-Login-Gate + Verify. * * Deckt die vier Kernzusagen ab: * 1. TOTP-Nutzer erhalten mit Passwort allein KEINE Session, sondern eine `requiresMfa`-Antwort * samt Pending-Cookie (Gate). * 2. Ein vertrauenswuerdiges Geraet ueberspringt das Gate (normaler Login). * 3. Nutzer ohne TOTP loggen sich unveraendert ein (Regression — Zweig ist additiv). * 4. Die Verify-Route schliesst den Login bei korrektem TOTP/Recovery-Code ab, lehnt falsche Codes * ab und schickt einen fehlenden Pending-Zustand zurueck zur Anmeldung. */ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) })); vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } })); vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() })); vi.mock("~/utils/db/users.db.server", () => ({ getUserByEmail: vi.fn(), updateUserPassword: vi.fn(), getUser: vi.fn(), getUserTotpStatus: vi.fn(), getActiveTotpSecretEncrypted: vi.fn(), consumeUserMfaRecoveryCode: vi.fn(), })); vi.mock("~/utils/session.server", () => ({ createUserSession: vi.fn(), getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }), getUserSession: vi.fn(), setLoggedUser: vi.fn().mockResolvedValue({ userId: "u1", defaultTenantId: "t1", defaultTenant: { id: "t1", slug: "t1" } }), validateCSRFToken: vi.fn(), commitPendingMfaLoginCookie: vi.fn().mockResolvedValue("RJ_session=pending; Path=/; HttpOnly"), })); vi.mock("~/services/auth/sessionLifecycle.server", () => ({ startServerSession: vi.fn().mockResolvedValue("server-token") })); vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) })); vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn().mockResolvedValue({ app: { features: { tenantHome: "/" } } }) })); vi.mock("~/utils/auth/passwordHasher.server", () => ({ verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }) })); vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } })); vi.mock("~/services/auth/password-policy.server", () => ({ validatePassword: vi.fn().mockResolvedValue({ valid: true, errors: [] }) })); vi.mock("~/services/auth/login-captcha-gate.server", () => ({ clearFailedLogins: vi.fn(), getFailedLoginCount: vi.fn().mockReturnValue(0), loginRequiresCaptcha: vi.fn().mockReturnValue(false), recordFailedLogin: vi.fn(), })); vi.mock("@toolctrl/captcha", () => ({ getSitekey: vi.fn(), isCaptchaConfigured: vi.fn().mockReturnValue(false), resolveCaptchaConfig: vi.fn().mockReturnValue({}), verifyCaptchaToken: vi.fn(), })); vi.mock("~/services/auth/totp.server", () => ({ verifyTotpCode: vi.fn() })); vi.mock("~/utils/auth/mfa/challenge-rate-limit.server", () => ({ checkMfaChallengeRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() }), mfaChallengeRateLimitKey: vi.fn().mockReturnValue("u1:ip"), recordFailedMfaChallengeAttempt: vi.fn(), resetMfaChallengeRateLimit: vi.fn(), })); vi.mock("~/utils/auth/mfa/trusted-device.server", () => ({ isTrustedDevice: vi.fn().mockResolvedValue(false), rememberDeviceForUser: vi.fn().mockResolvedValue("RJ_trusted_device=marker; Path=/; HttpOnly"), })); vi.mock("@toolctrl/config", async () => { const actual = await vi.importActual("@toolctrl/config"); return { ...actual, loadRequiredWebConfig: () => ({ nodeEnv: "test", routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" }, }), }; }); vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) })); import { getUserByEmail, getUser, getUserTotpStatus, getActiveTotpSecretEncrypted, consumeUserMfaRecoveryCode } from "~/utils/db/users.db.server"; import { createUserSession, getUserSession, commitPendingMfaLoginCookie } from "~/utils/session.server"; import { verifyTotpCode } from "~/services/auth/totp.server"; import { checkMfaChallengeRateLimit, recordFailedMfaChallengeAttempt, resetMfaChallengeRateLimit } from "~/utils/auth/mfa/challenge-rate-limit.server"; import { isTrustedDevice, rememberDeviceForUser } from "~/utils/auth/mfa/trusted-device.server"; import AuthService from "./AuthService"; const getUserByEmailMock = vi.mocked(getUserByEmail); const getUserMock = vi.mocked(getUser); const getUserTotpStatusMock = vi.mocked(getUserTotpStatus); const getActiveTotpSecretEncryptedMock = vi.mocked(getActiveTotpSecretEncrypted); const consumeUserMfaRecoveryCodeMock = vi.mocked(consumeUserMfaRecoveryCode); const createUserSessionMock = vi.mocked(createUserSession); const getUserSessionMock = vi.mocked(getUserSession); const commitPendingMfaLoginCookieMock = vi.mocked(commitPendingMfaLoginCookie); const verifyTotpCodeMock = vi.mocked(verifyTotpCode); const checkMfaChallengeRateLimitMock = vi.mocked(checkMfaChallengeRateLimit); const isTrustedDeviceMock = vi.mocked(isTrustedDevice); const baseUser = { id: "u1", email: "alice@example.com", passwordHash: "hash", admin: null, locale: "de", defaultTenantId: "t1" }; function loginForm(): FormData { const f = new FormData(); f.append("email", "alice@example.com"); f.append("password", "Password123!"); f.append("redirectTo", ""); f.append("csrf", "csrf-token"); return f; } function sessionMock(values: Record) { return { get: vi.fn((key: string) => values[key]), set: vi.fn(), unset: vi.fn() }; } function redirectResponse() { return new Response(null, { status: 302, headers: { Location: "https://app.example.com/" } }); } beforeEach(() => { vi.clearAllMocks(); getUserByEmailMock.mockResolvedValue(baseUser as never); getUserMock.mockResolvedValue(baseUser as never); createUserSessionMock.mockImplementation(async () => redirectResponse()); isTrustedDeviceMock.mockResolvedValue(false); checkMfaChallengeRateLimitMock.mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() } as never); }); describe("loginFromRequest — 2FA gate", () => { it("returns requiresMfa and sets a pending cookie for a TOTP user instead of a session", async () => { getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never); const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm()); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(body.requiresMfa).toBe(true); expect(body.mfaVerifyPath).toBe("/login-mfa"); expect(response.headers.get("Set-Cookie")).toContain("RJ_session=pending"); expect(commitPendingMfaLoginCookieMock).toHaveBeenCalledWith(expect.any(Request), { userId: "u1", redirectTo: "" }); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("skips the gate for a trusted device and issues a normal session", async () => { getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never); isTrustedDeviceMock.mockResolvedValue(true); const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm()); expect(response.status).toBe(302); expect(commitPendingMfaLoginCookieMock).not.toHaveBeenCalled(); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("logs a non-TOTP user straight in (regression: branch is additive)", async () => { getUserTotpStatusMock.mockResolvedValue({ enabled: false } as never); const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm()); expect(response.status).toBe(302); expect(commitPendingMfaLoginCookieMock).not.toHaveBeenCalled(); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); }); describe("verifyMfaLoginFromRequest", () => { beforeEach(() => { getUserSessionMock.mockResolvedValue(sessionMock({ pendingMfaUserId: "u1", pendingMfaRedirectTo: "" }) as never); getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never); }); it("completes the login on a valid TOTP code", async () => { getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret"); verifyTotpCodeMock.mockReturnValue(true); const form = new FormData(); form.append("code", "123456"); const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form); expect(response.status).toBe(302); expect(verifyTotpCodeMock).toHaveBeenCalledWith("enc-secret", "123456"); expect(resetMfaChallengeRateLimit).toHaveBeenCalled(); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("completes the login on a valid recovery code when TOTP fails", async () => { getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret"); verifyTotpCodeMock.mockReturnValue(false); consumeUserMfaRecoveryCodeMock.mockResolvedValue({ consumed: true } as never); const form = new FormData(); form.append("code", "000000"); form.append("recoveryCode", "abcd-efgh-jkmn"); const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form); expect(response.status).toBe(302); expect(consumeUserMfaRecoveryCodeMock).toHaveBeenCalledWith("u1", "ABCD-EFGH-JKMN"); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("sets a trusted-device cookie when remember is checked", async () => { getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret"); verifyTotpCodeMock.mockReturnValue(true); const form = new FormData(); form.append("code", "123456"); form.append("rememberDevice", "on"); const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form); expect(rememberDeviceForUser).toHaveBeenCalledWith({ request: expect.any(Request), userId: "u1" }); expect(response.headers.getSetCookie?.().some((c) => c.includes("RJ_trusted_device")) ?? true).toBe(true); }); it("rejects a wrong code without issuing a session", async () => { getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret"); verifyTotpCodeMock.mockReturnValue(false); consumeUserMfaRecoveryCodeMock.mockResolvedValue({ consumed: false } as never); const form = new FormData(); form.append("code", "000000"); const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form); const body = (await response.json()) as Record; expect(response.status).toBe(400); expect(body.error).toBe("account.loginMfa.errors.invalidCode"); expect(recordFailedMfaChallengeAttempt).toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("returns 429 when the MFA rate limit is exceeded", async () => { checkMfaChallengeRateLimitMock.mockResolvedValue({ allowed: false, retryAfterSeconds: 300, resetAt: new Date() } as never); const form = new FormData(); form.append("code", "123456"); const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form); expect(response.status).toBe(429); expect(response.headers.get("Retry-After")).toBe("300"); expect(verifyTotpCodeMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("redirects to /login when there is no pending MFA state", async () => { getUserSessionMock.mockResolvedValue(sessionMock({}) as never); const form = new FormData(); form.append("code", "123456"); await expect(AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form)).rejects.toMatchObject({ status: 302, }); expect(createUserSessionMock).not.toHaveBeenCalled(); }); });