mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
refactor(api): emit moderation events and apply account actions (#3031)
This commit is contained in:
1 parent
c488906131
commit
364084c819
385 files changed
+8994
-22221
No files matched your search
@@ -4,10 +4,12 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
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 type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
|
||||
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
|
||||
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {APIConfig} from '@app/api/config/APIConfig';
|
||||
import 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 {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
|
||||
import {
|
||||
@@ -23,17 +25,6 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserSettings} from '@app/api/models/UserSettings';
|
||||
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
|
||||
import {
|
||||
type IAccountPolicyEvaluator,
|
||||
isAssessmentThresholdAuditEvent,
|
||||
normalizePolicyContactDomain,
|
||||
} from '@app/api/risk/AccountPolicyEvaluator';
|
||||
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
|
||||
import {deferPhoneFlagsUntilCommunityJoin} from '@app/api/risk/DeferredPhoneGate';
|
||||
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
|
||||
import type {IRiskAssessmentRepository} from '@app/api/risk/RiskAssessmentRepository';
|
||||
import {deriveLatestRiskContext} from '@app/api/risk/RiskHistoryContext';
|
||||
import * as AgeUtils from '@app/api/utils/AgeUtils';
|
||||
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
@@ -41,6 +32,7 @@ import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
|
||||
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
|
||||
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
|
||||
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';
|
||||
@@ -54,8 +46,6 @@ import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
|
||||
import {types} from 'cassandra-driver';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const DEFAULT_MINIMUM_AGE = 13;
|
||||
|
||||
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
|
||||
if (!match) {
|
||||
@@ -83,13 +73,6 @@ export interface RegistrationDependencies {
|
||||
singleCommunityService: SingleCommunityService;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
kvActivityTracker: KVActivityTracker;
|
||||
registrationRiskEvaluator: IRegistrationRiskEvaluator;
|
||||
accountPolicyEvaluator: IAccountPolicyEvaluator;
|
||||
isEmailDomainSuspicious: (domain: string) => Promise<boolean>;
|
||||
isEmailDomainDisposable: (domain: string) => Promise<boolean>;
|
||||
registrationEventsRepository: IRegistrationEventsRepository;
|
||||
riskAssessmentRepository: IRiskAssessmentRepository;
|
||||
riskHistoryRepository: Pick<IRiskHistoryRepository, 'upsertLatestContext' | 'recordOutcomeForUser'>;
|
||||
}
|
||||
|
||||
interface RegistrationTokenResult {
|
||||
@@ -114,20 +97,8 @@ export async function register(
|
||||
{data, request, requestCache}: RegisterParams,
|
||||
): Promise<RegisterResult> {
|
||||
const {users, snowflake, emailDnsValidation, config} = ctx.services;
|
||||
const {
|
||||
inviteService,
|
||||
instanceConfigRepository,
|
||||
singleCommunityService,
|
||||
discriminatorService,
|
||||
kvActivityTracker,
|
||||
registrationRiskEvaluator,
|
||||
accountPolicyEvaluator,
|
||||
isEmailDomainSuspicious,
|
||||
isEmailDomainDisposable,
|
||||
registrationEventsRepository,
|
||||
riskAssessmentRepository,
|
||||
riskHistoryRepository,
|
||||
} = deps;
|
||||
const {inviteService, instanceConfigRepository, singleCommunityService, discriminatorService, kvActivityTracker} =
|
||||
deps;
|
||||
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
|
||||
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
|
||||
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
|
||||
@@ -152,7 +123,7 @@ export async function register(
|
||||
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
|
||||
}
|
||||
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
|
||||
const minAge = accountPolicyEvaluator.getMinimumAgeForRegion(countryCode, DEFAULT_MINIMUM_AGE);
|
||||
const minAge = getRegionalMinimumAge(countryCode);
|
||||
if (!AuthUtility.validateAge(ctx, {dateOfBirth: dateOfBirthInput, minAge})) {
|
||||
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.MUST_BE_MINIMUM_AGE, {minAge});
|
||||
}
|
||||
@@ -163,28 +134,17 @@ export async function register(
|
||||
}
|
||||
const rawEmail = data.email ?? null;
|
||||
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
|
||||
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
|
||||
let contactDomain: string | null = null;
|
||||
let contactDomainAdminListed = false;
|
||||
let contactDomainDisposable = false;
|
||||
let contactDomainBlocked = false;
|
||||
let contactDomainStepUpRequired = false;
|
||||
if (rawEmail) {
|
||||
contactDomain = normalizePolicyContactDomain(extractEmailDomain(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);
|
||||
}
|
||||
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
|
||||
if (contactDomainBlocked) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
[contactDomainAdminListed, contactDomainDisposable] = contactDomain
|
||||
? await Promise.all([isEmailDomainSuspicious(contactDomain), isEmailDomainDisposable(contactDomain)])
|
||||
: [false, false];
|
||||
contactDomainStepUpRequired = contactDomainBlocked || contactDomainAdminListed || contactDomainDisposable;
|
||||
const emailTaken = await users.findByEmail(rawEmail);
|
||||
if (emailTaken) throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
@@ -330,108 +290,25 @@ export async function register(
|
||||
void kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
|
||||
Logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
|
||||
});
|
||||
const isUnclaimed = !rawEmail;
|
||||
const usernameIsUserChosen = data.username != null || data.global_name != null;
|
||||
const riskResult = await registrationRiskEvaluator.evaluate({
|
||||
email: rawEmail,
|
||||
clientIp,
|
||||
locale: userLocale,
|
||||
timezone: null,
|
||||
userAgent,
|
||||
username,
|
||||
globalName: data.global_name ?? null,
|
||||
usernameIsUserChosen,
|
||||
isUnclaimed,
|
||||
});
|
||||
const policyDecision = accountPolicyEvaluator.evaluate({
|
||||
contact: {
|
||||
value: rawEmail,
|
||||
domain: contactDomain,
|
||||
domainAdminListed: contactDomainAdminListed,
|
||||
domainDisposable: contactDomainDisposable,
|
||||
domainBlocked: contactDomainBlocked,
|
||||
domainStepUpRequired: contactDomainStepUpRequired,
|
||||
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,
|
||||
suspicious_flags: user.suspiciousActivityFlags ?? 0,
|
||||
flags: user.flags.toString(),
|
||||
},
|
||||
region: {
|
||||
code: countryCode,
|
||||
stepUpRequired: countryRequiresInboundPhoneVerification(countryCode),
|
||||
},
|
||||
assessment: {
|
||||
raw: riskResult.assessment,
|
||||
level: riskResult.level,
|
||||
action: riskResult.recommendedAction,
|
||||
},
|
||||
});
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
|
||||
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
|
||||
null,
|
||||
user.id.toString(),
|
||||
);
|
||||
const createdAt = new Date();
|
||||
const riskContext = deriveLatestRiskContext({
|
||||
userId: userId.toString(),
|
||||
email: rawEmail ?? null,
|
||||
clientIp,
|
||||
asn: riskResult.assessment.signals.geoIpAsn?.asn ?? null,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
if (combinedFlags !== 0) {
|
||||
user = await users.patchUpsert(user.id, {suspicious_activity_flags: combinedFlags}, user.toRow());
|
||||
}
|
||||
registrationEventsRepository
|
||||
.recordEvent({
|
||||
userId: userId.toString(),
|
||||
email: rawEmail ?? null,
|
||||
emailDomain: riskContext.emailDomain,
|
||||
ip: clientIp,
|
||||
locale: userLocale,
|
||||
createdAt,
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(
|
||||
{userId: userId.toString(), error},
|
||||
'[AuthRegistration] Failed to record registration velocity event',
|
||||
);
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
await riskHistoryRepository.upsertLatestContext(riskContext);
|
||||
if (policyDecision.riskHistoryOutcomeCodes.length > 0) {
|
||||
await riskHistoryRepository.recordOutcomeForUser({
|
||||
userId: userId.toString(),
|
||||
occurredAt: createdAt,
|
||||
source: 'registration_risk',
|
||||
outcomeCodes: [...policyDecision.riskHistoryOutcomeCodes],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist direct risk history');
|
||||
}
|
||||
})();
|
||||
riskAssessmentRepository
|
||||
.recordAssessment({
|
||||
userId,
|
||||
ip: clientIp,
|
||||
email: rawEmail ?? null,
|
||||
locale: userLocale,
|
||||
assessment: riskResult.assessment,
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist risk assessment');
|
||||
});
|
||||
for (const event of policyDecision.auditEvents) {
|
||||
if (isAssessmentThresholdAuditEvent(event)) {
|
||||
Logger.warn(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
email: rawEmail,
|
||||
ip: clientIp,
|
||||
score: riskResult.assessment.riskScore,
|
||||
reasoning: riskResult.assessment.reasoning,
|
||||
policyRuleId: event.ruleId,
|
||||
},
|
||||
'[AuthRegistration] Account policy emitted assessment threshold notice',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
|
||||
await users.createAuthorizedIp(userId, clientIp);
|
||||
if (registrationAccess.pendingApproval) {
|
||||
@@ -440,24 +317,11 @@ export async function register(
|
||||
user_id: user.id.toString(),
|
||||
};
|
||||
}
|
||||
if (policyDecision.inviteAutoJoinEnabled) {
|
||||
await maybeAutoJoinInvite(inviteService, {
|
||||
userId,
|
||||
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
|
||||
requestCache,
|
||||
});
|
||||
} else {
|
||||
Logger.info(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
riskLevel: riskResult.level,
|
||||
riskScore: riskResult.assessment.riskScore,
|
||||
inviteCode: data.invite_code,
|
||||
reason: policyDecision.inviteAutoJoinSkipReason,
|
||||
},
|
||||
'[AuthRegistration] Skipping invite auto-join because account policy disabled it',
|
||||
);
|
||||
}
|
||||
await maybeAutoJoinInvite(inviteService, {
|
||||
userId,
|
||||
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
|
||||
requestCache,
|
||||
});
|
||||
await singleCommunityService.joinStockCommunity(userId, requestCache);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
|
||||
Reference in new issue
Block a user