Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/authService.ts
T

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