diff --git a/fluxer_api/package.json b/fluxer_api/package.json index c02daaae..863e9a51 100644 --- a/fluxer_api/package.json +++ b/fluxer_api/package.json @@ -54,7 +54,7 @@ "transliteration": "2.6.0", "tsx": "4.21.0", "twilio": "5.11.1", - "ua-parser-js": "2.0.7", + "bowser": "2.13.1", "uint8array-extras": "1.5.0", "undici": "7.16.0", "unique-names-generator": "4.7.1", diff --git a/fluxer_api/pnpm-lock.yaml b/fluxer_api/pnpm-lock.yaml index 444361cd..a9a14dd8 100644 --- a/fluxer_api/pnpm-lock.yaml +++ b/fluxer_api/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: argon2: specifier: 0.44.0 version: 0.44.0 + bowser: + specifier: 2.13.1 + version: 2.13.1 cassandra-driver: specifier: 4.8.0 version: 4.8.0 @@ -131,9 +134,6 @@ importers: twilio: specifier: 5.11.1 version: 5.11.1 - ua-parser-js: - specifier: 2.0.7 - version: 2.0.7 uint8array-extras: specifier: 1.5.0 version: 1.5.0 @@ -1948,9 +1948,6 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - detect-europe-js@0.1.2: - resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2260,9 +2257,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-standalone-pwa@0.1.1: - resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} - is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -3001,13 +2995,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ua-is-frozen@0.1.2: - resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} - - ua-parser-js@2.0.7: - resolution: {integrity: sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w==} - hasBin: true - uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -5439,8 +5426,6 @@ snapshots: denque@2.1.0: {} - detect-europe-js@0.1.2: {} - detect-libc@2.1.2: {} dom-serializer@2.0.0: @@ -5796,8 +5781,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-standalone-pwa@0.1.1: {} - is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -6587,14 +6570,6 @@ snapshots: typescript@5.9.3: {} - ua-is-frozen@0.1.2: {} - - ua-parser-js@2.0.7: - dependencies: - detect-europe-js: 0.1.2 - is-standalone-pwa: 0.1.1 - ua-is-frozen: 0.1.2 - uint8array-extras@1.5.0: {} uint8arrays@3.0.0: diff --git a/fluxer_api/src/auth/services/AuthRegistrationService.ts b/fluxer_api/src/auth/services/AuthRegistrationService.ts index 9f3eb83e..81394591 100644 --- a/fluxer_api/src/auth/services/AuthRegistrationService.ts +++ b/fluxer_api/src/auth/services/AuthRegistrationService.ts @@ -18,8 +18,8 @@ */ import crypto from 'node:crypto'; +import Bowser from 'bowser'; import {types} from 'cassandra-driver'; -import {UAParser} from 'ua-parser-js'; import type {RegisterRequest} from '~/auth/AuthModel'; import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '~/BrandedTypes'; import {Config} from '~/Config'; @@ -81,9 +81,10 @@ const MINIMUM_AGE_BY_COUNTRY: Record = { }; const DEFAULT_MINIMUM_AGE = 13; - const USER_AGENT_TRUNCATE_LENGTH = 512; +type CountryResultDetailed = Awaited>; + interface RegistrationMetadataContext { metadata: Map; clientIp: string; @@ -112,19 +113,43 @@ const AGE_BUCKETS: Array<{label: string; min: number; max: number}> = [ ]; function determineAgeGroup(age: number | null): string { - if (age === null || age < 0) { - return 'unknown'; - } - + if (age === null || age < 0) return 'unknown'; for (const bucket of AGE_BUCKETS) { - if (age >= bucket.min && age <= bucket.max) { - return bucket.label; - } + if (age >= bucket.min && age <= bucket.max) return bucket.label; } - return '65+'; } +function sanitizeEmail(email: string | null | undefined): {raw: string | null; key: string | null} { + const key = email ? email.toLowerCase() : null; + return {raw: email ?? null, key}; +} + +function isIpv6(ip: string): boolean { + return ip.includes(':'); +} + +function rateLimitError(message: string): FluxerAPIError { + return new FluxerAPIError({code: APIErrorCodes.RATE_LIMITED, message, status: 429}); +} + +function parseDobLocalDate(dateOfBirth: string): types.LocalDate { + try { + return types.LocalDate.fromString(dateOfBirth); + } catch { + throw InputValidationError.create('date_of_birth', 'Invalid date of birth format'); + } +} + +function safeJsonParse(value: string): T | null { + try { + return JSON.parse(value) as T; + } catch (error) { + Logger.warn({error}, 'Failed to parse JSON from environment variable'); + return null; + } +} + interface RegisterParams { data: RegisterRequest; request: Request; @@ -132,9 +157,11 @@ interface RegisterParams { } export class AuthRegistrationService { + private instanceConfigRepository = new InstanceConfigRepository(); + constructor( private repository: IUserRepository, - private inviteService: InviteService, + private inviteService: InviteService | null, private rateLimitService: IRateLimitService, private emailService: IEmailService, private snowflakeService: SnowflakeService, @@ -158,9 +185,13 @@ export class AuthRegistrationService { throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy'); } - const countryCode = await IpUtils.getCountryCodeFromReq(request); + const now = new Date(); + const metrics = getMetricsService(); + const clientIp = IpUtils.requireClientIp(request); + const countryCode = await IpUtils.getCountryCodeFromReq(request); const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp); + const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE; if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) { throw InputValidationError.create( @@ -173,109 +204,43 @@ export class AuthRegistrationService { throw InputValidationError.create('password', 'Password is too common'); } + const {raw: rawEmail, key: emailKey} = sanitizeEmail(data.email); + const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits; + await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey}); - if (enforceRateLimits && data.email) { - const emailRateLimit = await this.rateLimitService.checkLimit({ - identifier: `registration:email:${data.email}`, - maxAttempts: 3, - windowMs: 15 * 60 * 1000, - }); + const {betaCode, hasValidBetaCode} = await this.resolveBetaCode(data.beta_code ?? null); - if (!emailRateLimit.allowed) { - throw new FluxerAPIError({ - code: APIErrorCodes.RATE_LIMITED, - message: 'Too many registration attempts. Please try again later.', - status: 429, - }); - } - } - - if (enforceRateLimits) { - const ipRateLimit = await this.rateLimitService.checkLimit({ - identifier: `registration:ip:${clientIp}`, - maxAttempts: 5, - windowMs: 30 * 60 * 1000, - }); - - if (!ipRateLimit.allowed) { - throw new FluxerAPIError({ - code: APIErrorCodes.RATE_LIMITED, - message: 'Too many registration attempts from this IP. Please try again later.', - status: 429, - }); - } - } - - let betaCode = null; - let hasValidBetaCode = false; - if (data.beta_code) { - if (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY') { - hasValidBetaCode = false; - } else { - betaCode = await this.repository.getBetaCode(data.beta_code); - if (betaCode && !betaCode.redeemerId) { - hasValidBetaCode = true; - } - } - } - - const rawEmail = data.email?.trim() || null; - const normalizedEmail = rawEmail?.toLowerCase() || null; - - if (normalizedEmail) { - const emailTaken = await this.repository.findByEmail(normalizedEmail); - if (emailTaken) { - throw InputValidationError.create('email', 'Email already in use'); - } + if (rawEmail) { + const emailTaken = await this.repository.findByEmail(rawEmail); + if (emailTaken) throw InputValidationError.create('email', 'Email already in use'); } const username = data.username || generateRandomUsername(); - - const discriminatorResult = await this.discriminatorService.generateDiscriminator({ - username, - isPremium: false, - }); - - if (!discriminatorResult.available || discriminatorResult.discriminator === -1) { - throw InputValidationError.create('username', 'Too many users with this username'); - } - - const discriminator = discriminatorResult.discriminator; - - let userId: UserID; - if (normalizedEmail && process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) { - const mapping = JSON.parse(process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) as Record; - const emailHash = crypto.createHash('sha256').update(normalizedEmail).digest('hex'); - const mappedUserId = mapping[emailHash]; - userId = mappedUserId ? createUserID(BigInt(mappedUserId)) : createUserID(this.snowflakeService.generate()); - } else { - userId = createUserID(this.snowflakeService.generate()); - } + const discriminator = await this.allocateDiscriminator(username); + const userId = this.generateUserId(emailKey); const acceptLanguage = request.headers.get('accept-language'); const userLocale = parseAcceptLanguage(acceptLanguage); + const passwordHash = data.password ? await this.hashPassword(data.password) : null; - const instanceConfigRepository = new InstanceConfigRepository(); - const instanceConfig = await instanceConfigRepository.getInstanceConfig(); - const isManualReviewActive = instanceConfigRepository.isManualReviewActiveNow(instanceConfig); + const instanceConfig = await this.instanceConfigRepository.getInstanceConfig(); + const isManualReviewActive = this.instanceConfigRepository.isManualReviewActiveNow(instanceConfig); const shouldRequireVerification = (isManualReviewActive && Config.nodeEnv === 'production') || (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY'); + const isPendingVerification = shouldRequireVerification && !hasValidBetaCode; - let baseFlags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n; - if (isPendingVerification) { - baseFlags |= UserFlags.PENDING_MANUAL_VERIFICATION; - } + let flags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n; + if (isPendingVerification) flags |= UserFlags.PENDING_MANUAL_VERIFICATION; - const now = new Date(); const user = await this.repository.create({ user_id: userId, username, - discriminator: discriminator, + discriminator, global_name: data.global_name || null, bot: false, system: false, @@ -284,7 +249,7 @@ export class AuthRegistrationService { email_bounced: false, phone: null, password_hash: passwordHash, - password_last_changed_at: passwordHash ? new Date() : null, + password_last_changed_at: passwordHash ? now : null, totp_secret: null, authenticator_types: new Set(), avatar_hash: null, @@ -294,9 +259,9 @@ export class AuthRegistrationService { bio: null, pronouns: null, accent_color: null, - date_of_birth: types.LocalDate.fromString(data.date_of_birth), + date_of_birth: parseDobLocalDate(data.date_of_birth), locale: userLocale, - flags: baseFlags, + flags, premium_type: null, premium_since: null, premium_until: null, @@ -307,8 +272,8 @@ export class AuthRegistrationService { stripe_customer_id: null, has_ever_purchased: null, suspicious_activity_flags: null, - terms_agreed_at: new Date(), - privacy_agreed_at: new Date(), + terms_agreed_at: now, + privacy_agreed_at: now, last_active_at: now, last_active_ip: clientIp, temp_banned_until: null, @@ -331,17 +296,17 @@ export class AuthRegistrationService { await this.redisActivityTracker.updateActivity(user.id, now); - getMetricsService().counter({ + metrics.counter({ name: 'user.registration', dimensions: { country: countryCode ?? 'unknown', state: countryResultDetailed.region ?? 'unknown', - ip_version: clientIp.includes(':') ? 'v6' : 'v4', + ip_version: isIpv6(clientIp) ? 'v6' : 'v4', }, }); const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null; - getMetricsService().counter({ + metrics.counter({ name: 'user.age', dimensions: { country: countryCode ?? 'unknown', @@ -359,52 +324,29 @@ export class AuthRegistrationService { }), ); - const userSearchService = getUserSearchService(); - if (userSearchService) { - await userSearchService.indexUser(user).catch((error) => { - Logger.error({userId: user.id, error}, 'Failed to index user in search'); - }); - } + await this.maybeIndexUser(user); - if (rawEmail) { - const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken()); - await this.repository.createEmailVerificationToken({ - token_: emailVerifyToken, - user_id: userId, - email: rawEmail, - }); + if (rawEmail) await this.maybeSendVerificationEmail({user, email: rawEmail}); + if (betaCode) await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, now); - await this.emailService.sendEmailVerification(rawEmail, user.username, emailVerifyToken, user.locale); - } + const registrationMetadata = await this.buildRegistrationMetadataContext({ + user, + clientIp, + request, + countryResultDetailed, + }); - if (betaCode) { - await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, new Date()); - } - - const registrationMetadata = await this.buildRegistrationMetadataContext(user, clientIp, request); - - if (isPendingVerification) { - await this.repository.createPendingVerification(userId, new Date(), registrationMetadata.metadata); - } + if (isPendingVerification) + await this.repository.createPendingVerification(userId, now, registrationMetadata.metadata); await this.repository.createAuthorizedIp(userId, clientIp); - const inviteCodeToJoin = data.invite_code || Config.instance.autoJoinInviteCode; - if (inviteCodeToJoin != null) { - if (isPendingVerification) { - await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCodeToJoin); - } else if (this.inviteService) { - try { - await this.inviteService.acceptInvite({ - userId, - inviteCode: createInviteCode(inviteCodeToJoin), - requestCache, - }); - } catch (error) { - Logger.warn({inviteCode: inviteCodeToJoin, error}, 'Failed to auto-join invite on registration'); - } - } - } + await this.maybeAutoJoinInvite({ + userId, + inviteCode: data.invite_code || Config.instance.autoJoinInviteCode, + isPendingVerification, + requestCache, + }); const [token] = await this.createAuthSession({user, request}); @@ -421,36 +363,206 @@ export class AuthRegistrationService { }; } - private async buildRegistrationMetadataContext( - user: User, - clientIp: string, - request: Request, - ): Promise { - const countryResult = await IpUtils.getCountryCodeDetailed(clientIp); - const userAgentHeader = request.headers.get('user-agent') ?? ''; - const trimmedUserAgent = userAgentHeader.trim(); - const parsedUserAgent = new UAParser(trimmedUserAgent).getResult(); + private async maybeIndexUser(user: User): Promise { + const userSearchService = getUserSearchService(); + if (!userSearchService) return; + try { + await userSearchService.indexUser(user); + } catch (error) { + Logger.error({userId: user.id, error}, 'Failed to index user in search'); + } + } + + private async maybeSendVerificationEmail(params: {user: User; email: string}): Promise { + const {user, email} = params; + const token = createEmailVerificationToken(await this.generateSecureToken()); + + await this.repository.createEmailVerificationToken({ + token_: token, + user_id: user.id, + email, + }); + + await this.emailService.sendEmailVerification(email, user.username, token, user.locale); + } + + private async maybeAutoJoinInvite(params: { + userId: UserID; + inviteCode: string | null | undefined; + isPendingVerification: boolean; + requestCache: RequestCache; + }): Promise { + const {userId, inviteCode, isPendingVerification, requestCache} = params; + if (inviteCode == null) return; + + if (isPendingVerification) { + await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCode); + return; + } + + if (!this.inviteService) return; + + try { + await this.inviteService.acceptInvite({ + userId, + inviteCode: createInviteCode(inviteCode), + requestCache, + }); + } catch (error) { + Logger.warn({inviteCode, error}, 'Failed to auto-join invite on registration'); + } + } + + private async enforceRegistrationRateLimits(params: { + enforceRateLimits: boolean; + clientIp: string; + emailKey: string | null; + }): Promise { + const {enforceRateLimits, clientIp, emailKey} = params; + if (!enforceRateLimits) return; + + if (emailKey) { + const emailRateLimit = await this.rateLimitService.checkLimit({ + identifier: `registration:email:${emailKey}`, + maxAttempts: 3, + windowMs: 15 * 60 * 1000, + }); + + if (!emailRateLimit.allowed) throw rateLimitError('Too many registration attempts. Please try again later.'); + } + + const ipRateLimit = await this.rateLimitService.checkLimit({ + identifier: `registration:ip:${clientIp}`, + maxAttempts: 5, + windowMs: 30 * 60 * 1000, + }); + + if (!ipRateLimit.allowed) + throw rateLimitError('Too many registration attempts from this IP. Please try again later.'); + } + + private async resolveBetaCode(betaCodeInput: string | null): Promise<{ + betaCode: Awaited> | null; + hasValidBetaCode: boolean; + }> { + if (!betaCodeInput) return {betaCode: null, hasValidBetaCode: false}; + if (Config.nodeEnv === 'development' && betaCodeInput === 'NOVERIFY') + return {betaCode: null, hasValidBetaCode: false}; + + const betaCode = await this.repository.getBetaCode(betaCodeInput); + return {betaCode, hasValidBetaCode: Boolean(betaCode && !betaCode.redeemerId)}; + } + + private async allocateDiscriminator(username: string): Promise { + const result = await this.discriminatorService.generateDiscriminator({username, isPremium: false}); + if (!result.available || result.discriminator === -1) { + throw InputValidationError.create('username', 'Too many users with this username'); + } + return result.discriminator; + } + + private generateUserId(emailKey: string | null): UserID { + const mappingJson = process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE; + + if (emailKey && mappingJson) { + const mapping = safeJsonParse>(mappingJson); + if (mapping) { + const emailHash = crypto.createHash('sha256').update(emailKey).digest('hex'); + const mapped = mapping[emailHash]; + if (mapped) { + try { + return createUserID(BigInt(mapped)); + } catch (error) { + Logger.warn({error}, 'Invalid snowflake mapping value; falling back to generated ID'); + } + } + } + } + + return createUserID(this.snowflakeService.generate()); + } + + private truncateUserAgent(userAgent: string): string { + if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) return userAgent; + return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`; + } + + private parseUserAgentSafe(userAgent: string): {osInfo: string; browserInfo: string; deviceInfo: string} { + try { + const result = Bowser.parse(userAgent); + return { + osInfo: this.formatOsInfo(result.os) ?? 'Unknown', + browserInfo: this.formatNameVersion(result.browser?.name, result.browser?.version) ?? 'Unknown', + deviceInfo: this.formatDeviceInfo(result.platform), + }; + } catch (error) { + Logger.warn({error}, 'Failed to parse user agent with Bowser'); + return {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'}; + } + } + + private formatNameVersion(name?: string, version?: string): string | null { + if (!name) return null; + return version ? `${name} ${version}` : name; + } + + private formatOsInfo(os?: {name?: string; version?: string; versionName?: string}): string | null { + if (!os?.name) return null; + if (os.versionName && os.version) return `${os.name} ${os.versionName} (${os.version})`; + if (os.versionName) return `${os.name} ${os.versionName}`; + if (os.version) return `${os.name} ${os.version}`; + return os.name; + } + + private formatDeviceInfo(platform?: {type?: string; vendor?: string; model?: string}): string { + const type = this.formatPlatformType(platform?.type); + const vendorModel = [platform?.vendor, platform?.model].filter(Boolean).join(' ').trim(); + + if (vendorModel && type) return `${vendorModel} (${type})`; + if (vendorModel) return vendorModel; + if (type) return type; + return 'Desktop/Unknown'; + } + + private formatPlatformType(type?: string): string | null { + switch ((type ?? '').toLowerCase()) { + case 'mobile': + return 'Mobile'; + case 'tablet': + return 'Tablet'; + case 'desktop': + return 'Desktop'; + default: + return null; + } + } + + private async buildRegistrationMetadataContext(params: { + user: User; + clientIp: string; + request: Request; + countryResultDetailed: CountryResultDetailed; + }): Promise { + const {user, clientIp, request, countryResultDetailed} = params; + + const userAgentHeader = (request.headers.get('user-agent') ?? '').trim(); const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`; const displayName = user.globalName || user.username; const emailDisplay = user.email || 'Not provided'; - const normalizedUserAgent = trimmedUserAgent.length > 0 ? trimmedUserAgent : 'Not provided'; - const truncatedUserAgent = this.truncateUserAgent(normalizedUserAgent); - const normalizedIp = countryResult.normalizedIp ?? clientIp; - const geoipReason = countryResult.reason ?? 'none'; - const osInfo = parsedUserAgent.os.name - ? `${parsedUserAgent.os.name}${parsedUserAgent.os.version ? ` ${parsedUserAgent.os.version}` : ''}` - : 'Unknown'; - const browserInfo = parsedUserAgent.browser.name - ? `${parsedUserAgent.browser.name}${parsedUserAgent.browser.version ? ` ${parsedUserAgent.browser.version}` : ''}` - : 'Unknown'; - const deviceInfo = parsedUserAgent.device.vendor - ? `${parsedUserAgent.device.vendor} ${parsedUserAgent.device.model || ''}`.trim() - : 'Desktop/Unknown'; + const hasUserAgent = userAgentHeader.length > 0; + const userAgentForDisplay = hasUserAgent ? userAgentHeader : 'Not provided'; + const truncatedUserAgent = this.truncateUserAgent(userAgentForDisplay); + const uaInfo = hasUserAgent + ? this.parseUserAgentSafe(userAgentHeader) + : {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'}; + + const normalizedIp = countryResultDetailed.normalizedIp ?? clientIp; + const geoipReason = countryResultDetailed.reason ?? 'none'; + const locationLabel = IpUtils.formatGeoipLocation(countryResultDetailed); const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService); - const locationLabel = IpUtils.formatGeoipLocation(countryResult); const metadataEntries: Array<[string, string]> = [ ['fluxer_tag', fluxerTag], @@ -458,38 +570,30 @@ export class AuthRegistrationService { ['email', emailDisplay], ['ip_address', clientIp], ['normalized_ip', normalizedIp], - ['country_code', countryResult.countryCode], + ['country_code', countryResultDetailed.countryCode], ['location', locationLabel], ['geoip_reason', geoipReason], - ['os', osInfo], - ['browser', browserInfo], - ['device', deviceInfo], + ['os', uaInfo.osInfo], + ['browser', uaInfo.browserInfo], + ['device', uaInfo.deviceInfo], ['user_agent', truncatedUserAgent], ]; - if (countryResult.city) { - metadataEntries.push(['city', countryResult.city]); - } - if (countryResult.region) { - metadataEntries.push(['region', countryResult.region]); - } - if (countryResult.countryName) { - metadataEntries.push(['country_name', countryResult.countryName]); - } - if (ipAddressReverse) { - metadataEntries.push(['ip_address_reverse', ipAddressReverse]); - } + if (countryResultDetailed.city) metadataEntries.push(['city', countryResultDetailed.city]); + if (countryResultDetailed.region) metadataEntries.push(['region', countryResultDetailed.region]); + if (countryResultDetailed.countryName) metadataEntries.push(['country_name', countryResultDetailed.countryName]); + if (ipAddressReverse) metadataEntries.push(['ip_address_reverse', ipAddressReverse]); return { metadata: new Map(metadataEntries), clientIp, - countryCode: countryResult.countryCode, + countryCode: countryResultDetailed.countryCode, location: locationLabel, - city: countryResult.city, - region: countryResult.region, - osInfo, - browserInfo, - deviceInfo, + city: countryResultDetailed.city, + region: countryResultDetailed.region, + osInfo: uaInfo.osInfo, + browserInfo: uaInfo.browserInfo, + deviceInfo: uaInfo.deviceInfo, truncatedUserAgent, fluxerTag, displayName, @@ -498,14 +602,6 @@ export class AuthRegistrationService { }; } - private truncateUserAgent(userAgent: string): string { - if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) { - return userAgent; - } - - return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`; - } - private async sendRegistrationWebhook( user: User, context: RegistrationMetadataContext, @@ -513,40 +609,22 @@ export class AuthRegistrationService { ): Promise { if (!webhookUrl) return; - const { - clientIp, - countryCode, - location, - city, - osInfo, - browserInfo, - deviceInfo, - truncatedUserAgent, - fluxerTag, - displayName, - email, - ipAddressReverse, - } = context; - - const locationDisplay = city ? location : countryCode; + const locationDisplay = context.city ? context.location : context.countryCode; const embedFields = [ {name: 'User ID', value: user.id.toString(), inline: true}, - {name: 'FluxerTag', value: fluxerTag, inline: true}, - {name: 'Display Name', value: displayName, inline: true}, - {name: 'Email', value: email, inline: true}, - {name: 'IP Address', value: clientIp, inline: true}, + {name: 'FluxerTag', value: context.fluxerTag, inline: true}, + {name: 'Display Name', value: context.displayName, inline: true}, + {name: 'Email', value: context.email, inline: true}, + {name: 'IP Address', value: context.clientIp, inline: true}, + ...(context.ipAddressReverse ? [{name: 'Reverse DNS', value: context.ipAddressReverse, inline: true}] : []), {name: 'Location', value: locationDisplay, inline: true}, - {name: 'OS', value: osInfo, inline: true}, - {name: 'Browser', value: browserInfo, inline: true}, - {name: 'Device', value: deviceInfo, inline: true}, - {name: 'User Agent', value: truncatedUserAgent, inline: false}, + {name: 'OS', value: context.osInfo, inline: true}, + {name: 'Browser', value: context.browserInfo, inline: true}, + {name: 'Device', value: context.deviceInfo, inline: true}, + {name: 'User Agent', value: context.truncatedUserAgent, inline: false}, ]; - if (ipAddressReverse) { - embedFields.splice(6, 0, {name: 'Reverse DNS', value: ipAddressReverse, inline: true}); - } - const payload = { username: 'Registration Monitor', embeds: [ diff --git a/fluxer_api/src/auth/services/AuthSessionService.ts b/fluxer_api/src/auth/services/AuthSessionService.ts index 4d38dbaf..a0120934 100644 --- a/fluxer_api/src/auth/services/AuthSessionService.ts +++ b/fluxer_api/src/auth/services/AuthSessionService.ts @@ -17,11 +17,12 @@ * along with Fluxer. If not, see . */ -import {UAParser} from 'ua-parser-js'; +import Bowser from 'bowser'; import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel'; import type {UserID} from '~/BrandedTypes'; import {AccessDeniedError} from '~/Errors'; import type {IGatewayService} from '~/infrastructure/IGatewayService'; +import {Logger} from '~/Logger'; import type {AuthSession, User} from '~/Models'; import type {IUserRepository} from '~/user/IUserRepository'; import * as IpUtils from '~/utils/IpUtils'; @@ -41,6 +42,44 @@ interface UpdateUserActivityParams { clientIp: string; } +function formatNameVersion(name?: string | null, version?: string | null): string { + if (!name) return 'Unknown'; + if (!version) return name; + return `${name} ${version}`; +} + +function nullIfUnknown(value: string | null | undefined): string | null { + if (!value) return null; + return value === IpUtils.UNKNOWN_LOCATION ? null : value; +} + +function parseUserAgent(userAgentRaw: string): {clientOs: string; detectedPlatform: string} { + const ua = userAgentRaw.trim(); + if (!ua) return {clientOs: 'Unknown', detectedPlatform: 'Unknown'}; + + try { + const parser = Bowser.getParser(ua); + const osName = parser.getOSName() || 'Unknown'; + const osVersion = parser.getOSVersion() || null; + const browserName = parser.getBrowserName() || 'Unknown'; + const browserVersion = parser.getBrowserVersion() || null; + + return { + clientOs: formatNameVersion(osName, osVersion), + detectedPlatform: formatNameVersion(browserName, browserVersion), + }; + } catch (error) { + Logger.warn({error}, 'Failed to parse user agent'); + return {clientOs: 'Unknown', detectedPlatform: 'Unknown'}; + } +} + +function resolveClientPlatform(platformHeader: string | null, detectedPlatform: string): string { + if (!platformHeader) return detectedPlatform; + if (platformHeader === 'desktop') return 'Fluxer Desktop'; + return detectedPlatform; +} + export class AuthSessionService { constructor( private repository: IUserRepository, @@ -50,33 +89,30 @@ export class AuthSessionService { ) {} async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> { - if (user.isBot) { - throw new AccessDeniedError('Bot users cannot create auth sessions'); - } + if (user.isBot) throw new AccessDeniedError('Bot users cannot create auth sessions'); + const now = new Date(); const token = await this.generateAuthToken(); const ip = IpUtils.requireClientIp(request); - const userAgent = request.headers.get('user-agent') || ''; - const platformHeader = request.headers.get('x-fluxer-platform')?.toLowerCase() ?? null; - const parsedUserAgent = new UAParser(userAgent).getResult(); - const geoipResult = await IpUtils.getCountryCodeDetailed(ip); - const clientLocationLabel = IpUtils.formatGeoipLocation(geoipResult); - const detectedPlatform = parsedUserAgent.browser.name ?? 'Unknown'; - const clientPlatform = platformHeader === 'desktop' ? 'Fluxer Desktop' : detectedPlatform; + + const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null; + const uaRaw = request.headers.get('user-agent') ?? ''; + const uaInfo = parseUserAgent(uaRaw); + + const geoip = await IpUtils.getCountryCodeDetailed(ip); + const locationLabel = nullIfUnknown(IpUtils.formatGeoipLocation(geoip)); + const countryLabel = nullIfUnknown(geoip.countryName ?? geoip.countryCode ?? null); const authSession = await this.repository.createAuthSession({ user_id: user.id, session_id_hash: Buffer.from(this.getTokenIdHash(token)), - created_at: new Date(), - approx_last_used_at: new Date(), + created_at: now, + approx_last_used_at: now, client_ip: ip, - client_os: parsedUserAgent.os.name ?? 'Unknown', - client_platform: clientPlatform, - client_country: - (geoipResult.countryName ?? geoipResult.countryCode) === IpUtils.UNKNOWN_LOCATION - ? null - : (geoipResult.countryName ?? geoipResult.countryCode), - client_location: clientLocationLabel === IpUtils.UNKNOWN_LOCATION ? null : clientLocationLabel, + client_os: uaInfo.clientOs, + client_platform: resolveClientPlatform(platformHeader, uaInfo.detectedPlatform), + client_country: countryLabel, + client_location: locationLabel, version: 1, }); @@ -101,50 +137,49 @@ export class AuthSessionService { } async revokeToken(token: string): Promise { - const tokenHash = this.getTokenIdHash(token); - const authSession = await this.repository.getAuthSessionByToken(Buffer.from(tokenHash)); + const tokenHash = Buffer.from(this.getTokenIdHash(token)); + const authSession = await this.repository.getAuthSessionByToken(tokenHash); + if (!authSession) return; - if (authSession) { - await this.repository.revokeAuthSession(Buffer.from(tokenHash)); - await this.gatewayService.terminateSession({ - userId: authSession.userId, - sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')], - }); - } + await this.repository.revokeAuthSession(tokenHash); + + await this.gatewayService.terminateSession({ + userId: authSession.userId, + sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')], + }); } async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise { const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url')); await this.repository.deleteAuthSessions(user.id, hashes); + await this.gatewayService.terminateSession({ userId: user.id, - sessionIdHashes: sessionIdHashes, + sessionIdHashes, }); } async terminateAllUserSessions(userId: UserID): Promise { const authSessions = await this.repository.listAuthSessions(userId); - await this.repository.deleteAuthSessions( - userId, - authSessions.map((session) => session.sessionIdHash), - ); + if (authSessions.length === 0) return; + + const hashes = authSessions.map((s) => s.sessionIdHash); + await this.repository.deleteAuthSessions(userId, hashes); + await this.gatewayService.terminateSession({ userId, - sessionIdHashes: authSessions.map((session) => Buffer.from(session.sessionIdHash).toString('base64url')), + sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')), }); } - async dispatchAuthSessionChange({ - userId, - oldAuthSessionIdHash, - newAuthSessionIdHash, - newToken, - }: { + async dispatchAuthSessionChange(params: { userId: UserID; oldAuthSessionIdHash: string; newAuthSessionIdHash: string; newToken: string; }): Promise { + const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params; + await this.gatewayService.dispatchPresence({ userId, event: 'AUTH_SESSION_CHANGE', diff --git a/fluxer_app/package.json b/fluxer_app/package.json index 695163da..15a7de09 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -59,6 +59,7 @@ "@radix-ui/react-switch": "^1.2.6", "@sentry/react": "10.32.1", "@simplewebauthn/browser": "13.2.2", + "bowser": "2.13.1", "clsx": "2.1.1", "colorjs.io": "0.6.0", "combokeys": "3.0.1", @@ -97,7 +98,6 @@ "react-zoom-pan-pinch": "3.7.0", "rxjs": "7.8.2", "thumbhash": "0.1.1", - "ua-parser-js": "2.0.7", "undici": "7.16.0", "unique-names-generator": "4.7.1", "update-electron-app": "3.1.2", diff --git a/fluxer_app/pnpm-lock.yaml b/fluxer_app/pnpm-lock.yaml index d58cf54f..3a0b6c8f 100644 --- a/fluxer_app/pnpm-lock.yaml +++ b/fluxer_app/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: '@simplewebauthn/browser': specifier: 13.2.2 version: 13.2.2 + bowser: + specifier: 2.13.1 + version: 2.13.1 clsx: specifier: 2.1.1 version: 2.1.1 @@ -187,9 +190,6 @@ importers: thumbhash: specifier: 0.1.1 version: 0.1.1 - ua-parser-js: - specifier: 2.0.7 - version: 2.0.7 undici: specifier: 7.16.0 version: 7.16.0 @@ -3737,6 +3737,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -4159,9 +4162,6 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-europe-js@0.1.2: - resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4892,9 +4892,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-standalone-pwa@0.1.1: - resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} - is-there@4.5.2: resolution: {integrity: sha512-ixMkfz3rtS1vEsLf0TjgjqUn96Q0ukpUVDMnPYVocJyTzu2G/QgEtqYddcHZawHO+R31cKVPggJmBLrm1vJCOg==} @@ -6609,13 +6606,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ua-is-frozen@0.1.2: - resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} - - ua-parser-js@2.0.7: - resolution: {integrity: sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w==} - hasBin: true - uiohook-napi@1.5.4: resolution: {integrity: sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==} engines: {node: '>= 16'} @@ -11098,6 +11088,8 @@ snapshots: boolean@3.2.0: optional: true + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -11543,8 +11535,6 @@ snapshots: destroy@1.2.0: {} - detect-europe-js@0.1.2: {} - detect-libc@2.1.2: {} detect-node@2.1.0: {} @@ -12432,8 +12422,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-standalone-pwa@0.1.1: {} - is-there@4.5.2: {} is-unicode-supported@0.1.0: {} @@ -14339,14 +14327,6 @@ snapshots: typescript@5.9.3: {} - ua-is-frozen@0.1.2: {} - - ua-parser-js@2.0.7: - dependencies: - detect-europe-js: 0.1.2 - is-standalone-pwa: 0.1.1 - ua-is-frozen: 0.1.2 - uiohook-napi@1.5.4: dependencies: node-gyp-build: 4.8.4 diff --git a/fluxer_app/rspack.config.mjs b/fluxer_app/rspack.config.mjs index 2d8d1486..4c519038 100644 --- a/fluxer_app/rspack.config.mjs +++ b/fluxer_app/rspack.config.mjs @@ -344,7 +344,7 @@ export default () => { reuseExistingChunk: true, }, utils: { - test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|ua-parser-js|match-sorter)[\\/]/, + test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|bowser|match-sorter)[\\/]/, name: 'utils', priority: 28, reuseExistingChunk: true, diff --git a/fluxer_app/src/utils/ClientInfoUtils.ts b/fluxer_app/src/utils/ClientInfoUtils.ts index 4c677fb0..3dfbc3a9 100644 --- a/fluxer_app/src/utils/ClientInfoUtils.ts +++ b/fluxer_app/src/utils/ClientInfoUtils.ts @@ -17,7 +17,7 @@ * along with Fluxer. If not, see . */ -import {UAParser} from 'ua-parser-js'; +import Bowser from 'bowser'; import Config from '~/Config'; import {getElectronAPI, isDesktop} from '~/utils/NativeUtils'; @@ -39,14 +39,16 @@ let cachedClientInfo: ClientInfo | null = null; let preloadPromise: Promise | null = null; const parseUserAgent = (): ClientInfo => { - const parser = new UAParser(); + const hasNavigator = typeof navigator !== 'undefined'; + const userAgent = hasNavigator ? navigator.userAgent : ''; + const parser = Bowser.getParser(userAgent); const result = parser.getResult(); return { browserName: normalize(result.browser.name), browserVersion: normalize(result.browser.version), osName: normalize(result.os.name), osVersion: normalize(result.os.version), - arch: normalize(result.cpu.architecture), + arch: normalize(hasNavigator ? navigator.platform : undefined), }; };