diff --git a/fluxer_api/src/api/auth/AuthLogin.ts b/fluxer_api/src/api/auth/AuthLogin.ts index eedd354b8..b9b12acd1 100644 --- a/fluxer_api/src/api/auth/AuthLogin.ts +++ b/fluxer_api/src/api/auth/AuthLogin.ts @@ -359,13 +359,36 @@ export async function login( const MFA_TICKET_MAX_ATTEMPTS = 5; const MFA_USER_MAX_ATTEMPTS = 10; -const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes'); + +async function consumeMfaAttempt( + ctx: ApiContext, + {userId, ticket, field}: {userId: string; ticket: string; field: string}, +): Promise { + const {cache, rateLimit} = ctx.services; + const userLimit = await rateLimit.checkLimit({ + identifier: `mfa:user:${userId}`, + maxAttempts: MFA_USER_MAX_ATTEMPTS, + windowMs: ms('15 minutes'), + }); + if (!userLimit.allowed) { + throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE); + } + const ticketLimit = await rateLimit.checkLimit({ + identifier: `mfa:ticket:${ticket}`, + maxAttempts: MFA_TICKET_MAX_ATTEMPTS, + windowMs: ms('5 minutes'), + }); + if (!ticketLimit.allowed) { + await cache.delete(`mfa-ticket:${ticket}`); + throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE); + } +} export async function loginMfaTotp( ctx: ApiContext, {code, ticket, request}: LoginMfaTotpParams, ): Promise { - const {users, cache} = ctx.services; + const {users, cache, rateLimit} = ctx.services; const userId = await cache.get(`mfa-ticket:${ticket}`); if (!userId) { throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT); @@ -378,32 +401,19 @@ export async function loginMfaTotp( if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) { throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED); } - const userAttemptsKey = `mfa-user-attempts:${user.id}`; - const userAttempts = (await cache.get(userAttemptsKey)) ?? 0; - if (userAttempts >= MFA_USER_MAX_ATTEMPTS) { - throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE); - } + await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'}); const isValid = await AuthMfa.verifyMfaCode(ctx, { userId: user.id, mfaSecret: user.totpSecret, code, allowBackup: true, }); - const attemptsKey = `mfa-ticket-attempts:${ticket}`; if (!isValid) { - await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW); - const attempts = ((await cache.get(attemptsKey)) ?? 0) + 1; - if (attempts >= MFA_TICKET_MAX_ATTEMPTS) { - await cache.delete(`mfa-ticket:${ticket}`); - await cache.delete(attemptsKey); - } else { - await cache.set(attemptsKey, attempts, seconds('5 minutes')); - } throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE); } await cache.delete(`mfa-ticket:${ticket}`); - await cache.delete(attemptsKey); - await cache.delete(userAttemptsKey); + await rateLimit.resetLimit(`mfa:ticket:${ticket}`); + await rateLimit.resetLimit(`mfa:user:${user.id}`); const [token] = await AuthSession.createAuthSession(ctx, { user, origin: AuthSession.resolveSessionOrigin(ctx, request), @@ -415,7 +425,7 @@ export async function loginMfaWebAuthn( ctx: ApiContext, {response, challenge, ticket, request}: LoginMfaWebAuthnParams, ): Promise { - const {users, cache} = ctx.services; + const {users, cache, rateLimit} = ctx.services; const userId = await cache.get(`mfa-ticket:${ticket}`); if (!userId) { throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT); @@ -425,8 +435,11 @@ export async function loginMfaWebAuthn( throw new UnknownUserError(); } AuthUtility.assertNonBotUser(ctx, user); + await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'}); await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket); await cache.delete(`mfa-ticket:${ticket}`); + await rateLimit.resetLimit(`mfa:ticket:${ticket}`); + await rateLimit.resetLimit(`mfa:user:${user.id}`); const [token] = await AuthSession.createAuthSession(ctx, { user, origin: AuthSession.resolveSessionOrigin(ctx, request), diff --git a/fluxer_api/src/api/auth/AuthPassword.ts b/fluxer_api/src/api/auth/AuthPassword.ts index 64054b252..c7d488e9b 100644 --- a/fluxer_api/src/api/auth/AuthPassword.ts +++ b/fluxer_api/src/api/auth/AuthPassword.ts @@ -267,7 +267,7 @@ export async function resetPassword( }, user.toRow(), ); - await users.deleteAllAuthSessions(user.id); + await AuthSession.terminateAllUserSessions(ctx, user.id); await users.deletePasswordResetToken(data.token); const hasMfa = updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || diff --git a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts index b291fc4b4..b921b396d 100644 --- a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts @@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse import type {ChannelID, GuildID, UserID} from '../../BrandedTypes'; import {SYSTEM_USER_ID} from '../../constants/Core'; import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate'; +import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement'; import type {IGatewayService} from '../../infrastructure/IGatewayService'; import type {Channel} from '../../models/Channel'; import type {GuildMember} from '../../models/GuildMember'; @@ -176,9 +177,15 @@ export abstract class BaseChannelAuthService { const hasPermission = async (permission: bigint): Promise => { return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id}); }; + const enforceGuildMfa = await createGuildMfaEnforcer({ + userRepository: this.userRepository, + guildData: guildDataResult!, + userId, + }); const checkPermission = async (permission: bigint): Promise => { const allowed = await hasPermission(permission); if (!allowed) throw new MissingPermissionsError(); + enforceGuildMfa(permission); }; await checkPermission(Permissions.VIEW_CHANNEL); const parentCategory = await this.getParentCategoryContentWarningView({ diff --git a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts index 0c75f5c46..3564143d6 100644 --- a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts @@ -186,12 +186,7 @@ export class ChannelOperationsService { let permissionOverwrites = channel.permissionOverwrites; if (data.permission_overwrites !== undefined) { const guildId = createGuildID(BigInt(guild.id)); - const canManageRoles = await this.gatewayService.checkPermission({ - guildId, - userId, - permission: Permissions.MANAGE_ROLES, - }); - if (!canManageRoles) throw new MissingPermissionsError(); + await checkPermission(Permissions.MANAGE_ROLES); const isOwner = guild.owner_id === userId.toString(); const channelPermissions = await this.gatewayService.getUserPermissions({ guildId, @@ -205,6 +200,17 @@ export class ChannelOperationsService { throw new MissingPermissionsError(); } } + const nextDeny = new Map(); + for (const overwrite of data.permission_overwrites ?? []) { + const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id); + nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS); + } + for (const [targetId, existing] of previousPermissionOverwrites ?? []) { + const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n); + if ((removedDeny & ~channelPermissions) !== 0n) { + throw new MissingPermissionsError(); + } + } } permissionOverwrites = new Map(); for (const overwrite of data.permission_overwrites ?? []) { @@ -590,6 +596,7 @@ export class ChannelOperationsService { const canManageRoles = await this.gatewayService.checkPermission({ guildId: channel.guildId, userId: params.userId, + channelId: channel.id, permission: Permissions.MANAGE_ROLES, }); if (!canManageRoles) throw new MissingPermissionsError(); @@ -615,6 +622,8 @@ export class ChannelOperationsService { const sanitizedDeny = protectedBits.deny; const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n; if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny; + if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError(); const previousPermissionOverwrites = channel.permissionOverwrites; const overwrites = new Map(channel.permissionOverwrites ?? []); overwrites.set( @@ -697,6 +706,7 @@ export class ChannelOperationsService { const canManageRoles = await this.gatewayService.checkPermission({ guildId: channel.guildId, userId: params.userId, + channelId: channel.id, permission: Permissions.MANAGE_ROLES, }); if (!canManageRoles) throw new MissingPermissionsError(); @@ -705,6 +715,15 @@ export class ChannelOperationsService { const removedRole = overwrites.get(createRoleID(params.overwriteId)); const removedUser = overwrites.get(createUserID(params.overwriteId)); const removed = removedRole ?? removedUser; + if (removed) { + const userPermissions = await this.gatewayService.getUserPermissions({ + guildId: channel.guildId, + userId: params.userId, + channelId: channel.id, + }); + const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n; + if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + } overwrites.delete(createRoleID(params.overwriteId)); overwrites.delete(createUserID(params.overwriteId)); const updated = await this.channelRepository.channelData.upsert({ diff --git a/fluxer_api/src/api/database/types/PaymentTypes.ts b/fluxer_api/src/api/database/types/PaymentTypes.ts index 2afc34db0..0096234ac 100644 --- a/fluxer_api/src/api/database/types/PaymentTypes.ts +++ b/fluxer_api/src/api/database/types/PaymentTypes.ts @@ -17,6 +17,7 @@ export interface GiftCodeRow { stripe_payment_intent_id: Nullish; visionary_sequence_number: Nullish; checkout_session_id: Nullish; + revoked_at?: Nullish; version: number; } @@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [ 'stripe_payment_intent_id', 'visionary_sequence_number', 'checkout_session_id', + 'revoked_at', 'version', ] as const; export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const; diff --git a/fluxer_api/src/api/guild/services/GuildChannelService.ts b/fluxer_api/src/api/guild/services/GuildChannelService.ts index 8fc85ab31..c78dbea8a 100644 --- a/fluxer_api/src/api/guild/services/GuildChannelService.ts +++ b/fluxer_api/src/api/guild/services/GuildChannelService.ts @@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService'; import type {UserCacheService} from '../../infrastructure/UserCacheService'; import type {LimitConfigService} from '../../limits/LimitConfigService'; import type {RequestCache} from '../../middleware/RequestCacheMiddleware'; +import type {IUserRepository} from '../../user/IUserRepository'; import type {GuildAuditLogService} from '../GuildAuditLogService'; import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate'; import {ChannelOperationsService} from './channel/ChannelOperationsService'; +import {createGuildMfaEnforcer} from './GuildMfaEnforcement'; export class GuildChannelService { private readonly channelOps: ChannelOperationsService; @@ -30,6 +32,7 @@ export class GuildChannelService { snowflakeService: ISnowflakeService, guildAuditLogService: GuildAuditLogService, limitConfigService: LimitConfigService, + private readonly userRepository: IUserRepository, ) { this.channelOps = new ChannelOperationsService( channelRepository, @@ -131,5 +134,12 @@ export class GuildChannelService { permission: params.permission, }); if (!hasPermission) throw new MissingPermissionsError(); + const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId}); + const enforceGuildMfa = await createGuildMfaEnforcer({ + userRepository: this.userRepository, + guildData, + userId: params.userId, + }); + enforceGuildMfa(params.permission); } } diff --git a/fluxer_api/src/api/guild/services/GuildDataService.ts b/fluxer_api/src/api/guild/services/GuildDataService.ts index c2950af4b..d4ad5feed 100644 --- a/fluxer_api/src/api/guild/services/GuildDataService.ts +++ b/fluxer_api/src/api/guild/services/GuildDataService.ts @@ -51,7 +51,7 @@ export class GuildDataService { private readonly guildAuditLogService: GuildAuditLogService, private readonly limitConfigService: LimitConfigService, ) { - this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService); + this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository); this.operationsService = new GuildOperationsService( this.guildRepository, this.channelRepository, diff --git a/fluxer_api/src/api/guild/services/GuildMemberService.ts b/fluxer_api/src/api/guild/services/GuildMemberService.ts index 8bd2b9b7b..d8943617c 100644 --- a/fluxer_api/src/api/guild/services/GuildMemberService.ts +++ b/fluxer_api/src/api/guild/services/GuildMemberService.ts @@ -50,7 +50,7 @@ export class GuildMemberService { ipInfoService: IpInfoService, ) { this.userRepository = userRepository; - this.authService = new GuildMemberAuthService(gatewayService); + this.authService = new GuildMemberAuthService(gatewayService, userRepository); this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService); this.auditService = new GuildMemberAuditService(guildAuditLogService); this.eventService = new GuildMemberEventService(gatewayService, userCacheService); diff --git a/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts b/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts new file mode 100644 index 000000000..89e5ef9ed --- /dev/null +++ b/fluxer_api/src/api/guild/services/GuildMfaEnforcement.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants'; +import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import type {UserID} from '../../BrandedTypes'; +import type {IUserRepository} from '../../user/IUserRepository'; + +export const ELEVATED_MFA_PERMISSIONS = + Permissions.KICK_MEMBERS | + Permissions.BAN_MEMBERS | + Permissions.ADMINISTRATOR | + Permissions.MANAGE_CHANNELS | + Permissions.MANAGE_GUILD | + Permissions.MANAGE_MESSAGES | + Permissions.MANAGE_ROLES | + Permissions.MANAGE_WEBHOOKS | + Permissions.MODERATE_MEMBERS; + +export async function createGuildMfaEnforcer(params: { + userRepository: IUserRepository; + guildData: Pick; + userId: UserID; +}): Promise<(permission: bigint) => void> { + const {userRepository, guildData, userId} = params; + const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString(); + let actorLacksMfa = false; + if (requiresGuildMfa) { + const actor = await userRepository.findUnique(userId); + actorLacksMfa = !actor || actor.authenticatorTypes.size === 0; + } + return (permission: bigint) => { + if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) { + throw new MfaNotEnabledError(); + } + }; +} diff --git a/fluxer_api/src/api/guild/services/GuildModerationService.ts b/fluxer_api/src/api/guild/services/GuildModerationService.ts index cbc80122e..ae560a3cf 100644 --- a/fluxer_api/src/api/guild/services/GuildModerationService.ts +++ b/fluxer_api/src/api/guild/services/GuildModerationService.ts @@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService'; import type {GuildAuditLogChange} from '../GuildAuditLogTypes'; import {mapGuildBansToResponse} from '../GuildModel'; import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate'; +import {createGuildMfaEnforcer} from './GuildMfaEnforcement'; import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService'; export class GuildModerationService { @@ -44,6 +45,19 @@ export class GuildModerationService { this.searchIndexService = new GuildMemberSearchIndexService(); } + private async checkModerationPermission(params: { + guildId: GuildID; + userId: UserID; + permission: bigint; + }): Promise { + const {guildId, userId, permission} = params; + const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); + if (!hasPermission) throw new MissingPermissionsError(); + const guildData = await this.gatewayService.getGuildData({guildId, userId}); + const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); + enforceGuildMfa(permission); + } + async banMember( params: { userId: UserID; @@ -57,12 +71,7 @@ export class GuildModerationService { auditLogReason?: string | null, ): Promise { const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params; - const hasPermission = await this.gatewayService.checkPermission({ - guildId, - userId, - permission: Permissions.BAN_MEMBERS, - }); - if (!hasPermission) throw new MissingPermissionsError(); + await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); if (userId === targetId) throw new UnknownGuildMemberError(); const targetUser = await this.userRepository.findUnique(targetId); if (!targetUser) { @@ -145,12 +154,7 @@ export class GuildModerationService { requestCache: RequestCache; }): Promise> { const {userId, guildId, requestCache} = params; - const hasPermission = await this.gatewayService.checkPermission({ - guildId, - userId, - permission: Permissions.BAN_MEMBERS, - }); - if (!hasPermission) throw new MissingPermissionsError(); + await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); const bans = await this.guildRepository.listBans(guildId); return await mapGuildBansToResponse(bans, this.userCacheService, requestCache); } @@ -164,12 +168,7 @@ export class GuildModerationService { auditLogReason?: string | null, ): Promise { const {userId, guildId, targetId} = params; - const hasPermission = await this.gatewayService.checkPermission({ - guildId, - userId, - permission: Permissions.BAN_MEMBERS, - }); - if (!hasPermission) throw new MissingPermissionsError(); + await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); const ban = await this.guildRepository.getBan(guildId, targetId); if (!ban) { throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED); diff --git a/fluxer_api/src/api/guild/services/GuildRoleService.ts b/fluxer_api/src/api/guild/services/GuildRoleService.ts index 9333d9624..b1feec578 100644 --- a/fluxer_api/src/api/guild/services/GuildRoleService.ts +++ b/fluxer_api/src/api/guild/services/GuildRoleService.ts @@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService'; import {resolveLimitSafe} from '../../limits/LimitConfigUtils'; import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder'; import {GuildRole} from '../../models/GuildRole'; +import type {IUserRepository} from '../../user/IUserRepository'; import {applyProtectedRolePermissions} from '../../utils/featureUtils'; import {computePermissionsDiff} from '../../utils/PermissionUtils'; import type {GuildAuditLogService} from '../GuildAuditLogService'; @@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes'; import {mapGuildRoleToResponse} from '../GuildModel'; import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository'; import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository'; +import {createGuildMfaEnforcer} from './GuildMfaEnforcement'; interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {} @@ -62,6 +64,7 @@ export class GuildRoleService { private readonly gatewayService: IGatewayService, private readonly guildAuditLogService: GuildAuditLogService, private readonly limitConfigService: LimitConfigService, + private readonly userRepository: IUserRepository, ) {} async systemCreateRole(params: { @@ -446,9 +449,11 @@ export class GuildRoleService { private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { const guildData = await this.gatewayService.getGuildData({guildId, userId}); + const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); const checkPermission = async (permission: bigint) => { const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); if (!hasPermission) throw new MissingPermissionsError(); + enforceGuildMfa(permission); }; const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId}); return { diff --git a/fluxer_api/src/api/guild/services/GuildService.ts b/fluxer_api/src/api/guild/services/GuildService.ts index 302d0c849..82a3a6d25 100644 --- a/fluxer_api/src/api/guild/services/GuildService.ts +++ b/fluxer_api/src/api/guild/services/GuildService.ts @@ -2,9 +2,8 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; -import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants'; +import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; -import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; @@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService'; import {GuildContentService} from './GuildContentService'; import {GuildDataService} from './GuildDataService'; import {GuildMemberService} from './GuildMemberService'; +import {createGuildMfaEnforcer} from './GuildMfaEnforcement'; import {GuildModerationService} from './GuildModerationService'; import {GuildRoleService} from './GuildRoleService'; import {GuildSearchService} from './GuildSearchService'; @@ -95,17 +95,6 @@ interface GuildAuth { canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise; } -const ELEVATED_MFA_PERMISSIONS = - Permissions.KICK_MEMBERS | - Permissions.BAN_MEMBERS | - Permissions.ADMINISTRATOR | - Permissions.MANAGE_CHANNELS | - Permissions.MANAGE_GUILD | - Permissions.MANAGE_MESSAGES | - Permissions.MANAGE_ROLES | - Permissions.MANAGE_WEBHOOKS | - Permissions.MODERATE_MEMBERS; - export class GuildService { public readonly data: GuildDataService; public readonly members: GuildMemberService; @@ -182,6 +171,7 @@ export class GuildService { gatewayService, guildAuditLogService, limitConfigService, + userRepository, ); this.moderation = new GuildModerationService( guildRepository, @@ -211,6 +201,7 @@ export class GuildService { snowflakeService, guildAuditLogService, limitConfigService, + userRepository, ); this.search = new GuildSearchService( channelRepository, @@ -561,17 +552,7 @@ export class GuildService { async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { const guildData = await this.gatewayService.getGuildData({guildId, userId}); if (!guildData) throw new MissingAccessError(); - const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString(); - let actorLacksMfa = false; - if (requiresGuildMfa) { - const actor = await this.userRepository.findUnique(userId); - actorLacksMfa = !actor || actor.authenticatorTypes.size === 0; - } - const enforceGuildMfa = (permission: bigint) => { - if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) { - throw new MfaNotEnabledError(); - } - }; + const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); const checkPermission = async (permission: bigint) => { const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); if (!hasPermission) throw new MissingPermissionsError(); diff --git a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts index 4f7eb3676..140a3daad 100644 --- a/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts +++ b/fluxer_api/src/api/guild/services/channel/ChannelOperationsService.ts @@ -355,12 +355,18 @@ export class ChannelOperationsService { requestCache, }); if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) { - await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent}); + await this.syncPermissionsWithParent({ + guildId, + userId: params.userId, + channelId: target.id, + parentId: desiredParent, + }); } } private async syncPermissionsWithParent(params: { guildId: GuildID; + userId: UserID; channelId: ChannelID; parentId: ChannelID; }): Promise { @@ -368,6 +374,22 @@ export class ChannelOperationsService { if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return; const child = await this.channelRepository.findUnique(params.channelId); if (!child || child.guildId !== params.guildId) return; + const userPermissions = await this.gatewayService.getUserPermissions({ + guildId: params.guildId, + userId: params.userId, + channelId: child.id, + }); + if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) { + throw new MissingPermissionsError(); + } + for (const [targetId, existing] of child.permissionOverwrites) { + const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n; + if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + } + for (const [targetId, incoming] of parent.permissionOverwrites) { + const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n; + if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError(); + } await this.channelRepository.upsert({ ...child.toRow(), permission_overwrites: new Map( diff --git a/fluxer_api/src/api/guild/services/data/GuildDataHelpers.ts b/fluxer_api/src/api/guild/services/data/GuildDataHelpers.ts index ec268eb0c..399d4f6a1 100644 --- a/fluxer_api/src/api/guild/services/data/GuildDataHelpers.ts +++ b/fluxer_api/src/api/guild/services/data/GuildDataHelpers.ts @@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../.. import type {IGatewayService} from '../../../infrastructure/IGatewayService'; import {Logger} from '../../../Logger'; import type {Guild} from '../../../models/Guild'; +import type {IUserRepository} from '../../../user/IUserRepository'; import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils'; import {requirePermission} from '../../../utils/PermissionUtils'; import type {GuildAuditLogService} from '../../GuildAuditLogService'; import type {GuildAuditLogChange} from '../../GuildAuditLogTypes'; import {mapGuildToGuildResponse} from '../../GuildModel'; import {GuildRepository} from '../../repositories/GuildRepository'; +import {createGuildMfaEnforcer} from '../GuildMfaEnforcement'; interface GuildAuth { guildData: GuildResponse; @@ -24,6 +26,7 @@ export class GuildDataHelpers { constructor( private readonly gatewayService: IGatewayService, private readonly guildAuditLogService: GuildAuditLogService, + private readonly userRepository: IUserRepository, ) {} private readonly guildRepository = new GuildRepository(); @@ -33,7 +36,7 @@ export class GuildDataHelpers { try { const guildData = await this.gatewayService.getGuildData({guildId, userId}); if (!guildData) throw new UnknownGuildError(); - return this.createGuildAuth({guildData, guildId, userId}); + return await this.createGuildAuth({guildData, guildId, userId}); } catch (error) { if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) { throw new AccessDeniedError(); @@ -42,10 +45,16 @@ export class GuildDataHelpers { } } - private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth { + private async createGuildAuth(params: { + guildData: GuildResponse; + guildId: GuildID; + userId: UserID; + }): Promise { const {guildData, guildId, userId} = params; + const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); const checkPermission = async (permission: bigint) => { await requirePermission(this.gatewayService, {guildId, userId, permission}); + enforceGuildMfa(permission); }; return {guildData, checkPermission}; } diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts index 1f4e93d73..7fdb49f41 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts @@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {GuildID, RoleID, UserID} from '../../../BrandedTypes'; import type {IGatewayService} from '../../../infrastructure/IGatewayService'; +import type {IUserRepository} from '../../../user/IUserRepository'; +import {createGuildMfaEnforcer} from '../GuildMfaEnforcement'; interface GuildAuth { guildData: GuildResponse; @@ -16,14 +18,19 @@ interface GuildAuth { } export class GuildMemberAuthService { - constructor(private readonly gatewayService: IGatewayService) {} + constructor( + private readonly gatewayService: IGatewayService, + private readonly userRepository: IUserRepository, + ) {} async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { const guildData = await this.gatewayService.getGuildData({guildId, userId}); if (!guildData) throw new MissingAccessError(); + const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); const checkPermission = async (permission: bigint) => { const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); if (!hasPermission) throw new MissingPermissionsError(); + enforceGuildMfa(permission); }; const checkTargetMember = async (targetUserId: UserID) => { const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId}); diff --git a/fluxer_api/src/api/models/GiftCode.ts b/fluxer_api/src/api/models/GiftCode.ts index cc0bfb91b..b6389483e 100644 --- a/fluxer_api/src/api/models/GiftCode.ts +++ b/fluxer_api/src/api/models/GiftCode.ts @@ -113,6 +113,7 @@ export class GiftCode { readonly stripePaymentIntentId: string | null; readonly visionarySequenceNumber: number | null; readonly checkoutSessionId: string | null; + readonly revokedAt: Date | null; readonly version: number; constructor(row: GiftCodeRow) { @@ -128,6 +129,7 @@ export class GiftCode { this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null; this.visionarySequenceNumber = row.visionary_sequence_number ?? null; this.checkoutSessionId = row.checkout_session_id ?? null; + this.revokedAt = row.revoked_at ?? null; this.version = row.version; } @@ -144,6 +146,7 @@ export class GiftCode { stripe_payment_intent_id: this.stripePaymentIntentId, visionary_sequence_number: this.visionarySequenceNumber, checkout_session_id: this.checkoutSessionId, + revoked_at: this.revokedAt, version: this.version, }; } diff --git a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts index 0b4e894de..cd22b7023 100644 --- a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts @@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler { reason: 'gift_refund', chargeId: charge.id, }); + } else if (!giftCode.revokedAt) { + await this.userRepository.revokeGiftCode(giftCode.code); + Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund'); } return; } @@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler { {giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId}, 'Premium revoked due to gift chargeback', ); + } else if (!giftCode.revokedAt) { + await this.userRepository.revokeGiftCode(giftCode.code); + Logger.debug( + {giftCode: giftCode.code, chargeId: extractId(dispute.charge)}, + 'Revoked unredeemed gift code after chargeback', + ); } await this.paymentFraudService.enforceAccountFraudAction({ userId: giftCode.createdByUserId, diff --git a/fluxer_api/src/api/stripe/services/StripeGiftService.ts b/fluxer_api/src/api/stripe/services/StripeGiftService.ts index ace1ce18f..9d3b5d4a1 100644 --- a/fluxer_api/src/api/stripe/services/StripeGiftService.ts +++ b/fluxer_api/src/api/stripe/services/StripeGiftService.ts @@ -40,7 +40,7 @@ export class StripeGiftService { async getGiftCode(code: string): Promise { const giftCode = await this.userRepository.findGiftCode(code); - if (!giftCode) { + if (!giftCode || giftCode.revokedAt) { throw new UnknownGiftCodeError(); } return giftCode; @@ -62,7 +62,7 @@ export class StripeGiftService { } try { const giftCode = await this.userRepository.findGiftCode(code); - if (!giftCode) { + if (!giftCode || giftCode.revokedAt) { Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption'); throw new UnknownGiftCodeError(); } @@ -258,6 +258,7 @@ export class StripeGiftService { const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000; const cutoff = Date.now() - redeemedGracePeriodMs; return gifts + .filter((gift) => gift.revokedAt === null) .filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 3ee9be586..571517612 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -252,11 +252,11 @@ export class StripeRefundService { return; } const user = await this.userRepository.findUnique(userId); - if (!user || user.firstRefundAt) { + if (!user) { return; } const subscriptionId = refund.metadata.subscription_id; - if (subscriptionId) { + if (subscriptionId && !user.firstRefundAt) { try { await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); } catch (error) { diff --git a/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts b/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts index 55368d065..a7e54e26f 100644 --- a/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts +++ b/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts @@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow { duration_type: durationType, duration_quantity: durationQuantity, duration_months: durationMonths, + revoked_at: data.revoked_at ?? null, }; } @@ -171,6 +172,10 @@ export class GiftCodeRepository { await batch.execute(); } + async revokeGiftCode(code: string): Promise { + await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())})); + } + async updateGiftCode(code: string, data: Partial): Promise { const batch = new BatchBuilder(); const patch: Record> = {}; diff --git a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts index 60067dee1..84991c144 100644 --- a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts @@ -37,6 +37,7 @@ export interface IUserContentRepository { findGiftCodesByRedeemer(userId: UserID): Promise>; redeemGiftCode(code: string, userId: UserID): Promise; unredeemGiftCode(code: string, userId: UserID): Promise; + revokeGiftCode(code: string): Promise; updateGiftCode(code: string, data: Partial): Promise; linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise; listPushSubscriptions(userId: UserID): Promise>; diff --git a/fluxer_api/src/api/user/repositories/UserContentRepository.ts b/fluxer_api/src/api/user/repositories/UserContentRepository.ts index c1adc1e93..71d07bcdd 100644 --- a/fluxer_api/src/api/user/repositories/UserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserContentRepository.ts @@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository { return this.giftCodeRepository.unredeemGiftCode(code, userId); } + async revokeGiftCode(code: string): Promise { + return this.giftCodeRepository.revokeGiftCode(code); + } + async updateGiftCode(code: string, data: Partial): Promise { return this.giftCodeRepository.updateGiftCode(code, data); } diff --git a/fluxer_api/src/api/user/repositories/UserRepository.ts b/fluxer_api/src/api/user/repositories/UserRepository.ts index 199ee994e..512851cde 100644 --- a/fluxer_api/src/api/user/repositories/UserRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserRepository.ts @@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate { return this.contentRepo.unredeemGiftCode(code, userId); } + async revokeGiftCode(code: string): Promise { + return this.contentRepo.revokeGiftCode(code); + } + async updateGiftCode(code: string, data: Partial): Promise { return this.contentRepo.updateGiftCode(code, data); }