Files
fluxer/fluxer_api/src/api/auth/AuthRegistration.ts
T

525 lines
20 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {sendVerificationEmail} from '@app/api/auth/AuthEmail';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {
getLocalPartAtInstance,
getPrimaryInstanceHost,
usernameFromInstanceLocalPart,
} from '@app/api/auth/InstanceAddress';
import {createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {isBlockedEmailDomain} from '@app/api/infrastructure/activity/SharedLists';
import type {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {withAccountIdentitySetupLock} from '@app/api/instance/AccountIdentitySetupLock';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
import {Logger} from '@app/api/Logger';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {
deriveAvailableUsername,
isUsernameTaken,
reserveUsername,
type UsernameReservation,
} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import * as AgeUtils from '@app/api/utils/AgeUtils';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {hashPassword} from '@app/api/utils/PasswordUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {parseAcceptLanguage} from '@fluxer/i18n/src/locale/resolution/AcceptLanguageNegotiation';
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
if (!match) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const probe = new Date(Date.UTC(year, month - 1, day));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
return new types.LocalDate(year, month, day);
}
interface RegisterParams {
data: RegisterRequest;
request: Request;
requestCache: RequestCache;
}
export interface RegistrationDependencies {
inviteService: InviteService;
instanceConfigRepository: InstanceConfigRepository;
singleCommunityService: SingleCommunityService;
discriminatorService: DiscriminatorService;
kvActivityTracker: KVActivityTracker;
}
interface RegistrationTokenResult {
user_id: string;
token: string;
}
interface RegistrationPendingApprovalResult {
registration_pending_approval: true;
user_id: string;
}
type RegisterResult = RegistrationTokenResult | RegistrationPendingApprovalResult;
function shouldRequireHostedLegalConsent(config: APIConfig): boolean {
return !config.instance.selfHosted;
}
export async function register(
ctx: ApiContext,
deps: RegistrationDependencies,
params: RegisterParams,
): Promise<RegisterResult> {
if (!ctx.services.config.instance.selfHosted || (await deps.instanceConfigRepository.isAccountIdentityLocked())) {
return await registerAccount(ctx, deps, params);
}
return await withAccountIdentitySetupLock(ctx.services.cache, () => registerAccount(ctx, deps, params));
}
async function registerAccount(
ctx: ApiContext,
deps: RegistrationDependencies,
{data, request, requestCache}: RegisterParams,
): Promise<RegisterResult> {
const {users, snowflake, emailDnsValidation, config} = ctx.services;
const {inviteService, instanceConfigRepository, singleCommunityService, discriminatorService, kvActivityTracker} =
deps;
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
const accountIdentity = await instanceConfigRepository.getAccountIdentity();
const usernameMode = accountIdentity.mode === AccountIdentityModes.USERNAME;
const uniqueUsernames = accountIdentity.tagStyle === TagStyles.NONE;
let olderAppUsername: string | undefined;
if (usernameMode) {
assertUsernameModeRegistration(data);
olderAppUsername = data.username ? undefined : requestedUsernameFromOlderApp(config, data.email);
}
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
const requiresPrivacyConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.privacy_url !== null;
if ((requiresTermsConsent || requiresPrivacyConsent) && !data.consent) {
throw InputValidationError.fromCode('consent', ValidationErrorCodes.MUST_AGREE_TO_TOS_AND_PRIVACY_POLICY);
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
const clientIp = requireConfiguredClientIp(request, config.proxy);
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
let dateOfBirth: types.LocalDate | null = null;
let isAdult = true;
if (collectDateOfBirth) {
const dateOfBirthInput = data.date_of_birth?.trim();
if (!dateOfBirthInput) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
const minAge = getRegionalMinimumAge(countryCode);
if (!AuthUtility.validateAge(ctx, {dateOfBirth: dateOfBirthInput, minAge})) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.MUST_BE_MINIMUM_AGE, {minAge});
}
isAdult = AgeUtils.isUserAdult(dateOfBirthInput);
}
if (data.password && (await AuthPassword.isPasswordPwned(ctx, data.password))) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const rawEmail = usernameMode ? null : (data.email ?? null);
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
if (rawEmail) {
if (isBlockedEmailDomain(extractEmailDomain(rawEmail))) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
await assertEmailNotBlocklisted(rawEmail, 'email');
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const emailTaken = await users.findByEmail(rawEmail);
if (emailTaken) throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
let usernameCandidate: string | undefined = data.username ?? olderAppUsername;
let discriminator: number | null = null;
if (uniqueUsernames) {
discriminator = USERNAME_MODE_DISCRIMINATOR;
if (usernameCandidate) {
if (await isUsernameTaken(users, usernameCandidate)) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_ALREADY_TAKEN);
}
} else {
usernameCandidate = await deriveAvailableUsername(
users,
deriveUsernameFromDisplayName(data.global_name ?? '') ?? generateRandomUsername(),
);
}
}
if (!usernameCandidate) {
const derivedUsername = deriveUsernameFromDisplayName(data.global_name ?? '');
if (derivedUsername) {
try {
discriminator = await allocateDiscriminator(discriminatorService, derivedUsername);
usernameCandidate = derivedUsername;
} catch (error) {
if (!(error instanceof InputValidationError)) {
throw error;
}
}
}
}
if (!usernameCandidate) {
usernameCandidate = generateRandomUsername();
discriminator = await allocateDiscriminator(discriminatorService, usernameCandidate);
} else if (discriminator === null) {
discriminator = await allocateDiscriminator(discriminatorService, usernameCandidate);
}
const username = usernameCandidate!;
const grantBootstrapAdmin =
shouldAttemptBootstrapAdminGrant(config, {
rawEmail,
hasPassword: Boolean(data.password),
usernameMode,
pendingApproval: registrationAccess.pendingApproval,
setupConfigured: appPublicConfig.setup.configured,
}) && !(await instanceConfigRepository.isAdminBootstrapped());
if (
profileSubstringBlocklistCache.containsBannedSubstring('username', username) ||
(data.global_name && profileSubstringBlocklistCache.containsBannedSubstring('global_name', data.global_name))
) {
throw new ContentBlockedError();
}
const userId = createUserID(await snowflake.generate());
const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await hashPassword(data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
const userRow: UserRow = {
...EMPTY_USER_ROW,
user_id: userId,
username,
discriminator,
global_name: data.global_name || null,
bot: false,
system: false,
email: rawEmail,
email_verified: !emailEnabled,
email_bounced: false,
password_hash: passwordHash,
password_last_changed_at: passwordHash ? now : null,
authenticator_types: new Set(),
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
date_of_birth: dateOfBirth,
locale: userLocale,
flags,
terms_agreed_at: requiresTermsConsent ? now : null,
privacy_agreed_at: requiresPrivacyConsent ? now : null,
last_active_at: now,
last_active_ip: clientIp,
acls: grantBootstrapAdmin ? new Set([AdminACLs.WILDCARD]) : null,
traits: registrationAccess.pendingApproval ? new Set([REGISTRATION_PENDING_APPROVAL_TRAIT]) : null,
version: 1,
};
const registrationUrlUse = await claimRegistrationUrlUse(
instanceConfigRepository,
registrationAccess.registrationUrl,
userId,
);
let user: User;
let createAttempted = false;
let usernameReservation: UsernameReservation | null = null;
try {
if (uniqueUsernames) {
usernameReservation = await reserveUsername({users, cache: ctx.services.cache}, username);
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username: userRow.username,
discriminator: userRow.discriminator,
global_name: userRow.global_name,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
}
createAttempted = true;
user = await users.create(userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
userId,
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
}
throw error;
} finally {
await usernameReservation?.release();
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
locale: userLocale,
isAdult,
theme: data.theme,
}),
);
void kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
Logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
});
await emitActivity(
'registration',
user.id.toString(),
{
user_id: user.id.toString(),
method: data.password ? 'password' : rawEmail ? 'other' : 'unclaimed',
email: rawEmail,
username,
username_user_chosen: data.username != null || data.global_name != null,
global_name: data.global_name ?? null,
locale: userLocale,
timezone: null,
invite_code: data.invite_code?.trim() || null,
flags: user.flags.toString(),
date_of_birth: user.dateOfBirth,
},
null,
user.id.toString(),
);
if (rawEmail && emailEnabled) await sendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.pendingApproval) {
return {
registration_pending_approval: true,
user_id: user.id.toString(),
};
}
await maybeAutoJoinInvite(inviteService, {
userId,
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
requestCache,
});
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
if (grantBootstrapAdmin) {
await instanceConfigRepository.markAdminBootstrapped();
}
return {
user_id: user.id.toString(),
token,
};
}
function shouldAttemptBootstrapAdminGrant(
config: APIConfig,
params: {
rawEmail: string | null;
hasPassword: boolean;
usernameMode: boolean;
pendingApproval: boolean;
setupConfigured: boolean;
},
): boolean {
const localDevInstance = config.nodeEnv === 'development' && !config.dev.testModeEnabled;
const setupBootstrapOpen = !params.setupConfigured;
const claimedAccount = params.usernameMode ? params.hasPassword : params.rawEmail !== null;
return (
(config.instance.selfHosted || localDevInstance || setupBootstrapOpen) && claimedAccount && !params.pendingApproval
);
}
function assertUsernameModeRegistration(data: RegisterRequest): void {
if (data.password && !data.username && data.email == null) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_LENGTH_INVALID);
}
}
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
if (email == null) return undefined;
const localPart = getLocalPartAtInstance(config, email.trim());
if (localPart === null) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INSTANCE_ADDRESS_REQUIRED, {
host: getPrimaryInstanceHost(config),
});
}
const candidate = usernameFromInstanceLocalPart(localPart);
if (candidate === null) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_INVALID_CHARACTERS);
}
const parsed = UsernameType.safeParse(candidate);
if (!parsed.success) {
throw inputValidationErrorFromZodIssues(parsed.error.issues.map((issue) => ({...issue, path: ['username']})));
}
return parsed.data;
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
userId: UserID,
): Promise<RegistrationUrlClaim | null> {
if (registrationUrl === null) return null;
const use = await instanceConfigRepository.claimRegistrationUrlUse(registrationUrl.id, userId.toString());
if (use === null) {
throw new RegistrationUrlInvalidError();
}
return use;
}
async function withdrawSignupOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
signup: {userId: UserID; registrationUrlUse: RegistrationUrlClaim | null; pendingApproval: boolean},
): Promise<void> {
try {
if (signup.registrationUrlUse !== null) {
await instanceConfigRepository.releaseRegistrationUrlUse(signup.registrationUrlUse);
}
if (signup.pendingApproval) {
await instanceConfigRepository.removePendingRegistration(signup.userId.toString());
}
} catch (error) {
Logger.warn(
{userId: signup.userId.toString(), registrationUrlId: signup.registrationUrlUse?.registration_url_id, error},
'[AuthRegistration] Failed to withdraw the registration URL use or pending approval of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
): Promise<{pendingApproval: boolean; registrationUrl: InstanceRegistrationUrl | null}> {
const registrationConfig = await instanceConfigRepository.getRegistrationConfig();
const normalizedCode = registrationUrlCode?.trim();
let registrationUrl: InstanceRegistrationUrl | null = null;
if (normalizedCode) {
if (!registrationConfig.admin_registration_urls_enabled) {
throw new RegistrationUrlInvalidError();
}
registrationUrl = await instanceConfigRepository.resolveRegistrationUrlCode(normalizedCode);
if (!registrationUrl) {
throw new RegistrationUrlInvalidError();
}
}
if (!registrationUrl && registrationConfig.mode === 'closed') {
throw new RegistrationClosedError();
}
return {
pendingApproval: registrationUrl ? registrationUrl.approval_required : registrationConfig.mode === 'approval',
registrationUrl,
};
}
async function maybeAutoJoinInvite(
inviteService: InviteService,
params: {
userId: UserID;
inviteCode: string | null | undefined;
requestCache: RequestCache;
},
): Promise<void> {
const {userId, inviteCode, requestCache} = params;
const normalizedInviteCode = inviteCode?.trim();
if (!normalizedInviteCode) return;
try {
await inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(normalizedInviteCode),
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
}
}
async function enforceRegistrationRateLimits(
ctx: ApiContext,
params: {
enforceRateLimits: boolean;
clientIp: string;
emailKey: string | null;
},
): Promise<void> {
const {rateLimit} = ctx.services;
const {enforceRateLimits, clientIp, emailKey} = params;
if (!enforceRateLimits) return;
if (emailKey) {
const emailRateLimit = await rateLimit.checkLimit({
identifier: `registration:email:${emailKey}`,
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed) throw createRateLimitError(emailRateLimit);
}
const ipRateLimit = await rateLimit.checkLimit({
identifier: `registration:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: 3,
windowMs: ms('1 hour'),
});
if (!ipRateLimit.allowed) throw createRateLimitError(ipRateLimit);
const subnet = getSubnet(clientIp);
if (subnet) {
const subnetRateLimit = await rateLimit.checkLimit({
identifier: `registration:subnet:${subnet}`,
maxAttempts: 15,
windowMs: ms('1 hour'),
});
if (!subnetRateLimit.allowed) throw createRateLimitError(subnetRateLimit);
}
}
async function allocateDiscriminator(discriminatorService: DiscriminatorService, username: string): Promise<number> {
const result = await discriminatorService.generateDiscriminator({username});
if (!result.available || result.discriminator === -1) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.TOO_MANY_USERS_WITH_THIS_USERNAME);
}
return result.discriminator;
}