import { TenantUserType } from "~/application/enums/tenants/TenantUserType"; import { getTranslations } from "~/locale/i18next.server"; import UserValidationUtils from "../app/UserValidationUtils"; import { getAppConfiguration } from "../db/appConfiguration.db.server"; import { getAllRoles } from "../db/permissions/roles.db.server"; import { createTenant, createTenantUser, getTenantBySlug } from "../db/tenants.db.server"; import { getUserByEmail, register } from "../db/users.db.server"; import { queueTransactionalEmail } from "~/modules/emails/services/TransactionalEmailQueue.server"; import { createStripeCustomer, isStripeConfigured } from "../stripe.server"; import crypto from "crypto"; import { createRegistration, getRegistrationByEmail, updateRegistration } from "../db/registration.db.server"; import { db } from "~/utils/db.server"; import { getClientIPAddress } from "~/utils/server/IpUtils"; import { addBlacklistAttempt, findInBlacklist } from "../db/blacklist.db.server"; import { getAbsoluteUrl } from "../url.server"; import { getUserInfo } from "../session.server"; import { TenantTypesApi } from "../api/.server/TenantTypesApi"; import EventsService from "../../modules/events/services/.server/EventsService"; import { AccountCreatedDto } from "~/modules/events/dtos/AccountCreatedDto"; import { autosubscribeToTrialOrFreePlan } from "./.server/pricingService"; import IpAddressServiceServer from "~/modules/ipAddress/services/IpAddressService.server"; import EmailTemplates from "~/modules/emails/utils/EmailTemplates"; import { getMailTranslator, getMailTranslatorFromRequest } from "~/locale/mail-translator.server"; import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server"; import { verifyLeadToken } from "~/utils/leads/leadToken.server"; import { recordExistingTenantLeadConversion } from "~/utils/leads/convertLead.server"; import { createScopedLogger } from "~/utils/logger.server"; import { resolveCaptchaConfig, verifyCaptchaToken, getSitekey, isCaptchaConfigured } from "@toolctrl/captcha"; import { validateDesiredTenantSlug } from "./tenantService"; import { seedShkRolesForTenant } from "./shkRoles.server"; const authServiceLogger = createScopedLogger("auth-service"); 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; } export type RegistrationData = { email?: string; password?: string; company?: string; firstName?: string; lastName?: string; avatarURL?: string; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string; /** mCaptcha proof-of-work token from the client widget (hidden `mcaptcha__token` field). */ captchaToken?: string; }; export async function getRegistrationFormData(request: Request): Promise { const formData = await request.formData(); const email = formData.get("email")?.toString().toLowerCase().trim(); const password = formData.get("password")?.toString(); const company = formData.get("company")?.toString(); const firstName = formData.get("first-name")?.toString(); const lastName = formData.get("last-name")?.toString(); const slug = formData.get("slug")?.toString(); const leadId = formData.get("leadId")?.toString().trim() || undefined; const leadToken = formData.get("leadToken")?.toString().trim() || undefined; const leadPlanId = formData.get("leadPlanId")?.toString().trim() || undefined; const captchaToken = formData.get("mcaptcha__token")?.toString().trim() || undefined; const ipError = await IpAddressServiceServer.log(request, { action: "register", description: email ? "registration-attempt" : "{empty email}", metadata: { emailDomain: email?.includes("@") ? email.substring(email.lastIndexOf("@") + 1) : undefined, slug }, }).catch((e) => e.message); if (ipError) { const { t } = await getTranslations(request); throw Error(translateIpAddressError(t, ipError)); } return { email, password, company, firstName, lastName, slug, leadId, leadToken, leadPlanId, captchaToken }; } async function resolveLeadConversionContext(registrationData: RegistrationData, invalidMessage: string): Promise<{ leadId: string; planId: string | null } | null> { const hasAnyLeadField = Boolean(registrationData.leadId || registrationData.leadToken || registrationData.leadPlanId); if (!hasAnyLeadField) return null; if (process.env.LEAD_CONVERSION_ENABLED === "false") return null; if (!registrationData.email || !registrationData.leadId || !registrationData.leadToken) { throw Error(invalidMessage); } const payload = verifyLeadToken(registrationData.leadToken); if (!payload || payload.leadId !== registrationData.leadId) { throw Error(invalidMessage); } const lead = await db.lead.findUnique({ where: { id: registrationData.leadId }, select: { email: true, convertedToTenantId: true }, }); if (!lead || lead.convertedToTenantId || lead.email.toLowerCase().trim() !== registrationData.email.toLowerCase().trim()) { throw Error(invalidMessage); } return { leadId: registrationData.leadId, planId: payload.planId ?? registrationData.leadPlanId ?? null, }; } export async function validateRegistration({ request, registrationData, addToTrialOrFreePlan, checkEmailVerification = true, stripeCustomerId, }: { request: Request; registrationData: RegistrationData; addToTrialOrFreePlan: boolean; checkEmailVerification?: boolean; stripeCustomerId?: string; }) { const { t } = await getTranslations(request); const userInfo = await getUserInfo(request); const appConfiguration = await getAppConfiguration({ request }); const { email, password, company, firstName, lastName, avatarURL, slug } = registrationData; if (!email || !UserValidationUtils.validateEmail(email)) { throw Error(t("account.register.errors.invalidEmail")); } // Anti-spam: verify the mCaptcha PoW token for human signups (email/password is // the only signup path after EU-Sterilisation removed social OAuth). No-op when // captcha is unconfigured; verification fails open if the instance is unreachable // (CAPTCHA_FAIL_OPEN). const captcha = resolveCaptchaConfig(process.env); if (isCaptchaConfigured(captcha)) { const captchaOk = await verifyCaptchaToken({ token: registrationData.captchaToken, sitekey: getSitekey(captcha, "login"), secret: captcha.accountSecret, instanceUrl: captcha.instanceUrl, failOpen: captcha.failOpen, logger: authServiceLogger, }); if (!captchaOk) { throw Error(t("account.register.errors.captchaFailed")); } } const leadConversionContext = await resolveLeadConversionContext(registrationData, t("account.register.errors.invalidLeadConversion")); if (!appConfiguration.auth.requireEmailVerification && (typeof password !== "string" || password.length === 0)) { throw Error(t("account.register.errors.passwordRequired")); } else if (!appConfiguration.auth.requireEmailVerification && password) { const policyResult = await validatePolicyPassword(password, { email }); if (!policyResult.valid) { const firstError = policyResult.errors[0]; throw Error(firstError ? t(firstError.code, firstError.args ?? {}) : t("auth:errors.invalidPassword")); } } else if (appConfiguration.auth.requireOrganization && typeof company !== "string") { throw Error(t("account.register.errors.organizationRequired")); } else if (appConfiguration.auth.requireName && (typeof firstName !== "string" || typeof lastName !== "string")) { throw Error(t("account.register.errors.nameRequired")); } if (company && company.length > 100) { throw Error(t("account.register.errors.companyMaxLength")); } else if (firstName && firstName.length > 50) { throw Error(t("account.register.errors.firstNameMaxLength")); } else if (lastName && lastName.length > 50) { throw Error(t("account.register.errors.lastNameMaxLength")); } const ipAddress = getClientIPAddress(request.headers)?.toString() ?? ""; const blacklistedEmail = await findInBlacklist("email", email); if (blacklistedEmail) { await addBlacklistAttempt(blacklistedEmail); throw Error(t("account.register.errors.blacklist.email")); } const blacklistedDomain = await findInBlacklist("domain", email.substring(email.lastIndexOf("@") + 1)); if (blacklistedDomain) { await addBlacklistAttempt(blacklistedDomain); throw Error(t("account.register.errors.blacklist.domain")); } const blacklistedIp = await findInBlacklist("ip", ipAddress); if (blacklistedIp) { await addBlacklistAttempt(blacklistedIp); throw Error(t("account.register.errors.blacklist.ip")); } const existingUser = await getUserByEmail(email); if (existingUser) { throw Error(t("api.errors.userAlreadyRegistered")); } // Vom Nutzer GEWAEHLTER Slug: reservierte/zu-kurze Werte mit Klartext ablehnen, statt sie // spaeter in createUserAndTenant/getAvailableTenantSlug still umzuschreiben (S1/R-P2a). Greift // unabhaengig von auth.slug.require, sobald ein Slug uebermittelt wurde. if (typeof slug === "string" && slug.trim().length > 0) { const slugCheck = validateDesiredTenantSlug(slug); if (!slugCheck.ok && slugCheck.reason !== "empty") { throw Error(t(slugCheck.reason === "reserved" ? "account.register.errors.slugReserved" : "account.register.errors.slugTooShort")); } } if (appConfiguration.auth.slug?.require) { if (!slug) { throw Error(t(appConfiguration.auth.slug.type === "tenant" ? "account.register.errors.slugRequired" : "account.register.errors.usernameRequired")); } const existingTenant = await getTenantBySlug(slug); if (existingTenant) { throw Error(t(appConfiguration.auth.slug.type === "tenant" ? "account.register.errors.slugTaken" : "account.register.errors.usernameTaken")); } } if (checkEmailVerification && appConfiguration.auth.requireEmailVerification) { return { email, ipAddress, verificationRequired: true }; } const locale = userInfo.lng; const registered = await createUserAndTenant({ request, email, password, company, firstName, lastName, stripeCustomerId, avatarURL, locale, slug, }); if (addToTrialOrFreePlan) { await autosubscribeToTrialOrFreePlan({ request, t, tenantId: registered.tenant.id, userId: registered.user.id }); } if (leadConversionContext) { try { await recordExistingTenantLeadConversion({ leadId: leadConversionContext.leadId, tenantId: registered.tenant.id, planId: leadConversionContext.planId, conversionPath: "self-service", }); } catch (error) { authServiceLogger.warn( { op: "lead.selfServiceConversion.record_failed", leadId: leadConversionContext.leadId, tenantId: registered.tenant.id, err: { name: error instanceof Error ? error.name : "Error" }, }, "Registration succeeded but lead conversion attribution could not be recorded." ); } } return { email, ipAddress, verificationRequired: false, registered }; } interface CreateRegistrationFormDto { request: Request; email: string; ipAddress: string; company?: string; firstName?: string; lastName?: string; recreateToken?: boolean; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string | null; } export async function createRegistrationForm({ request, email, company, firstName, lastName, ipAddress, recreateToken, slug, leadId, leadToken, leadPlanId }: CreateRegistrationFormDto) { const registration = await getRegistrationByEmail(email); if (registration) { if (registration.createdTenantId) { throw Error("api.errors.userAlreadyRegistered"); } else { if (recreateToken) { const newToken = crypto.randomBytes(20).toString("hex"); await updateRegistration(registration.id, { firstName, lastName, company, token: newToken, leadConversionLeadId: leadId ?? null, leadConversionToken: leadToken ?? null, leadConversionPlanId: leadPlanId ?? null, }); const emailData = { action_url: getAbsoluteUrl(request, `/verify/${newToken}`), name: firstName ?? "", }; const mailTranslator = await getMailTranslatorFromRequest(request); await queueTransactionalEmail({ to: email, alias: "email-verification", ...EmailTemplates.VERIFICATION_EMAIL.parse({ appConfiguration: await getAppConfiguration({ request }), data: emailData, t: mailTranslator.t, locale: mailTranslator.locale, }), data: emailData, priorityOverride: "critical", }); } } } else { var token = crypto.randomBytes(20).toString("hex"); await createRegistration({ email, firstName: firstName ?? "", lastName: lastName ?? "", company: company ?? "", token, selectedSubscriptionPriceId: null, leadConversionLeadId: leadId ?? null, leadConversionToken: leadToken ?? null, leadConversionPlanId: leadPlanId ?? null, ipAddress, slug: slug ?? null, }); const emailData = { action_url: getAbsoluteUrl(request, `/verify/${token}`), name: firstName ?? "", }; const mailTranslator = await getMailTranslatorFromRequest(request); await queueTransactionalEmail({ to: email, alias: "email-verification", ...EmailTemplates.VERIFICATION_EMAIL.parse({ appConfiguration: await getAppConfiguration({ request }), data: emailData, t: mailTranslator.t, locale: mailTranslator.locale, }), data: emailData, priorityOverride: "critical", }); } } interface CreateUserAndTenantDto { request: Request; email: string; password?: string; company?: string; firstName?: string; lastName?: string; stripeCustomerId?: string; avatarURL?: string; locale?: string; slug?: string; } export async function createUserAndTenant({ request, email, password, company, firstName, lastName, stripeCustomerId, avatarURL, locale, slug, }: CreateUserAndTenantDto) { let tenantName = company ?? email.split("@")[0]; if (!stripeCustomerId && isStripeConfigured()) { const stripeCustomer = await createStripeCustomer(email, tenantName); if (!stripeCustomer) { throw Error("Could not create Stripe customer"); } stripeCustomerId = stripeCustomer.id; } const tenant = await createTenant({ name: tenantName, subscriptionCustomerId: stripeCustomerId, slug }); if (!tenant) { throw Error("Could not create tenant"); } const user = await register({ email: email, password: password ?? "", firstName: firstName ?? "", lastName: lastName ?? "", avatarURL, locale, defaultTenantId: tenant.id, request, }); if (!user) { throw Error("Could not create user"); } const roles = await getAllRoles("app"); await createTenantUser( { tenantId: tenant.id, userId: user.id, type: TenantUserType.OWNER, }, roles ); await TenantTypesApi.setTenantTypes({ tenantId: tenant.id }); // R-P2b — neuen (SHK-)Tenant mit den SHK-Standard-Rollen-Templates bestuecken. Best-effort: // ein Seed-Fehler darf die Registrierung nicht blockieren (Rollen sind anpassbar/nachseedbar). await seedShkRolesForTenant(tenant.id).catch((error: unknown) => { authServiceLogger.warn( { op: "register.seedShkRoles.failed", tenantId: tenant.id, err: { name: error instanceof Error ? error.name : "Error" } }, "Tenant created but SHK standard roles could not be seeded; they can be re-seeded later." ); }); const emailData = { action_url: getAbsoluteUrl(request, "/login"), name: firstName, }; const welcomeMailTranslator = await getMailTranslator(user.locale ?? locale); await queueTransactionalEmail({ to: email, alias: "welcome", tenantId: tenant.id, userId: user.id, ...EmailTemplates.WELCOME_EMAIL.parse({ appConfiguration: await getAppConfiguration({ request }), data: emailData, t: welcomeMailTranslator.t, locale: welcomeMailTranslator.locale, }), data: emailData, }); await EventsService.create({ request, event: "account.created", tenantId: tenant.id, userId: user.id, data: { tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }, user: { id: user.id, email: user.email }, } satisfies AccountCreatedDto, }); return { user, tenant }; }