Skip to content

Commit fa72373

Browse files
authored
Merge pull request #30 from flutter-news-app-full-source-code/feature_rate_limiting
Feature rate limiting
2 parents a861015 + 0bab60d commit fa72373

15 files changed

+346
-30
lines changed

.env.example

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
# JWT_SECRET_KEY="your-super-secret-and-long-jwt-key"
1313

1414
# OPTIONAL: The duration for which a JWT is valid, in hours.
15-
# Defaults to 1 hour if not specified.
16-
# JWT_EXPIRY_HOURS="1"
15+
# Defaults to 720 hour (1 month) if not specified.
16+
# JWT_EXPIRY_HOURS="720"
1717

1818
# REQUIRED FOR PRODUCTION: The specific origin URL of your web client.
1919
# This allows the client (e.g., the HT Dashboard) to make requests to the API.
@@ -34,7 +34,7 @@
3434
# Use "https://api.eu.sendgrid.com" for EU-based accounts.
3535
# SENDGRID_API_URL="https://api.sendgrid.com"
3636

37-
# ADMIN OVERRIDE: Sets the single administrator account for the application.
37+
# REQUIRED: Sets the single administrator account for the application.
3838
# On server startup, the system ensures that the user with this email is the
3939
# one and only administrator.
4040
# - If no admin exists, one will be created with this email.
@@ -43,3 +43,16 @@
4343
# - If an admin with this email already exists, nothing changes.
4444
# This provides a secure way to set or recover the admin account.
4545
# OVERRIDE_ADMIN_EMAIL="[email protected]"
46+
47+
48+
# OPTIONAL: Limit for the /auth/request-code endpoint (requests per window).
49+
# RATE_LIMIT_REQUEST_CODE_LIMIT=3
50+
51+
# OPTIONAL: Window for the /auth/request-code endpoint, in hours.
52+
# RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24
53+
54+
# OPTIONAL: Limit for the generic /data API endpoints (requests per window).
55+
# RATE_LIMIT_DATA_API_LIMIT=1000
56+
57+
# OPTIONAL: Window for the /data API endpoints, in minutes.
58+
# RATE_LIMIT_DATA_API_WINDOW_MINUTES=60

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ This API server comes packed with all the features you need to launch a professi
2828
* Securely control access to API features and data management capabilities based on user roles.
2929
> **Your Advantage:** A powerful, built-in security model that protects your data and ensures users only access what they're supposed to. 🛡️
3030
31+
#### 🛡️ **Built-in API Rate Limiting**
32+
* Protects critical endpoints like email verification and data access from abuse and denial-of-service attacks.
33+
* Features configurable, user-aware limits that distinguish between guests and authenticated users.
34+
* Includes a bypass for trusted roles (admin, publisher) to ensure dashboard functionality is never impeded.
35+
> **Your Advantage:** Your API is protected from day one against common abuse vectors, ensuring stability and preventing costly overages on services like email providers. ✅
36+
3137
#### ⚙️ **Centralized App & User Settings**
3238
* Effortlessly sync user-specific settings like theme, language, and font styles across devices.
3339
* Manage personalized content preferences, including saved headlines and followed topics/sources.

lib/src/config/app_dependencies.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/dashbo
1313
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart';
1414
import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart';
1515
import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart';
16+
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart';
1617
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart';
1718
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart';
19+
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
1820
import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart';
1921
import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart';
2022
import 'package:flutter_news_app_api_server_full_source_code/src/services/verification_code_storage_service.dart';
@@ -65,6 +67,7 @@ class AppDependencies {
6567
late final DashboardSummaryService dashboardSummaryService;
6668
late final PermissionService permissionService;
6769
late final UserPreferenceLimitService userPreferenceLimitService;
70+
late final RateLimitService rateLimitService;
6871

6972
/// Initializes all application dependencies.
7073
///
@@ -222,6 +225,10 @@ class AppDependencies {
222225
permissionService: permissionService,
223226
log: Logger('DefaultUserPreferenceLimitService'),
224227
);
228+
rateLimitService = MongoDbRateLimitService(
229+
connectionManager: _mongoDbConnectionManager,
230+
log: Logger('MongoDbRateLimitService'),
231+
);
225232

226233
_isInitialized = true;
227234
_log.info('Application dependencies initialized successfully.');
@@ -238,6 +245,7 @@ class AppDependencies {
238245
if (!_isInitialized) return;
239246
await _mongoDbConnectionManager.close();
240247
tokenBlacklistService.dispose();
248+
rateLimitService.dispose();
241249
_isInitialized = false;
242250
_log.info('Application dependencies disposed.');
243251
}

lib/src/config/environment_config.dart

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ abstract final class EnvironmentConfig {
109109
/// The value is read from the `JWT_EXPIRY_HOURS` environment variable.
110110
/// Defaults to 1 hour if not set or if parsing fails.
111111
static Duration get jwtExpiryDuration {
112-
final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1');
112+
final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '720'); // 1 month
113113
return Duration(hours: hours ?? 1);
114114
}
115115

@@ -139,4 +139,37 @@ abstract final class EnvironmentConfig {
139139
/// This is used to set or replace the single administrator account on startup.
140140
/// Returns `null` if the `OVERRIDE_ADMIN_EMAIL` is not set.
141141
static String? get overrideAdminEmail => _env['OVERRIDE_ADMIN_EMAIL'];
142+
143+
/// Retrieves the request limit for the request-code endpoint.
144+
///
145+
/// Defaults to 3 if not set or if parsing fails.
146+
static int get rateLimitRequestCodeLimit {
147+
return int.tryParse(_env['RATE_LIMIT_REQUEST_CODE_LIMIT'] ?? '3') ?? 3;
148+
}
149+
150+
/// Retrieves the time window for the request-code endpoint rate limit.
151+
///
152+
/// Defaults to 24 hours if not set or if parsing fails.
153+
static Duration get rateLimitRequestCodeWindow {
154+
final hours =
155+
int.tryParse(_env['RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS'] ?? '24') ??
156+
24;
157+
return Duration(hours: hours);
158+
}
159+
160+
/// Retrieves the request limit for the data API endpoints.
161+
///
162+
/// Defaults to 1000 if not set or if parsing fails.
163+
static int get rateLimitDataApiLimit {
164+
return int.tryParse(_env['RATE_LIMIT_DATA_API_LIMIT'] ?? '1000') ?? 1000;
165+
}
166+
167+
/// Retrieves the time window for the data API rate limit.
168+
///
169+
/// Defaults to 60 minutes if not set or if parsing fails.
170+
static Duration get rateLimitDataApiWindow {
171+
final minutes =
172+
int.tryParse(_env['RATE_LIMIT_DATA_API_WINDOW_MINUTES'] ?? '60') ?? 60;
173+
return Duration(minutes: minutes);
174+
}
142175
}

lib/src/middlewares/error_handler.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ Middleware errorHandler() {
6666

6767
/// Maps HttpException subtypes to appropriate HTTP status codes.
6868
int _mapExceptionToStatusCode(HttpException exception) {
69+
// Special case for rate limiting
70+
if (exception is ForbiddenException &&
71+
exception.message.contains('too many requests')) {
72+
return 429; // Too Many Requests
73+
}
74+
6975
return switch (exception) {
7076
InvalidInputException() => HttpStatus.badRequest, // 400
7177
AuthenticationException() => HttpStatus.unauthorized, // 401
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import 'package:dart_frog/dart_frog.dart';
2+
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
3+
4+
/// Extracts the client's IP address from the request.
5+
///
6+
/// It prioritizes the 'X-Forwarded-For' header, which is standard for
7+
/// identifying the originating IP of a client connecting through a proxy
8+
/// or load balancer. If that header is not present, it falls back to the
9+
/// direct connection's IP address.
10+
String? _getIpAddress(RequestContext context) {
11+
final headers = context.request.headers;
12+
// The 'X-Forwarded-For' header can contain a comma-separated list of IPs.
13+
// The first one is typically the original client IP.
14+
final xff = headers['X-Forwarded-For']?.split(',').first.trim();
15+
if (xff != null && xff.isNotEmpty) {
16+
return xff;
17+
}
18+
// Fallback to the direct connection IP if XFF is not available.
19+
return context.request.connectionInfo.remoteAddress.address;
20+
}
21+
22+
/// Middleware to enforce rate limiting on a route.
23+
///
24+
/// This middleware uses the [RateLimitService] to track and limit the number
25+
/// of requests from a unique source (identified by a key) within a specific
26+
/// time window.
27+
///
28+
/// - [limit]: The maximum number of requests allowed.
29+
/// - [window]: The time duration in which the requests are counted.
30+
/// - [keyExtractor]: A function that extracts a unique key from the request
31+
/// context. This could be an IP address, an email from the body, etc.
32+
Middleware rateLimiter({
33+
required int limit,
34+
required Duration window,
35+
required Future<String?> Function(RequestContext) keyExtractor,
36+
}) {
37+
return (handler) {
38+
return (context) async {
39+
final rateLimitService = context.read<RateLimitService>();
40+
final key = await keyExtractor(context);
41+
42+
// If a key cannot be extracted, we bypass the rate limiter.
43+
// This is a safeguard; for IP-based limiting, a key should always exist.
44+
if (key == null || key.isEmpty) {
45+
return handler(context);
46+
}
47+
48+
// The checkRequest method will throw a ForbiddenException if the
49+
// limit is exceeded. This will be caught by the global error handler.
50+
await rateLimitService.checkRequest(
51+
key: key,
52+
limit: limit,
53+
window: window,
54+
);
55+
56+
// If the check passes, proceed to the next handler.
57+
return handler(context);
58+
};
59+
};
60+
}
61+
62+
/// A specific implementation of the keyExtractor for IP-based rate limiting.
63+
Future<String?> ipKeyExtractor(RequestContext context) async {
64+
return _getIpAddress(context);
65+
}

lib/src/rbac/permissions.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,7 @@ abstract class Permissions {
6161
// User Preference Permissions
6262
static const String userPreferenceBypassLimits =
6363
'user_preference.bypass_limits';
64+
65+
// General System Permissions
66+
static const String rateLimitingBypass = 'rate_limiting.bypass';
6467
}

lib/src/rbac/role_permissions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ final Set<String> _dashboardPublisherPermissions = {
3434
Permissions.headlineUpdate,
3535
Permissions.headlineDelete,
3636
Permissions.dashboardLogin,
37+
Permissions.rateLimitingBypass,
3738
};
3839

3940
final Set<String> _dashboardAdminPermissions = {
4041
..._dashboardPublisherPermissions,
42+
Permissions.rateLimitingBypass,
4143
Permissions.topicCreate,
4244
Permissions.topicUpdate,
4345
Permissions.topicDelete,

lib/src/services/database_seeding_service.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:core/core.dart';
22
import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart';
3+
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart';
34
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart';
45
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart';
56
import 'package:logging/logging.dart';
@@ -300,6 +301,25 @@ class DatabaseSeedingService {
300301
],
301302
});
302303

304+
// Index for the rate limit attempts collection
305+
await _db.runCommand({
306+
'createIndexes': kRateLimitAttemptsCollection,
307+
'indexes': [
308+
{
309+
// This is a TTL index. MongoDB will automatically delete request
310+
// attempt documents 24 hours after they are created.
311+
'key': {'createdAt': 1},
312+
'name': 'createdAt_ttl_index',
313+
'expireAfterSeconds': 86400, // 24 hours
314+
},
315+
{
316+
// Index on the key field for faster lookups.
317+
'key': {'key': 1},
318+
'name': 'key_index',
319+
},
320+
],
321+
});
322+
303323
_log.info('Database indexes are set up correctly.');
304324
} on Exception catch (e, s) {
305325
_log.severe('Failed to create database indexes.', e, s);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'package:core/core.dart';
2+
import 'package:data_mongodb/data_mongodb.dart';
3+
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
4+
import 'package:logging/logging.dart';
5+
import 'package:mongo_dart/mongo_dart.dart';
6+
7+
/// The name of the MongoDB collection for storing rate limit attempts.
8+
const String kRateLimitAttemptsCollection = 'rate_limit_attempts';
9+
10+
/// {@template mongodb_rate_limit_service}
11+
/// A MongoDB-backed implementation of [RateLimitService].
12+
///
13+
/// This service tracks request attempts in a dedicated MongoDB collection.
14+
/// It relies on a TTL (Time-To-Live) index on the `createdAt` field to
15+
/// ensure that old request records are automatically purged by the database,
16+
/// which is highly efficient.
17+
/// {@endtemplate}
18+
class MongoDbRateLimitService implements RateLimitService {
19+
/// {@macro mongodb_rate_limit_service}
20+
MongoDbRateLimitService({
21+
required MongoDbConnectionManager connectionManager,
22+
required Logger log,
23+
}) : _connectionManager = connectionManager,
24+
_log = log;
25+
26+
final MongoDbConnectionManager _connectionManager;
27+
final Logger _log;
28+
29+
DbCollection get _collection =>
30+
_connectionManager.db.collection(kRateLimitAttemptsCollection);
31+
32+
@override
33+
Future<void> checkRequest({
34+
required String key,
35+
required int limit,
36+
required Duration window,
37+
}) async {
38+
try {
39+
final now = DateTime.now();
40+
final windowStart = now.subtract(window);
41+
42+
// 1. Count recent requests for the given key within the time window.
43+
final recentRequestsCount = await _collection.count(
44+
where.eq('key', key).and(where.gte('createdAt', windowStart)),
45+
);
46+
47+
_log.finer(
48+
'Rate limit check for key "$key": Found $recentRequestsCount '
49+
'requests in the last ${window.inMinutes} minutes (limit is $limit).',
50+
);
51+
52+
// 2. If the limit is reached or exceeded, throw an exception.
53+
if (recentRequestsCount >= limit) {
54+
_log.warning(
55+
'Rate limit exceeded for key "$key". '
56+
'($recentRequestsCount >= $limit)',
57+
);
58+
throw const ForbiddenException(
59+
'You have made too many requests. Please try again later.',
60+
);
61+
}
62+
63+
// 3. If the limit is not reached, record the new request.
64+
await _collection.insertOne({
65+
'_id': ObjectId(),
66+
'key': key,
67+
'createdAt': now,
68+
});
69+
_log.finer('Recorded new request for key "$key".');
70+
} on HttpException {
71+
// Re-throw exceptions that we've thrown intentionally.
72+
rethrow;
73+
} catch (e, s) {
74+
_log.severe('Error during rate limit check for key "$key"', e, s);
75+
throw const OperationFailedException(
76+
'An unexpected error occurred while checking request rate limits.',
77+
);
78+
}
79+
}
80+
81+
@override
82+
void dispose() {
83+
// This is a no-op because the underlying database connection is managed
84+
// by the injected MongoDbConnectionManager, which has its own lifecycle.
85+
_log.finer('dispose() called, no action needed.');
86+
}
87+
}

0 commit comments

Comments
 (0)