This repository has no description
Something went wrong. Try again.
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297// SPDX-License-Identifier: AGPL-3.0-or-later
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';import type {ApiContext} from '../../ApiContext';import * as AuthPassword from '../../auth/AuthPassword';import * as AuthSession from '../../auth/AuthSession';import type {SudoVerificationResult} from '../../auth/services/SudoVerificationService';import {deriveSudoMethods, userHasMfa} from '../../auth/services/SudoVerificationService';import type {UserRow} from '../../database/types/UserTypes';import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';import type {LimitConfigService} from '../../limits/LimitConfigService';import {resolveLimitSafe} from '../../limits/LimitConfigUtils';import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';import type {AuthSession as AuthSessionModel} from '../../models/AuthSession';import type {User} from '../../models/User';import {enforceFluxerTagChangeRateLimit} from '../FluxerTagChangeRateLimit';import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';import {isProfileSubstringExempt} from '../UserHelpers';
interface UserUpdateMetadata { invalidateAuthSessions?: boolean;}
type UserFieldUpdates = Partial<UserRow>;
interface UserAccountSecurityServiceDeps { apiContext: ApiContext; userAccountRepository: IUserAccountRepository; discriminatorService: IDiscriminatorService; rateLimitService: IRateLimitService; limitConfigService: LimitConfigService;}
export class UserAccountSecurityService { constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
async processSecurityUpdates(params: { user: User; data: UserUpdateRequest; sudoContext?: SudoVerificationResult; }): Promise<{ updates: UserFieldUpdates; metadata: UserUpdateMetadata; }> { const {user, data, sudoContext} = params; const updates: UserFieldUpdates = { password_hash: user.passwordHash, username: user.username, discriminator: user.discriminator, global_name: user.isBot ? null : user.globalName, email: user.email, }; const metadata: UserUpdateMetadata = { invalidateAuthSessions: false, }; const isUnclaimedAccount = user.isUnclaimedAccount(); const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token'; const identityVerifiedViaPassword = sudoContext?.method === 'password'; const hasMfa = userHasMfa(user); const rawEmail = data.email?.trim(); const normalizedEmail = rawEmail?.toLowerCase(); const hasPasswordRequiredChanges = (data.username !== undefined && data.username !== user.username) || (data.discriminator !== undefined && data.discriminator !== user.discriminator) || (data.email !== undefined && normalizedEmail !== user.email?.toLowerCase()) || data.new_password !== undefined; const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount; if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) { throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user)); } if (isUnclaimedAccount && data.new_password) { updates.password_hash = await this.hashNewPassword(data.new_password); updates.password_last_changed_at = new Date(); metadata.invalidateAuthSessions = false; } else if (data.new_password) { if (!data.password) { throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET); } if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) { throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user)); } updates.password_hash = await this.hashNewPassword(data.new_password); updates.password_last_changed_at = new Date(); metadata.invalidateAuthSessions = true; } if (data.username !== undefined) { const {newUsername, newDiscriminator} = await this.updateUsername({ user, username: data.username, requestedDiscriminator: data.discriminator, }); if ( !isProfileSubstringExempt(user) && profileSubstringBlocklistCache.containsBannedSubstring('username', newUsername) ) { throw new ContentBlockedError(); } updates.username = newUsername; updates.discriminator = newDiscriminator; } else if (data.discriminator !== undefined) { updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator}); } await this.enforceFluxerTagChangeRateLimit({ user, nextUsername: updates.username ?? user.username, nextDiscriminator: updates.discriminator ?? user.discriminator, errorPath: data.discriminator !== undefined && data.username === undefined ? 'discriminator' : 'username', }); const usernameRealChange = data.username !== undefined && data.username.toLowerCase() !== user.username.toLowerCase(); const discriminatorChanged = updates.discriminator !== user.discriminator; const shouldMarkPremiumDiscriminator = (discriminatorChanged || usernameRealChange) && user.isPremium() && user.premiumType !== UserPremiumTypes.LIFETIME; if (shouldMarkPremiumDiscriminator && (user.premiumFlags & PremiumFlags.DISCRIMINATOR) === 0) { updates.premium_flags = user.premiumFlags | PremiumFlags.DISCRIMINATOR; } if (user.isBot) { updates.global_name = null; } else if (data.global_name !== undefined) { if (data.global_name !== user.globalName) { } if ( data.global_name && !isProfileSubstringExempt(user) && profileSubstringBlocklistCache.containsBannedSubstring('global_name', data.global_name) ) { throw new ContentBlockedError(); } updates.global_name = data.global_name; } if (rawEmail) { if (normalizedEmail && normalizedEmail !== user.email?.toLowerCase()) { const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail); if (existing && existing.id !== user.id) { throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE); } } updates.email = rawEmail; } return {updates, metadata}; }
async invalidateAndRecreateSessions({ user, oldAuthSession, request, }: { user: User; oldAuthSession: AuthSessionModel; request: Request; }): Promise<void> { await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, { user, currentAuthSession: oldAuthSession, request, }); }
private async hashNewPassword(newPassword: string): Promise<string> { if (await AuthPassword.isPasswordPwned(this.deps.apiContext, newPassword)) { throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON); } return await AuthPassword.hashPassword(this.deps.apiContext, newPassword); }
private async updateUsername({ user, username, requestedDiscriminator, }: { user: User; username: string; requestedDiscriminator?: number; }): Promise<{ newUsername: string; newDiscriminator: number; }> { const normalizedRequestedDiscriminator = requestedDiscriminator == null ? undefined : Number(requestedDiscriminator); if ( user.username.toLowerCase() === username.toLowerCase() && (normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator) ) { return { newUsername: username, newDiscriminator: user.discriminator, }; } const ctx = createLimitMatchContext({user}); const hasCustomDiscriminator = resolveLimitSafe( this.deps.limitConfigService.getConfigSnapshot(), ctx, 'feature_custom_discriminator', 0, ); const isCaseOnlyChange = user.username.toLowerCase() === username.toLowerCase(); if (hasCustomDiscriminator === 0) { if (isCaseOnlyChange) { return { newUsername: username, newDiscriminator: user.discriminator, }; } const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({ username, requestedDiscriminator: undefined, user: undefined, }); if (!discriminatorResult.available || discriminatorResult.discriminator === -1) { throw InputValidationError.fromCode( 'username', ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT, ); } return { newUsername: username, newDiscriminator: discriminatorResult.discriminator, }; } const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator; if (discriminatorToUse === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) { throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR); } const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({ username, requestedDiscriminator: discriminatorToUse, user, }); if (!discriminatorResult.available || discriminatorResult.discriminator === -1) { throw InputValidationError.fromCode( 'username', discriminatorToUse !== undefined ? ValidationErrorCodes.TAG_ALREADY_TAKEN : ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT, ); } return { newUsername: username, newDiscriminator: discriminatorResult.discriminator, }; }
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> { const ctx = createLimitMatchContext({user}); const hasCustomDiscriminator = resolveLimitSafe( this.deps.limitConfigService.getConfigSnapshot(), ctx, 'feature_custom_discriminator', 0, ); if (hasCustomDiscriminator === 0) { throw InputValidationError.fromCode( 'discriminator', ValidationErrorCodes.CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM, ); } if (discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) { throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR); } const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({ username: user.username, requestedDiscriminator: discriminator, user, }); if (!discriminatorResult.available) { throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.TAG_ALREADY_TAKEN); } return discriminator; }
private async enforceFluxerTagChangeRateLimit(params: { user: User; nextUsername: string; nextDiscriminator: number; errorPath: 'username' | 'discriminator'; }): Promise<void> { const {user, nextUsername, nextDiscriminator, errorPath} = params; if (nextUsername === user.username && nextDiscriminator === user.discriminator) { return; } await enforceFluxerTagChangeRateLimit({ rateLimitService: this.deps.rateLimitService, userId: user.id, errorPath, }); }}