-
Notifications
You must be signed in to change notification settings - Fork 0
Feature rate limiting #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
adcc0eb
feat(auth): introduce rate limit service interface
fulleni 484d7db
feat(rate-limit): implement MongoDB-backed rate limit service
fulleni 6efac46
feat(rate-limiting): implement rate limiting service
fulleni 2f82e09
feat(database): add rate limit attempts TTL and key indexes
fulleni 167af20
feat(middlewares): implement rate limiter middleware
fulleni b7e6faa
fix(error_handler): map rate limiting errors to 429 status code
fulleni c889ba4
feat(routes): add rate limit service to middleware
fulleni d2c8e7e
feat(auth): add rate limiting to request-code endpoint
fulleni c19ec59
lint: misc
fulleni f09468f
chore(env): add rate limiting configuration variables
fulleni 9453122
feat(config): add rate limit configuration parameters
fulleni 65f76a2
refactor(auth): move request-code handler to index.dart and apply rat…
fulleni 9fd3a44
feat(auth): add rate limiting middleware to request code endpoint
fulleni 17df05c
style(auth): remove extra whitespace in request-code handler
fulleni 6134a81
feat(rbac): add rate limiting bypass permission
fulleni baec763
feat(api): implement rate limiting for data routes
fulleni 8f7a85a
refactor(routes): remove redundant middleware documentation
fulleni cd5e29b
docs(env): clarify rate limiting configuration in .env.example
fulleni 0377190
docs(README): add built-in API rate limiting features
fulleni 17fc972
fix(config): update default JWT expiry hours to 1 month
fulleni 0bab60d
docs(env): update JWT expiry default and admin configuration
fulleni File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,8 +12,8 @@ | |
# JWT_SECRET_KEY="your-super-secret-and-long-jwt-key" | ||
|
||
# OPTIONAL: The duration for which a JWT is valid, in hours. | ||
# Defaults to 1 hour if not specified. | ||
# JWT_EXPIRY_HOURS="1" | ||
# Defaults to 720 hour (1 month) if not specified. | ||
# JWT_EXPIRY_HOURS="720" | ||
|
||
# REQUIRED FOR PRODUCTION: The specific origin URL of your web client. | ||
# This allows the client (e.g., the HT Dashboard) to make requests to the API. | ||
|
@@ -34,7 +34,7 @@ | |
# Use "https://api.eu.sendgrid.com" for EU-based accounts. | ||
# SENDGRID_API_URL="https://api.sendgrid.com" | ||
|
||
# ADMIN OVERRIDE: Sets the single administrator account for the application. | ||
# REQUIRED: Sets the single administrator account for the application. | ||
# On server startup, the system ensures that the user with this email is the | ||
# one and only administrator. | ||
# - If no admin exists, one will be created with this email. | ||
|
@@ -43,3 +43,16 @@ | |
# - If an admin with this email already exists, nothing changes. | ||
# This provides a secure way to set or recover the admin account. | ||
# OVERRIDE_ADMIN_EMAIL="[email protected]" | ||
|
||
|
||
# OPTIONAL: Limit for the /auth/request-code endpoint (requests per window). | ||
# RATE_LIMIT_REQUEST_CODE_LIMIT=3 | ||
|
||
# OPTIONAL: Window for the /auth/request-code endpoint, in hours. | ||
# RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24 | ||
|
||
# OPTIONAL: Limit for the generic /data API endpoints (requests per window). | ||
# RATE_LIMIT_DATA_API_LIMIT=1000 | ||
|
||
# OPTIONAL: Window for the /data API endpoints, in minutes. | ||
# RATE_LIMIT_DATA_API_WINDOW_MINUTES=60 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import 'package:dart_frog/dart_frog.dart'; | ||
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; | ||
|
||
/// Extracts the client's IP address from the request. | ||
/// | ||
/// It prioritizes the 'X-Forwarded-For' header, which is standard for | ||
/// identifying the originating IP of a client connecting through a proxy | ||
/// or load balancer. If that header is not present, it falls back to the | ||
/// direct connection's IP address. | ||
String? _getIpAddress(RequestContext context) { | ||
final headers = context.request.headers; | ||
// The 'X-Forwarded-For' header can contain a comma-separated list of IPs. | ||
// The first one is typically the original client IP. | ||
final xff = headers['X-Forwarded-For']?.split(',').first.trim(); | ||
if (xff != null && xff.isNotEmpty) { | ||
return xff; | ||
} | ||
// Fallback to the direct connection IP if XFF is not available. | ||
return context.request.connectionInfo.remoteAddress.address; | ||
} | ||
|
||
/// Middleware to enforce rate limiting on a route. | ||
/// | ||
/// This middleware uses the [RateLimitService] to track and limit the number | ||
/// of requests from a unique source (identified by a key) within a specific | ||
/// time window. | ||
/// | ||
/// - [limit]: The maximum number of requests allowed. | ||
/// - [window]: The time duration in which the requests are counted. | ||
/// - [keyExtractor]: A function that extracts a unique key from the request | ||
/// context. This could be an IP address, an email from the body, etc. | ||
Middleware rateLimiter({ | ||
required int limit, | ||
required Duration window, | ||
required Future<String?> Function(RequestContext) keyExtractor, | ||
}) { | ||
return (handler) { | ||
return (context) async { | ||
final rateLimitService = context.read<RateLimitService>(); | ||
final key = await keyExtractor(context); | ||
|
||
// If a key cannot be extracted, we bypass the rate limiter. | ||
// This is a safeguard; for IP-based limiting, a key should always exist. | ||
if (key == null || key.isEmpty) { | ||
return handler(context); | ||
} | ||
|
||
// The checkRequest method will throw a ForbiddenException if the | ||
// limit is exceeded. This will be caught by the global error handler. | ||
await rateLimitService.checkRequest( | ||
key: key, | ||
limit: limit, | ||
window: window, | ||
); | ||
|
||
// If the check passes, proceed to the next handler. | ||
return handler(context); | ||
}; | ||
}; | ||
} | ||
|
||
/// A specific implementation of the keyExtractor for IP-based rate limiting. | ||
Future<String?> ipKeyExtractor(RequestContext context) async { | ||
return _getIpAddress(context); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import 'package:core/core.dart'; | ||
import 'package:data_mongodb/data_mongodb.dart'; | ||
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; | ||
import 'package:logging/logging.dart'; | ||
import 'package:mongo_dart/mongo_dart.dart'; | ||
|
||
/// The name of the MongoDB collection for storing rate limit attempts. | ||
const String kRateLimitAttemptsCollection = 'rate_limit_attempts'; | ||
|
||
/// {@template mongodb_rate_limit_service} | ||
/// A MongoDB-backed implementation of [RateLimitService]. | ||
/// | ||
/// This service tracks request attempts in a dedicated MongoDB collection. | ||
/// It relies on a TTL (Time-To-Live) index on the `createdAt` field to | ||
/// ensure that old request records are automatically purged by the database, | ||
/// which is highly efficient. | ||
/// {@endtemplate} | ||
class MongoDbRateLimitService implements RateLimitService { | ||
/// {@macro mongodb_rate_limit_service} | ||
MongoDbRateLimitService({ | ||
required MongoDbConnectionManager connectionManager, | ||
required Logger log, | ||
}) : _connectionManager = connectionManager, | ||
_log = log; | ||
|
||
final MongoDbConnectionManager _connectionManager; | ||
final Logger _log; | ||
|
||
DbCollection get _collection => | ||
_connectionManager.db.collection(kRateLimitAttemptsCollection); | ||
|
||
@override | ||
Future<void> checkRequest({ | ||
required String key, | ||
required int limit, | ||
required Duration window, | ||
}) async { | ||
try { | ||
final now = DateTime.now(); | ||
final windowStart = now.subtract(window); | ||
|
||
// 1. Count recent requests for the given key within the time window. | ||
final recentRequestsCount = await _collection.count( | ||
where.eq('key', key).and(where.gte('createdAt', windowStart)), | ||
); | ||
|
||
_log.finer( | ||
'Rate limit check for key "$key": Found $recentRequestsCount ' | ||
'requests in the last ${window.inMinutes} minutes (limit is $limit).', | ||
); | ||
|
||
// 2. If the limit is reached or exceeded, throw an exception. | ||
if (recentRequestsCount >= limit) { | ||
_log.warning( | ||
'Rate limit exceeded for key "$key". ' | ||
'($recentRequestsCount >= $limit)', | ||
); | ||
throw const ForbiddenException( | ||
'You have made too many requests. Please try again later.', | ||
); | ||
fulleni marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// 3. If the limit is not reached, record the new request. | ||
await _collection.insertOne({ | ||
'_id': ObjectId(), | ||
'key': key, | ||
'createdAt': now, | ||
}); | ||
_log.finer('Recorded new request for key "$key".'); | ||
} on HttpException { | ||
// Re-throw exceptions that we've thrown intentionally. | ||
rethrow; | ||
} catch (e, s) { | ||
_log.severe('Error during rate limit check for key "$key"', e, s); | ||
throw const OperationFailedException( | ||
'An unexpected error occurred while checking request rate limits.', | ||
); | ||
} | ||
} | ||
|
||
@override | ||
void dispose() { | ||
// This is a no-op because the underlying database connection is managed | ||
// by the injected MongoDbConnectionManager, which has its own lifecycle. | ||
_log.finer('dispose() called, no action needed.'); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import 'package:core/core.dart'; | ||
|
||
/// {@template rate_limit_service} | ||
/// Defines the interface for a service that provides rate-limiting capabilities. | ||
/// | ||
/// This service is used to check and record requests against a specific key | ||
/// (e.g., an IP address or email) to prevent abuse of sensitive or expensive | ||
/// endpoints. | ||
/// {@endtemplate} | ||
abstract class RateLimitService { | ||
/// {@macro rate_limit_service} | ||
const RateLimitService(); | ||
|
||
/// Checks if a request associated with the given [key] is allowed. | ||
/// | ||
/// This method performs the following logic: | ||
/// 1. Counts the number of recent requests for the [key] within the [window]. | ||
/// 2. If the count is greater than or equal to the [limit], it throws a | ||
/// [ForbiddenException] indicating the rate limit has been exceeded. | ||
/// 3. If the count is below the limit, it records the current request | ||
/// (e.g., by storing a timestamp) and allows the request to proceed. | ||
/// | ||
/// - [key]: A unique identifier for the request source (e.g., IP address). | ||
/// - [limit]: The maximum number of requests allowed within the window. | ||
/// - [window]: The time duration to consider for counting requests. | ||
/// | ||
/// Throws [ForbiddenException] if the rate limit is exceeded. | ||
/// Throws [OperationFailedException] for unexpected errors during the check. | ||
Future<void> checkRequest({ | ||
required String key, | ||
required int limit, | ||
required Duration window, | ||
}); | ||
|
||
/// Disposes of any resources used by the service (e.g., timers). | ||
void dispose(); | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.