mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(api): close auth, billing and authorization bypasses (#2065)
This commit is contained in:
1 parent
9a54bbba2d
commit
5474be3efa
23 files changed
+217
-78
No files matched your search
@@ -359,13 +359,36 @@ export async function login(
|
|||||||
|
|
||||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
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<void> {
|
||||||
|
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(
|
export async function loginMfaTotp(
|
||||||
ctx: ApiContext,
|
ctx: ApiContext,
|
||||||
{code, ticket, request}: LoginMfaTotpParams,
|
{code, ticket, request}: LoginMfaTotpParams,
|
||||||
): Promise<LoginTokenResult> {
|
): Promise<LoginTokenResult> {
|
||||||
const {users, cache} = ctx.services;
|
const {users, cache, rateLimit} = ctx.services;
|
||||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||||
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
|
|||||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||||
}
|
}
|
||||||
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
|
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
|
||||||
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
|
|
||||||
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
|
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
|
||||||
}
|
|
||||||
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
mfaSecret: user.totpSecret,
|
mfaSecret: user.totpSecret,
|
||||||
code,
|
code,
|
||||||
allowBackup: true,
|
allowBackup: true,
|
||||||
});
|
});
|
||||||
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
|
|
||||||
const attempts = ((await cache.get<number>(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);
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||||
}
|
}
|
||||||
await cache.delete(`mfa-ticket:${ticket}`);
|
await cache.delete(`mfa-ticket:${ticket}`);
|
||||||
await cache.delete(attemptsKey);
|
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||||
await cache.delete(userAttemptsKey);
|
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||||
user,
|
user,
|
||||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||||
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
|
|||||||
ctx: ApiContext,
|
ctx: ApiContext,
|
||||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||||
): Promise<LoginTokenResult> {
|
): Promise<LoginTokenResult> {
|
||||||
const {users, cache} = ctx.services;
|
const {users, cache, rateLimit} = ctx.services;
|
||||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||||
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
|
|||||||
throw new UnknownUserError();
|
throw new UnknownUserError();
|
||||||
}
|
}
|
||||||
AuthUtility.assertNonBotUser(ctx, user);
|
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 AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||||
await cache.delete(`mfa-ticket:${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, {
|
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||||
user,
|
user,
|
||||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export async function resetPassword(
|
|||||||
},
|
},
|
||||||
user.toRow(),
|
user.toRow(),
|
||||||
);
|
);
|
||||||
await users.deleteAllAuthSessions(user.id);
|
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||||
await users.deletePasswordResetToken(data.token);
|
await users.deletePasswordResetToken(data.token);
|
||||||
const hasMfa =
|
const hasMfa =
|
||||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
|
|||||||
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
|
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
|
||||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||||
|
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
|
||||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||||
import type {Channel} from '../../models/Channel';
|
import type {Channel} from '../../models/Channel';
|
||||||
import type {GuildMember} from '../../models/GuildMember';
|
import type {GuildMember} from '../../models/GuildMember';
|
||||||
@@ -176,9 +177,15 @@ export abstract class BaseChannelAuthService {
|
|||||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||||
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
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<void> => {
|
const checkPermission = async (permission: bigint): Promise<void> => {
|
||||||
const allowed = await hasPermission(permission);
|
const allowed = await hasPermission(permission);
|
||||||
if (!allowed) throw new MissingPermissionsError();
|
if (!allowed) throw new MissingPermissionsError();
|
||||||
|
enforceGuildMfa(permission);
|
||||||
};
|
};
|
||||||
await checkPermission(Permissions.VIEW_CHANNEL);
|
await checkPermission(Permissions.VIEW_CHANNEL);
|
||||||
const parentCategory = await this.getParentCategoryContentWarningView({
|
const parentCategory = await this.getParentCategoryContentWarningView({
|
||||||
|
|||||||
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
|
|||||||
let permissionOverwrites = channel.permissionOverwrites;
|
let permissionOverwrites = channel.permissionOverwrites;
|
||||||
if (data.permission_overwrites !== undefined) {
|
if (data.permission_overwrites !== undefined) {
|
||||||
const guildId = createGuildID(BigInt(guild.id));
|
const guildId = createGuildID(BigInt(guild.id));
|
||||||
const canManageRoles = await this.gatewayService.checkPermission({
|
await checkPermission(Permissions.MANAGE_ROLES);
|
||||||
guildId,
|
|
||||||
userId,
|
|
||||||
permission: Permissions.MANAGE_ROLES,
|
|
||||||
});
|
|
||||||
if (!canManageRoles) throw new MissingPermissionsError();
|
|
||||||
const isOwner = guild.owner_id === userId.toString();
|
const isOwner = guild.owner_id === userId.toString();
|
||||||
const channelPermissions = await this.gatewayService.getUserPermissions({
|
const channelPermissions = await this.gatewayService.getUserPermissions({
|
||||||
guildId,
|
guildId,
|
||||||
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
|
|||||||
throw new MissingPermissionsError();
|
throw new MissingPermissionsError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const nextDeny = new Map<RoleID | UserID, bigint>();
|
||||||
|
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();
|
permissionOverwrites = new Map();
|
||||||
for (const overwrite of data.permission_overwrites ?? []) {
|
for (const overwrite of data.permission_overwrites ?? []) {
|
||||||
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
|
|||||||
const canManageRoles = await this.gatewayService.checkPermission({
|
const canManageRoles = await this.gatewayService.checkPermission({
|
||||||
guildId: channel.guildId,
|
guildId: channel.guildId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
channelId: channel.id,
|
||||||
permission: Permissions.MANAGE_ROLES,
|
permission: Permissions.MANAGE_ROLES,
|
||||||
});
|
});
|
||||||
if (!canManageRoles) throw new MissingPermissionsError();
|
if (!canManageRoles) throw new MissingPermissionsError();
|
||||||
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
|
|||||||
const sanitizedDeny = protectedBits.deny;
|
const sanitizedDeny = protectedBits.deny;
|
||||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||||
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
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 previousPermissionOverwrites = channel.permissionOverwrites;
|
||||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||||
overwrites.set(
|
overwrites.set(
|
||||||
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
|
|||||||
const canManageRoles = await this.gatewayService.checkPermission({
|
const canManageRoles = await this.gatewayService.checkPermission({
|
||||||
guildId: channel.guildId,
|
guildId: channel.guildId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
channelId: channel.id,
|
||||||
permission: Permissions.MANAGE_ROLES,
|
permission: Permissions.MANAGE_ROLES,
|
||||||
});
|
});
|
||||||
if (!canManageRoles) throw new MissingPermissionsError();
|
if (!canManageRoles) throw new MissingPermissionsError();
|
||||||
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
|
|||||||
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
||||||
const removedUser = overwrites.get(createUserID(params.overwriteId));
|
const removedUser = overwrites.get(createUserID(params.overwriteId));
|
||||||
const removed = removedRole ?? removedUser;
|
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(createRoleID(params.overwriteId));
|
||||||
overwrites.delete(createUserID(params.overwriteId));
|
overwrites.delete(createUserID(params.overwriteId));
|
||||||
const updated = await this.channelRepository.channelData.upsert({
|
const updated = await this.channelRepository.channelData.upsert({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
|
|||||||
stripe_payment_intent_id: Nullish<string>;
|
stripe_payment_intent_id: Nullish<string>;
|
||||||
visionary_sequence_number: Nullish<number>;
|
visionary_sequence_number: Nullish<number>;
|
||||||
checkout_session_id: Nullish<string>;
|
checkout_session_id: Nullish<string>;
|
||||||
|
revoked_at?: Nullish<Date>;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
|
|||||||
'stripe_payment_intent_id',
|
'stripe_payment_intent_id',
|
||||||
'visionary_sequence_number',
|
'visionary_sequence_number',
|
||||||
'checkout_session_id',
|
'checkout_session_id',
|
||||||
|
'revoked_at',
|
||||||
'version',
|
'version',
|
||||||
] as const;
|
] as const;
|
||||||
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
|
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
|||||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||||
|
import type {IUserRepository} from '../../user/IUserRepository';
|
||||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||||
import {ChannelOperationsService} from './channel/ChannelOperationsService';
|
import {ChannelOperationsService} from './channel/ChannelOperationsService';
|
||||||
|
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||||
|
|
||||||
export class GuildChannelService {
|
export class GuildChannelService {
|
||||||
private readonly channelOps: ChannelOperationsService;
|
private readonly channelOps: ChannelOperationsService;
|
||||||
@@ -30,6 +32,7 @@ export class GuildChannelService {
|
|||||||
snowflakeService: ISnowflakeService,
|
snowflakeService: ISnowflakeService,
|
||||||
guildAuditLogService: GuildAuditLogService,
|
guildAuditLogService: GuildAuditLogService,
|
||||||
limitConfigService: LimitConfigService,
|
limitConfigService: LimitConfigService,
|
||||||
|
private readonly userRepository: IUserRepository,
|
||||||
) {
|
) {
|
||||||
this.channelOps = new ChannelOperationsService(
|
this.channelOps = new ChannelOperationsService(
|
||||||
channelRepository,
|
channelRepository,
|
||||||
@@ -131,5 +134,12 @@ export class GuildChannelService {
|
|||||||
permission: params.permission,
|
permission: params.permission,
|
||||||
});
|
});
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export class GuildDataService {
|
|||||||
private readonly guildAuditLogService: GuildAuditLogService,
|
private readonly guildAuditLogService: GuildAuditLogService,
|
||||||
private readonly limitConfigService: LimitConfigService,
|
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.operationsService = new GuildOperationsService(
|
||||||
this.guildRepository,
|
this.guildRepository,
|
||||||
this.channelRepository,
|
this.channelRepository,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class GuildMemberService {
|
|||||||
ipInfoService: IpInfoService,
|
ipInfoService: IpInfoService,
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.authService = new GuildMemberAuthService(gatewayService);
|
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
|
||||||
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
|
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
|
||||||
this.auditService = new GuildMemberAuditService(guildAuditLogService);
|
this.auditService = new GuildMemberAuditService(guildAuditLogService);
|
||||||
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
|
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
|
||||||
|
|||||||
@@ -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<GuildResponse, 'mfa_level' | 'owner_id'>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
|
|||||||
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
||||||
import {mapGuildBansToResponse} from '../GuildModel';
|
import {mapGuildBansToResponse} from '../GuildModel';
|
||||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||||
|
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||||
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
|
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
|
||||||
|
|
||||||
export class GuildModerationService {
|
export class GuildModerationService {
|
||||||
@@ -44,6 +45,19 @@ export class GuildModerationService {
|
|||||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkModerationPermission(params: {
|
||||||
|
guildId: GuildID;
|
||||||
|
userId: UserID;
|
||||||
|
permission: bigint;
|
||||||
|
}): Promise<void> {
|
||||||
|
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(
|
async banMember(
|
||||||
params: {
|
params: {
|
||||||
userId: UserID;
|
userId: UserID;
|
||||||
@@ -57,12 +71,7 @@ export class GuildModerationService {
|
|||||||
auditLogReason?: string | null,
|
auditLogReason?: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
|
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
|
||||||
const hasPermission = await this.gatewayService.checkPermission({
|
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||||
guildId,
|
|
||||||
userId,
|
|
||||||
permission: Permissions.BAN_MEMBERS,
|
|
||||||
});
|
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
|
||||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||||
const targetUser = await this.userRepository.findUnique(targetId);
|
const targetUser = await this.userRepository.findUnique(targetId);
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
@@ -145,12 +154,7 @@ export class GuildModerationService {
|
|||||||
requestCache: RequestCache;
|
requestCache: RequestCache;
|
||||||
}): Promise<Array<GuildBanResponse>> {
|
}): Promise<Array<GuildBanResponse>> {
|
||||||
const {userId, guildId, requestCache} = params;
|
const {userId, guildId, requestCache} = params;
|
||||||
const hasPermission = await this.gatewayService.checkPermission({
|
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||||
guildId,
|
|
||||||
userId,
|
|
||||||
permission: Permissions.BAN_MEMBERS,
|
|
||||||
});
|
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
|
||||||
const bans = await this.guildRepository.listBans(guildId);
|
const bans = await this.guildRepository.listBans(guildId);
|
||||||
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
|
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
|
||||||
}
|
}
|
||||||
@@ -164,12 +168,7 @@ export class GuildModerationService {
|
|||||||
auditLogReason?: string | null,
|
auditLogReason?: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {userId, guildId, targetId} = params;
|
const {userId, guildId, targetId} = params;
|
||||||
const hasPermission = await this.gatewayService.checkPermission({
|
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||||
guildId,
|
|
||||||
userId,
|
|
||||||
permission: Permissions.BAN_MEMBERS,
|
|
||||||
});
|
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
|
||||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||||
if (!ban) {
|
if (!ban) {
|
||||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
|
|||||||
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
||||||
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
|
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
|
||||||
import {GuildRole} from '../../models/GuildRole';
|
import {GuildRole} from '../../models/GuildRole';
|
||||||
|
import type {IUserRepository} from '../../user/IUserRepository';
|
||||||
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
|
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
|
||||||
import {computePermissionsDiff} from '../../utils/PermissionUtils';
|
import {computePermissionsDiff} from '../../utils/PermissionUtils';
|
||||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||||
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
|||||||
import {mapGuildRoleToResponse} from '../GuildModel';
|
import {mapGuildRoleToResponse} from '../GuildModel';
|
||||||
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
|
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
|
||||||
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
|
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
|
||||||
|
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||||
|
|
||||||
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
|
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export class GuildRoleService {
|
|||||||
private readonly gatewayService: IGatewayService,
|
private readonly gatewayService: IGatewayService,
|
||||||
private readonly guildAuditLogService: GuildAuditLogService,
|
private readonly guildAuditLogService: GuildAuditLogService,
|
||||||
private readonly limitConfigService: LimitConfigService,
|
private readonly limitConfigService: LimitConfigService,
|
||||||
|
private readonly userRepository: IUserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async systemCreateRole(params: {
|
async systemCreateRole(params: {
|
||||||
@@ -446,9 +449,11 @@ export class GuildRoleService {
|
|||||||
|
|
||||||
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||||
|
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||||
const checkPermission = async (permission: bigint) => {
|
const checkPermission = async (permission: bigint) => {
|
||||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
if (!hasPermission) throw new MissingPermissionsError();
|
||||||
|
enforceGuildMfa(permission);
|
||||||
};
|
};
|
||||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
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 {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 {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||||
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
|
|||||||
import {GuildContentService} from './GuildContentService';
|
import {GuildContentService} from './GuildContentService';
|
||||||
import {GuildDataService} from './GuildDataService';
|
import {GuildDataService} from './GuildDataService';
|
||||||
import {GuildMemberService} from './GuildMemberService';
|
import {GuildMemberService} from './GuildMemberService';
|
||||||
|
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||||
import {GuildModerationService} from './GuildModerationService';
|
import {GuildModerationService} from './GuildModerationService';
|
||||||
import {GuildRoleService} from './GuildRoleService';
|
import {GuildRoleService} from './GuildRoleService';
|
||||||
import {GuildSearchService} from './GuildSearchService';
|
import {GuildSearchService} from './GuildSearchService';
|
||||||
@@ -95,17 +95,6 @@ interface GuildAuth {
|
|||||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export class GuildService {
|
||||||
public readonly data: GuildDataService;
|
public readonly data: GuildDataService;
|
||||||
public readonly members: GuildMemberService;
|
public readonly members: GuildMemberService;
|
||||||
@@ -182,6 +171,7 @@ export class GuildService {
|
|||||||
gatewayService,
|
gatewayService,
|
||||||
guildAuditLogService,
|
guildAuditLogService,
|
||||||
limitConfigService,
|
limitConfigService,
|
||||||
|
userRepository,
|
||||||
);
|
);
|
||||||
this.moderation = new GuildModerationService(
|
this.moderation = new GuildModerationService(
|
||||||
guildRepository,
|
guildRepository,
|
||||||
@@ -211,6 +201,7 @@ export class GuildService {
|
|||||||
snowflakeService,
|
snowflakeService,
|
||||||
guildAuditLogService,
|
guildAuditLogService,
|
||||||
limitConfigService,
|
limitConfigService,
|
||||||
|
userRepository,
|
||||||
);
|
);
|
||||||
this.search = new GuildSearchService(
|
this.search = new GuildSearchService(
|
||||||
channelRepository,
|
channelRepository,
|
||||||
@@ -561,17 +552,7 @@ export class GuildService {
|
|||||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||||
if (!guildData) throw new MissingAccessError();
|
if (!guildData) throw new MissingAccessError();
|
||||||
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
|
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||||
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 checkPermission = async (permission: bigint) => {
|
const checkPermission = async (permission: bigint) => {
|
||||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
if (!hasPermission) throw new MissingPermissionsError();
|
||||||
|
|||||||
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
|
|||||||
requestCache,
|
requestCache,
|
||||||
});
|
});
|
||||||
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
|
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: {
|
private async syncPermissionsWithParent(params: {
|
||||||
guildId: GuildID;
|
guildId: GuildID;
|
||||||
|
userId: UserID;
|
||||||
channelId: ChannelID;
|
channelId: ChannelID;
|
||||||
parentId: ChannelID;
|
parentId: ChannelID;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
|
|||||||
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
|
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
|
||||||
const child = await this.channelRepository.findUnique(params.channelId);
|
const child = await this.channelRepository.findUnique(params.channelId);
|
||||||
if (!child || child.guildId !== params.guildId) return;
|
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({
|
await this.channelRepository.upsert({
|
||||||
...child.toRow(),
|
...child.toRow(),
|
||||||
permission_overwrites: new Map(
|
permission_overwrites: new Map(
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
|
|||||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||||
import {Logger} from '../../../Logger';
|
import {Logger} from '../../../Logger';
|
||||||
import type {Guild} from '../../../models/Guild';
|
import type {Guild} from '../../../models/Guild';
|
||||||
|
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||||
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
|
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
|
||||||
import {requirePermission} from '../../../utils/PermissionUtils';
|
import {requirePermission} from '../../../utils/PermissionUtils';
|
||||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||||
import {mapGuildToGuildResponse} from '../../GuildModel';
|
import {mapGuildToGuildResponse} from '../../GuildModel';
|
||||||
import {GuildRepository} from '../../repositories/GuildRepository';
|
import {GuildRepository} from '../../repositories/GuildRepository';
|
||||||
|
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||||
|
|
||||||
interface GuildAuth {
|
interface GuildAuth {
|
||||||
guildData: GuildResponse;
|
guildData: GuildResponse;
|
||||||
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly gatewayService: IGatewayService,
|
private readonly gatewayService: IGatewayService,
|
||||||
private readonly guildAuditLogService: GuildAuditLogService,
|
private readonly guildAuditLogService: GuildAuditLogService,
|
||||||
|
private readonly userRepository: IUserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private readonly guildRepository = new GuildRepository();
|
private readonly guildRepository = new GuildRepository();
|
||||||
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
|
|||||||
try {
|
try {
|
||||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||||
if (!guildData) throw new UnknownGuildError();
|
if (!guildData) throw new UnknownGuildError();
|
||||||
return this.createGuildAuth({guildData, guildId, userId});
|
return await this.createGuildAuth({guildData, guildId, userId});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
|
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
|
||||||
throw new AccessDeniedError();
|
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<GuildAuth> {
|
||||||
const {guildData, guildId, userId} = params;
|
const {guildData, guildId, userId} = params;
|
||||||
|
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||||
const checkPermission = async (permission: bigint) => {
|
const checkPermission = async (permission: bigint) => {
|
||||||
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
||||||
|
enforceGuildMfa(permission);
|
||||||
};
|
};
|
||||||
return {guildData, checkPermission};
|
return {guildData, checkPermission};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||||
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||||
|
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||||
|
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||||
|
|
||||||
interface GuildAuth {
|
interface GuildAuth {
|
||||||
guildData: GuildResponse;
|
guildData: GuildResponse;
|
||||||
@@ -16,14 +18,19 @@ interface GuildAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GuildMemberAuthService {
|
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<GuildAuth> {
|
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||||
if (!guildData) throw new MissingAccessError();
|
if (!guildData) throw new MissingAccessError();
|
||||||
|
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||||
const checkPermission = async (permission: bigint) => {
|
const checkPermission = async (permission: bigint) => {
|
||||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||||
if (!hasPermission) throw new MissingPermissionsError();
|
if (!hasPermission) throw new MissingPermissionsError();
|
||||||
|
enforceGuildMfa(permission);
|
||||||
};
|
};
|
||||||
const checkTargetMember = async (targetUserId: UserID) => {
|
const checkTargetMember = async (targetUserId: UserID) => {
|
||||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
|
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export class GiftCode {
|
|||||||
readonly stripePaymentIntentId: string | null;
|
readonly stripePaymentIntentId: string | null;
|
||||||
readonly visionarySequenceNumber: number | null;
|
readonly visionarySequenceNumber: number | null;
|
||||||
readonly checkoutSessionId: string | null;
|
readonly checkoutSessionId: string | null;
|
||||||
|
readonly revokedAt: Date | null;
|
||||||
readonly version: number;
|
readonly version: number;
|
||||||
|
|
||||||
constructor(row: GiftCodeRow) {
|
constructor(row: GiftCodeRow) {
|
||||||
@@ -128,6 +129,7 @@ export class GiftCode {
|
|||||||
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
|
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
|
||||||
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
|
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
|
||||||
this.checkoutSessionId = row.checkout_session_id ?? null;
|
this.checkoutSessionId = row.checkout_session_id ?? null;
|
||||||
|
this.revokedAt = row.revoked_at ?? null;
|
||||||
this.version = row.version;
|
this.version = row.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@ export class GiftCode {
|
|||||||
stripe_payment_intent_id: this.stripePaymentIntentId,
|
stripe_payment_intent_id: this.stripePaymentIntentId,
|
||||||
visionary_sequence_number: this.visionarySequenceNumber,
|
visionary_sequence_number: this.visionarySequenceNumber,
|
||||||
checkout_session_id: this.checkoutSessionId,
|
checkout_session_id: this.checkoutSessionId,
|
||||||
|
revoked_at: this.revokedAt,
|
||||||
version: this.version,
|
version: this.version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
|
|||||||
reason: 'gift_refund',
|
reason: 'gift_refund',
|
||||||
chargeId: charge.id,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
|
|||||||
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
|
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
|
||||||
'Premium revoked due to gift chargeback',
|
'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({
|
await this.paymentFraudService.enforceAccountFraudAction({
|
||||||
userId: giftCode.createdByUserId,
|
userId: giftCode.createdByUserId,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class StripeGiftService {
|
|||||||
|
|
||||||
async getGiftCode(code: string): Promise<GiftCode> {
|
async getGiftCode(code: string): Promise<GiftCode> {
|
||||||
const giftCode = await this.userRepository.findGiftCode(code);
|
const giftCode = await this.userRepository.findGiftCode(code);
|
||||||
if (!giftCode) {
|
if (!giftCode || giftCode.revokedAt) {
|
||||||
throw new UnknownGiftCodeError();
|
throw new UnknownGiftCodeError();
|
||||||
}
|
}
|
||||||
return giftCode;
|
return giftCode;
|
||||||
@@ -62,7 +62,7 @@ export class StripeGiftService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const giftCode = await this.userRepository.findGiftCode(code);
|
const giftCode = await this.userRepository.findGiftCode(code);
|
||||||
if (!giftCode) {
|
if (!giftCode || giftCode.revokedAt) {
|
||||||
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
|
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
|
||||||
throw new UnknownGiftCodeError();
|
throw new UnknownGiftCodeError();
|
||||||
}
|
}
|
||||||
@@ -258,6 +258,7 @@ export class StripeGiftService {
|
|||||||
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
|
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
const cutoff = Date.now() - redeemedGracePeriodMs;
|
const cutoff = Date.now() - redeemedGracePeriodMs;
|
||||||
return gifts
|
return gifts
|
||||||
|
.filter((gift) => gift.revokedAt === null)
|
||||||
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
|
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,11 +252,11 @@ export class StripeRefundService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await this.userRepository.findUnique(userId);
|
const user = await this.userRepository.findUnique(userId);
|
||||||
if (!user || user.firstRefundAt) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subscriptionId = refund.metadata.subscription_id;
|
const subscriptionId = refund.metadata.subscription_id;
|
||||||
if (subscriptionId) {
|
if (subscriptionId && !user.firstRefundAt) {
|
||||||
try {
|
try {
|
||||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
|
|||||||
duration_type: durationType,
|
duration_type: durationType,
|
||||||
duration_quantity: durationQuantity,
|
duration_quantity: durationQuantity,
|
||||||
duration_months: durationMonths,
|
duration_months: durationMonths,
|
||||||
|
revoked_at: data.revoked_at ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
|
|||||||
await batch.execute();
|
await batch.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revokeGiftCode(code: string): Promise<void> {
|
||||||
|
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
|
||||||
|
}
|
||||||
|
|
||||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||||
const batch = new BatchBuilder();
|
const batch = new BatchBuilder();
|
||||||
const patch: Record<string, DbOp<unknown>> = {};
|
const patch: Record<string, DbOp<unknown>> = {};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
|
|||||||
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
|
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
|
||||||
redeemGiftCode(code: string, userId: UserID): Promise<void>;
|
redeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||||
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
|
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||||
|
revokeGiftCode(code: string): Promise<void>;
|
||||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
|
|||||||
return this.giftCodeRepository.unredeemGiftCode(code, userId);
|
return this.giftCodeRepository.unredeemGiftCode(code, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revokeGiftCode(code: string): Promise<void> {
|
||||||
|
return this.giftCodeRepository.revokeGiftCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
|
|||||||
return this.contentRepo.unredeemGiftCode(code, userId);
|
return this.contentRepo.unredeemGiftCode(code, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revokeGiftCode(code: string): Promise<void> {
|
||||||
|
return this.contentRepo.revokeGiftCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||||
return this.contentRepo.updateGiftCode(code, data);
|
return this.contentRepo.updateGiftCode(code, data);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user