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.
This commit is contained in:
vchikalkin 2025-08-26 12:32:22 +03:00
parent 5018560f29
commit 7c1f79db2a
9 changed files with 241 additions and 128 deletions

View File

@ -99,6 +99,7 @@ msg-unhandled = ❓ Неизвестная команда. Попробуйте
# Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка
{ $error }
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного

View File

@ -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<Context> = (error) => {
export const errorHandler: ErrorHandler<Context> = 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),

View File

@ -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');

View File

@ -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<VariablesOf<typeof GQL.UpdateCustomerDocument>, 'documentId'>) {
await this.checkBanStatus();
const { customer } = await this._getUser();
// Проверяем, что пользователь не пытается изменить поле bannedUntil
@ -108,7 +118,19 @@ async updateCustomer(variables: Omit<VariablesOf<typeof GQL.UpdateCustomerDocume
// В RegistrationService
async updateCustomer(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
// Проверяем, что пользователь не пытается изменить поле 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<typeof GQL.UpdateCustomerDocument>)
- Защита работает во всех сервисах, которые обновляют данные пользователей
- Единая ошибка `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<PropsWithChildren>) {
export function BanCheck({ children }: Readonly<PropsWithChildren>) {
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<PropsWithChildren>) {
return (
<Provider>
<BanCheck>
<UpdateProfile />
<main className="grow">{children}</main>
<BottomNav />
</BanCheck>
</Provider>
);
}
```
## 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 <div>Вы заблокированы</div>;
}
return <div>Обычный контент</div>;
}
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` защищено от изменения пользователями** - только администраторы могут изменять статус блокировки
**Многоуровневая защита** - проверка на всех уровнях приложения
**Универсальность** - работает в веб-приложении и боте
**Простота использования** - один вызов `checkBanStatus()` в начале метода
**Безопасность** - пользователи не могут обойти бан
**UX** - понятные сообщения и страница для забаненных
**DRY принцип** - нет дублирования кода
**Легкость расширения** - просто добавить новые проверки

View File

@ -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 };

View File

@ -6,9 +6,11 @@ import { type VariablesOf } from '@graphql-typed-document-node/core';
export class CustomersService extends BaseService {
async addMasters(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
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<typeof GQL.GetClientsDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -53,6 +57,8 @@ export class CustomersService extends BaseService {
}
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -66,6 +72,8 @@ export class CustomersService extends BaseService {
}
async getMasters(variables?: VariablesOf<typeof GQL.GetMastersDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -81,9 +89,11 @@ export class CustomersService extends BaseService {
async updateCustomer(
variables: Omit<VariablesOf<typeof GQL.UpdateCustomerDocument>, 'documentId'>,
) {
await this.checkIsBanned();
const { customer } = await this._getUser();
// Проверяем, что пользователь не пытается изменить поле bannedUntil
// Пров еряем, что пользователь не пытается изменить поле bannedUntil
if (variables.data.bannedUntil !== undefined) {
throw new Error(ERRORS.NO_PERMISSION);
}

View File

@ -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<typeof GQL.CreateCustomerDocument>) {
@ -19,6 +20,10 @@ export class RegistrationService {
}
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
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<typeof GQL.UpdateCustomerDocument>) {
// Проверяем бан для существующего пользователя
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 };
}
}

View File

@ -6,6 +6,8 @@ import { type VariablesOf } from '@graphql-typed-document-node/core';
export class ServicesService extends BaseService {
async createService(variables: VariablesOf<typeof GQL.CreateServiceDocument>) {
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<typeof GQL.GetServiceDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -39,6 +43,8 @@ export class ServicesService extends BaseService {
}
async getServices(variables: VariablesOf<typeof GQL.GetServicesDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -50,6 +56,8 @@ export class ServicesService extends BaseService {
}
async updateService(variables: VariablesOf<typeof GQL.UpdateServiceDocument>) {
await this.checkIsBanned();
await this.checkPermission(variables);
const { mutate } = await getClientWithToken();

View File

@ -24,6 +24,8 @@ export const ERRORS = {
export class SlotsService extends BaseService {
async createSlot(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
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<typeof GQL.DeleteSlotDocument>) {
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<typeof GQL.GetSlotsDocument>,
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<typeof GQL.GetSlotDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -157,6 +165,8 @@ export class SlotsService extends BaseService {
}
async getSlots(variables: VariablesOf<typeof GQL.GetSlotsDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -168,6 +178,8 @@ export class SlotsService extends BaseService {
}
async updateSlot(variables: VariablesOf<typeof GQL.UpdateSlotDocument>) {
await this.checkIsBanned();
await this.checkPermission(variables);
await this.checkBeforeUpdateDatetime(variables);