From 7c1f79db2a6d7547fa1eee3055482a2a39d59d76 Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Tue, 26 Aug 2025 12:32:22 +0300 Subject: [PATCH] feat(ban-system): implement multi-level user ban checks across services - Added a comprehensive ban checking system to prevent access for banned users at multiple levels, including database, API, and client-side. - Introduced `bannedUntil` field in the customer model to manage temporary and permanent bans effectively. - Enhanced `BaseService` and various service classes to include ban checks, ensuring that banned users cannot perform actions or access data. - Updated error handling to provide consistent feedback for banned users across the application. - Improved user experience with a dedicated ban check component and a user-friendly ban notification page. --- apps/bot/locales/ru.ftl | 1 + apps/bot/src/bot/handlers/errors.ts | 8 +- apps/bot/src/index.ts | 20 ++- apps/web/BAN_CHECK_README.md | 238 ++++++++++++++------------- packages/graphql/api/base.ts | 33 +++- packages/graphql/api/customers.ts | 14 +- packages/graphql/api/registration.ts | 35 ++++ packages/graphql/api/services.ts | 8 + packages/graphql/api/slots.ts | 12 ++ 9 files changed, 241 insertions(+), 128 deletions(-) diff --git a/apps/bot/locales/ru.ftl b/apps/bot/locales/ru.ftl index 7d0b3fe..a663c8b 100644 --- a/apps/bot/locales/ru.ftl +++ b/apps/bot/locales/ru.ftl @@ -99,6 +99,7 @@ msg-unhandled = ❓ Неизвестная команда. Попробуйте # Ошибки err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд +err-banned = 🚫 Ваш аккаунт заблокирован err-with-details = ❌ Произошла ошибка { $error } err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного \ No newline at end of file diff --git a/apps/bot/src/bot/handlers/errors.ts b/apps/bot/src/bot/handlers/errors.ts index 5545275..074f660 100644 --- a/apps/bot/src/bot/handlers/errors.ts +++ b/apps/bot/src/bot/handlers/errors.ts @@ -1,10 +1,16 @@ import { type Context } from '../context'; import { getUpdateInfo } from '../helpers/logging'; +import { KEYBOARD_REMOVE } from '@/config/keyboards'; +import { ERRORS } from '@repo/graphql/constants/errors'; import { type ErrorHandler } from 'grammy'; -export const errorHandler: ErrorHandler = (error) => { +export const errorHandler: ErrorHandler = async (error) => { const { ctx } = error; + const text = error.message.includes(ERRORS.NO_PERMISSION) ? 'err-banned' : 'err-generic'; + + await ctx.reply(ctx.t(text), { ...KEYBOARD_REMOVE, parse_mode: 'HTML' }); + ctx.logger.error({ err: error.error, update: getUpdateInfo(ctx), diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 3f3efb3..2bf1f47 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -8,20 +8,22 @@ const bot = createBot({ token: environment.BOT_TOKEN, }); -const runner = run(bot); +bot.catch((error) => { + logger.error('Grammy bot error:'); + logger.error(`Message: ${error?.message}`); + logger.error(error.error); +}); +const runner = run(bot); const redis = getRedisInstance(); -// Graceful shutdown function async function gracefulShutdown(signal: string) { logger.info(`Received ${signal}, starting graceful shutdown...`); try { - // Stop the bot await runner.stop(); logger.info('Bot stopped'); - // Disconnect Redis redis.disconnect(); logger.info('Redis disconnected'); } catch (error) { @@ -30,9 +32,15 @@ async function gracefulShutdown(signal: string) { } } -// Stopping the bot when the Node.js process -// is about to be terminated process.once('SIGINT', () => gracefulShutdown('SIGINT')); process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('unhandledRejection', (reason) => { + logger.error('Unhandled Rejection: ' + reason); +}); + +process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception: ' + error); +}); + logger.info('Bot started'); diff --git a/apps/web/BAN_CHECK_README.md b/apps/web/BAN_CHECK_README.md index 96046ee..f2318ab 100644 --- a/apps/web/BAN_CHECK_README.md +++ b/apps/web/BAN_CHECK_README.md @@ -1,51 +1,44 @@ -# Проверка бана пользователей +# Система проверки бана пользователей -В проекте реализована система проверки бана пользователей на нескольких уровнях: +## Обзор -## Поле `bannedUntil` в базе данных +Реализована многоуровневая система проверки бана пользователей, которая предотвращает доступ заблокированных пользователей к функциональности приложения. -Проверка бана основана на поле `bannedUntil` в таблице `Customer`: +## 1. База данных (`bannedUntil` поле) -- `bannedUntil: null` - пользователь не забанен (по умолчанию) -- `bannedUntil: "2024-12-31T23:59:59Z"` - забанен до этой даты -- `bannedUntil: "2099-12-31T23:59:59Z"` - забанен навсегда (100 лет) +В Strapi добавлено поле `bannedUntil` типа `datetime` в модель `Customer`: +- `null` = пользователь не забанен +- `дата в будущем` = временный бан до указанной даты +- `дата в далеком будущем` = постоянный бан -## Утилита для проверки бана +## 2. Утилита проверки (`packages/utils/src/customer.ts`) ```typescript -// utils/customer.ts export function isCustomerBanned(customer: { bannedUntil?: string | null }): boolean { return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil)); } ``` -## 1. Next Auth Configuration (`config/auth.ts`) +## 3. Next Auth проверка (`apps/web/config/auth.ts`) -Основная проверка происходит в callback `authorize` провайдера Credentials: +В `authorize` callback добавлена проверка бана: ```typescript async authorize(credentials) { const { telegramId } = credentials ?? {}; - - if (!telegramId) { - throw new Error('Invalid Telegram ID'); - } - + if (!telegramId) { throw new Error('Invalid Telegram ID'); } + try { - // Проверяем, не забанен ли пользователь const { query } = await getClientWithToken(); const result = await query({ query: GetCustomerDocument, variables: { telegramId: Number(telegramId) }, }); - const customer = result.data.customers.at(0); - // Если пользователь не найден или забанен if (!customer || isCustomerBanned(customer)) { throw new Error('User is banned or not found'); } - return { id: telegramId }; } catch (error) { throw new Error('Authentication failed'); @@ -53,49 +46,66 @@ async authorize(credentials) { } ``` -**Преимущества:** -- Проверка происходит на уровне аутентификации -- Забаненные пользователи не могут войти в систему -- Автоматическая блокировка при попытке входа +## 4. Универсальная проверка в BaseService (`packages/graphql/api/base.ts`) -## 2. BaseService (`packages/graphql/api/base.ts`) - -Проверка на уровне API сервисов: +Добавлен метод `checkIsBanned()` в `BaseService`: ```typescript -protected async _getUser() { +/** + * Универсальная проверка статуса бана пользователя + * Должна вызываться в начале каждого метода сервиса + */ +protected async checkIsBanned() { const { query } = await getClientWithToken(); - const result = await query({ query: GQL.GetCustomerDocument, variables: this._user, }); - const customer = result.data.customers.at(0); - - if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER); - - // Проверяем, не забанен ли пользователь - if (isCustomerBanned(customer)) { - throw new Error(ERRORS.USER_BANNED); + + if (!customer) { + throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER); } - + + if (isCustomerBanned(customer)) { + throw new Error(ERRORS.NO_PERMISSION); + } + return { customer }; } ``` -**Преимущества:** -- Все API запросы автоматически проверяют статус пользователя -- Единая точка проверки для всех сервисов -- Автоматическое отклонение запросов от забаненных пользователей +**Использование в сервисах:** +```typescript +async someMethod() { + await this.checkIsBanned(); // Проверка бана в начале метода + // ... остальная логика +} +``` -## 3. Защита от изменения статуса бана (`packages/graphql/api/customers.ts` и `registration.ts`) +**Обновленные сервисы:** +- ✅ `CustomersService` - все методы +- ✅ `ServicesService` - все методы +- ✅ `OrdersService` - все методы +- ✅ `SlotsService` - все методы +- ✅ `RegistrationService` - добавлена собственная проверка + +**Преимущества:** +- Автоматическая проверка во всех сервисах, наследующих от BaseService +- Единая точка проверки бана +- Работает как в веб-приложении, так и в боте +- Простота добавления в новые методы +- Защита всех API методов от забаненных пользователей + +## 5. Защита от изменения статуса бана (`packages/graphql/api/customers.ts` и `registration.ts`) Пользователи не могут изменять поле `bannedUntil` самостоятельно: ```typescript // В CustomersService async updateCustomer(variables: Omit, 'documentId'>) { + await this.checkBanStatus(); + const { customer } = await this._getUser(); // Проверяем, что пользователь не пытается изменить поле bannedUntil @@ -108,7 +118,19 @@ async updateCustomer(variables: Omit) { - // Проверяем, что пользователь не пытается изменить поле bannedUntil + // Проверяем бан для существующего пользователя + if (variables.documentId) { + const { query } = await getClientWithToken(); + const result = await query({ + query: GQL.GetCustomerDocument, + variables: { documentId: variables.documentId }, + }); + const customer = result.data.customers.at(0); + if (customer && isCustomerBanned(customer)) { + throw new Error(ERRORS.NO_PERMISSION); + } + } + if (variables.data.bannedUntil) { throw new Error(ERRORS.NO_PERMISSION); } @@ -124,105 +146,95 @@ async updateCustomer(variables: VariablesOf) - Защита работает во всех сервисах, которые обновляют данные пользователей - Единая ошибка `NO_PERMISSION` для всех случаев отсутствия доступа -## 4. Client-side Check (`components/auth/check-banned.tsx`) +## 6. Client-side Check (`components/auth/ban-check.tsx`) -Проверка на клиентской стороне: +React компонент для проверки бана на клиенте: ```typescript -import { useIsBanned } from '@/hooks/api/customers'; -import { redirect, RedirectType } from 'next/navigation'; -import { type PropsWithChildren, useEffect } from 'react'; - -export function CheckBanned({ children }: Readonly) { +export function BanCheck({ children }: Readonly) { + const { data: session } = useSession(); + const router = useRouter(); const isBanned = useIsBanned(); useEffect(() => { - if (isBanned) { - redirect('/banned', RedirectType.replace); + if (session?.user?.telegramId && isBanned) { + router.push('/banned'); } - }, [isBanned]); + }, [session?.user?.telegramId, isBanned, router]); - if (isBanned) return null; + if (session?.user?.telegramId && isBanned) { + return null; + } - return children; + return <>{children}; } - ``` -**Преимущества:** -- Мгновенная реакция на изменение статуса -- Автоматическое перенаправление на страницу бана -- Предотвращение рендеринга контента для забаненных пользователей +**Использование в layout:** +```typescript +export default function Layout({ children }: Readonly) { + return ( + + + +
{children}
+ +
+
+ ); +} +``` -## 5. Hook для проверки бана (`hooks/api/customers.ts`) +## 7. Hook для проверки бана (`hooks/api/customers.ts`) ```typescript export const useIsBanned = () => { const { data: { customer } = {} } = useCustomerQuery(); - if (!customer) return false; - return isCustomerBanned(customer); }; ``` -**Использование:** -```typescript -const isBanned = useIsBanned(); +## 8. Страница для забаненных пользователей (`apps/web/app/(auth)/banned/page.tsx`) -if (isBanned) { - // Показать сообщение о бане -} -``` +Создана специальная страница с информацией о бане и возможностью выхода из аккаунта. -## 6. Страница бана (`app/(auth)/banned/page.tsx`) - -Специальная страница для забаненных пользователей с информацией о причинах блокировки и возможностью выхода из аккаунта. - -## Рекомендации по использованию - -1. **Основная защита**: Next Auth configuration - предотвращает вход забаненных пользователей -2. **API защита**: BaseService - блокирует все API запросы от забаненных пользователей -3. **Защита от изменения**: CustomersService - предотвращает изменение статуса бана пользователями -4. **UI защита**: BanCheck компонент - обеспечивает корректное отображение интерфейса -5. **Дополнительные проверки**: useIsBanned хук - для условной логики в компонентах - -## Добавление новых проверок - -Для добавления проверки бана в новый компонент: +## 9. Централизованные ошибки (`packages/graphql/constants/errors.ts`) ```typescript -import { useIsBanned } from '@/hooks/api/customers'; - -function MyComponent() { - const isBanned = useIsBanned(); - - if (isBanned) { - return
Вы заблокированы
; - } - - return
Обычный контент
; -} +export const ERRORS = { + NO_PERMISSION: 'Нет доступа', +} as const; ``` -## Автоматическое снятие бана +## Архитектура системы -Система автоматически снимает бан, когда текущая дата превышает `bannedUntil`: - -```typescript -// Пример: пользователь забанен до 2024-12-31 -const customer = { - bannedUntil: "2024-12-31T23:59:59Z" -}; - -// После 2024-12-31 функция вернет false -const isBanned = isCustomerBanned(customer); // false +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Next Auth │ │ BaseService │ │ Client-side │ +│ (авторизация) │ │ (API методы) │ │ (UI) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ isCustomerBanned│ │ checkBanStatus()│ │ BanCheck │ +│ (утилита) │ │ (метод) │ │ (компонент) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + ▼ + ┌─────────────────────────┐ + │ bannedUntil (DB) │ + │ (Strapi/PostgreSQL) │ + └─────────────────────────┘ ``` -## Безопасность +## Преимущества системы -- Проверки выполняются на нескольких уровнях для максимальной безопасности -- Серверные проверки (Next Auth, BaseService) являются основными -- Клиентские проверки обеспечивают UX -- Все проверки основаны на поле `bannedUntil` из базы данных -- **Поле `bannedUntil` защищено от изменения пользователями** - только администраторы могут изменять статус блокировки \ No newline at end of file +✅ **Многоуровневая защита** - проверка на всех уровнях приложения +✅ **Универсальность** - работает в веб-приложении и боте +✅ **Простота использования** - один вызов `checkBanStatus()` в начале метода +✅ **Безопасность** - пользователи не могут обойти бан +✅ **UX** - понятные сообщения и страница для забаненных +✅ **DRY принцип** - нет дублирования кода +✅ **Легкость расширения** - просто добавить новые проверки \ No newline at end of file diff --git a/packages/graphql/api/base.ts b/packages/graphql/api/base.ts index c9e47ff..fced362 100644 --- a/packages/graphql/api/base.ts +++ b/packages/graphql/api/base.ts @@ -1,13 +1,13 @@ /* eslint-disable canonical/id-match */ import { getClientWithToken } from '../apollo/client'; +import { ERRORS } from '../constants/errors'; import * as GQL from '../types'; import { isCustomerBanned } from '@repo/utils/customer'; -export const ERRORS = { +const BASE_ERRORS = { MISSING_TELEGRAM_ID: 'Не указан Telegram ID', NOT_FOUND_CUSTOMER: 'Пользователь не найден', - USER_BANNED: 'Пользователь заблокирован', -}; +} as const; type UserProfile = { telegramId: number; @@ -18,7 +18,7 @@ export class BaseService { constructor(user: UserProfile) { if (!user?.telegramId) { - throw new Error(ERRORS.MISSING_TELEGRAM_ID); + throw new Error(BASE_ERRORS.MISSING_TELEGRAM_ID); } this._user = user; @@ -34,10 +34,31 @@ export class BaseService { const customer = result.data.customers.at(0); - if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER); + if (!customer) throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER); if (isCustomerBanned(customer)) { - throw new Error(ERRORS.USER_BANNED); + throw new Error(ERRORS.NO_PERMISSION); + } + + return { customer }; + } + + protected async checkIsBanned() { + const { query } = await getClientWithToken(); + + const result = await query({ + query: GQL.GetCustomerDocument, + variables: this._user, + }); + + const customer = result.data.customers.at(0); + + if (!customer) { + throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER); + } + + if (isCustomerBanned(customer)) { + throw new Error(ERRORS.NO_PERMISSION); } return { customer }; diff --git a/packages/graphql/api/customers.ts b/packages/graphql/api/customers.ts index d675cc3..c8bec94 100644 --- a/packages/graphql/api/customers.ts +++ b/packages/graphql/api/customers.ts @@ -6,9 +6,11 @@ import { type VariablesOf } from '@graphql-typed-document-node/core'; export class CustomersService extends BaseService { async addMasters(variables: VariablesOf) { + await this.checkIsBanned(); + const newMasterIds = variables.data.masters; - // Проверяем, что пользователь не пытается изменить поле bannedUntil + // Проверяем, что пользователь не пытается изменить поле bannedUntil if (variables.data.bannedUntil !== undefined) { throw new Error(ERRORS.NO_PERMISSION); } @@ -40,6 +42,8 @@ export class CustomersService extends BaseService { } async getClients(variables?: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -53,6 +57,8 @@ export class CustomersService extends BaseService { } async getCustomer(variables: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -66,6 +72,8 @@ export class CustomersService extends BaseService { } async getMasters(variables?: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -81,9 +89,11 @@ export class CustomersService extends BaseService { async updateCustomer( variables: Omit, 'documentId'>, ) { + await this.checkIsBanned(); + const { customer } = await this._getUser(); - // Проверяем, что пользователь не пытается изменить поле bannedUntil + // Пров еряем, что пользователь не пытается изменить поле bannedUntil if (variables.data.bannedUntil !== undefined) { throw new Error(ERRORS.NO_PERMISSION); } diff --git a/packages/graphql/api/registration.ts b/packages/graphql/api/registration.ts index 5c21d30..d65946c 100644 --- a/packages/graphql/api/registration.ts +++ b/packages/graphql/api/registration.ts @@ -2,6 +2,7 @@ import { getClientWithToken } from '../apollo/client'; import { ERRORS } from '../constants/errors'; import * as GQL from '../types'; import { type VariablesOf } from '@graphql-typed-document-node/core'; +import { isCustomerBanned } from '@repo/utils/customer'; export class RegistrationService { async createCustomer(variables: VariablesOf) { @@ -19,6 +20,10 @@ export class RegistrationService { } async getCustomer(variables: VariablesOf) { + if (variables.telegramId) { + await this.checkBanStatus(variables.telegramId); + } + const { query } = await getClientWithToken(); const result = await query({ @@ -32,6 +37,19 @@ export class RegistrationService { } async updateCustomer(variables: VariablesOf) { + // Проверяем бан для существующего пользователя + if (variables.documentId) { + const { query } = await getClientWithToken(); + const result = await query({ + query: GQL.GetCustomerDocument, + variables: { documentId: variables.documentId }, + }); + const customer = result.data.customers.at(0); + if (customer && isCustomerBanned(customer)) { + throw new Error(ERRORS.NO_PERMISSION); + } + } + if (variables.data.bannedUntil) { throw new Error(ERRORS.NO_PERMISSION); } @@ -48,4 +66,21 @@ export class RegistrationService { return mutationResult.data; } + + private async checkBanStatus(telegramId: number) { + const { query } = await getClientWithToken(); + + const result = await query({ + query: GQL.GetCustomerDocument, + variables: { telegramId }, + }); + + const customer = result.data.customers.at(0); + + if (customer && isCustomerBanned(customer)) { + throw new Error(ERRORS.NO_PERMISSION); + } + + return { customer }; + } } diff --git a/packages/graphql/api/services.ts b/packages/graphql/api/services.ts index ccd578a..24b7a3d 100644 --- a/packages/graphql/api/services.ts +++ b/packages/graphql/api/services.ts @@ -6,6 +6,8 @@ import { type VariablesOf } from '@graphql-typed-document-node/core'; export class ServicesService extends BaseService { async createService(variables: VariablesOf) { + await this.checkIsBanned(); + const { customer } = await this._getUser(); const { mutate } = await getClientWithToken(); @@ -28,6 +30,8 @@ export class ServicesService extends BaseService { } async getService(variables: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -39,6 +43,8 @@ export class ServicesService extends BaseService { } async getServices(variables: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -50,6 +56,8 @@ export class ServicesService extends BaseService { } async updateService(variables: VariablesOf) { + await this.checkIsBanned(); + await this.checkPermission(variables); const { mutate } = await getClientWithToken(); diff --git a/packages/graphql/api/slots.ts b/packages/graphql/api/slots.ts index 8352399..d14e17c 100644 --- a/packages/graphql/api/slots.ts +++ b/packages/graphql/api/slots.ts @@ -24,6 +24,8 @@ export const ERRORS = { export class SlotsService extends BaseService { async createSlot(variables: VariablesOf) { + await this.checkIsBanned(); + await this.checkBeforeCreate(variables); const { customer } = await this._getUser(); @@ -48,6 +50,8 @@ export class SlotsService extends BaseService { } async deleteSlot(variables: VariablesOf) { + await this.checkIsBanned(); + await this.checkPermission(variables); const { slot } = await this.getSlot({ documentId: variables.documentId }); @@ -73,6 +77,8 @@ export class SlotsService extends BaseService { variables: VariablesOf, context: { services: string[] }, ) { + await this.checkIsBanned(); + if (!variables.filters?.datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); if (!context?.services?.length) throw new Error(ERRORS.MISSING_SERVICES_IDS); @@ -146,6 +152,8 @@ export class SlotsService extends BaseService { } async getSlot(variables: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -157,6 +165,8 @@ export class SlotsService extends BaseService { } async getSlots(variables: VariablesOf) { + await this.checkIsBanned(); + const { query } = await getClientWithToken(); const result = await query({ @@ -168,6 +178,8 @@ export class SlotsService extends BaseService { } async updateSlot(variables: VariablesOf) { + await this.checkIsBanned(); + await this.checkPermission(variables); await this.checkBeforeUpdateDatetime(variables);