441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
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<RegistrationData> {
|
|
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 };
|
|
}
|