mirror of
https://github.com/foss42/apidash.git
synced 2025-12-05 12:34:26 +08:00
Implement OAuth2 rate limiting with exponential backoff
Co-authored-by: animator <615622+animator@users.noreply.github.com>
This commit is contained in:
126
packages/better_networking/lib/services/oauth2_rate_limiter.dart
Normal file
126
packages/better_networking/lib/services/oauth2_rate_limiter.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/// Rate limiter for OAuth2 authentication attempts
|
||||||
|
/// Implements exponential backoff to prevent abuse and brute force attacks
|
||||||
|
class OAuth2RateLimiter {
|
||||||
|
static final Map<String, _RateLimitState> _states = {};
|
||||||
|
|
||||||
|
// Maximum attempts before lockout
|
||||||
|
static const int _maxAttempts = 5;
|
||||||
|
|
||||||
|
// Initial delay after first failure (in seconds)
|
||||||
|
static const int _initialDelay = 2;
|
||||||
|
|
||||||
|
// Maximum delay (in seconds)
|
||||||
|
static const int _maxDelay = 300; // 5 minutes
|
||||||
|
|
||||||
|
// Time window for reset (in minutes)
|
||||||
|
static const int _resetWindow = 30;
|
||||||
|
|
||||||
|
/// Check if an OAuth operation can proceed
|
||||||
|
/// Returns null if allowed, or DateTime when the operation can be retried
|
||||||
|
static DateTime? canProceed(String key) {
|
||||||
|
final state = _states[key];
|
||||||
|
|
||||||
|
if (state == null) {
|
||||||
|
// First attempt, no restrictions
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Check if we should reset the counter (time window passed)
|
||||||
|
if (now.difference(state.firstAttempt).inMinutes >= _resetWindow) {
|
||||||
|
_states.remove(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in cooldown period
|
||||||
|
if (state.nextAttemptAt != null && now.isBefore(state.nextAttemptAt!)) {
|
||||||
|
return state.nextAttemptAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if max attempts exceeded
|
||||||
|
if (state.attemptCount >= _maxAttempts) {
|
||||||
|
// Calculate next allowed attempt with exponential backoff
|
||||||
|
final delaySeconds = _calculateDelay(state.attemptCount);
|
||||||
|
final nextAttempt = state.lastAttempt.add(Duration(seconds: delaySeconds));
|
||||||
|
|
||||||
|
if (now.isBefore(nextAttempt)) {
|
||||||
|
return nextAttempt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed authentication attempt
|
||||||
|
static void recordFailure(String key) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final state = _states[key];
|
||||||
|
|
||||||
|
if (state == null) {
|
||||||
|
_states[key] = _RateLimitState(
|
||||||
|
firstAttempt: now,
|
||||||
|
lastAttempt: now,
|
||||||
|
attemptCount: 1,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final delaySeconds = _calculateDelay(state.attemptCount + 1);
|
||||||
|
|
||||||
|
_states[key] = _RateLimitState(
|
||||||
|
firstAttempt: state.firstAttempt,
|
||||||
|
lastAttempt: now,
|
||||||
|
attemptCount: state.attemptCount + 1,
|
||||||
|
nextAttemptAt: now.add(Duration(seconds: delaySeconds)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful authentication (clears the rate limit)
|
||||||
|
static void recordSuccess(String key) {
|
||||||
|
_states.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate delay with exponential backoff
|
||||||
|
static int _calculateDelay(int attemptCount) {
|
||||||
|
if (attemptCount <= 1) return 0;
|
||||||
|
|
||||||
|
// Exponential backoff: 2^(n-1) seconds, capped at _maxDelay
|
||||||
|
final delay = _initialDelay * (1 << (attemptCount - 2));
|
||||||
|
return delay > _maxDelay ? _maxDelay : delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate rate limit key from client credentials
|
||||||
|
static String generateKey(String clientId, String tokenUrl) {
|
||||||
|
return '$clientId:$tokenUrl';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remaining cooldown time in seconds
|
||||||
|
static int? getCooldownSeconds(String key) {
|
||||||
|
final canProceedAt = canProceed(key);
|
||||||
|
if (canProceedAt == null) return null;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = canProceedAt.difference(now);
|
||||||
|
return diff.inSeconds > 0 ? diff.inSeconds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all rate limiting states (for testing or admin purposes)
|
||||||
|
static void clearAll() {
|
||||||
|
_states.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RateLimitState {
|
||||||
|
final DateTime firstAttempt;
|
||||||
|
final DateTime lastAttempt;
|
||||||
|
final int attemptCount;
|
||||||
|
final DateTime? nextAttemptAt;
|
||||||
|
|
||||||
|
_RateLimitState({
|
||||||
|
required this.firstAttempt,
|
||||||
|
required this.lastAttempt,
|
||||||
|
required this.attemptCount,
|
||||||
|
this.nextAttemptAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../../models/auth/auth_oauth2_model.dart';
|
|||||||
import '../../services/http_client_manager.dart';
|
import '../../services/http_client_manager.dart';
|
||||||
import '../../services/oauth_callback_server.dart';
|
import '../../services/oauth_callback_server.dart';
|
||||||
import '../../services/oauth2_secure_storage.dart';
|
import '../../services/oauth2_secure_storage.dart';
|
||||||
|
import '../../services/oauth2_rate_limiter.dart';
|
||||||
import '../platform_utils.dart';
|
import '../platform_utils.dart';
|
||||||
|
|
||||||
/// Advanced OAuth2 authorization code grant handler that returns both the client and server
|
/// Advanced OAuth2 authorization code grant handler that returns both the client and server
|
||||||
@@ -24,6 +25,17 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
|
|||||||
String? state,
|
String? state,
|
||||||
String? scope,
|
String? scope,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Check rate limiting
|
||||||
|
final rateLimitKey = OAuth2RateLimiter.generateKey(identifier, tokenEndpoint.toString());
|
||||||
|
final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey);
|
||||||
|
|
||||||
|
if (canProceedAt != null) {
|
||||||
|
final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey);
|
||||||
|
throw Exception(
|
||||||
|
'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for existing credentials first - try secure storage, then file
|
// Check for existing credentials first - try secure storage, then file
|
||||||
// Try secure storage first (preferred method)
|
// Try secure storage first (preferred method)
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +47,8 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
|
|||||||
if (secureCredJson != null) {
|
if (secureCredJson != null) {
|
||||||
final credentials = oauth2.Credentials.fromJson(secureCredJson);
|
final credentials = oauth2.Credentials.fromJson(secureCredJson);
|
||||||
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
|
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
|
||||||
|
// Successful retrieval, clear rate limit
|
||||||
|
OAuth2RateLimiter.recordSuccess(rateLimitKey);
|
||||||
return (
|
return (
|
||||||
oauth2.Client(credentials, identifier: identifier, secret: secret),
|
oauth2.Client(credentials, identifier: identifier, secret: secret),
|
||||||
null,
|
null,
|
||||||
@@ -177,8 +191,14 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record successful authentication
|
||||||
|
OAuth2RateLimiter.recordSuccess(rateLimitKey);
|
||||||
|
|
||||||
return (client, callbackServer);
|
return (client, callbackServer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Record failed authentication attempt
|
||||||
|
OAuth2RateLimiter.recordFailure(rateLimitKey);
|
||||||
|
|
||||||
// Clean up the callback server immediately on error
|
// Clean up the callback server immediately on error
|
||||||
if (callbackServer != null) {
|
if (callbackServer != null) {
|
||||||
try {
|
try {
|
||||||
@@ -199,6 +219,20 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
|
|||||||
required AuthOAuth2Model oauth2Model,
|
required AuthOAuth2Model oauth2Model,
|
||||||
required File? credentialsFile,
|
required File? credentialsFile,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Check rate limiting
|
||||||
|
final rateLimitKey = OAuth2RateLimiter.generateKey(
|
||||||
|
oauth2Model.clientId,
|
||||||
|
oauth2Model.accessTokenUrl,
|
||||||
|
);
|
||||||
|
final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey);
|
||||||
|
|
||||||
|
if (canProceedAt != null) {
|
||||||
|
final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey);
|
||||||
|
throw Exception(
|
||||||
|
'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Try secure storage first
|
// Try secure storage first
|
||||||
try {
|
try {
|
||||||
final secureCredJson = await OAuth2SecureStorage.retrieveCredentials(
|
final secureCredJson = await OAuth2SecureStorage.retrieveCredentials(
|
||||||
@@ -209,6 +243,7 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
|
|||||||
if (secureCredJson != null) {
|
if (secureCredJson != null) {
|
||||||
final credentials = oauth2.Credentials.fromJson(secureCredJson);
|
final credentials = oauth2.Credentials.fromJson(secureCredJson);
|
||||||
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
|
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
|
||||||
|
OAuth2RateLimiter.recordSuccess(rateLimitKey);
|
||||||
return oauth2.Client(
|
return oauth2.Client(
|
||||||
credentials,
|
credentials,
|
||||||
identifier: oauth2Model.clientId,
|
identifier: oauth2Model.clientId,
|
||||||
@@ -284,11 +319,17 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record successful authentication
|
||||||
|
OAuth2RateLimiter.recordSuccess(rateLimitKey);
|
||||||
|
|
||||||
// Clean up the HTTP client
|
// Clean up the HTTP client
|
||||||
httpClientManager.closeClient(requestId);
|
httpClientManager.closeClient(requestId);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Record failed authentication attempt
|
||||||
|
OAuth2RateLimiter.recordFailure(rateLimitKey);
|
||||||
|
|
||||||
// Clean up the HTTP client on error
|
// Clean up the HTTP client on error
|
||||||
httpClientManager.closeClient(requestId);
|
httpClientManager.closeClient(requestId);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
Reference in New Issue
Block a user