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,100 @@
|
||||
/**
|
||||
* Phase 10 Task #68 — DSGVO login guard unit test.
|
||||
*
|
||||
* Asserts that the login flow rejects users whose `pendingDeletion`
|
||||
* flag is set (HTTP 423) or whose `pseudonymized` flag is set (HTTP 410)
|
||||
* so the worker job and the UI converge on the same gate.
|
||||
*/
|
||||
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" }),
|
||||
setLoggedUser: vi.fn().mockResolvedValue({ defaultTenantId: "t1" }),
|
||||
validateCSRFToken: vi.fn(),
|
||||
}));
|
||||
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() }));
|
||||
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/admin-fallback.server", () => ({ assertAdminFallbackLoginAllowed: vi.fn(), AdminFallbackLoginRejected: class extends Error {} }));
|
||||
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
|
||||
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("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
|
||||
|
||||
import { getUserByEmail } from "~/utils/db/users.db.server";
|
||||
import AuthService from "./AuthService";
|
||||
|
||||
const getUserByEmailMock = vi.mocked(getUserByEmail);
|
||||
|
||||
function buildForm(): FormData {
|
||||
const f = new FormData();
|
||||
f.append("email", "alice@example.com");
|
||||
f.append("password", "correct-horse-battery-staple");
|
||||
f.append("redirectTo", "/");
|
||||
f.append("csrf", "");
|
||||
return f;
|
||||
}
|
||||
|
||||
describe("AuthService DSGVO login guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("blocks login (423 Locked) when the user is pendingDeletion=true", async () => {
|
||||
getUserByEmailMock.mockResolvedValue({
|
||||
id: "u1",
|
||||
email: "alice@example.com",
|
||||
passwordHash: "$argon2id$v=19$x",
|
||||
firstName: "Alice",
|
||||
lastName: "Anderson",
|
||||
pendingDeletion: true,
|
||||
pseudonymized: false,
|
||||
admin: null,
|
||||
locale: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(423);
|
||||
});
|
||||
|
||||
it("blocks login (410 Gone) when the user is pseudonymized=true", async () => {
|
||||
getUserByEmailMock.mockResolvedValue({
|
||||
id: "u1",
|
||||
email: "alice@example.com",
|
||||
passwordHash: "$argon2id$v=19$x",
|
||||
firstName: "Anonymized",
|
||||
lastName: "User-abcd1234",
|
||||
pendingDeletion: false,
|
||||
pseudonymized: true,
|
||||
admin: null,
|
||||
locale: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Phase 01 / #27 — password-policy login soft-check.
|
||||
*
|
||||
* Asserts that AuthService.loginFromRequest calls validatePassword in
|
||||
* non-throwing mode after a successful credential verification and
|
||||
* propagates the result onto the new session as `passwordPolicyOutdated`.
|
||||
* Critically: a policy-violating password must NEVER block the login —
|
||||
* the soft-banner is the only consequence.
|
||||
*/
|
||||
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" }),
|
||||
setLoggedUser: vi.fn().mockResolvedValue({ userId: "u1", defaultTenantId: "t1", defaultTenant: { id: "t1", slug: "t1" } }),
|
||||
validateCSRFToken: vi.fn(),
|
||||
}));
|
||||
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/admin-fallback.server", () => ({
|
||||
assertAdminFallbackLoginAllowed: vi.fn(),
|
||||
AdminFallbackLoginRejected: class extends Error {},
|
||||
}));
|
||||
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
|
||||
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("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
|
||||
vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
|
||||
vi.mock("~/services/auth/password-policy.server", () => ({
|
||||
validatePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getUserByEmail } from "~/utils/db/users.db.server";
|
||||
import { createUserSession } from "~/utils/session.server";
|
||||
import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server";
|
||||
import AuthService from "./AuthService";
|
||||
|
||||
const getUserByEmailMock = vi.mocked(getUserByEmail);
|
||||
const createUserSessionMock = vi.mocked(createUserSession);
|
||||
const validatePolicyPasswordMock = vi.mocked(validatePolicyPassword);
|
||||
|
||||
function buildForm(): FormData {
|
||||
const f = new FormData();
|
||||
f.append("email", "user@example.com");
|
||||
f.append("password", "whatever-they-typed");
|
||||
f.append("redirectTo", "/");
|
||||
f.append("csrf", "");
|
||||
return f;
|
||||
}
|
||||
|
||||
const baseUser = {
|
||||
id: "u1",
|
||||
email: "user@example.com",
|
||||
passwordHash: "$argon2id$dummy",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
pendingDeletion: false,
|
||||
pseudonymized: false,
|
||||
admin: null,
|
||||
locale: null,
|
||||
defaultTenantId: "t1",
|
||||
};
|
||||
|
||||
describe("AuthService password-policy soft-check (#27)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createUserSessionMock.mockResolvedValue(new Response(null, { status: 302 }));
|
||||
});
|
||||
|
||||
it("flags passwordPolicyOutdated=true on the session when the submitted password fails the policy", async () => {
|
||||
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
validatePolicyPasswordMock.mockResolvedValue({
|
||||
valid: false,
|
||||
errors: [{ code: "auth.password.errors.tooShort", args: { minLength: 12 } }],
|
||||
});
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
await AuthService.loginFromRequest(req, buildForm());
|
||||
|
||||
expect(validatePolicyPasswordMock).toHaveBeenCalledWith("whatever-they-typed", { email: "user@example.com" });
|
||||
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
|
||||
const sessionArg = createUserSessionMock.mock.calls[0][0];
|
||||
expect(sessionArg.passwordPolicyOutdated).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the flag when the submitted password satisfies the policy", async () => {
|
||||
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
validatePolicyPasswordMock.mockResolvedValue({ valid: true, errors: [] });
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
await AuthService.loginFromRequest(req, buildForm());
|
||||
|
||||
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
|
||||
const sessionArg = createUserSessionMock.mock.calls[0][0];
|
||||
expect(sessionArg.passwordPolicyOutdated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never blocks login when validatePassword throws — fails open with policyOutdated=false", async () => {
|
||||
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
validatePolicyPasswordMock.mockRejectedValue(new Error("HIBP down"));
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
await AuthService.loginFromRequest(req, buildForm());
|
||||
|
||||
// Session is still created — login is never blocked by the soft-check.
|
||||
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
|
||||
const sessionArg = createUserSessionMock.mock.calls[0][0];
|
||||
expect(sessionArg.passwordPolicyOutdated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Phase 01 / #20 — Magic-link-only account login guard.
|
||||
*
|
||||
* Asserts that AuthService.loginFromRequest refuses a password-login
|
||||
* attempt for a user whose `passwordHash` is null (magic-link-only
|
||||
* account provisioned by the invitation flow), and surfaces a
|
||||
* `requiresMagicLink: true` hint pointing at /forgot-password.
|
||||
*
|
||||
* The critical guarantee: no path grants a session on a null hash, and
|
||||
* the dummy-hash fallthrough never produces "bad credentials" for these
|
||||
* accounts — the user gets a friendly, actionable error instead.
|
||||
*/
|
||||
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" }),
|
||||
setLoggedUser: vi.fn().mockResolvedValue({ defaultTenantId: "t1" }),
|
||||
validateCSRFToken: vi.fn(),
|
||||
}));
|
||||
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() }));
|
||||
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/admin-fallback.server", () => ({ assertAdminFallbackLoginAllowed: vi.fn(), AdminFallbackLoginRejected: class extends Error {} }));
|
||||
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
|
||||
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("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
|
||||
vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
|
||||
|
||||
import { getUserByEmail } from "~/utils/db/users.db.server";
|
||||
import { verifyPassword } from "~/utils/auth/passwordHasher.server";
|
||||
import { setLoggedUser } from "~/utils/session.server";
|
||||
import AuthService from "./AuthService";
|
||||
|
||||
const getUserByEmailMock = vi.mocked(getUserByEmail);
|
||||
const verifyPasswordMock = vi.mocked(verifyPassword);
|
||||
const setLoggedUserMock = vi.mocked(setLoggedUser);
|
||||
|
||||
function buildForm(): FormData {
|
||||
const f = new FormData();
|
||||
f.append("email", "invited@example.com");
|
||||
f.append("password", "anything-they-typed");
|
||||
f.append("redirectTo", "/");
|
||||
f.append("csrf", "");
|
||||
return f;
|
||||
}
|
||||
|
||||
describe("AuthService magic-link-only account guard (#20)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects login when the user's passwordHash is null and signals requiresMagicLink", async () => {
|
||||
getUserByEmailMock.mockResolvedValue({
|
||||
id: "u-invited",
|
||||
email: "invited@example.com",
|
||||
passwordHash: null,
|
||||
firstName: "Inv",
|
||||
lastName: "Ited",
|
||||
pendingDeletion: false,
|
||||
pseudonymized: false,
|
||||
admin: null,
|
||||
locale: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
|
||||
|
||||
const req = new Request("https://app.example.com/login", { method: "POST" });
|
||||
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(400);
|
||||
const body = await result.json();
|
||||
expect(body.requiresMagicLink).toBe(true);
|
||||
expect(body.magicLinkHref).toBe("/forgot-password");
|
||||
expect(body.error).toBe("auth:errors.passwordlessAccountUseMagicLink");
|
||||
// Critical: must never have called verifyPassword for a null-hash row.
|
||||
expect(verifyPasswordMock).not.toHaveBeenCalled();
|
||||
// Critical: must never have created a session.
|
||||
expect(setLoggedUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* PII-protection regression test for AuthService.
|
||||
*
|
||||
* Verifies that no `console.*` call remains in the file (those would
|
||||
* bypass the redact-paths Pino pipeline) and that we do not log
|
||||
* raw email / password / token / sessionToken fields through webLogger.
|
||||
*/
|
||||
describe("AuthService PII discipline", () => {
|
||||
const SOURCE = readFileSync(resolve(__dirname, "AuthService.ts"), "utf8");
|
||||
|
||||
it("contains no live console.* call sites", () => {
|
||||
const liveConsole = SOURCE.split("\n").filter((line, idx, all) => {
|
||||
if (!/console\.(log|error|warn|info|debug)\s*\(/.test(line)) return false;
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false;
|
||||
const prev = idx > 0 ? all[idx - 1].trim() : "";
|
||||
if (/eslint-disable-next-line[^\n]*no-console/.test(prev)) return false;
|
||||
if (/eslint-disable-line[^\n]*no-console/.test(line)) return false;
|
||||
return true;
|
||||
});
|
||||
expect(liveConsole).toEqual([]);
|
||||
});
|
||||
|
||||
it("never passes plaintext email / password / token / sessionToken as authLogger fields", () => {
|
||||
// Scan property KEYS only (not values like `Boolean(token)` inside helpers).
|
||||
const pattern = /authLogger\.(info|warn|error|debug)\s*\(\s*\{([\s\S]*?)\}/g;
|
||||
const forbiddenKeys = ["email", "password", "sessionToken", "token", "passwordHash", "rawPassword"];
|
||||
const offenders: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(SOURCE)) !== null) {
|
||||
const objContent = match[2];
|
||||
for (const key of forbiddenKeys) {
|
||||
const keyRe = new RegExp(`(^|[\\s,{])${key}\\s*:`, "m");
|
||||
if (keyRe.test(objContent)) offenders.push(`key "${key}" in ${match[0].slice(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
expect(offenders, `authLogger must not log PII fields: ${offenders.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses createScopedLogger('auth') for structured logging", () => {
|
||||
expect(SOURCE).toMatch(/createScopedLogger\(\s*["']auth["']\s*\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn() }));
|
||||
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: vi.fn(), validatePassword: vi.fn() } }));
|
||||
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
|
||||
vi.mock("~/utils/db/users.db.server", () => ({ getUserByEmail: vi.fn() }));
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
createUserSession: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
setLoggedUser: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
}));
|
||||
vi.mock("bcryptjs", () => ({ default: { compare: vi.fn() } }));
|
||||
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn() }));
|
||||
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
|
||||
vi.mock("~/utils/services/authService", () => ({
|
||||
createRegistrationForm: vi.fn(),
|
||||
getRegistrationFormData: vi.fn(),
|
||||
validateRegistration: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/utils/db/registration.db.server", () => ({ getRegistrationByToken: vi.fn(), updateRegistration: vi.fn() }));
|
||||
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn() } }));
|
||||
vi.mock("@toolctrl/config", async () => {
|
||||
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
|
||||
return {
|
||||
...actual,
|
||||
loadRequiredWebConfig: () => ({
|
||||
routing: {
|
||||
appBaseDomain: "example.com",
|
||||
webAppHost: "app.example.com",
|
||||
adminAppHost: "admin.example.com",
|
||||
apiHost: "api.example.com",
|
||||
protocol: "https",
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { getTenant } from "~/utils/db/tenants.db.server";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import { createRegistrationForm, getRegistrationFormData, validateRegistration } from "~/utils/services/authService";
|
||||
import { createUserSession, getUserInfo, setLoggedUser, validateCSRFToken } from "~/utils/session.server";
|
||||
import { getRegistrationByToken, updateRegistration } from "~/utils/db/registration.db.server";
|
||||
import { getSafeLoginRedirect, isProductionTestAccountBlocked, isStagingDemoLoginAllowed } from "./AuthService";
|
||||
|
||||
const getTenantMock = vi.mocked(getTenant);
|
||||
const getTranslationsMock = vi.mocked(getTranslations);
|
||||
const getAppConfigurationMock = vi.mocked(getAppConfiguration);
|
||||
const createRegistrationFormMock = vi.mocked(createRegistrationForm);
|
||||
const getRegistrationFormDataMock = vi.mocked(getRegistrationFormData);
|
||||
const validateRegistrationMock = vi.mocked(validateRegistration);
|
||||
const getRegistrationByTokenMock = vi.mocked(getRegistrationByToken);
|
||||
const updateRegistrationMock = vi.mocked(updateRegistration);
|
||||
const setLoggedUserMock = vi.mocked(setLoggedUser);
|
||||
const createUserSessionMock = vi.mocked(createUserSession);
|
||||
const getUserInfoMock = vi.mocked(getUserInfo);
|
||||
const validateCSRFTokenMock = vi.mocked(validateCSRFToken);
|
||||
|
||||
describe("AuthService login redirects", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getTenantMock.mockResolvedValue({ id: "tenant-1", slug: "staging-admin" } as Awaited<ReturnType<typeof getTenant>>);
|
||||
});
|
||||
|
||||
it("rejects unsafe redirect targets", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "https://evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "//evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("rejects removed app and API redirect targets", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"/dashboard"
|
||||
);
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/v1/health", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/api/v1/health", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("keeps app tenant redirects for non-admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"https://app.example.com/staging-admin/dashboard"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat default tenant IDs as URL slugs", async () => {
|
||||
getTenantMock.mockResolvedValueOnce({ id: "tenant-1", slug: null } as unknown as Awaited<ReturnType<typeof getTenant>>);
|
||||
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-1/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"/dashboard"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects legacy admin redirects for non-admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/admin", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("rejects canonical admin-host redirects for non-admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
|
||||
"/staging-admin/dashboard"
|
||||
);
|
||||
await expect(
|
||||
getSafeLoginRedirect({ redirectTo: "/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })
|
||||
).resolves.toBe("/staging-admin/dashboard");
|
||||
});
|
||||
|
||||
it("keeps non-default tenant deep links on the app host for non-admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-b/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
|
||||
"https://app.example.com/tenant-b/dashboard"
|
||||
);
|
||||
});
|
||||
|
||||
it("moves non-admin root fallback redirects to the app host", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("https://app.example.com/");
|
||||
});
|
||||
|
||||
it("falls back to admin home when an admin login carries an app tenant redirect", async () => {
|
||||
await expect(
|
||||
getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
|
||||
).resolves.toBe("https://admin.example.com/dashboard");
|
||||
await expect(
|
||||
getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
|
||||
).resolves.toBe("https://admin.example.com/dashboard");
|
||||
});
|
||||
|
||||
it("moves admin redirects to the admin host for admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/accounts/users", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"https://admin.example.com/accounts/users"
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes legacy admin redirects for admin users", async () => {
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"https://admin.example.com/dashboard"
|
||||
);
|
||||
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
|
||||
"https://admin.example.com/accounts/users?filter=active"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthService production demo login guard", () => {
|
||||
it("blocks local default and legacy demo accounts even when staging demo login is enabled", () => {
|
||||
const env = { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" };
|
||||
|
||||
expect(isProductionTestAccountBlocked("admin@email.com", env)).toBe(true);
|
||||
expect(isProductionTestAccountBlocked("alex.martinez@company.com", env)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps seeded CRM demo users blocked unless the staging demo guard is explicit", () => {
|
||||
expect(isStagingDemoLoginAllowed({ SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
|
||||
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
|
||||
expect(isProductionTestAccountBlocked("luna.davis@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
|
||||
|
||||
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true" })).toBe(true);
|
||||
expect(isProductionTestAccountBlocked("luna.davis@company.com", { ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthService registerFromRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
|
||||
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
|
||||
getAppConfigurationMock.mockResolvedValue({ app: { features: { tenantHome: "/:tenant/" } } } as Awaited<ReturnType<typeof getAppConfiguration>>);
|
||||
getTenantMock.mockResolvedValue({ id: "tenant_1", slug: "ada-labs" } as Awaited<ReturnType<typeof getTenant>>);
|
||||
getRegistrationFormDataMock.mockImplementation(async (request) => {
|
||||
const formData = await request.formData();
|
||||
return {
|
||||
email: formData.get("email")?.toString().toLowerCase().trim(),
|
||||
password: formData.get("password")?.toString(),
|
||||
company: formData.get("company")?.toString(),
|
||||
firstName: formData.get("first-name")?.toString(),
|
||||
lastName: formData.get("last-name")?.toString(),
|
||||
slug: formData.get("slug")?.toString(),
|
||||
leadId: formData.get("leadId")?.toString().trim() || undefined,
|
||||
leadToken: formData.get("leadToken")?.toString().trim() || undefined,
|
||||
leadPlanId: formData.get("leadPlanId")?.toString().trim() || undefined,
|
||||
};
|
||||
});
|
||||
validateRegistrationMock.mockResolvedValue({
|
||||
email: "ada@example.test",
|
||||
ipAddress: "203.0.113.5",
|
||||
verificationRequired: false,
|
||||
registered: {
|
||||
user: { id: "user_1", email: "ada@example.test", locale: "de" },
|
||||
tenant: { id: "tenant_1", slug: "ada-labs" },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof validateRegistration>>);
|
||||
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
|
||||
createUserSessionMock.mockImplementation(async (_session, redirectTo) => new Response(null, { status: 302, headers: { Location: redirectTo } }));
|
||||
});
|
||||
|
||||
it("passes lead conversion form fields to direct registration and starts the tenant session", async () => {
|
||||
const AuthService = (await import("./AuthService")).default;
|
||||
const formData = new URLSearchParams({
|
||||
email: "ada@example.test",
|
||||
password: "Password123!",
|
||||
company: "Ada Labs",
|
||||
"first-name": "Ada",
|
||||
"last-name": "Lovelace",
|
||||
slug: "ada-labs",
|
||||
leadId: "lead_1",
|
||||
leadToken: "signed-token",
|
||||
leadPlanId: "plan_pro",
|
||||
});
|
||||
const request = new Request("https://app.example.com/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await AuthService.registerFromRequest(request);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("https://app.example.com/ada-labs/dashboard");
|
||||
expect(validateCSRFTokenMock).toHaveBeenCalledWith(request);
|
||||
expect(getRegistrationFormDataMock).toHaveBeenCalledWith(request);
|
||||
expect(validateRegistrationMock).toHaveBeenCalledWith({
|
||||
request,
|
||||
registrationData: {
|
||||
email: "ada@example.test",
|
||||
password: "Password123!",
|
||||
company: "Ada Labs",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
slug: "ada-labs",
|
||||
leadId: "lead_1",
|
||||
leadToken: "signed-token",
|
||||
leadPlanId: "plan_pro",
|
||||
},
|
||||
addToTrialOrFreePlan: true,
|
||||
});
|
||||
expect(createRegistrationFormMock).not.toHaveBeenCalled();
|
||||
expect(setLoggedUserMock).toHaveBeenCalledWith({ id: "user_1", email: "ada@example.test", locale: "de" });
|
||||
expect(createUserSessionMock).toHaveBeenCalledWith(
|
||||
{
|
||||
lng: "de",
|
||||
userId: "user_1",
|
||||
defaultTenantId: "tenant_1",
|
||||
},
|
||||
"https://app.example.com/ada-labs/dashboard"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthService verifyFromRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
|
||||
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
|
||||
getRegistrationFormDataMock.mockResolvedValue({ email: "ada@example.test", password: "Password123!" });
|
||||
getRegistrationByTokenMock.mockResolvedValue({
|
||||
id: "registration_1",
|
||||
createdTenantId: null,
|
||||
leadConversionLeadId: "lead_1",
|
||||
leadConversionToken: "signed-token",
|
||||
leadConversionPlanId: "plan_pro",
|
||||
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
|
||||
validateRegistrationMock.mockResolvedValue({
|
||||
email: "ada@example.test",
|
||||
ipAddress: "203.0.113.5",
|
||||
verificationRequired: false,
|
||||
registered: {
|
||||
user: { id: "user_1", email: "ada@example.test", locale: "de" },
|
||||
tenant: { id: "tenant_1", slug: "ada" },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof validateRegistration>>);
|
||||
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
|
||||
createUserSessionMock.mockResolvedValue(new Response(null, { status: 302, headers: { Location: "/ada/dashboard" } }) as Awaited<ReturnType<typeof createUserSession>>);
|
||||
});
|
||||
|
||||
it("hydrates persisted lead conversion fields before validating the verified registration", async () => {
|
||||
const AuthService = (await import("./AuthService")).default;
|
||||
const request = new Request("https://example.test/verify/signed-registration-token", { method: "POST" });
|
||||
|
||||
await AuthService.verifyFromRequest({ request, params: { id: "signed-registration-token" } });
|
||||
|
||||
expect(validateRegistrationMock).toHaveBeenCalledWith({
|
||||
request,
|
||||
registrationData: {
|
||||
email: "ada@example.test",
|
||||
password: "Password123!",
|
||||
leadId: "lead_1",
|
||||
leadToken: "signed-token",
|
||||
leadPlanId: "plan_pro",
|
||||
},
|
||||
checkEmailVerification: false,
|
||||
addToTrialOrFreePlan: true,
|
||||
});
|
||||
expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", { createdTenantId: "tenant_1" });
|
||||
});
|
||||
|
||||
it("rejects missing verification tokens before parsing form data or mutating registration state", async () => {
|
||||
getRegistrationByTokenMock.mockResolvedValueOnce(null);
|
||||
const AuthService = (await import("./AuthService")).default;
|
||||
|
||||
const response = await AuthService.verifyFromRequest({
|
||||
request: new Request("https://example.test/verify/missing-token", { method: "POST" }),
|
||||
params: { id: "missing-token" },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
|
||||
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
|
||||
expect(validateRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(updateRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(setLoggedUserMock).not.toHaveBeenCalled();
|
||||
expect(createUserSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects already-consumed verification tokens before tenant/user creation work", async () => {
|
||||
getRegistrationByTokenMock.mockResolvedValueOnce({
|
||||
id: "registration_1",
|
||||
createdTenantId: "tenant_1",
|
||||
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
|
||||
const AuthService = (await import("./AuthService")).default;
|
||||
|
||||
const response = await AuthService.verifyFromRequest({
|
||||
request: new Request("https://example.test/verify/used-token", { method: "POST" }),
|
||||
params: { id: "used-token" },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
|
||||
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
|
||||
expect(validateRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(updateRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(setLoggedUserMock).not.toHaveBeenCalled();
|
||||
expect(createUserSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not consume the verification token when registration validation does not create an account", async () => {
|
||||
validateRegistrationMock.mockResolvedValueOnce({
|
||||
email: "ada@example.test",
|
||||
ipAddress: "203.0.113.5",
|
||||
verificationRequired: false,
|
||||
registered: undefined,
|
||||
} as Awaited<ReturnType<typeof validateRegistration>>);
|
||||
const AuthService = (await import("./AuthService")).default;
|
||||
|
||||
const response = await AuthService.verifyFromRequest({
|
||||
request: new Request("https://example.test/verify/signed-registration-token", { method: "POST" }),
|
||||
params: { id: "signed-registration-token" },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data).toEqual({ error: "shared.unknownError" });
|
||||
expect(updateRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(setLoggedUserMock).not.toHaveBeenCalled();
|
||||
expect(createUserSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import UserValidationUtils from "~/utils/app/UserValidationUtils";
|
||||
import { createLogLogin } from "~/utils/db/logs.db.server";
|
||||
import { getUserByEmail, getUser, getUserTotpStatus, getActiveTotpSecretEncrypted, consumeUserMfaRecoveryCode } from "~/utils/db/users.db.server";
|
||||
import { setLoggedUser, createUserSession, getUserInfo, getUserSession, validateCSRFToken, commitPendingMfaLoginCookie } from "~/utils/session.server";
|
||||
import { startServerSession } from "~/services/auth/sessionLifecycle.server";
|
||||
import { updateUserPassword } from "~/utils/db/users.db.server";
|
||||
import { verifyTotpCode } from "~/services/auth/totp.server";
|
||||
import {
|
||||
checkMfaChallengeRateLimit,
|
||||
mfaChallengeRateLimitKey,
|
||||
recordFailedMfaChallengeAttempt,
|
||||
resetMfaChallengeRateLimit,
|
||||
} from "~/utils/auth/mfa/challenge-rate-limit.server";
|
||||
import { isTrustedDevice, rememberDeviceForUser } from "~/utils/auth/mfa/trusted-device.server";
|
||||
import { verifyPassword } from "~/utils/auth/passwordHasher.server";
|
||||
import { getTenant } from "~/utils/db/tenants.db.server";
|
||||
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import { getRegistrationFormData, validateRegistration, createRegistrationForm } from "~/utils/services/authService";
|
||||
import { getRegistrationByToken, updateRegistration } from "~/utils/db/registration.db.server";
|
||||
import { Params, redirect } from "react-router";
|
||||
import IpAddressServiceServer from "~/modules/ipAddress/services/IpAddressService.server";
|
||||
import { isCanonicalAdminHostRoute } from "~/utils/app/HostRouting.server";
|
||||
import { absoluteAdminPath, absoluteAppPath, absoluteAppRootPath } from "~/utils/app/CrossHostUrls";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { isRemovedRoutePath } from "~/utils/app/RedirectSafety.server";
|
||||
import { checkAuthRateLimit } from "~/services/auth/auth-rate-limit.server";
|
||||
import { clearFailedLogins, getFailedLoginCount, loginRequiresCaptcha, recordFailedLogin } from "~/services/auth/login-captcha-gate.server";
|
||||
import { getSitekey, isCaptchaConfigured, resolveCaptchaConfig, verifyCaptchaToken } from "@toolctrl/captcha";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server";
|
||||
|
||||
const authLogger = createScopedLogger("auth");
|
||||
|
||||
const DUMMY_PASSWORD_HASH = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi8VfIf6z9SjG8/aYx8vvjE5LiE3D0C";
|
||||
|
||||
// D1 — tenant-faehige Verify-Route, an die das Login nach Passwort-OK fuer den zweiten Faktor
|
||||
// weiterleitet (siehe routeModules/auth/login-mfa).
|
||||
export const LOGIN_MFA_VERIFY_PATH = "/login-mfa";
|
||||
|
||||
function isSixDigitTotpCode(code: FormDataEntryValue | null): code is string {
|
||||
return typeof code === "string" && /^\d{6}$/.test(code);
|
||||
}
|
||||
|
||||
function normalizeRecoveryCodeInput(code: FormDataEntryValue | null) {
|
||||
return typeof code === "string" ? code.trim().toUpperCase() : "";
|
||||
}
|
||||
|
||||
const blockedProductionSeedAccountEmails = new Set(["admin@email.com", "alex.martinez@company.com"]);
|
||||
const guardedStagingDemoAccountEmails = new Set(["john.doe@company.com", "luna.davis@company.com"]);
|
||||
|
||||
function translateIpAddressError(t: (key: string) => string, error: string): string {
|
||||
if (error === "blacklisted") {
|
||||
return t("admin.accounts.ipAddresses.errors.blacklisted");
|
||||
}
|
||||
if (error === "unauthorized") {
|
||||
return t("admin.accounts.ipAddresses.errors.unauthorized");
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
type DemoLoginEnvironment = {
|
||||
ALLOW_STAGING_DEMO_SEED?: string;
|
||||
SEED_DEMO_DATA?: string;
|
||||
};
|
||||
|
||||
export function isStagingDemoLoginAllowed(env?: DemoLoginEnvironment) {
|
||||
const demoLoginEnv = env ?? (process.env as DemoLoginEnvironment);
|
||||
return demoLoginEnv.SEED_DEMO_DATA === "true" && demoLoginEnv.ALLOW_STAGING_DEMO_SEED === "true";
|
||||
}
|
||||
|
||||
export function isProductionTestAccountBlocked(email: string, env?: DemoLoginEnvironment) {
|
||||
if (blockedProductionSeedAccountEmails.has(email)) {
|
||||
return true;
|
||||
}
|
||||
if (guardedStagingDemoAccountEmails.has(email)) {
|
||||
return !isStagingDemoLoginAllowed(env);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getHome({ isAdmin, tenantId, request }: { isAdmin: boolean; tenantId: string; request: Request }) {
|
||||
let appHome = "";
|
||||
const appConfiguration = await getAppConfiguration({ request });
|
||||
if (isAdmin) {
|
||||
appHome = absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard");
|
||||
} else if (appConfiguration.app.features.tenantHome === "/:tenant/") {
|
||||
const tenant = await getTenant(tenantId);
|
||||
if (tenant) {
|
||||
appHome = absoluteAppPath(loadRequiredWebConfig().routing, tenant.slug, "dashboard");
|
||||
} else {
|
||||
appHome = absoluteAppRootPath(loadRequiredWebConfig().routing);
|
||||
}
|
||||
} else {
|
||||
appHome = absoluteAppRootPath(loadRequiredWebConfig().routing);
|
||||
}
|
||||
return appHome;
|
||||
}
|
||||
|
||||
function isSafeRelativeRedirect(redirectTo: string) {
|
||||
return redirectTo.startsWith("/") && !redirectTo.startsWith("//") && !redirectTo.includes("\\");
|
||||
}
|
||||
|
||||
function canonicalAdminRedirect(redirectTo: string) {
|
||||
if (redirectTo === "/admin") {
|
||||
return absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard");
|
||||
}
|
||||
|
||||
if (redirectTo.startsWith("/admin?") || redirectTo.startsWith("/admin#")) {
|
||||
return `${absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard")}${redirectTo.slice("/admin".length)}`;
|
||||
}
|
||||
|
||||
if (redirectTo.startsWith("/admin/")) {
|
||||
return absoluteAdminPath(loadRequiredWebConfig().routing, redirectTo.slice("/admin/".length));
|
||||
}
|
||||
|
||||
return absoluteAdminPath(loadRequiredWebConfig().routing, redirectTo);
|
||||
}
|
||||
|
||||
function isLegacyAdminRedirect(redirectTo: string) {
|
||||
return redirectTo === "/admin" || redirectTo.startsWith("/admin/") || redirectTo.startsWith("/admin?") || redirectTo.startsWith("/admin#");
|
||||
}
|
||||
|
||||
export async function getSafeLoginRedirect({
|
||||
redirectTo,
|
||||
isAdmin,
|
||||
defaultTenantId,
|
||||
fallback,
|
||||
}: {
|
||||
redirectTo: string;
|
||||
isAdmin: boolean;
|
||||
defaultTenantId: string;
|
||||
fallback: string;
|
||||
}) {
|
||||
if (!redirectTo || !isSafeRelativeRedirect(redirectTo)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const redirectPathname = new URL(redirectTo, "http://local.invalid").pathname;
|
||||
if (isRemovedRoutePath(redirectPathname) && !(isAdmin && isLegacyAdminRedirect(redirectTo))) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!isAdmin && (isLegacyAdminRedirect(redirectTo) || isCanonicalAdminHostRoute(redirectTo))) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (redirectTo === "/app" || redirectTo.startsWith("/app/")) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const defaultTenant = defaultTenantId ? await getTenant(defaultTenantId) : null;
|
||||
const tenantSlug = defaultTenant?.slug;
|
||||
const targetsDefaultTenantId = Boolean(defaultTenantId && (redirectTo === `/${defaultTenantId}` || redirectTo.startsWith(`/${defaultTenantId}/`)));
|
||||
if (targetsDefaultTenantId) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (tenantSlug && (redirectTo === `/${tenantSlug}` || redirectTo.startsWith(`/${tenantSlug}/`))) {
|
||||
const path = redirectTo === `/${tenantSlug}` ? undefined : redirectTo.slice(`/${tenantSlug}/`.length);
|
||||
return absoluteAppPath(loadRequiredWebConfig().routing, tenantSlug, path);
|
||||
}
|
||||
if (redirectTo === "/") {
|
||||
return absoluteAppRootPath(loadRequiredWebConfig().routing);
|
||||
}
|
||||
return `${absoluteAppRootPath(loadRequiredWebConfig().routing).replace(/\/$/, "")}${redirectTo}`;
|
||||
}
|
||||
|
||||
const canonicalRedirectTo = canonicalAdminRedirect(redirectTo);
|
||||
|
||||
if (tenantSlug && (redirectTo === `/${tenantSlug}` || redirectTo.startsWith(`/${tenantSlug}/`))) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return canonicalRedirectTo;
|
||||
}
|
||||
|
||||
async function loginFromRequest(request: Request, form: FormData) {
|
||||
const userInfo = await getUserInfo(request);
|
||||
const { t } = await getTranslations(request);
|
||||
// `form` was parsed by the caller, so the request body stream is already
|
||||
// drained. Hand the FormData to validateCSRFToken to avoid the
|
||||
// TypeError: unusable that undici raises when cloning a consumed body
|
||||
// (B-009 follow-up).
|
||||
await validateCSRFToken(request, { formData: form });
|
||||
const email = form.get("email")?.toString().toLowerCase().trim();
|
||||
const password = form.get("password");
|
||||
const redirectTo = form.get("redirectTo");
|
||||
if (typeof email !== "string" || typeof password !== "string" || typeof redirectTo !== "string") {
|
||||
throw new Error(t("auth:errors.invalidFormData"));
|
||||
}
|
||||
|
||||
const fields = { email, password };
|
||||
const fieldErrors = {
|
||||
email: !UserValidationUtils.validateEmail(email) ? t("auth:errors.invalidEmail") : undefined,
|
||||
password: !UserValidationUtils.validatePassword(password) ? t("auth:errors.invalidPassword") : undefined,
|
||||
};
|
||||
if (Object.values(fieldErrors).some(Boolean)) return Response.json({ fieldErrors, fields }, { status: 400 });
|
||||
|
||||
const rateLimit = checkAuthRateLimit({
|
||||
request,
|
||||
action: "login",
|
||||
identifier: email,
|
||||
limit: 10,
|
||||
windowMs: 60 * 1000,
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
return Response.json({ fields, error: t("api.errors.tooManyRequests") }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
// Brute-Force-Soft-Gate: ab dem 3. Fehlversuch (pro IP+Email) ein mCaptcha verlangen,
|
||||
// bevor der Passwort-Check laeuft. No-op, solange mCaptcha nicht konfiguriert ist.
|
||||
const captchaConfig = resolveCaptchaConfig(process.env);
|
||||
if (isCaptchaConfigured(captchaConfig) && loginRequiresCaptcha(getFailedLoginCount({ request, identifier: email }))) {
|
||||
const captchaOk = await verifyCaptchaToken({
|
||||
token: form.get("captchaToken")?.toString(),
|
||||
sitekey: getSitekey(captchaConfig, "login"),
|
||||
secret: captchaConfig.accountSecret,
|
||||
instanceUrl: captchaConfig.instanceUrl,
|
||||
failOpen: captchaConfig.failOpen,
|
||||
logger: authLogger,
|
||||
});
|
||||
if (!captchaOk) {
|
||||
return Response.json(
|
||||
{ fields, requiresCaptcha: true, sitekey: getSitekey(captchaConfig, "login"), error: t("auth:errors.captchaRequired") },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await getUserByEmail(email);
|
||||
if (loadRequiredWebConfig().nodeEnv !== "development") {
|
||||
const ipError = await IpAddressServiceServer.log(request, { action: "login", description: email }).catch((e) => e.message);
|
||||
if (ipError) {
|
||||
return Response.json({ error: translateIpAddressError(t, ipError) }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isProductionTestAccountBlocked(email)) {
|
||||
return Response.json({ fields: { email, password }, error: t("auth:errors.productionTestAccountBlocked") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
// Phase 01 / #20 — Magic-link-only accounts have `passwordHash = null`
|
||||
// (invitation flow provisions them without a password). Reject the
|
||||
// password-login attempt *before* the verify step so we never grant a
|
||||
// session on the dummy-hash fallthrough, and surface a friendly
|
||||
// message pointing the user at the magic-link entrypoint. We still
|
||||
// return 400 so the response shape stays compatible with existing
|
||||
// clients and login-form error rendering.
|
||||
if (user && user.passwordHash === null) {
|
||||
return Response.json(
|
||||
{
|
||||
fields,
|
||||
error: t("auth:errors.passwordlessAccountUseMagicLink"),
|
||||
requiresMagicLink: true,
|
||||
magicLinkHref: "/forgot-password",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { valid: isCorrectPassword, rehashedHash } = await verifyPassword(password, user?.passwordHash ?? DUMMY_PASSWORD_HASH);
|
||||
if (!user) {
|
||||
recordFailedLogin({ request, identifier: email });
|
||||
return Response.json({ fields, error: t("api.errors.invalidPassword") }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
recordFailedLogin({ request, identifier: email });
|
||||
return Response.json({ fields, error: t("api.errors.invalidPassword") }, { status: 400 });
|
||||
}
|
||||
|
||||
if (rehashedHash) {
|
||||
// Drain a legacy bcrypt user to argon2 transparently on successful login.
|
||||
// Best-effort: never block the login on a rehash-persist failure.
|
||||
await updateUserPassword({ passwordHash: rehashedHash }, user.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Phase 10 Task #68 — block login for users currently in the DSGVO
|
||||
// 30-day grace period or after pseudonymisation. We return a 423 / 410
|
||||
// so the UI can render a dedicated "marked for deletion — reactivate?"
|
||||
// screen instead of issuing a session.
|
||||
if ((user as { pendingDeletion?: boolean }).pendingDeletion === true) {
|
||||
return Response.json({ fields, error: t("auth:errors.accountPendingDeletion") }, { status: 423 });
|
||||
}
|
||||
if ((user as { pseudonymized?: boolean }).pseudonymized === true) {
|
||||
return Response.json({ fields, error: t("auth:errors.accountPseudonymized") }, { status: 410 });
|
||||
}
|
||||
|
||||
// Erfolgreicher Login: Brute-Force-Failure-Counter zuruecksetzen (Captcha-Gate faellt wieder weg).
|
||||
clearFailedLogins({ request, identifier: email });
|
||||
|
||||
// D1 (Produktentscheidung 18.06.) — 2FA-Login-Gate: TOTP-Nutzer erhalten mit Passwort allein
|
||||
// KEINE Session. Stattdessen wird ein signierter Pending-MFA-Zustand ins Cookie geschrieben
|
||||
// (pendingMfaUserId, NICHT userId) und der Client zur Verify-Route geschickt. Ein als
|
||||
// vertrauenswuerdig markiertes Geraet (Trust-Device-Cookie) ueberspringt den zweiten Faktor.
|
||||
const totpStatus = await getUserTotpStatus(user.id);
|
||||
if (totpStatus?.enabled && !(await isTrustedDevice(request, user.id))) {
|
||||
const setCookie = await commitPendingMfaLoginCookie(request, { userId: user.id, redirectTo });
|
||||
authLogger.info({ op: "login.mfaRequired", userId: user.id }, "Password accepted; awaiting second factor before issuing a session.");
|
||||
return Response.json(
|
||||
{ requiresMfa: true, redirectTo, mfaVerifyPath: LOGIN_MFA_VERIFY_PATH },
|
||||
{ status: 200, headers: { "Set-Cookie": setCookie } }
|
||||
);
|
||||
}
|
||||
|
||||
await createLogLogin(request, user);
|
||||
const userSession = await setLoggedUser(user);
|
||||
// const tenant = await getTenant(userSession.defaultTenantId);
|
||||
const appHome = await getHome({
|
||||
isAdmin: !!user.admin,
|
||||
tenantId: userSession.defaultTenantId,
|
||||
request,
|
||||
});
|
||||
const safeRedirectTo = await getSafeLoginRedirect({
|
||||
redirectTo,
|
||||
isAdmin: !!user.admin,
|
||||
defaultTenantId: userSession.defaultTenantId,
|
||||
fallback: appHome,
|
||||
});
|
||||
// Phase 01 / #27 — soft-check the submitted password against the current
|
||||
// policy. Never blocks the login; only sets a session flag so the tenant
|
||||
// dashboard layout can render a dismissible banner. validatePassword talks
|
||||
// to HIBP and is fire-and-forget here: any failure is swallowed and treated
|
||||
// as "policy not outdated" so a network blip never wedges sign-in.
|
||||
let passwordPolicyOutdated = false;
|
||||
try {
|
||||
const policyResult = await validatePolicyPassword(password, { email });
|
||||
passwordPolicyOutdated = !policyResult.valid;
|
||||
if (passwordPolicyOutdated) {
|
||||
authLogger.info(
|
||||
{
|
||||
op: "login.passwordPolicyOutdated",
|
||||
userId: user.id,
|
||||
errorCount: policyResult.errors.length,
|
||||
},
|
||||
"Login succeeded with a password that no longer satisfies the current policy."
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
authLogger.warn(
|
||||
{
|
||||
op: "login.passwordPolicySoftCheck",
|
||||
err: { name: (e as { name?: string } | null)?.name ?? "Error" },
|
||||
},
|
||||
"Password policy soft-check raised; falling back to policyOutdated=false."
|
||||
);
|
||||
}
|
||||
// R-P2a S4b — serverseitige Session-Zeile additiv anlegen (best-effort; ein Store-Fehler darf
|
||||
// den Login nicht blockieren) und ihren Token ins Cookie geben.
|
||||
const sessionToken = await startServerSession({ request, userId: user.id }).catch(() => null);
|
||||
return createUserSession(
|
||||
{
|
||||
...userInfo,
|
||||
...userSession,
|
||||
lng: user.locale ?? userInfo.lng,
|
||||
passwordPolicyOutdated: passwordPolicyOutdated || undefined,
|
||||
sessionToken: sessionToken ?? undefined,
|
||||
},
|
||||
safeRedirectTo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 — schliesst den 2FA-Login ab. Liest den Pending-Zustand (`pendingMfaUserId`) aus dem Cookie,
|
||||
* verifiziert TOTP ODER einen Wiederherstellungscode und erstellt erst dann die echte Session
|
||||
* (setLoggedUser + serverseitige Session-Zeile + createUserSession, das `userId` setzt und den
|
||||
* Pending-Zustand loescht). Bei "remember device" wird zusaetzlich ein Trust-Marker gesetzt.
|
||||
*
|
||||
* Rueckgaben:
|
||||
* - kein Pending-Zustand / unbekannter Nutzer -> redirect zur Login-Seite
|
||||
* - Rate-Limit ueberschritten -> 429
|
||||
* - falscher Code -> 400 mit { error }
|
||||
* - Erfolg -> Redirect mit Session-Cookie (+ optional Trust-Cookie)
|
||||
*/
|
||||
async function verifyMfaLoginFromRequest(request: Request, form: FormData) {
|
||||
const { t } = await getTranslations(request);
|
||||
const session = await getUserSession(request);
|
||||
const pendingMfaUserId = session.get("pendingMfaUserId");
|
||||
if (typeof pendingMfaUserId !== "string" || pendingMfaUserId.length === 0) {
|
||||
// Kein laufender 2FA-Login (Cookie fehlt/abgelaufen) -> sauber zurueck zur Anmeldung.
|
||||
throw redirect("/login");
|
||||
}
|
||||
|
||||
await validateCSRFToken(request, { formData: form });
|
||||
|
||||
const user = await getUser(pendingMfaUserId);
|
||||
const totpStatus = user ? await getUserTotpStatus(user.id) : null;
|
||||
if (!user || !totpStatus?.enabled) {
|
||||
// Pending-Nutzer existiert nicht mehr oder hat TOTP zwischenzeitlich deaktiviert.
|
||||
throw redirect("/login");
|
||||
}
|
||||
|
||||
const rateLimitKey = mfaChallengeRateLimitKey(request, user.id);
|
||||
const rateLimit = await checkMfaChallengeRateLimit(rateLimitKey);
|
||||
if (!rateLimit.allowed) {
|
||||
return Response.json({ error: t("account.loginMfa.errors.tooManyAttempts") }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
const code = form.get("code");
|
||||
if (isSixDigitTotpCode(code)) {
|
||||
const encryptedSecret = await getActiveTotpSecretEncrypted(user.id);
|
||||
try {
|
||||
verified = Boolean(encryptedSecret && verifyTotpCode(encryptedSecret, code));
|
||||
} catch {
|
||||
verified = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const recoveryCode = normalizeRecoveryCodeInput(form.get("recoveryCode"));
|
||||
if (recoveryCode.length > 0) {
|
||||
const result = await consumeUserMfaRecoveryCode(user.id, recoveryCode);
|
||||
verified = Boolean(result?.consumed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
await recordFailedMfaChallengeAttempt(rateLimitKey);
|
||||
return Response.json({ error: t("account.loginMfa.errors.invalidCode") }, { status: 400 });
|
||||
}
|
||||
|
||||
await resetMfaChallengeRateLimit(rateLimitKey);
|
||||
|
||||
// Ab hier identisch zum normalen Login-Abschluss in loginFromRequest.
|
||||
const userInfo = await getUserInfo(request);
|
||||
await createLogLogin(request, user);
|
||||
const userSession = await setLoggedUser(user);
|
||||
const appHome = await getHome({ isAdmin: !!user.admin, tenantId: userSession.defaultTenantId, request });
|
||||
const rawRedirectTo = session.get("pendingMfaRedirectTo");
|
||||
const safeRedirectTo = await getSafeLoginRedirect({
|
||||
redirectTo: typeof rawRedirectTo === "string" ? rawRedirectTo : "",
|
||||
isAdmin: !!user.admin,
|
||||
defaultTenantId: userSession.defaultTenantId,
|
||||
fallback: appHome,
|
||||
});
|
||||
const sessionToken = await startServerSession({ request, userId: user.id }).catch(() => null);
|
||||
const response = await createUserSession(
|
||||
{
|
||||
...userInfo,
|
||||
...userSession,
|
||||
lng: user.locale ?? userInfo.lng,
|
||||
sessionToken: sessionToken ?? undefined,
|
||||
},
|
||||
safeRedirectTo
|
||||
);
|
||||
|
||||
// Trust-Device: dieses Geraet kuenftig ohne zweiten Faktor zulassen. Legt eine widerrufbare
|
||||
// TrustedDevice-Zeile an und gibt den Token als zusaetzliches Set-Cookie aus.
|
||||
if (form.get("rememberDevice") === "on" || form.get("rememberDevice") === "true") {
|
||||
response.headers.append("Set-Cookie", await rememberDeviceForUser({ request, userId: user.id }));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function registerFromRequest(request: Request) {
|
||||
const { t } = await getTranslations(request);
|
||||
const userInfo = await getUserInfo(request);
|
||||
try {
|
||||
await validateCSRFToken(request);
|
||||
const registrationData = await getRegistrationFormData(request);
|
||||
const result = await validateRegistration({ request, registrationData, addToTrialOrFreePlan: true });
|
||||
if (result.verificationRequired) {
|
||||
await createRegistrationForm({ ...registrationData, email: result.email, ipAddress: result.ipAddress, recreateToken: false, request });
|
||||
return Response.json({ verificationEmailSent: true });
|
||||
} else if (result.registered) {
|
||||
const userSession = await setLoggedUser(result.registered.user);
|
||||
const home = await getHome({
|
||||
isAdmin: false,
|
||||
tenantId: userSession.defaultTenantId,
|
||||
request,
|
||||
});
|
||||
const sessionToken = await startServerSession({ request, userId: result.registered.user.id }).catch(() => null);
|
||||
return createUserSession(
|
||||
{
|
||||
...userInfo,
|
||||
...userSession,
|
||||
lng: result.registered.user.locale ?? userInfo.lng,
|
||||
sessionToken: sessionToken ?? undefined,
|
||||
},
|
||||
home
|
||||
);
|
||||
}
|
||||
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
|
||||
} catch (e: any) {
|
||||
// Intentionally do NOT log e.message verbatim — auth flow errors may
|
||||
// contain user-provided email/registration tokens. Log only the error
|
||||
// class and a short tag so we can still investigate via Sentry.
|
||||
authLogger.warn(
|
||||
{
|
||||
err: { name: e?.name ?? "Error", message: typeof e?.message === "string" ? "[redacted]" : "[unknown]" },
|
||||
op: "verifyAndRegister",
|
||||
},
|
||||
"Auth verify-and-register failed"
|
||||
);
|
||||
return Response.json({ error: t(e.message) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyFromRequest({ request, params }: { request: Request; params: Params }) {
|
||||
const { t } = await getTranslations(request);
|
||||
const userInfo = await getUserInfo(request);
|
||||
|
||||
const registration = await getRegistrationByToken(params.id ?? "");
|
||||
if (!registration || registration.createdTenantId) {
|
||||
return Response.json({ error: t("api.errors.userAlreadyRegistered") }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const registrationData = await getRegistrationFormData(request);
|
||||
registrationData.leadId = registration.leadConversionLeadId ?? undefined;
|
||||
registrationData.leadToken = registration.leadConversionToken ?? undefined;
|
||||
registrationData.leadPlanId = registration.leadConversionPlanId ?? undefined;
|
||||
const result = await validateRegistration({ request, registrationData, checkEmailVerification: false, addToTrialOrFreePlan: true });
|
||||
if (!result.registered) {
|
||||
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
|
||||
}
|
||||
await updateRegistration(registration.id, { createdTenantId: result.registered.tenant.id });
|
||||
const userSession = await setLoggedUser(result.registered.user);
|
||||
const appHome = await getHome({
|
||||
isAdmin: false,
|
||||
tenantId: userSession.defaultTenantId,
|
||||
request,
|
||||
});
|
||||
const sessionToken = await startServerSession({ request, userId: result.registered.user.id }).catch(() => null);
|
||||
return createUserSession(
|
||||
{
|
||||
...userInfo,
|
||||
...userSession,
|
||||
lng: result.registered.user.locale ?? userInfo.lng,
|
||||
sessionToken: sessionToken ?? undefined,
|
||||
},
|
||||
appHome
|
||||
);
|
||||
} catch {
|
||||
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
loginFromRequest,
|
||||
verifyMfaLoginFromRequest,
|
||||
registerFromRequest,
|
||||
verifyFromRequest,
|
||||
};
|
||||
Reference in new issue
Block a user