Files
fluxer/fluxer_api/src/api/user/services/UserAccountRequestService.ts
T

623 lines
23 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
import type {
EmailChangeApplyRequest,
UserUpdateWithVerificationRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
import * as AuthEmailRevert from '../../auth/AuthEmailRevert';
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import type {IRegistrationRiskEvaluator} from '../../auth/services/IRegistrationRiskEvaluator';
import {requireSudoMode, type SudoVerificationResult} from '../../auth/services/SudoVerificationService';
import {createChannelID, createGuildID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {UserConnectionRow} from '../../database/types/ConnectionTypes';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import {Logger} from '../../Logger';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {AuthSession} from '../../models/AuthSession';
import type {User} from '../../models/User';
import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '../../risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '../../risk/adapters/VelocityAdapter';
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
import {derivePlusAddressBase} from '../../risk/PlusAddressUtils';
import type {IRiskAssessmentRepository} from '../../risk/RiskAssessmentRepository';
import {deriveLatestRiskContext} from '../../risk/RiskHistoryContext';
import {
RecommendedAction,
type RiskAssessment,
RiskConfidence,
RiskDecisionMethod,
RiskLevel,
} from '../../risk/RiskTypes';
import type {HonoEnv} from '../../types/HonoEnv';
import type {IUserRepository} from '../IUserRepository';
import {mapUserToPartialResponseWithCache} from '../UserCacheHelpers';
import {
canUseProfileTimezone,
createPremiumClearPatch,
getEffectiveSuspiciousFlags,
shouldStripExpiredPremium,
} from '../UserHelpers';
import {mapGuildMemberToProfileResponse, mapUserToPrivateResponse, mapUserToProfileResponse} from '../UserMappers';
import type {EmailChangeService} from './EmailChangeService';
import type {UserAccountService} from './UserAccountService';
import type {UserChannelService} from './UserChannelService';
export type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
type UserUpdatePayload = Omit<
UserUpdateWithVerificationRequestData,
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
>;
const EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS: ReadonlyArray<keyof UserUpdatePayload> = [
'username',
'discriminator',
'global_name',
'avatar',
'banner',
'bio',
'pronouns',
'accent_color',
'timezone',
'timezone_privacy_flags',
'premium_badge_hidden',
'premium_badge_masked',
'premium_badge_timestamp_hidden',
'premium_badge_sequence_hidden',
];
function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
}
function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequestData,
): UserUpdateWithVerificationRequestData {
if (canUseProfileTimezone(user)) {
return body;
}
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
return rest as UserUpdateWithVerificationRequestData;
}
function hasDefinedUserUpdatePayload(data: UserUpdatePayload): boolean {
return Object.values(data).some((value) => value !== undefined);
}
function createPolicyOnlyEmailSetAssessment(): RiskAssessment {
return {
suspicious: false,
level: RiskLevel.Low,
confidence: RiskConfidence.Low,
riskScore: 0,
reasoning: 'email-set policy evaluation',
recommendedAction: RecommendedAction.Allow,
method: RiskDecisionMethod.Noop,
modelUsed: 'account-policy',
rounds: 0,
elapsedMs: 0,
signals: {},
};
}
interface UserProfileParams {
currentUserId: UserID;
targetUserId: UserID;
guildId?: bigint;
withMutualFriends?: boolean;
withMutualGuilds?: boolean;
requestCache: RequestCache;
}
export class UserAccountRequestService {
constructor(
private readonly emailChangeService: EmailChangeService,
private readonly userAccountService: UserAccountService,
private readonly userChannelService: UserChannelService,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly isEmailDomainSuspicious: (domain: string) => Promise<boolean>,
private readonly isEmailDomainDisposable: (domain: string) => Promise<boolean>,
private readonly registrationRiskEvaluator: IRegistrationRiskEvaluator,
private readonly accountPolicyEvaluator: IAccountPolicyEvaluator,
private readonly registrationEventsRepository: IRegistrationEventsRepository,
private readonly riskAssessmentRepository: IRiskAssessmentRepository,
private readonly riskHistoryRepository: Pick<
IRiskHistoryRepository,
'upsertLatestContext' | 'recordOutcomeForUser'
>,
) {}
getCurrentUserResponse(params: {
authTokenType?: 'session' | 'bearer' | 'bot' | 'admin_api_key';
oauthBearerScopes?: Set<string> | null;
allowSuspicious?: boolean;
user?: User;
}): UserPrivateResponse {
const tokenType = params.authTokenType;
const allowSuspicious = params.allowSuspicious ?? false;
if (tokenType === 'bearer') {
const bearerUser = params.user;
if (!bearerUser) {
throw new UnauthorizedError();
}
if (!allowSuspicious) {
this.enforceUserAccess(bearerUser);
}
const includeEmail = params.oauthBearerScopes?.has('email') ?? false;
const response = mapUserToPrivateResponse(bearerUser);
if (!includeEmail) {
response.email = null;
}
this.stripBearerSensitiveFields(response);
return response;
}
const user = params.user;
if (user) {
if (!allowSuspicious) {
this.enforceUserAccess(user);
}
return mapUserToPrivateResponse(user);
}
throw new UnauthorizedError();
}
async updateCurrentUser(params: {
ctx: Context<HonoEnv>;
user: User;
body: UserUpdateWithVerificationRequestData;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
const {ctx, body, authSession} = params;
let {user} = params;
const oldEmail = user.email;
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
webauthn_response: _webauthnResponse,
webauthn_challenge: _webauthnChallenge,
email_token: emailToken,
...userUpdateDataRest
} = sanitizedBody;
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
const emailTokenProvided = emailToken !== undefined;
if (!emailTokenProvided && !hasDefinedUserUpdatePayload(userUpdateData)) {
return mapUserToPrivateResponse(user);
}
this.enforceSuspiciousSelfUpdateAllowance(user, sanitizedBody);
if (userUpdateData.email !== undefined) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
}
const isUnclaimed = user.isUnclaimedAccount();
if (!isUnclaimed && userUpdateData.new_password !== undefined && !userUpdateData.password) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
if (isUnclaimed) {
const allowed = new Set(['new_password', 'has_dismissed_premium_onboarding', 'has_unread_gift_inventory']);
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
if (disallowedField) {
throw InputValidationError.fromCode(
disallowedField,
ValidationErrorCodes.UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN,
);
}
}
if (!isUnclaimed && hasProfileCustomizationUpdate(userUpdateData)) {
requireEmailVerified(user, 'profile');
}
let emailFromToken: string | null = null;
let emailVerifiedViaToken = false;
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
let sudoResult: SudoVerificationResult | null = null;
if (needsVerification) {
sudoResult = await requireSudoMode(ctx, user, sanitizedBody);
}
if (emailTokenProvided && emailToken) {
emailFromToken = await this.emailChangeService.getTokenEmail(user.id, emailToken);
userUpdateData = {...userUpdateData, email: emailFromToken};
emailVerifiedViaToken = true;
const request = ctx.req.raw;
const userAgent = request.headers.get('user-agent');
const currentSuspiciousFlags = user.suspiciousActivityFlags ?? 0;
let nextSuspiciousFlags = currentSuspiciousFlags;
let emailSetRiskAssessment: RiskAssessment | null = null;
let emailSetRecommendedAction = RecommendedAction.Allow;
let emailSetRiskIp: string | null = null;
const contactContext = createAccountPolicyContactContext(emailFromToken);
const contactPolicyDecision = this.accountPolicyEvaluator.evaluateContact(contactContext);
const skipContactFollowupRisk = contactPolicyDecision.hasCapability('followup_risk_exempt');
const skipStoredFollowupRiskChecks = this.shouldSkipFollowupRiskChecks(user);
const skipFollowupRiskChecks = skipStoredFollowupRiskChecks || skipContactFollowupRisk;
const plusTaggedEmailChange = derivePlusAddressBase(emailFromToken) !== null;
const shouldRunPlusAddressRiskCheck = plusTaggedEmailChange && !user.hasEverPurchased;
const newDomain = contactContext.domain;
let contactDomainAdminListed = false;
let contactDomainDisposable = false;
let contactDomainBlocked = false;
let contactDomainStepUpRequired = false;
if (!skipFollowupRiskChecks && newDomain) {
const [adminFlagged, isDisposable] = await Promise.all([
this.isEmailDomainSuspicious(newDomain),
this.isEmailDomainDisposable(newDomain),
]);
contactDomainAdminListed = adminFlagged;
contactDomainDisposable = isDisposable;
contactDomainBlocked = this.accountPolicyEvaluator.isBlockedRegistrationEmailDomain(newDomain);
contactDomainStepUpRequired = contactDomainBlocked || contactDomainAdminListed || contactDomainDisposable;
}
if (
!skipContactFollowupRisk &&
((!skipStoredFollowupRiskChecks && isUnclaimed) || shouldRunPlusAddressRiskCheck)
) {
try {
emailSetRiskIp = requireClientIp(request, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const riskResult = await this.registrationRiskEvaluator.evaluate({
email: emailFromToken,
clientIp: emailSetRiskIp,
locale: null,
timezone: null,
userAgent,
});
emailSetRiskAssessment = riskResult.assessment;
emailSetRecommendedAction = riskResult.recommendedAction;
} catch (error) {
Logger.warn({error, userId: user.id}, 'Risk assessment failed during email set');
throw error;
}
}
const policyAssessment = emailSetRiskAssessment ?? createPolicyOnlyEmailSetAssessment();
const policyDecision = this.accountPolicyEvaluator.evaluate({
contact: {
...contactContext,
domainAdminListed: contactDomainAdminListed,
domainDisposable: contactDomainDisposable,
domainBlocked: contactDomainBlocked,
domainStepUpRequired: contactDomainStepUpRequired,
},
region: {
code: null,
stepUpRequired: false,
},
assessment: {
raw: policyAssessment,
level: policyAssessment.level,
action: emailSetRecommendedAction,
},
});
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
user = await this.userRepository.patchUpsert(
user.id,
{suspicious_activity_flags: nextSuspiciousFlags},
user.toRow(),
);
}
if (emailSetRiskAssessment || nextSuspiciousFlags !== currentSuspiciousFlags) {
const occurredAt = new Date();
const resolvedClientIp =
emailSetRiskIp ??
requireClientIp(request, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const riskContext = deriveLatestRiskContext({
userId: user.id.toString(),
email: emailFromToken,
clientIp: resolvedClientIp,
asn: emailSetRiskAssessment?.signals.geoIpAsn?.asn ?? null,
updatedAt: occurredAt,
});
(async () => {
try {
await this.riskHistoryRepository.upsertLatestContext(riskContext);
if (nextSuspiciousFlags !== currentSuspiciousFlags && nextSuspiciousFlags !== 0) {
await this.riskHistoryRepository.recordOutcomeForUser({
userId: user.id.toString(),
occurredAt,
source: isUnclaimed ? 'claim_risk' : 'email_change_risk',
outcomeCodes: ['challenged'],
});
}
} catch (error) {
Logger.warn({error, userId: user.id}, 'Failed to persist claim-time risk history');
}
})();
if (emailSetRiskAssessment) {
this.riskAssessmentRepository
.recordAssessment({
userId: user.id,
ip: resolvedClientIp,
email: emailFromToken,
locale: null,
assessment: emailSetRiskAssessment,
})
.catch((error) => {
Logger.warn({error, userId: user.id}, 'Failed to persist claim-time risk assessment');
});
}
}
}
const updatedUser = await this.userAccountService.update({
user,
oldAuthSession: authSession,
data: userUpdateData,
request: ctx.req.raw,
sudoContext: sudoResult ?? undefined,
emailVerifiedViaToken,
});
if (emailTokenProvided && emailToken) {
await this.emailChangeService.deleteToken(emailToken);
}
const emailActuallyChanged =
!!emailFromToken &&
!!updatedUser.email &&
(oldEmail == null || oldEmail.toLowerCase() !== updatedUser.email.toLowerCase());
if (emailActuallyChanged) {
try {
const request = ctx.req.raw;
const resolvedClientIp = requireClientIp(request, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const occurredAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: updatedUser.id.toString(),
email: updatedUser.email,
clientIp: resolvedClientIp,
asn: null,
updatedAt: occurredAt,
});
this.registrationEventsRepository
.recordEvent({
userId: updatedUser.id.toString(),
email: updatedUser.email,
emailDomain: riskContext.emailDomain,
ip: resolvedClientIp,
locale: updatedUser.locale ?? null,
createdAt: occurredAt,
})
.catch((error) => {
Logger.warn({error, userId: updatedUser.id}, 'Failed to record email-set registration event');
});
} catch (error) {
Logger.warn({error, userId: updatedUser.id}, 'Failed to resolve client IP for email-set registration event');
}
}
if (emailActuallyChanged && oldEmail) {
try {
await AuthEmailRevert.issueEmailRevertToken(ctx.get('apiContext'), {
user: updatedUser,
previousEmail: oldEmail,
newEmail: updatedUser.email,
});
} catch (error) {
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
}
}
return mapUserToPrivateResponse(updatedUser);
}
async applyEmailChange(params: {
ctx: Context<HonoEnv>;
user: User;
body: EmailChangeApplyRequest;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
return this.updateCurrentUser({
ctx: params.ctx,
user: params.user,
body: params.body,
authSession: params.authSession,
});
}
async preloadMessages(params: {
userId: UserID;
channels: ReadonlyArray<bigint>;
requestCache: RequestCache;
}): Promise<Record<string, unknown>> {
const channelIds = params.channels.map((channelId) => createChannelID(channelId));
return this.userChannelService.preloadDMMessages({
userId: params.userId,
channelIds,
});
}
async getUserProfile(params: UserProfileParams): Promise<UserProfileFullResponse> {
const guildId = params.guildId ? createGuildID(params.guildId) : undefined;
const profile = await this.userAccountService.lookupService.getUserProfile({
userId: params.currentUserId,
targetId: params.targetUserId,
guildId,
withMutualFriends: params.withMutualFriends,
withMutualGuilds: params.withMutualGuilds,
requestCache: params.requestCache,
});
let profileUser = profile.user;
let premiumType = profile.premiumType;
let premiumSince = profile.premiumSince;
let premiumLifetimeSequence = profile.premiumLifetimeSequence;
if (shouldStripExpiredPremium(profileUser)) {
try {
const sanitizedUser = await this.userRepository.patchUpsert(
profileUser.id,
createPremiumClearPatch(),
profileUser.toRow(),
);
if (sanitizedUser) {
profileUser = sanitizedUser;
profile.user = sanitizedUser;
premiumType = undefined;
premiumSince = undefined;
premiumLifetimeSequence = undefined;
}
} catch (error) {
Logger.warn(
{userId: profileUser.id.toString(), error},
'Failed to sanitize expired premium fields before returning profile',
);
}
}
const restrictProfile = profile.restrictProfile;
const userProfile = mapUserToProfileResponse(profileUser, {restrictProfile});
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {restrictProfile});
const timezoneOffset = profile.timezoneVisible ? getCurrentTimeZoneOffsetMinutes(profileUser.timezone) : null;
const mutualFriends = profile.mutualFriends
? await Promise.all(
profile.mutualFriends.map((user) =>
mapUserToPartialResponseWithCache({
user,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
),
)
: undefined;
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
return {
user: await mapUserToPartialResponseWithCache({
user: profileUser,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
}),
user_profile: userProfile,
guild_member: profile.guildMember ?? undefined,
guild_member_profile: guildMemberProfile ?? undefined,
premium_type: premiumType,
premium_since: premiumSince?.toISOString(),
premium_lifetime_sequence: premiumLifetimeSequence,
mutual_friends: mutualFriends,
mutual_guilds: profile.mutualGuilds,
connected_accounts: connectedAccounts,
timezone_offset: timezoneOffset,
profile_limited: restrictProfile ? true : undefined,
};
}
checkTagAvailability(params: {currentUser: User; username: string; discriminator: number}): boolean {
const currentUser = params.currentUser;
const discriminator = params.discriminator;
if (
params.username.toLowerCase() === currentUser.username.toLowerCase() &&
discriminator === currentUser.discriminator
) {
return false;
}
return true;
}
private stripBearerSensitiveFields(response: UserPrivateResponse): void {
response.acls = [];
response.traits = [];
response.email_bounced = undefined;
response.mfa_enabled = false;
response.authenticator_types = undefined;
response.password_last_changed_at = null;
response.required_actions = [];
response.nsfw_allowed = false;
response.premium_since = null;
response.premium_until = null;
response.premium_will_cancel = false;
response.premium_billing_cycle = null;
response.premium_lifetime_sequence = null;
response.premium_badge_hidden = false;
response.premium_badge_masked = false;
response.premium_badge_timestamp_hidden = false;
response.premium_badge_sequence_hidden = false;
response.premium_purchase_disabled = false;
response.premium_enabled_override = false;
response.premium_perks_disabled = false;
response.has_dismissed_premium_onboarding = false;
response.has_ever_purchased = false;
response.has_unread_gift_inventory = false;
response.unread_gift_inventory_count = 0;
response.pending_bulk_message_deletion = null;
}
private shouldSkipFollowupRiskChecks(user: User): boolean {
return user.hasEverPurchased || ((user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0;
}
private enforceUserAccess(user: User): void {
const flags = getEffectiveSuspiciousFlags(user);
if (flags !== 0) {
throw new AccountSuspiciousActivityError(flags);
}
}
private enforceSuspiciousSelfUpdateAllowance(user: User, body: UserUpdateWithVerificationRequestData): void {
const flags = getEffectiveSuspiciousFlags(user);
if (flags === 0) {
return;
}
if (this.isAllowedSuspiciousRecoveryUpdate(body)) {
return;
}
throw new AccountSuspiciousActivityError(flags);
}
private isAllowedSuspiciousRecoveryUpdate(body: UserUpdateWithVerificationRequestData): boolean {
if (!body.email_token) {
return false;
}
const allowedKeys = new Set([
'email_token',
'password',
'mfa_method',
'mfa_code',
'webauthn_response',
'webauthn_challenge',
]);
for (const [key, value] of Object.entries(body)) {
if (value === undefined) {
continue;
}
if (!allowedKeys.has(key)) {
return false;
}
}
return true;
}
private requiresSensitiveUserVerification(user: User, data: UserUpdatePayload, emailTokenProvided: boolean): boolean {
const isUnclaimed = user.isUnclaimedAccount();
const usernameChanged = data.username !== undefined && data.username !== user.username;
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
const emailChanged = data.email !== undefined && data.email !== user.email;
const newPasswordProvided = data.new_password !== undefined;
if (isUnclaimed) {
return usernameChanged || discriminatorChanged;
}
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
}
private mapConnectionsToResponse(connections: Array<UserConnectionRow>): Array<ConnectionResponse> {
return connections
.sort((a, b) => a.sort_order - b.sort_order)
.map((connection) => ({
id: connection.connection_id,
type: connection.connection_type,
name: connection.name,
verified: connection.verified,
visibility_flags: connection.visibility_flags,
sort_order: connection.sort_order,
}));
}
}