import { createHash, randomUUID } from "node:crypto"; import { CatalogError } from "@hivegrid/foundation"; 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, isPendingMfaLoginFresh, isPendingMfaPrimaryMethod, PENDING_MFA_PRIMARY_METHOD_KEY, } from "~/utils/session.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 { getAppConfiguration } from "~/utils/db/appConfiguration.db.server"; import { createRegistrationForm, getRegistrationFormData, isSignupCreationRequestKey, RegistrationValidationError, resolvePendingRegistrationResendRecovery, validateRegistration, } from "~/utils/services/authService"; import { getRegistrationVerificationStateByToken, 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 "@hivegrid/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 { getSecret, getSitekey, isCaptchaConfigured, resolveCaptchaConfig, verifyCaptchaToken } from "@hivegrid/captcha"; import { createScopedLogger } from "~/utils/logger.server"; import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server"; import { AdminFallbackLoginRejected, assertAdminFallbackLoginAllowedForIdentity, isCompletedAdminFallbackLoginResponse, } from "~/services/auth/admin-fallback.server"; import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server"; import { deriveVerificationCreationRequestKey } from "~/domain/tenant-bootstrap/signupCreationRequestKey.server"; import { clearRegistrationResendRecovery, commitRegistrationResendRecovery, readRegistrationResendRecovery, } from "~/services/auth/registration-resend-recovery.server"; const authLogger = createScopedLogger("auth"); const DUMMY_PASSWORD_HASH = "$argon2id$v=19$m=19456,t=2,p=1$CGZ841jS/Rerhh41+cREiw$piaCzFMbzHjq946K2t0iN7iMI/389iUpLNm2HROGO4Y"; const REGISTRATION_VERIFY_RATE_LIMIT = 5; const REGISTRATION_VERIFY_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; function registrationVerifyRateLimitIdentifier(token: string): string { return createHash("sha256").update("registration-verify:v1\0").update(token).digest("hex"); } // 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 t("shared.unexpectedError"); } // The demo-login gate is a RUNTIME switch, not a seeding switch. It used to ride // on SEED_DEMO_DATA / ALLOW_STAGING_DEMO_SEED / SEED_DEMO_PRO_MANAGED, but the // RLS deploy contract forbids every SEED_*-prefixed key on a runtime resource // (`assert-runtime-db-env` rejects them, and seeding is not approved under forced // RLS). Carrying the gate on those names made the staging demo accounts // unreachable the moment the contract was enforced. It now has its own name, so // enabling demo logins never implies enabling a seed path. // // SENTRY_ENVIRONMENT is still required to be `staging`: the flag alone must not be // able to unlock a demo account in production, even if someone sets it there. type DemoLoginEnvironment = { SENTRY_ENVIRONMENT?: string; STAGING_DEMO_LOGIN_ENABLED?: string; }; export function isStagingDemoLoginAllowed(env?: DemoLoginEnvironment) { const demoLoginEnv = env ?? (process.env as DemoLoginEnvironment); return demoLoginEnv.SENTRY_ENVIRONMENT?.trim() === "staging" && demoLoginEnv.STAGING_DEMO_LOGIN_ENABLED?.trim() === "true"; } export function isProductionTestAccountBlocked(email: string, env?: DemoLoginEnvironment) { const runtimeEnv = env ?? (process.env as DemoLoginEnvironment); if (blockedProductionSeedAccountEmails.has(email)) { return true; } if (guardedStagingDemoAccountEmails.has(email) || email.endsWith("@mitarbeiter.example.test")) { return !isStagingDemoLoginAllowed(runtimeEnv); } return false; } async function getHome({ isAdmin, tenantSlug, request }: { isAdmin: boolean; tenantSlug?: string | null; request: Request }) { let appHome = ""; const appConfiguration = await getAppConfiguration({ request }); if (isAdmin) { appHome = absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard"); } else if (appConfiguration.app.features.tenantHome === "/:tenant/") { if (tenantSlug) { appHome = absoluteAppPath(loadRequiredWebConfig().routing, tenantSlug, "dashboard"); } else { appHome = absoluteAppRootPath(loadRequiredWebConfig().routing); } } else { appHome = absoluteAppRootPath(loadRequiredWebConfig().routing); } return appHome; } function getRegistrationBillingDestination( tenantSlug: string, selectedSubscriptionPriceId: string, selectedOfficeSeatQuantity?: number, selectedFieldSeatQuantity?: number ): string { const destination = new URL(absoluteAppPath(loadRequiredWebConfig().routing, tenantSlug, "settings/billing")); destination.searchParams.set("subscription_required", "1"); destination.searchParams.set("selected_price", selectedSubscriptionPriceId); if (selectedOfficeSeatQuantity !== undefined && selectedFieldSeatQuantity !== undefined) { destination.searchParams.set("office_seats", String(selectedOfficeSeatQuantity)); destination.searchParams.set("field_seats", String(selectedFieldSeatQuantity)); } return destination.toString(); } 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, defaultTenantSlug, fallback, }: { redirectTo: string; isAdmin: boolean; defaultTenantId: string; defaultTenantSlug?: string | null; 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 tenantSlug = defaultTenantSlug ?? undefined; 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")); } // Never return the submitted password in action data. React Router hydrates // error responses into the document, so only non-secret fields may be echoed. const fields = { email }; 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 = await 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: after repeated failures per IP+email, require the // configured self-hosted captcha before checking the password. const captchaConfig = resolveCaptchaConfig(process.env); if (isCaptchaConfigured(captchaConfig) && loginRequiresCaptcha(await getFailedLoginCount({ request, identifier: email }))) { const captchaOk = await verifyCaptchaToken({ token: form.get("cap-token")?.toString() ?? form.get("mcaptcha__token")?.toString() ?? form.get("captchaToken")?.toString(), sitekey: getSitekey(captchaConfig, "login"), secret: getSecret(captchaConfig, "login"), instanceUrl: captchaConfig.instanceUrl, provider: captchaConfig.provider, 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, error: t("auth:errors.productionTestAccountBlocked") }, { status: 400 }); } } // Phase 01 / #20 — Magic-link-only accounts have `passwordHash = null` // (invitation flow provisions them without a password). We must never // grant a session on such a row, but we must also not reveal that the // account exists: an early distinct "use the magic link" response (plus // skipping verifyPassword) let an attacker enumerate invited accounts by // their content and timing. Instead we fall through to the SAME dummy-hash // comparison and the SAME generic invalid-credentials response as an // unknown email — verifyPassword against DUMMY_PASSWORD_HASH always returns // false for a null-hash row, so no session is ever issued. The magic-link // hint is surfaced generically on the /forgot-password flow, not here. const { valid: isCorrectPassword, rehashedHash } = await verifyPassword(password, user?.passwordHash ?? DUMMY_PASSWORD_HASH); // Keep disabled identities on the same generic credentials path so they // cannot be enumerated and never receive an immediately unusable session. if (!user || user.passwordHash === null || !isCorrectPassword || user.active === false) { await recordFailedLogin({ request, identifier: email }); return Response.json({ fields, error: t("api.errors.invalidPassword") }, { status: 400 }); } // Admin identities may only use a password as the explicitly enabled, // allowlisted emergency fallback. This identity-based check also prevents // an AdminUser from bypassing the admin-host policy through an app-host // login; the resulting app cookie remains host-isolated from admin. const isAdminFallbackLogin = Boolean(user.admin); if (isAdminFallbackLogin) { try { assertAdminFallbackLoginAllowedForIdentity(user); } catch (error) { if (!(error instanceof AdminFallbackLoginRejected)) { throw error; } await recordFailedLogin({ request, identifier: email }); await writeAdminSsoAuditEvent({ action: "admin.login.fallback.failure", actorAdminUserId: user.admin?.userId, request, meta: { reason: error.reason, email, method: "password" }, }); // Preserve the same public response as invalid credentials so resolving // an AdminUser never becomes an account-enumeration oracle. 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). await 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, primaryMethod: "password" }); 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 { defaultTenant, ...userSessionData } = userSession; const appHome = await getHome({ isAdmin: !!user.admin, tenantSlug: defaultTenant?.slug, request, }); const safeRedirectTo = await getSafeLoginRedirect({ redirectTo, isAdmin: !!user.admin, defaultTenantId: userSessionData.defaultTenantId, defaultTenantSlug: defaultTenant?.slug, 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." ); } const response = await createUserSession( { ...userInfo, ...userSessionData, lng: user.locale ?? userInfo.lng, passwordPolicyOutdated: passwordPolicyOutdated || undefined, }, safeRedirectTo, isAdminFallbackLogin ? { request, rotate: true, adminAuthenticationMethods: ["local-development", "mfa"], adminMfaAssuredAt: new Date(), } : { request, rotate: true } ); if (isAdminFallbackLogin && isCompletedAdminFallbackLoginResponse(response)) { await writeAdminSsoAuditEvent({ action: "admin.login.fallback.success", actorAdminUserId: user.admin?.userId, request, meta: { email, severity: "warn", method: "password" }, }); } return response; } /** * 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"); const pendingMfaStartedAt = session.get("pendingMfaStartedAt"); const rawPendingMfaPrimaryMethod = session.get(PENDING_MFA_PRIMARY_METHOD_KEY); const pendingMfaPrimaryMethod = isPendingMfaPrimaryMethod(rawPendingMfaPrimaryMethod) ? rawPendingMfaPrimaryMethod : undefined; if (typeof pendingMfaUserId !== "string" || pendingMfaUserId.length === 0 || !isPendingMfaLoginFresh(pendingMfaStartedAt)) { // 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); // The pending cookie is signed and records which primary factor established // the challenge. Authentik is the only AdminUser path that may finish // without the emergency fallback policy; every other path is re-checked at // the last possible point so disabling/removing the allowlist during the MFA // challenge prevents a real admin session from being issued. const isAdminFallbackMfa = Boolean(user.admin && pendingMfaPrimaryMethod !== "admin-sso"); if (isAdminFallbackMfa) { try { assertAdminFallbackLoginAllowedForIdentity(user); } catch (error) { if (!(error instanceof AdminFallbackLoginRejected)) { throw error; } await writeAdminSsoAuditEvent({ action: "admin.login.fallback.failure", actorAdminUserId: user.admin?.userId, request, meta: { reason: error.reason, email: user.email, method: pendingMfaPrimaryMethod ?? "unknown", stage: "mfa-complete", }, }); throw redirect("/login?error=fallback_disabled"); } } // Ab hier identisch zum normalen Login-Abschluss in loginFromRequest. const userInfo = await getUserInfo(request); await createLogLogin(request, user); const userSession = await setLoggedUser(user); const { defaultTenant, ...userSessionData } = userSession; const appHome = await getHome({ isAdmin: !!user.admin, tenantSlug: defaultTenant?.slug, request }); const rawRedirectTo = session.get("pendingMfaRedirectTo"); const safeRedirectTo = await getSafeLoginRedirect({ redirectTo: typeof rawRedirectTo === "string" ? rawRedirectTo : "", isAdmin: !!user.admin, defaultTenantId: userSessionData.defaultTenantId, defaultTenantSlug: defaultTenant?.slug, fallback: appHome, }); const response = await createUserSession( { ...userInfo, ...userSessionData, lng: user.locale ?? userInfo.lng, }, safeRedirectTo, { request, rotate: true } ); if (isAdminFallbackMfa && isCompletedAdminFallbackLoginResponse(response)) { await writeAdminSsoAuditEvent({ action: "admin.login.fallback.success", actorAdminUserId: user.admin?.userId, request, meta: { email: user.email, severity: "warn", method: pendingMfaPrimaryMethod ?? "unknown", mfa: true, }, }); } // 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); let creationRequestKey: string | undefined; let verificationFlowAttempted = false; let verificationEmail: string | undefined; let shouldResetCaptcha = false; try { await validateCSRFToken(request); const registrationData = await getRegistrationFormData(request); shouldResetCaptcha = registrationData.intent !== "resendVerification"; if (registrationData.creationRequestKey && isSignupCreationRequestKey(registrationData.creationRequestKey)) { creationRequestKey = registrationData.creationRequestKey; } const isResendVerification = registrationData.intent === "resendVerification"; let resendRecovery: { registrationId: string; catchUpOnly: boolean } | undefined; if (isResendVerification && registrationData.email) { const signedRecovery = await readRegistrationResendRecovery(request); if (signedRecovery?.email === registrationData.email.trim().toLowerCase()) { const resolvedRecovery = await resolvePendingRegistrationResendRecovery(signedRecovery); if (resolvedRecovery) { registrationData.creationRequestKey = resolvedRecovery.creationRequestKey; creationRequestKey = resolvedRecovery.creationRequestKey; resendRecovery = { registrationId: resolvedRecovery.registrationId, catchUpOnly: resolvedRecovery.catchUpOnly, }; } } } if (!isResendVerification && (!registrationData.acceptTerms || !registrationData.acceptPrivacy)) { throw new RegistrationValidationError("account.register.errors.acceptTermsAndPrivacy"); } const result = await validateRegistration({ request, registrationData, addToTrialOrFreePlan: false, requireSubscriptionSelection: true, ...(isResendVerification ? { captchaAlreadyVerified: true } : {}), }); if (result.verificationRequired) { verificationFlowAttempted = true; verificationEmail = result.email; if (!creationRequestKey) { throw new RegistrationValidationError("auth:errors.invalidFormData"); } const createdRegistration = await createRegistrationForm({ ...registrationData, email: result.email, ipAddress: result.ipAddress, resendOnly: isResendVerification, request, creationRequestKey, selectedSubscriptionPriceId: result.selectedSubscriptionPriceId, selectedOfficeSeatQuantity: result.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: result.selectedFieldSeatQuantity, ...(resendRecovery ? { resendRecovery } : {}), }); creationRequestKey = createdRegistration.creationRequestKey; return await registrationVerificationResponse(verificationEmail, creationRequestKey); } else if (result.registered) { const userSession = await setLoggedUser(result.registered.user); const { defaultTenant, ...userSessionData } = userSession; const tenantSlug = defaultTenant?.slug ?? result.registered.tenant.slug; const home = result.selectedSubscriptionPriceId ? getRegistrationBillingDestination(tenantSlug, result.selectedSubscriptionPriceId, result.selectedOfficeSeatQuantity, result.selectedFieldSeatQuantity) : await getHome({ isAdmin: false, tenantSlug, request, }); const response = await createUserSession( { ...userInfo, ...userSessionData, lng: result.registered.user.locale ?? userInfo.lng, }, home, { request, rotate: true } ); return await clearRegistrationRecoveryOnSessionResponse(response); } return Response.json( { error: t("shared.unknownError"), ...(creationRequestKey ? { creationRequestKey } : {}), ...(shouldResetCaptcha ? { captchaResetKey: randomUUID() } : {}), }, { status: 400 } ); } catch (e: unknown) { // 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 instanceof Error ? e.name : "Error", message: e instanceof Error ? "[redacted]" : "[unknown]" }, op: "verifyAndRegister", }, "Auth verify-and-register failed" ); if (verificationFlowAttempted && isHiddenRegistrationExistenceConflict(e) && creationRequestKey) { authLogger.info( { op: "registration.existence_hidden", reason: e instanceof RegistrationValidationError ? "registered" : "pending-conflict", }, "Registration existence response normalized" ); if (verificationEmail) { return await registrationVerificationResponse(verificationEmail, creationRequestKey); } } const publicError = e instanceof RegistrationValidationError ? t(e.i18nKey, e.i18nArgs ?? {}) : e instanceof CatalogError ? t(e.i18nKey) : t("shared.unknownError"); return Response.json( { error: publicError, ...(creationRequestKey ? { creationRequestKey } : {}), // CAP proofs are single-use. Any later validation failure after a // successful verification has consumed the proof as well, so every // failed initial registration must mount a fresh challenge for retry. ...(shouldResetCaptcha ? { captchaResetKey: randomUUID() } : {}), }, { status: 400 } ); } } async function registrationVerificationResponse(email: string, creationRequestKey: string): Promise { return Response.json( { verificationEmailSent: true, creationRequestKey }, { headers: { "Cache-Control": "no-store", "Set-Cookie": await commitRegistrationResendRecovery({ email, creationRequestKey }), }, } ); } async function clearRegistrationRecoveryOnSessionResponse(response: Response): Promise { response.headers.append("Set-Cookie", await clearRegistrationResendRecovery()); return response; } function isHiddenRegistrationExistenceConflict(error: unknown): boolean { return ( (error instanceof RegistrationValidationError && error.i18nKey === "api.errors.userAlreadyRegistered") || (error instanceof CatalogError && error.code === "foundation.idempotency_conflict") ); } async function verifyFromRequest({ request, params }: { request: Request; params: Params }) { const { t } = await getTranslations(request); await validateCSRFToken(request); const verificationToken = params.id ?? ""; const rateLimit = await checkAuthRateLimit({ request, action: "registration-verify", identifier: registrationVerifyRateLimitIdentifier(verificationToken), limit: REGISTRATION_VERIFY_RATE_LIMIT, windowMs: REGISTRATION_VERIFY_RATE_LIMIT_WINDOW_MS, }); if (!rateLimit.allowed) { return Response.json( { error: t("api.errors.tooManyRequests") }, { status: 429, headers: { "Cache-Control": "no-store", "Retry-After": String(rateLimit.retryAfterSeconds), }, } ); } const userInfo = await getUserInfo(request); const verificationState = await getRegistrationVerificationStateByToken(verificationToken); if (verificationState.status !== "pending") { const errorKey = verificationState.status === "expired" ? "account.verify.expiredLink" : verificationState.status === "used" ? "account.verify.usedLink" : "account.verify.invalidLink"; return Response.json({ error: t(errorKey) }, { status: 400 }); } const registration = verificationState.registration; let creationRequestKey: string | undefined; try { const submittedData = await getRegistrationFormData(request); const expectedCreationRequestKey = deriveVerificationCreationRequestKey(registration.id); if ( !submittedData.creationRequestKey || !isSignupCreationRequestKey(submittedData.creationRequestKey) || submittedData.creationRequestKey !== expectedCreationRequestKey ) { throw new Error("Invalid verification creation request key"); } creationRequestKey = expectedCreationRequestKey; // The URL token selects the persisted registration. Only the password and // captcha proof may come from the browser; identity and lead fields are // immutable here so hidden-form tampering cannot change the new account. const registrationData = { email: registration.email, password: submittedData.password, company: registration.company ?? undefined, firstName: registration.firstName, lastName: registration.lastName, slug: registration.slug ?? undefined, leadId: registration.leadConversionLeadId ?? undefined, leadToken: registration.leadConversionToken ?? undefined, leadPlanId: registration.leadConversionPlanId ?? undefined, selectedSubscriptionPriceId: registration.selectedSubscriptionPriceId ?? undefined, selectedOfficeSeatQuantity: registration.selectedOfficeSeatQuantity ?? undefined, selectedFieldSeatQuantity: registration.selectedFieldSeatQuantity ?? undefined, creationRequestKey, captchaToken: submittedData.captchaToken, }; const result = await validateRegistration({ request, registrationData, checkEmailVerification: false, captchaAlreadyVerified: true, addToTrialOrFreePlan: false, requireSubscriptionSelection: true, requireRegistrationSeatQuantities: registration.selectedOfficeSeatQuantity !== null && registration.selectedFieldSeatQuantity !== null, }); if (!result.registered) { return Response.json({ error: t("shared.unknownError"), ...(creationRequestKey ? { creationRequestKey } : {}) }, { status: 400 }); } // The compare-and-set below is the intentional replay boundary. Before it // commits, response-loss may safely retry the same key/payload. Afterwards // the token stays consumed; the committed password account recovers via // normal login instead of turning this verification URL into a login link. await updateRegistration(registration.id, { expectedToken: verificationToken }, { createdTenantId: result.registered.tenant.id }); const userSession = await setLoggedUser(result.registered.user); const { defaultTenant, ...userSessionData } = userSession; const tenantSlug = defaultTenant?.slug ?? result.registered.tenant.slug; const appHome = result.selectedSubscriptionPriceId ? getRegistrationBillingDestination(tenantSlug, result.selectedSubscriptionPriceId, result.selectedOfficeSeatQuantity, result.selectedFieldSeatQuantity) : await getHome({ isAdmin: false, tenantSlug, request, }); const response = await createUserSession( { ...userInfo, ...userSessionData, lng: result.registered.user.locale ?? userInfo.lng, }, appHome, { request, rotate: true } ); return await clearRegistrationRecoveryOnSessionResponse(response); } catch { return Response.json({ error: t("shared.unknownError"), ...(creationRequestKey ? { creationRequestKey } : {}) }, { status: 400 }); } } export default { loginFromRequest, verifyMfaLoginFromRequest, registerFromRequest, verifyFromRequest, };