From 0df722d3bd75b99aa06f22002fad9ef10810acdb Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 11 Dec 2025 23:21:38 +0300 Subject: [PATCH] s --- mnemo_cards_backend/Dockerfile | 2 +- .../lib/api/v2/admin_auth_rate_limiter.dart | 194 ++++++++++++++++++ .../lib/api/v2/authorize_v2.dart | 1 + mnemo_cards_telegram_bot/bin/main.dart | 20 +- mnemo_cards_telegram_bot/lib/bot_config.dart | 19 ++ 5 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart diff --git a/mnemo_cards_backend/Dockerfile b/mnemo_cards_backend/Dockerfile index ca114ce..6a6f439 100644 --- a/mnemo_cards_backend/Dockerfile +++ b/mnemo_cards_backend/Dockerfile @@ -60,7 +60,7 @@ EXPOSE 3000 # Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0) # Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify) # Пробуем curl, если не работает - используем wget как fallback -HEALTHCHECK --interval=15s --timeout=10s --start-period=45s --retries=3 \ +HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || \ wget --quiet --tries=1 --timeout=8 --spider http://127.0.0.1:${PORT:-3000}/health || exit 1 diff --git a/mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart b/mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart new file mode 100644 index 0000000..18a757c --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart @@ -0,0 +1,194 @@ +import 'dart:collection'; +import 'package:shelf/shelf.dart'; + +/// Simple in-memory rate limiter for admin auth endpoints +/// In production, consider using Redis or similar distributed cache +class AdminAuthRateLimiter { + // Track request counts per IP + final Map _requestCounts = {}; + + // Limits + static const int maxCodeGenerationsPerHour = 100; // Max 10 codes per hour per IP + static const int maxVerifyAttemptsPerHour = 100; // Max 20 verify attempts per hour per IP + static const int maxStatusChecksPerMinute = 60; // Max 30 status checks per minute per IP + static const Duration codeGenerationWindow = Duration(hours: 1); + static const Duration verifyWindow = Duration(hours: 1); + static const Duration statusCheckWindow = Duration(minutes: 1); + + /// Check if IP can generate a new code + bool canGenerateCode(String ip) { + final now = DateTime.now(); + final data = _requestCounts.putIfAbsent( + ip, + () => _RateLimitData(), + ); + + // Clean old code generation requests + data.codeGenerations.removeWhere( + (timestamp) => now.difference(timestamp) > codeGenerationWindow, + ); + + if (data.codeGenerations.length >= maxCodeGenerationsPerHour) { + return false; + } + + data.codeGenerations.add(now); + return true; + } + + /// Check if IP can attempt verification + bool canVerify(String ip) { + final now = DateTime.now(); + final data = _requestCounts.putIfAbsent( + ip, + () => _RateLimitData(), + ); + + // Clean old verify attempts + data.verifyAttempts.removeWhere( + (timestamp) => now.difference(timestamp) > verifyWindow, + ); + + if (data.verifyAttempts.length >= maxVerifyAttemptsPerHour) { + return false; + } + + data.verifyAttempts.add(now); + return true; + } + + /// Check if IP can check code status + bool canCheckStatus(String ip) { + final now = DateTime.now(); + final data = _requestCounts.putIfAbsent( + ip, + () => _RateLimitData(), + ); + + // Clean old status checks + data.statusChecks.removeWhere( + (timestamp) => now.difference(timestamp) > statusCheckWindow, + ); + + if (data.statusChecks.length >= maxStatusChecksPerMinute) { + return false; + } + + data.statusChecks.add(now); + return true; + } + + /// Get remaining attempts for code generation + int getRemainingCodeGenerations(String ip) { + final data = _requestCounts[ip]; + if (data == null) return maxCodeGenerationsPerHour; + + final now = DateTime.now(); + data.codeGenerations.removeWhere( + (timestamp) => now.difference(timestamp) > codeGenerationWindow, + ); + + return maxCodeGenerationsPerHour - data.codeGenerations.length; + } + + /// Get remaining attempts for verification + int getRemainingVerifyAttempts(String ip) { + final data = _requestCounts[ip]; + if (data == null) return maxVerifyAttemptsPerHour; + + final now = DateTime.now(); + data.verifyAttempts.removeWhere( + (timestamp) => now.difference(timestamp) > verifyWindow, + ); + + return maxVerifyAttemptsPerHour - data.verifyAttempts.length; + } + + /// Cleanup old data periodically + void cleanup() { + final now = DateTime.now(); + _requestCounts.removeWhere((ip, data) { + final hasCodeGens = data.codeGenerations.any( + (timestamp) => now.difference(timestamp) <= codeGenerationWindow, + ); + final hasVerifyAttempts = data.verifyAttempts.any( + (timestamp) => now.difference(timestamp) <= verifyWindow, + ); + final hasStatusChecks = data.statusChecks.any( + (timestamp) => now.difference(timestamp) <= statusCheckWindow, + ); + + return !hasCodeGens && !hasVerifyAttempts && !hasStatusChecks; + }); + } +} + +class _RateLimitData { + final List codeGenerations = []; + final List verifyAttempts = []; + final List statusChecks = []; +} + +/// Get client IP from request +String _getClientIp(Request request) { + // Check X-Forwarded-For header (for proxies/load balancers) + final forwardedFor = request.headers['x-forwarded-for']; + if (forwardedFor != null && forwardedFor.isNotEmpty) { + // X-Forwarded-For can contain multiple IPs, take the first one + return forwardedFor.split(',').first.trim(); + } + + // Check X-Real-IP header + final realIp = request.headers['x-real-ip']; + if (realIp != null && realIp.isNotEmpty) { + return realIp; + } + + // Fallback to request IP + return request.headers['remote-addr'] ?? 'unknown'; +} + +/// Rate limiting middleware for admin auth endpoints +Middleware adminAuthRateLimit(AdminAuthRateLimiter rateLimiter) { + return (Handler innerHandler) { + return (Request request) async { + final path = request.requestedUri.path; + + // Only apply rate limiting to admin auth endpoints + if (!path.contains('/admin/auth/')) { + return await innerHandler(request); + } + + final ip = _getClientIp(request); + + // Apply rate limiting based on endpoint + if (path.contains('/admin/auth/request-code')) { + if (!rateLimiter.canGenerateCode(ip)) { + return Response( + 429, + headers: {'Content-Type': 'application/json'}, + body: '{"error":"TooManyRequests","message":"Too many code generation requests. Please try again later."}', + ); + } + } else if (path.contains('/admin/auth/verify-code')) { + if (!rateLimiter.canVerify(ip)) { + return Response( + 429, + headers: {'Content-Type': 'application/json'}, + body: '{"error":"TooManyRequests","message":"Too many verification attempts. Please try again later."}', + ); + } + } else if (path.contains('/admin/auth/code-status')) { + if (!rateLimiter.canCheckStatus(ip)) { + return Response( + 429, + headers: {'Content-Type': 'application/json'}, + body: '{"error":"TooManyRequests","message":"Too many status check requests. Please try again later."}', + ); + } + } + + return await innerHandler(request); + }; + }; +} diff --git a/mnemo_cards_backend/lib/api/v2/authorize_v2.dart b/mnemo_cards_backend/lib/api/v2/authorize_v2.dart index 42a72b2..17c0013 100644 --- a/mnemo_cards_backend/lib/api/v2/authorize_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/authorize_v2.dart @@ -11,6 +11,7 @@ const _publicAuthPaths = { '/auth/refresh', '/admin/auth/request-code', // Admin login code request (public) '/admin/auth/verify-code', // Admin login code verification (public) + '/admin/auth/code-status', // Admin login code status (public) '/tests', '/test', }; diff --git a/mnemo_cards_telegram_bot/bin/main.dart b/mnemo_cards_telegram_bot/bin/main.dart index 3c3c3c8..1227989 100644 --- a/mnemo_cards_telegram_bot/bin/main.dart +++ b/mnemo_cards_telegram_bot/bin/main.dart @@ -160,13 +160,8 @@ Future _run() async { try { final config = BotConfig.fromEnvironment(); - final lines = File('admins').readAsLinesSync(); - - final adminIds = lines.map((e) => e.trim()).toList(); - if (adminIds.isEmpty) { - print('[BOT] ERROR: No admins found in admins file'); - return; - } + final adminIds = config.adminIds; + print('[BOT] Admin IDs: $adminIds'); final username = (await Telegram(config.botToken).getMe()).username; teledart = TeleDart(config.botToken, Event(username!)); @@ -174,16 +169,6 @@ Future _run() async { // Store globally for shutdown handler _globalTeledart = teledart; - Future notifyAdmins(String message) async { - for (final admin in adminIds) { - try { - await teledart!.sendMessage(admin, message); - } catch (e) { - print('[BOT] ERROR: Failed to notify admin $admin: $e'); - } - } - } - print('[BOT] Using backend API at ${config.backendUrl}'); backendClient = BackendClient( @@ -671,6 +656,7 @@ Future _run() async { print('Required environment variables:'); print(' - TELEGRAM_BOT_TOKEN (required)'); print(' - TELEGRAM_BOT_API_KEY (required)'); + print(' - ADMIN_IDS (required, comma or space separated)'); print(' - BACKEND_URL or MNEMO_BACKEND_URL (optional, default: https://api.mnemo-cards.online)'); print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)'); diff --git a/mnemo_cards_telegram_bot/lib/bot_config.dart b/mnemo_cards_telegram_bot/lib/bot_config.dart index 171d4ba..e1163ec 100644 --- a/mnemo_cards_telegram_bot/lib/bot_config.dart +++ b/mnemo_cards_telegram_bot/lib/bot_config.dart @@ -6,12 +6,14 @@ class BotConfig { required this.shareDailyLimit, required this.apiKey, required this.botToken, + required this.adminIds, }); final String backendUrl; final int shareDailyLimit; final String apiKey; final String botToken; + final List adminIds; static const String defaultBackendUrl = 'https://api.mnemo-cards.online'; static const int defaultShareDailyLimit = 1; @@ -49,11 +51,28 @@ class BotConfig { throw Exception('TELEGRAM_BOT_TOKEN environment variable is required'); } + // Admin IDs: required, comma or space separated + final adminIdsStr = env['ADMIN_IDS']; + if (adminIdsStr == null || adminIdsStr.trim().isEmpty) { + throw Exception( + 'ADMIN_IDS environment variable is required'); + } + final adminIds = adminIdsStr + .split(RegExp(r'[,;\s]+')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (adminIds.isEmpty) { + throw Exception( + 'ADMIN_IDS must contain at least one admin ID'); + } + return BotConfig( backendUrl: backendUrl.trim(), shareDailyLimit: shareDailyLimit, apiKey: apiKey, botToken: botToken, + adminIds: adminIds, ); } }