246 lines
12 KiB
TypeScript
246 lines
12 KiB
TypeScript
/**
|
|
* 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<typeof import("@toolctrl/config")>("@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<string, string | undefined>) {
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
|
|
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();
|
|
});
|
|
});
|