Skip to content

Commit 94305fd

Browse files
authored
Merge pull request #25 from headlines-toolkit/harden_auth_impl
Harden auth impl
2 parents 7150020 + 89e2417 commit 94305fd

18 files changed

+442
-293
lines changed

.env.example

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@
66
# The application cannot start without a database connection.
77
# DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db"
88

9+
# REQUIRED: A secure, randomly generated secret for signing JWTs.
10+
# The application cannot start without this.
11+
# Generate a secure key using: dart pub global run dcli_scripts create_key
12+
# JWT_SECRET_KEY="your-super-secret-and-long-jwt-key"
13+
914
# REQUIRED FOR PRODUCTION: The specific origin URL of your web client.
1015
# This allows the client (e.g., the HT Dashboard) to make requests to the API.
1116
# For local development, this can be left unset as 'localhost' is allowed by default.
12-
# CORS_ALLOWED_ORIGIN="https://your-dashboard.com"
17+
# CORS_ALLOWED_ORIGIN="https://your-dashboard.com"
18+
19+
# REQUIRED FOR PRODUCTION: The issuer URL for JWTs.
20+
# Defaults to 'http://localhost:8080' for local development if not set.
21+
# For production, this MUST be the public URL of your API.
22+
# JWT_ISSUER="https://api.your-domain.com"
23+
24+
# OPTIONAL: The expiry duration for JWTs in hours.
25+
# Defaults to 1 hour if not set.
26+
# JWT_EXPIRY_HOURS="24"

lib/src/config/app_dependencies.dart

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import 'package:ht_api/src/services/dashboard_summary_service.dart';
66
import 'package:ht_api/src/services/database_seeding_service.dart';
77
import 'package:ht_api/src/services/default_user_preference_limit_service.dart';
88
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
9+
import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart';
10+
import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart';
911
import 'package:ht_api/src/services/token_blacklist_service.dart';
1012
import 'package:ht_api/src/services/user_preference_limit_service.dart';
1113
import 'package:ht_api/src/services/verification_code_storage_service.dart';
@@ -169,15 +171,19 @@ class AppDependencies {
169171
emailRepository = const HtEmailRepository(emailClient: emailClient);
170172

171173
// 5. Initialize Services
172-
tokenBlacklistService = InMemoryTokenBlacklistService(
173-
log: Logger('InMemoryTokenBlacklistService'),
174+
tokenBlacklistService = MongoDbTokenBlacklistService(
175+
connectionManager: _mongoDbConnectionManager,
176+
log: Logger('MongoDbTokenBlacklistService'),
174177
);
175178
authTokenService = JwtAuthTokenService(
176179
userRepository: userRepository,
177180
blacklistService: tokenBlacklistService,
178181
log: Logger('JwtAuthTokenService'),
179182
);
180-
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
183+
verificationCodeStorageService = MongoDbVerificationCodeStorageService(
184+
connectionManager: _mongoDbConnectionManager,
185+
log: Logger('MongoDbVerificationCodeStorageService'),
186+
);
181187
permissionService = const PermissionService();
182188
authService = AuthService(
183189
userRepository: userRepository,

lib/src/config/environment_config.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ abstract final class EnvironmentConfig {
7878
return dbUrl;
7979
}
8080

81+
/// Retrieves the JWT secret key from the environment.
82+
///
83+
/// The value is read from the `JWT_SECRET_KEY` environment variable.
84+
///
85+
/// Throws a [StateError] if the `JWT_SECRET_KEY` environment variable is not
86+
/// set, as the application cannot function without it.
87+
static String get jwtSecretKey {
88+
final jwtKey = _env['JWT_SECRET_KEY'];
89+
if (jwtKey == null || jwtKey.isEmpty) {
90+
_log.severe('JWT_SECRET_KEY not found in environment variables.');
91+
throw StateError(
92+
'FATAL: JWT_SECRET_KEY environment variable is not set. '
93+
'The application cannot start without a JWT secret.',
94+
);
95+
}
96+
return jwtKey;
97+
}
98+
8199
/// Retrieves the current environment mode (e.g., 'development').
82100
///
83101
/// The value is read from the `ENV` environment variable.
@@ -90,4 +108,20 @@ abstract final class EnvironmentConfig {
90108
/// This is used to configure CORS for production environments.
91109
/// Returns `null` if the variable is not set.
92110
static String? get corsAllowedOrigin => _env['CORS_ALLOWED_ORIGIN'];
111+
112+
/// Retrieves the JWT issuer URL from the environment.
113+
///
114+
/// The value is read from the `JWT_ISSUER` environment variable.
115+
/// Defaults to 'http://localhost:8080' if not set.
116+
static String get jwtIssuer =>
117+
_env['JWT_ISSUER'] ?? 'http://localhost:8080';
118+
119+
/// Retrieves the JWT expiry duration in hours from the environment.
120+
///
121+
/// The value is read from the `JWT_EXPIRY_HOURS` environment variable.
122+
/// Defaults to 1 hour if not set or if parsing fails.
123+
static Duration get jwtExpiryDuration {
124+
final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1');
125+
return Duration(hours: hours ?? 1);
126+
}
93127
}

lib/src/middlewares/authentication_middleware.dart

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import 'package:dart_frog/dart_frog.dart';
22
import 'package:ht_api/src/services/auth_token_service.dart';
33
import 'package:ht_shared/ht_shared.dart';
4+
import 'package:logging/logging.dart';
5+
6+
final _log = Logger('AuthMiddleware');
47

58
/// Middleware to handle authentication by verifying Bearer tokens.
69
///
@@ -17,69 +20,69 @@ import 'package:ht_shared/ht_shared.dart';
1720
Middleware authenticationProvider() {
1821
return (handler) {
1922
return (context) async {
20-
print('[AuthMiddleware] Entered.');
23+
_log.finer('Entered.');
2124
// Read the interface type
2225
AuthTokenService tokenService;
2326
try {
24-
print('[AuthMiddleware] Attempting to read AuthTokenService...');
27+
_log.finer('Attempting to read AuthTokenService...');
2528
tokenService = context.read<AuthTokenService>();
26-
print('[AuthMiddleware] Successfully read AuthTokenService.');
29+
_log.finer('Successfully read AuthTokenService.');
2730
} catch (e, s) {
28-
print('[AuthMiddleware] FAILED to read AuthTokenService: $e\n$s');
31+
_log.severe('FAILED to read AuthTokenService.', e, s);
2932
// Re-throw the error to be caught by the main error handler
3033
rethrow;
3134
}
3235
User? user;
3336

3437
// Extract the Authorization header
35-
print('[AuthMiddleware] Attempting to read Authorization header...');
38+
_log.finer('Attempting to read Authorization header...');
3639
final authHeader = context.request.headers['Authorization'];
37-
print('[AuthMiddleware] Authorization header value: $authHeader');
40+
_log.finer('Authorization header value: $authHeader');
3841

3942
if (authHeader != null && authHeader.startsWith('Bearer ')) {
4043
// Extract the token string
4144
final token = authHeader.substring(7); // Length of 'Bearer '
42-
print('[AuthMiddleware] Extracted Bearer token.');
45+
_log.finer('Extracted Bearer token.');
4346
try {
44-
print('[AuthMiddleware] Attempting to validate token...');
47+
_log.finer('Attempting to validate token...');
4548
// Validate the token using the service
4649
user = await tokenService.validateToken(token);
47-
print(
48-
'[AuthMiddleware] Token validation returned: ${user?.id ?? 'null'}',
50+
_log.finer(
51+
'Token validation returned: ${user?.id ?? 'null'}',
4952
);
5053
if (user != null) {
51-
print(
52-
'[AuthMiddleware] Authentication successful for user: ${user.id}',
53-
);
54+
_log.info('Authentication successful for user: ${user.id}');
5455
} else {
55-
print(
56-
'[AuthMiddleware] Invalid token provided (validateToken returned null).',
56+
_log.warning(
57+
'Invalid token provided (validateToken returned null).',
5758
);
5859
// Optional: Could throw UnauthorizedException here if *all* routes
5960
// using this middleware strictly require a valid token.
6061
// However, providing null allows routes to handle optional auth.
6162
}
6263
} on HtHttpException catch (e) {
6364
// Log token validation errors from the service
64-
print('Token validation failed: $e');
65+
_log.warning('Token validation failed.', e);
6566
// Let the error propagate if needed, or handle specific cases.
6667
// For now, we treat validation errors as resulting in no user.
6768
user = null; // Keep user null if HtHttpException occurred
6869
} catch (e, s) {
6970
// Catch unexpected errors during validation
70-
print(
71-
'[AuthMiddleware] Unexpected error during token validation: $e\n$s',
71+
_log.severe(
72+
'Unexpected error during token validation.',
73+
e,
74+
s,
7275
);
7376
user = null; // Keep user null if unexpected error occurred
7477
}
7578
} else {
76-
print('[AuthMiddleware] No valid Bearer token found in header.');
79+
_log.finer('No valid Bearer token found in header.');
7780
}
7881

7982
// Provide the User object (or null) into the context
8083
// This makes `context.read<User?>()` available downstream.
81-
print(
82-
'[AuthMiddleware] Providing User (${user?.id ?? 'null'}) to context.',
84+
_log.finer(
85+
'Providing User (${user?.id ?? 'null'}) to context.',
8386
);
8487
return handler(context.provide<User?>(() => user));
8588
};
@@ -96,14 +99,14 @@ Middleware requireAuthentication() {
9699
return (context) {
97100
final user = context.read<User?>();
98101
if (user == null) {
99-
print(
102+
_log.warning(
100103
'Authentication required but no valid user found. Denying access.',
101104
);
102105
// Throwing allows the central errorHandler to create the 401 response.
103106
throw const UnauthorizedException('Authentication required.');
104107
}
105108
// If user exists, proceed to the handler
106-
print('Authentication check passed for user: ${user.id}');
109+
_log.info('Authentication check passed for user: ${user.id}');
107110
return handler(context.provide<User>(() => user));
108111
};
109112
};

lib/src/middlewares/authorization_middleware.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import 'package:dart_frog/dart_frog.dart';
22
import 'package:ht_api/src/rbac/permission_service.dart';
33
import 'package:ht_api/src/registry/model_registry.dart';
44
import 'package:ht_shared/ht_shared.dart';
5+
import 'package:logging/logging.dart';
6+
7+
final _log = Logger('AuthorizationMiddleware');
58

69
/// {@template authorization_middleware}
710
/// Middleware to enforce role-based permissions and model-specific access rules.
@@ -81,9 +84,9 @@ Middleware authorizationMiddleware() {
8184
final permission = requiredPermissionConfig.permission;
8285
if (permission == null) {
8386
// This indicates a configuration error in ModelRegistry
84-
print(
85-
'[AuthorizationMiddleware] Configuration Error: specificPermission '
86-
'type requires a permission string for model "$modelName", method "$method".',
87+
_log.severe(
88+
'Configuration Error: specificPermission type requires a '
89+
'permission string for model "$modelName", method "$method".',
8790
);
8891
throw const OperationFailedException(
8992
'Internal Server Error: Authorization configuration error.',
@@ -97,9 +100,9 @@ Middleware authorizationMiddleware() {
97100
case RequiredPermissionType.unsupported:
98101
// This action is explicitly marked as not supported via this generic route.
99102
// Return Method Not Allowed.
100-
print(
101-
'[AuthorizationMiddleware] Action for model "$modelName", method "$method" '
102-
'is marked as unsupported via generic route.',
103+
_log.warning(
104+
'Action for model "$modelName", method "$method" is marked as '
105+
'unsupported via generic route.',
103106
);
104107
// Throw ForbiddenException to be caught by the errorHandler
105108
throw ForbiddenException(

lib/src/middlewares/error_handler.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import 'package:dart_frog/dart_frog.dart';
77
import 'package:ht_api/src/config/environment_config.dart';
88
import 'package:ht_shared/ht_shared.dart';
99
import 'package:json_annotation/json_annotation.dart';
10+
import 'package:logging/logging.dart';
11+
12+
final _log = Logger('ErrorHandler');
1013

1114
/// Middleware that catches errors and converts them into
1215
/// standardized JSON responses.
@@ -20,7 +23,7 @@ Middleware errorHandler() {
2023
} on HtHttpException catch (e, stackTrace) {
2124
// Handle specific HtHttpExceptions from the client/repository layers
2225
final statusCode = _mapExceptionToStatusCode(e);
23-
print('HtHttpException Caught: $e\n$stackTrace'); // Log for debugging
26+
_log.warning('HtHttpException Caught', e, stackTrace);
2427
return _jsonErrorResponse(
2528
statusCode: statusCode,
2629
exception: e,
@@ -32,23 +35,23 @@ Middleware errorHandler() {
3235
final message =
3336
'Invalid request body: Field "$field" has an '
3437
'invalid value or is missing. ${e.message}';
35-
print('CheckedFromJsonException Caught: $e\n$stackTrace');
38+
_log.warning('CheckedFromJsonException Caught', e, stackTrace);
3639
return _jsonErrorResponse(
3740
statusCode: HttpStatus.badRequest, // 400
3841
exception: InvalidInputException(message),
3942
context: context,
4043
);
4144
} on FormatException catch (e, stackTrace) {
4245
// Handle data format/parsing errors (often indicates bad client input)
43-
print('FormatException Caught: $e\n$stackTrace'); // Log for debugging
46+
_log.warning('FormatException Caught', e, stackTrace);
4447
return _jsonErrorResponse(
4548
statusCode: HttpStatus.badRequest, // 400
4649
exception: InvalidInputException('Invalid data format: ${e.message}'),
4750
context: context,
4851
);
4952
} catch (e, stackTrace) {
5053
// Handle any other unexpected errors
51-
print('Unhandled Exception Caught: $e\n$stackTrace');
54+
_log.severe('Unhandled Exception Caught', e, stackTrace);
5255
return _jsonErrorResponse(
5356
statusCode: HttpStatus.internalServerError, // 500
5457
exception: const UnknownException(

lib/src/services/database_seeding_service.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart';
2+
import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart';
13
import 'package:ht_shared/ht_shared.dart';
24
import 'package:logging/logging.dart';
35
import 'package:mongo_dart/mongo_dart.dart';
@@ -143,6 +145,48 @@ class DatabaseSeedingService {
143145
name: 'sources_text_index',
144146
);
145147

148+
// --- TTL and Unique Indexes via runCommand ---
149+
// The following indexes are created using the generic `runCommand` because
150+
// they require specific options not exposed by the simpler `createIndex`
151+
// helper method in the `mongo_dart` library.
152+
// Specifically, `expireAfterSeconds` is needed for TTL indexes.
153+
154+
// Indexes for the verification codes collection
155+
await _db.runCommand({
156+
'createIndexes': kVerificationCodesCollection,
157+
'indexes': [
158+
{
159+
// This is a TTL (Time-To-Live) index. MongoDB will automatically
160+
// delete documents from this collection when the `expiresAt` field's
161+
// value is older than the specified number of seconds (0).
162+
'key': {'expiresAt': 1},
163+
'name': 'expiresAt_ttl_index',
164+
'expireAfterSeconds': 0,
165+
},
166+
{
167+
// This ensures that each email can only have one pending
168+
// verification code at a time, preventing duplicates.
169+
'key': {'email': 1},
170+
'name': 'email_unique_index',
171+
'unique': true,
172+
}
173+
]
174+
});
175+
176+
// Index for the token blacklist collection
177+
await _db.runCommand({
178+
'createIndexes': kBlacklistedTokensCollection,
179+
'indexes': [
180+
{
181+
// This is a TTL index. MongoDB will automatically delete documents
182+
// (blacklisted tokens) when the `expiry` field's value is past.
183+
'key': {'expiry': 1},
184+
'name': 'expiry_ttl_index',
185+
'expireAfterSeconds': 0,
186+
}
187+
]
188+
});
189+
146190
_log.info('Database indexes are set up correctly.');
147191
} on Exception catch (e, s) {
148192
_log.severe('Failed to create database indexes.', e, s);

0 commit comments

Comments
 (0)