/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes'; import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService'; import type {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator'; import type {AuthService} from '@fluxer/api/src/auth/AuthService'; import {createPasswordResetToken, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes'; import {Logger} from '@fluxer/api/src/Logger'; import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService'; import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository'; import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService'; import {getIpAddressReverse, getLocationLabelFromIp} from '@fluxer/api/src/utils/IpUtils'; import {resolveSessionClientInfo} from '@fluxer/api/src/utils/UserAgentUtils'; import type {ICacheService} from '@fluxer/cache/src/ICacheService'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import type {IEmailService} from '@fluxer/email/src/IEmailService'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type { BulkUpdateUserFlagsRequest, DeleteWebAuthnCredentialRequest, DisableForSuspiciousActivityRequest, DisableMfaRequest, ListWebAuthnCredentialsRequest, SendPasswordResetRequest, SetUserAclsRequest, SetUserTraitsRequest, TerminateSessionsRequest, UnlinkPhoneRequest, UpdateSuspiciousActivityFlagsRequest, } from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import type {WebAuthnCredentialListResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas'; interface AdminUserSecurityServiceDeps { userRepository: IUserRepository; authService: AuthService; emailService: IEmailService; auditService: AdminAuditService; updatePropagator: AdminUserUpdatePropagator; botMfaMirrorService?: BotMfaMirrorService; contactChangeLogService: UserContactChangeLogService; cacheService: ICacheService; } export class AdminUserSecurityService { constructor(private readonly deps: AdminUserSecurityServiceDeps) {} async updateUserFlags({ userId, data, adminUserId, auditLogReason, }: { userId: UserID; data: {addFlags: Array; removeFlags: Array}; adminUserId: UserID; auditLogReason: string | null; }) { const {userRepository, auditService, updatePropagator} = this.deps; const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } let newFlags = user.flags; for (const flag of data.addFlags) { newFlags |= flag; } for (const flag of data.removeFlags) { newFlags &= ~flag; } const updatedUser = await userRepository.patchUpsert( userId, { flags: newFlags, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'update_flags', auditLogReason, metadata: new Map( ( [ ['add_flags', data.addFlags.map((f) => f.toString()).join(',')], ['remove_flags', data.removeFlags.map((f) => f.toString()).join(',')], ['new_flags', newFlags.toString()], ] as Array<[string, string]> ).filter(([_, v]) => v.length > 0), ), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { totp_secret: null, authenticator_types: null, }, user.toRow(), ); if (updatedUser) { await this.deps.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser); } await userRepository.clearMfaBackupCodes(userId); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'disable_mfa', auditLogReason, metadata: new Map(), }); } async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, emailService, authService, auditService} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } if (!user.email) { throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS); } const token = createPasswordResetToken(await authService.generateSecureToken()); await userRepository.createPasswordResetToken({ token_: token, user_id: userId, email: user.email, }); await emailService.sendPasswordResetEmail(user.email, user.username, token, user.locale); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'send_password_reset', auditLogReason, metadata: new Map([['email', user.email]]), }); } async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, authService, auditService} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } await authService.terminateAllUserSessions(userId); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'terminate_sessions', auditLogReason, metadata: new Map(), }); } async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { acls: new Set(data.acls), }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'set_acls', auditLogReason, metadata: new Map([['acls', data.acls.join(',')]]), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async setUserTraits(data: SetUserTraitsRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const traitSet = data.traits.length > 0 ? new Set(data.traits) : null; const updatedUser = await userRepository.patchUpsert( userId, { traits: traitSet, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'set_traits', auditLogReason, metadata: new Map(data.traits.length > 0 ? [['traits', data.traits.join(',')]] : []), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { phone: null, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await contactChangeLogService.recordDiff({ oldUser: user, newUser: updatedUser, reason: 'admin_action', actorUserId: adminUserId, }); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'unlink_phone', auditLogReason, metadata: new Map([['phone', user.phone ?? 'null']]), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async updateSuspiciousActivityFlags( data: UpdateSuspiciousActivityFlagsRequest, adminUserId: UserID, auditLogReason: string | null, ) { const {userRepository, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { suspicious_activity_flags: data.flags, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'update_suspicious_activity_flags', auditLogReason, metadata: new Map([['flags', data.flags.toString()]]), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async disableForSuspiciousActivity( data: DisableForSuspiciousActivityRequest, adminUserId: UserID, auditLogReason: string | null, ) { const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { flags: user.flags | UserFlags.DISABLED_SUSPICIOUS_ACTIVITY, suspicious_activity_flags: data.flags, password_hash: null, }, user.toRow(), ); await authService.terminateAllUserSessions(userId); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); if (user.email) { await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale); } await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'disable_suspicious_activity', auditLogReason, metadata: new Map([['flags', data.flags.toString()]]), }); return { user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService), }; } async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) { const {auditService} = this.deps; const successful: Array = []; const failed: Array<{id: string; error: string}> = []; const addFlags = data.add_flags.map((flag) => BigInt(flag)); const removeFlags = data.remove_flags.map((flag) => BigInt(flag)); for (const userIdBigInt of data.user_ids) { try { const userId = createUserID(userIdBigInt); await this.updateUserFlags({ userId, data: {addFlags, removeFlags}, adminUserId, auditLogReason: null, }); successful.push(userId.toString()); } catch (error) { failed.push({ id: userIdBigInt.toString(), error: error instanceof Error ? error.message : 'Unknown error', }); } } await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(0), action: 'bulk_update_user_flags', auditLogReason, metadata: new Map( ( [ ['user_count', data.user_ids.length.toString()], ['add_flags', data.add_flags.map((f) => f.toString()).join(',')], ['remove_flags', data.remove_flags.map((f) => f.toString()).join(',')], ] as Array<[string, string]> ).filter(([_, v]) => v.length > 0), ), }); return { successful, failed, }; } async listWebAuthnCredentials( data: ListWebAuthnCredentialsRequest, adminUserId: UserID, auditLogReason: string | null, ): Promise { const {userRepository, auditService} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const credentials = await userRepository.listWebAuthnCredentials(userId); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'list_webauthn_credentials', auditLogReason, metadata: new Map([['credential_count', credentials.length.toString()]]), }); return credentials.map((cred) => ({ id: cred.credentialId, name: cred.name, created_at: cred.createdAt.toISOString(), last_used_at: cred.lastUsedAt?.toISOString() ?? null, })); } async deleteWebAuthnCredential( data: DeleteWebAuthnCredentialRequest, adminUserId: UserID, auditLogReason: string | null, ) { const {userRepository, auditService} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const credential = await userRepository.getWebAuthnCredential(userId, data.credential_id); if (!credential) { throw new UnknownUserError(); } await userRepository.deleteWebAuthnCredential(userId, data.credential_id); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'delete_webauthn_credential', auditLogReason, metadata: new Map([['credential_id', data.credential_id]]), }); } async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, auditService, cacheService} = this.deps; const userIdTyped = createUserID(userId); const user = await userRepository.findUnique(userIdTyped); if (!user) { throw new UnknownUserError(); } const sessions = await userRepository.listAuthSessions(userIdTyped); const locationResults = await Promise.allSettled( sessions.map((session) => getLocationLabelFromIp(session.clientIp)), ); const reverseDnsResults = await Promise.allSettled( sessions.map((session) => getIpAddressReverse(session.clientIp, cacheService)), ); let failedCount = 0; for (const result of locationResults) { if (result.status === 'rejected') { failedCount++; Logger.warn({error: result.reason, userId: userId.toString()}, 'IP geolocation lookup failed'); } } if (locationResults.length > 0 && failedCount === locationResults.length) { throw new ServiceUnavailableError({ code: 'GEOLOCATION_SERVICE_UNAVAILABLE', message: 'Geolocation service is unavailable. All IP lookups failed.', }); } await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: userId, action: 'list_user_sessions', auditLogReason, metadata: new Map([['session_count', sessions.length.toString()]]), }); return { sessions: sessions.map((session, index) => { const locationResult = locationResults[index]; const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null; const reverseDnsResult = reverseDnsResults[index]; const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null; const {clientOs, clientPlatform} = resolveSessionClientInfo({ userAgent: session.clientUserAgent, isDesktopClient: session.clientIsDesktop, }); return { session_id_hash: session.sessionIdHash.toString('base64url'), created_at: session.createdAt.toISOString(), approx_last_used_at: session.approximateLastUsedAt.toISOString(), client_ip: session.clientIp, client_ip_reverse: clientIpReverse, client_os: clientOs, client_platform: clientPlatform, client_location: clientLocation, }; }), }; } }