Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/services/AuthService.ts
T

547 lines
23 KiB
TypeScript

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