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, };