s
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
This commit is contained in:
parent
aad1a51028
commit
0df722d3bd
5 changed files with 218 additions and 18 deletions
|
|
@ -60,7 +60,7 @@ EXPOSE 3000
|
||||||
# Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0)
|
# Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0)
|
||||||
# Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify)
|
# Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify)
|
||||||
# Пробуем curl, если не работает - используем wget как fallback
|
# Пробуем 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 || \
|
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
|
wget --quiet --tries=1 --timeout=8 --spider http://127.0.0.1:${PORT:-3000}/health || exit 1
|
||||||
|
|
||||||
|
|
|
||||||
194
mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart
Normal file
194
mnemo_cards_backend/lib/api/v2/admin_auth_rate_limiter.dart
Normal file
|
|
@ -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<String, _RateLimitData> _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<DateTime> codeGenerations = [];
|
||||||
|
final List<DateTime> verifyAttempts = [];
|
||||||
|
final List<DateTime> 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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ const _publicAuthPaths = {
|
||||||
'/auth/refresh',
|
'/auth/refresh',
|
||||||
'/admin/auth/request-code', // Admin login code request (public)
|
'/admin/auth/request-code', // Admin login code request (public)
|
||||||
'/admin/auth/verify-code', // Admin login code verification (public)
|
'/admin/auth/verify-code', // Admin login code verification (public)
|
||||||
|
'/admin/auth/code-status', // Admin login code status (public)
|
||||||
'/tests',
|
'/tests',
|
||||||
'/test',
|
'/test',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -160,13 +160,8 @@ Future<void> _run() async {
|
||||||
try {
|
try {
|
||||||
final config = BotConfig.fromEnvironment();
|
final config = BotConfig.fromEnvironment();
|
||||||
|
|
||||||
final lines = File('admins').readAsLinesSync();
|
final adminIds = config.adminIds;
|
||||||
|
print('[BOT] Admin IDs: $adminIds');
|
||||||
final adminIds = lines.map((e) => e.trim()).toList();
|
|
||||||
if (adminIds.isEmpty) {
|
|
||||||
print('[BOT] ERROR: No admins found in admins file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final username = (await Telegram(config.botToken).getMe()).username;
|
final username = (await Telegram(config.botToken).getMe()).username;
|
||||||
teledart = TeleDart(config.botToken, Event(username!));
|
teledart = TeleDart(config.botToken, Event(username!));
|
||||||
|
|
@ -174,16 +169,6 @@ Future<void> _run() async {
|
||||||
// Store globally for shutdown handler
|
// Store globally for shutdown handler
|
||||||
_globalTeledart = teledart;
|
_globalTeledart = teledart;
|
||||||
|
|
||||||
Future<void> 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}');
|
print('[BOT] Using backend API at ${config.backendUrl}');
|
||||||
|
|
||||||
backendClient = BackendClient(
|
backendClient = BackendClient(
|
||||||
|
|
@ -671,6 +656,7 @@ Future<void> _run() async {
|
||||||
print('Required environment variables:');
|
print('Required environment variables:');
|
||||||
print(' - TELEGRAM_BOT_TOKEN (required)');
|
print(' - TELEGRAM_BOT_TOKEN (required)');
|
||||||
print(' - TELEGRAM_BOT_API_KEY (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(' - BACKEND_URL or MNEMO_BACKEND_URL (optional, default: https://api.mnemo-cards.online)');
|
||||||
print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)');
|
print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ class BotConfig {
|
||||||
required this.shareDailyLimit,
|
required this.shareDailyLimit,
|
||||||
required this.apiKey,
|
required this.apiKey,
|
||||||
required this.botToken,
|
required this.botToken,
|
||||||
|
required this.adminIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String backendUrl;
|
final String backendUrl;
|
||||||
final int shareDailyLimit;
|
final int shareDailyLimit;
|
||||||
final String apiKey;
|
final String apiKey;
|
||||||
final String botToken;
|
final String botToken;
|
||||||
|
final List<String> adminIds;
|
||||||
|
|
||||||
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
|
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
|
||||||
static const int defaultShareDailyLimit = 1;
|
static const int defaultShareDailyLimit = 1;
|
||||||
|
|
@ -49,11 +51,28 @@ class BotConfig {
|
||||||
throw Exception('TELEGRAM_BOT_TOKEN environment variable is required');
|
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(
|
return BotConfig(
|
||||||
backendUrl: backendUrl.trim(),
|
backendUrl: backendUrl.trim(),
|
||||||
shareDailyLimit: shareDailyLimit,
|
shareDailyLimit: shareDailyLimit,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
botToken: botToken,
|
botToken: botToken,
|
||||||
|
adminIds: adminIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue