864 lines
36 KiB
TypeScript
864 lines
36 KiB
TypeScript
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<Response> {
|
|
return Response.json(
|
|
{ verificationEmailSent: true, creationRequestKey },
|
|
{
|
|
headers: {
|
|
"Cache-Control": "no-store",
|
|
"Set-Cookie": await commitRegistrationResendRecovery({ email, creationRequestKey }),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
async function clearRegistrationRecoveryOnSessionResponse(response: Response): Promise<Response> {
|
|
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,
|
|
};
|