diff --git a/.env.example b/.env.example index 86ad6eb..401f60c 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,21 @@ # The application cannot start without a database connection. # DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" +# REQUIRED: A secure, randomly generated secret for signing JWTs. +# The application cannot start without this. +# Generate a secure key using: dart pub global run dcli_scripts create_key +# JWT_SECRET_KEY="your-super-secret-and-long-jwt-key" + # 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. # For local development, this can be left unset as 'localhost' is allowed by default. -# CORS_ALLOWED_ORIGIN="https://your-dashboard.com" \ No newline at end of file +# CORS_ALLOWED_ORIGIN="https://your-dashboard.com" + +# REQUIRED FOR PRODUCTION: The issuer URL for JWTs. +# Defaults to 'http://localhost:8080' for local development if not set. +# For production, this MUST be the public URL of your API. +# JWT_ISSUER="https://api.your-domain.com" + +# OPTIONAL: The expiry duration for JWTs in hours. +# Defaults to 1 hour if not set. +# JWT_EXPIRY_HOURS="24" diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index dd87867..f9bddf9 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -6,6 +6,8 @@ import 'package:ht_api/src/services/dashboard_summary_service.dart'; import 'package:ht_api/src/services/database_seeding_service.dart'; import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; import 'package:ht_api/src/services/jwt_auth_token_service.dart'; +import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart'; +import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; @@ -169,15 +171,19 @@ class AppDependencies { emailRepository = const HtEmailRepository(emailClient: emailClient); // 5. Initialize Services - tokenBlacklistService = InMemoryTokenBlacklistService( - log: Logger('InMemoryTokenBlacklistService'), + tokenBlacklistService = MongoDbTokenBlacklistService( + connectionManager: _mongoDbConnectionManager, + log: Logger('MongoDbTokenBlacklistService'), ); authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, log: Logger('JwtAuthTokenService'), ); - verificationCodeStorageService = InMemoryVerificationCodeStorageService(); + verificationCodeStorageService = MongoDbVerificationCodeStorageService( + connectionManager: _mongoDbConnectionManager, + log: Logger('MongoDbVerificationCodeStorageService'), + ); permissionService = const PermissionService(); authService = AuthService( userRepository: userRepository, diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index e26aa78..55b37ed 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -78,6 +78,24 @@ abstract final class EnvironmentConfig { return dbUrl; } + /// Retrieves the JWT secret key from the environment. + /// + /// The value is read from the `JWT_SECRET_KEY` environment variable. + /// + /// Throws a [StateError] if the `JWT_SECRET_KEY` environment variable is not + /// set, as the application cannot function without it. + static String get jwtSecretKey { + final jwtKey = _env['JWT_SECRET_KEY']; + if (jwtKey == null || jwtKey.isEmpty) { + _log.severe('JWT_SECRET_KEY not found in environment variables.'); + throw StateError( + 'FATAL: JWT_SECRET_KEY environment variable is not set. ' + 'The application cannot start without a JWT secret.', + ); + } + return jwtKey; + } + /// Retrieves the current environment mode (e.g., 'development'). /// /// The value is read from the `ENV` environment variable. @@ -90,4 +108,20 @@ abstract final class EnvironmentConfig { /// This is used to configure CORS for production environments. /// Returns `null` if the variable is not set. static String? get corsAllowedOrigin => _env['CORS_ALLOWED_ORIGIN']; + + /// Retrieves the JWT issuer URL from the environment. + /// + /// The value is read from the `JWT_ISSUER` environment variable. + /// Defaults to 'http://localhost:8080' if not set. + static String get jwtIssuer => + _env['JWT_ISSUER'] ?? 'http://localhost:8080'; + + /// Retrieves the JWT expiry duration in hours from the environment. + /// + /// The value is read from the `JWT_EXPIRY_HOURS` environment variable. + /// Defaults to 1 hour if not set or if parsing fails. + static Duration get jwtExpiryDuration { + final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1'); + return Duration(hours: hours ?? 1); + } } diff --git a/lib/src/middlewares/authentication_middleware.dart b/lib/src/middlewares/authentication_middleware.dart index d479126..d2715a3 100644 --- a/lib/src/middlewares/authentication_middleware.dart +++ b/lib/src/middlewares/authentication_middleware.dart @@ -1,6 +1,9 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('AuthMiddleware'); /// Middleware to handle authentication by verifying Bearer tokens. /// @@ -17,43 +20,41 @@ import 'package:ht_shared/ht_shared.dart'; Middleware authenticationProvider() { return (handler) { return (context) async { - print('[AuthMiddleware] Entered.'); + _log.finer('Entered.'); // Read the interface type AuthTokenService tokenService; try { - print('[AuthMiddleware] Attempting to read AuthTokenService...'); + _log.finer('Attempting to read AuthTokenService...'); tokenService = context.read(); - print('[AuthMiddleware] Successfully read AuthTokenService.'); + _log.finer('Successfully read AuthTokenService.'); } catch (e, s) { - print('[AuthMiddleware] FAILED to read AuthTokenService: $e\n$s'); + _log.severe('FAILED to read AuthTokenService.', e, s); // Re-throw the error to be caught by the main error handler rethrow; } User? user; // Extract the Authorization header - print('[AuthMiddleware] Attempting to read Authorization header...'); + _log.finer('Attempting to read Authorization header...'); final authHeader = context.request.headers['Authorization']; - print('[AuthMiddleware] Authorization header value: $authHeader'); + _log.finer('Authorization header value: $authHeader'); if (authHeader != null && authHeader.startsWith('Bearer ')) { // Extract the token string final token = authHeader.substring(7); // Length of 'Bearer ' - print('[AuthMiddleware] Extracted Bearer token.'); + _log.finer('Extracted Bearer token.'); try { - print('[AuthMiddleware] Attempting to validate token...'); + _log.finer('Attempting to validate token...'); // Validate the token using the service user = await tokenService.validateToken(token); - print( - '[AuthMiddleware] Token validation returned: ${user?.id ?? 'null'}', + _log.finer( + 'Token validation returned: ${user?.id ?? 'null'}', ); if (user != null) { - print( - '[AuthMiddleware] Authentication successful for user: ${user.id}', - ); + _log.info('Authentication successful for user: ${user.id}'); } else { - print( - '[AuthMiddleware] Invalid token provided (validateToken returned null).', + _log.warning( + 'Invalid token provided (validateToken returned null).', ); // Optional: Could throw UnauthorizedException here if *all* routes // using this middleware strictly require a valid token. @@ -61,25 +62,27 @@ Middleware authenticationProvider() { } } on HtHttpException catch (e) { // Log token validation errors from the service - print('Token validation failed: $e'); + _log.warning('Token validation failed.', e); // Let the error propagate if needed, or handle specific cases. // For now, we treat validation errors as resulting in no user. user = null; // Keep user null if HtHttpException occurred } catch (e, s) { // Catch unexpected errors during validation - print( - '[AuthMiddleware] Unexpected error during token validation: $e\n$s', + _log.severe( + 'Unexpected error during token validation.', + e, + s, ); user = null; // Keep user null if unexpected error occurred } } else { - print('[AuthMiddleware] No valid Bearer token found in header.'); + _log.finer('No valid Bearer token found in header.'); } // Provide the User object (or null) into the context // This makes `context.read()` available downstream. - print( - '[AuthMiddleware] Providing User (${user?.id ?? 'null'}) to context.', + _log.finer( + 'Providing User (${user?.id ?? 'null'}) to context.', ); return handler(context.provide(() => user)); }; @@ -96,14 +99,14 @@ Middleware requireAuthentication() { return (context) { final user = context.read(); if (user == null) { - print( + _log.warning( 'Authentication required but no valid user found. Denying access.', ); // Throwing allows the central errorHandler to create the 401 response. throw const UnauthorizedException('Authentication required.'); } // If user exists, proceed to the handler - print('Authentication check passed for user: ${user.id}'); + _log.info('Authentication check passed for user: ${user.id}'); return handler(context.provide(() => user)); }; }; diff --git a/lib/src/middlewares/authorization_middleware.dart b/lib/src/middlewares/authorization_middleware.dart index 7d4d24d..7681e8b 100644 --- a/lib/src/middlewares/authorization_middleware.dart +++ b/lib/src/middlewares/authorization_middleware.dart @@ -2,6 +2,9 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('AuthorizationMiddleware'); /// {@template authorization_middleware} /// Middleware to enforce role-based permissions and model-specific access rules. @@ -81,9 +84,9 @@ Middleware authorizationMiddleware() { final permission = requiredPermissionConfig.permission; if (permission == null) { // This indicates a configuration error in ModelRegistry - print( - '[AuthorizationMiddleware] Configuration Error: specificPermission ' - 'type requires a permission string for model "$modelName", method "$method".', + _log.severe( + 'Configuration Error: specificPermission type requires a ' + 'permission string for model "$modelName", method "$method".', ); throw const OperationFailedException( 'Internal Server Error: Authorization configuration error.', @@ -97,9 +100,9 @@ Middleware authorizationMiddleware() { case RequiredPermissionType.unsupported: // This action is explicitly marked as not supported via this generic route. // Return Method Not Allowed. - print( - '[AuthorizationMiddleware] Action for model "$modelName", method "$method" ' - 'is marked as unsupported via generic route.', + _log.warning( + 'Action for model "$modelName", method "$method" is marked as ' + 'unsupported via generic route.', ); // Throw ForbiddenException to be caught by the errorHandler throw ForbiddenException( diff --git a/lib/src/middlewares/error_handler.dart b/lib/src/middlewares/error_handler.dart index f1b250b..6e5cae1 100644 --- a/lib/src/middlewares/error_handler.dart +++ b/lib/src/middlewares/error_handler.dart @@ -7,6 +7,9 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('ErrorHandler'); /// Middleware that catches errors and converts them into /// standardized JSON responses. @@ -20,7 +23,7 @@ Middleware errorHandler() { } on HtHttpException catch (e, stackTrace) { // Handle specific HtHttpExceptions from the client/repository layers final statusCode = _mapExceptionToStatusCode(e); - print('HtHttpException Caught: $e\n$stackTrace'); // Log for debugging + _log.warning('HtHttpException Caught', e, stackTrace); return _jsonErrorResponse( statusCode: statusCode, exception: e, @@ -32,7 +35,7 @@ Middleware errorHandler() { final message = 'Invalid request body: Field "$field" has an ' 'invalid value or is missing. ${e.message}'; - print('CheckedFromJsonException Caught: $e\n$stackTrace'); + _log.warning('CheckedFromJsonException Caught', e, stackTrace); return _jsonErrorResponse( statusCode: HttpStatus.badRequest, // 400 exception: InvalidInputException(message), @@ -40,7 +43,7 @@ Middleware errorHandler() { ); } on FormatException catch (e, stackTrace) { // Handle data format/parsing errors (often indicates bad client input) - print('FormatException Caught: $e\n$stackTrace'); // Log for debugging + _log.warning('FormatException Caught', e, stackTrace); return _jsonErrorResponse( statusCode: HttpStatus.badRequest, // 400 exception: InvalidInputException('Invalid data format: ${e.message}'), @@ -48,7 +51,7 @@ Middleware errorHandler() { ); } catch (e, stackTrace) { // Handle any other unexpected errors - print('Unhandled Exception Caught: $e\n$stackTrace'); + _log.severe('Unhandled Exception Caught', e, stackTrace); return _jsonErrorResponse( statusCode: HttpStatus.internalServerError, // 500 exception: const UnknownException( diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index cf91e5e..7a294e3 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,3 +1,5 @@ +import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart'; +import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; @@ -143,6 +145,48 @@ class DatabaseSeedingService { name: 'sources_text_index', ); + // --- TTL and Unique Indexes via runCommand --- + // The following indexes are created using the generic `runCommand` because + // they require specific options not exposed by the simpler `createIndex` + // helper method in the `mongo_dart` library. + // Specifically, `expireAfterSeconds` is needed for TTL indexes. + + // Indexes for the verification codes collection + await _db.runCommand({ + 'createIndexes': kVerificationCodesCollection, + 'indexes': [ + { + // This is a TTL (Time-To-Live) index. MongoDB will automatically + // delete documents from this collection when the `expiresAt` field's + // value is older than the specified number of seconds (0). + 'key': {'expiresAt': 1}, + 'name': 'expiresAt_ttl_index', + 'expireAfterSeconds': 0, + }, + { + // This ensures that each email can only have one pending + // verification code at a time, preventing duplicates. + 'key': {'email': 1}, + 'name': 'email_unique_index', + 'unique': true, + } + ] + }); + + // Index for the token blacklist collection + await _db.runCommand({ + 'createIndexes': kBlacklistedTokensCollection, + 'indexes': [ + { + // This is a TTL index. MongoDB will automatically delete documents + // (blacklisted tokens) when the `expiry` field's value is past. + 'key': {'expiry': 1}, + 'name': 'expiry_ttl_index', + 'expireAfterSeconds': 0, + } + ] + }); + _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { _log.severe('Failed to create database indexes.', e, s); diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 4aaa86a..9fd2f34 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -1,4 +1,5 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; @@ -31,24 +32,14 @@ class JwtAuthTokenService implements AuthTokenService { final TokenBlacklistService _blacklistService; final Logger _log; - // --- Configuration --- - - // WARNING: Hardcoding secrets is insecure. Use environment variables - // or a proper secrets management solution in production. - static const String _secretKey = - 'your-very-hardcoded-super-secret-key-replace-this-in-prod'; - - // Define token issuer and default expiry duration - static const String _issuer = 'http://localhost:8080'; - static const Duration _tokenExpiryDuration = Duration(hours: 1); - // --- Interface Implementation --- @override Future generateToken(User user) async { try { final now = DateTime.now(); - final expiry = now.add(_tokenExpiryDuration); + final expiry = now.add(EnvironmentConfig.jwtExpiryDuration); + final issuer = EnvironmentConfig.jwtIssuer; final jwt = JWT( { @@ -56,7 +47,7 @@ class JwtAuthTokenService implements AuthTokenService { 'sub': user.id, // Subject (user ID) - REQUIRED 'exp': expiry.millisecondsSinceEpoch ~/ 1000, // Expiration Time 'iat': now.millisecondsSinceEpoch ~/ 1000, // Issued At - 'iss': _issuer, // Issuer + 'iss': issuer, // Issuer 'jti': ObjectId().oid, // JWT ID (for potential blacklisting) // Custom claims (optional, include what's useful) 'email': user.email, // Kept for convenience @@ -64,16 +55,16 @@ class JwtAuthTokenService implements AuthTokenService { 'appRole': user.appRole.name, 'dashboardRole': user.dashboardRole.name, }, - issuer: _issuer, + issuer: issuer, subject: user.id, jwtId: ObjectId().oid, // Re-setting jti here for clarity if needed ); // Sign the token using HMAC-SHA256 final token = jwt.sign( - SecretKey(_secretKey), + SecretKey(EnvironmentConfig.jwtSecretKey), algorithm: JWTAlgorithm.HS256, - expiresIn: _tokenExpiryDuration, // Redundant but safe + expiresIn: EnvironmentConfig.jwtExpiryDuration, // Redundant but safe ); _log.info('Generated JWT for user ${user.id}'); @@ -93,7 +84,7 @@ class JwtAuthTokenService implements AuthTokenService { try { // Verify the token's signature and expiry _log.finer('[validateToken] Verifying token signature and expiry...'); - final jwt = JWT.verify(token, SecretKey(_secretKey)); + final jwt = JWT.verify(token, SecretKey(EnvironmentConfig.jwtSecretKey)); _log.finer('[validateToken] Token verified. Payload: ${jwt.payload}'); // --- Blacklist Check --- @@ -216,7 +207,7 @@ class JwtAuthTokenService implements AuthTokenService { _log.finer('[invalidateToken] Verifying signature (ignoring expiry)...'); final jwt = JWT.verify( token, - SecretKey(_secretKey), + SecretKey(EnvironmentConfig.jwtSecretKey), checkExpiresIn: false, // IMPORTANT: Don't fail if expired here checkHeaderType: true, // Keep other standard checks ); diff --git a/lib/src/services/mongodb_token_blacklist_service.dart b/lib/src/services/mongodb_token_blacklist_service.dart new file mode 100644 index 0000000..3974b7b --- /dev/null +++ b/lib/src/services/mongodb_token_blacklist_service.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:ht_api/src/services/token_blacklist_service.dart'; +import 'package:ht_data_mongodb/ht_data_mongodb.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// The name of the MongoDB collection used for storing blacklisted tokens. +const String kBlacklistedTokensCollection = 'blacklisted_tokens'; + +/// {@template mongodb_token_blacklist_service} +/// A MongoDB-backed implementation of [TokenBlacklistService]. +/// +/// Stores blacklisted JWT IDs (jti) in a dedicated MongoDB collection. +/// It leverages a TTL (Time-To-Live) index on the `expiry` field to have +/// MongoDB automatically purge expired tokens, ensuring efficient cleanup. +/// {@endtemplate} +class MongoDbTokenBlacklistService implements TokenBlacklistService { + /// {@macro mongodb_token_blacklist_service} + MongoDbTokenBlacklistService({ + required MongoDbConnectionManager connectionManager, + required Logger log, + }) : _connectionManager = connectionManager, + _log = log; + + final MongoDbConnectionManager _connectionManager; + final Logger _log; + + DbCollection get _collection => + _connectionManager.db.collection(kBlacklistedTokensCollection); + + @override + Future blacklist(String jti, DateTime expiry) async { + try { + // The document structure is simple: the JTI is the primary key (_id) + // and `expiry` is the TTL-indexed field. + await _collection.insertOne({ + '_id': jti, + 'expiry': expiry, + }); + _log.info('Blacklisted jti: $jti (expires: $expiry)'); + } on MongoDartError catch (e) { + // Handle the specific case of a duplicate key error, which means the + // token is already blacklisted. This is not a failure condition. + // We check the message because the error type may not be specific enough. + if (e.message.contains('duplicate key')) { + _log.warning( + 'Attempted to blacklist an already blacklisted jti: $jti', + ); + // Swallow the exception as the desired state is already achieved. + return; + } + // For other database errors, rethrow as a standard exception. + _log.severe('MongoDartError while blacklisting jti $jti: $e'); + throw OperationFailedException('Failed to blacklist token: $e'); + } catch (e) { + _log.severe('Unexpected error while blacklisting jti $jti: $e'); + throw OperationFailedException('Failed to blacklist token: $e'); + } + } + + @override + Future isBlacklisted(String jti) async { + try { + // We only need to check for the existence of the document. + // The TTL index handles removal of expired tokens automatically, + // so if a document exists, it is considered blacklisted. + final result = await _collection.findOne(where.eq('_id', jti)); + return result != null; + } catch (e) { + _log.severe('Error checking blacklist for jti $jti: $e'); + throw OperationFailedException('Failed to check token blacklist: $e'); + } + } + + @override + Future cleanupExpired() async { + // This is a no-op because the TTL index on the MongoDB collection + // handles the cleanup automatically on the server side. + _log.finer( + 'cleanupExpired() called, but no action is needed due to TTL index.', + ); + return Future.value(); + } + + @override + void dispose() { + // This is a no-op because the underlying database connection is managed + // by the injected MongoDbConnectionManager, which has its own lifecycle + // managed by AppDependencies. + _log.finer('dispose() called, no action needed.'); + } +} diff --git a/lib/src/services/mongodb_verification_code_storage_service.dart b/lib/src/services/mongodb_verification_code_storage_service.dart new file mode 100644 index 0000000..e4436f5 --- /dev/null +++ b/lib/src/services/mongodb_verification_code_storage_service.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:ht_api/src/services/verification_code_storage_service.dart'; +import 'package:ht_data_mongodb/ht_data_mongodb.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// The name of the MongoDB collection for storing verification codes. +const String kVerificationCodesCollection = 'verification_codes'; + +/// {@template mongodb_verification_code_storage_service} +/// A MongoDB-backed implementation of [VerificationCodeStorageService]. +/// +/// Stores verification codes in a dedicated MongoDB collection with a TTL +/// index on an `expiresAt` field for automatic cleanup. It uses a unique +/// index on the `email` field to ensure data integrity. +/// {@endtemplate} +class MongoDbVerificationCodeStorageService + implements VerificationCodeStorageService { + /// {@macro mongodb_verification_code_storage_service} + MongoDbVerificationCodeStorageService({ + required MongoDbConnectionManager connectionManager, + required Logger log, + this.codeExpiryDuration = const Duration(minutes: 15), + }) : _connectionManager = connectionManager, + _log = log; + + final MongoDbConnectionManager _connectionManager; + final Logger _log; + final Random _random = Random(); + + /// The duration for which generated codes are considered valid. + final Duration codeExpiryDuration; + + DbCollection get _collection => + _connectionManager.db.collection(kVerificationCodesCollection); + + String _generateNumericCode({int length = 6}) { + final buffer = StringBuffer(); + for (var i = 0; i < length; i++) { + buffer.write(_random.nextInt(10).toString()); + } + return buffer.toString(); + } + + @override + Future generateAndStoreSignInCode(String email) async { + final code = _generateNumericCode(); + final expiresAt = DateTime.now().add(codeExpiryDuration); + + try { + // Use updateOne with upsert: if a document for the email exists, + // it's updated with a new code and expiry; otherwise, it's created. + await _collection.updateOne( + where.eq('email', email), + modify + .set('code', code) + .set('expiresAt', expiresAt) + .setOnInsert('_id', ObjectId()), + upsert: true, + ); + _log.info( + 'Stored sign-in code for $email (expires: $expiresAt)', + ); + return code; + } catch (e) { + _log.severe('Failed to store sign-in code for $email: $e'); + throw OperationFailedException('Failed to store sign-in code: $e'); + } + } + + @override + Future validateSignInCode(String email, String code) async { + try { + final entry = await _collection.findOne(where.eq('email', email)); + if (entry == null) { + return false; // No code found for this email + } + + final storedCode = entry['code'] as String?; + final expiresAt = entry['expiresAt'] as DateTime?; + + // The TTL index handles automatic deletion, but this check prevents + // using a code in the brief window before it's deleted. + if (storedCode != code || + expiresAt == null || + DateTime.now().isAfter(expiresAt)) { + return false; // Code mismatch or expired + } + + return true; + } catch (e) { + _log.severe('Error validating sign-in code for $email: $e'); + throw OperationFailedException('Failed to validate sign-in code: $e'); + } + } + + @override + Future clearSignInCode(String email) async { + try { + // After successful validation, the code should be removed immediately. + await _collection.deleteOne(where.eq('email', email)); + _log.info('Cleared sign-in code for $email'); + } catch (e) { + _log.severe('Failed to clear sign-in code for $email: $e'); + throw OperationFailedException('Failed to clear sign-in code: $e'); + } + } + + @override + Future cleanupExpiredCodes() async { + // This is a no-op because the TTL index on the MongoDB collection + // handles the cleanup automatically on the server side. + _log.finer( + 'cleanupExpiredCodes() called, but no action is needed due to TTL index.', + ); + return Future.value(); + } + + @override + void dispose() { + // This is a no-op because the underlying database connection is managed + // by the injected MongoDbConnectionManager. + _log.finer('dispose() called, no action needed.'); + } +} diff --git a/lib/src/services/simple_auth_token_service.dart b/lib/src/services/simple_auth_token_service.dart deleted file mode 100644 index 618a29a..0000000 --- a/lib/src/services/simple_auth_token_service.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:ht_api/src/services/auth_token_service.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart'; -import 'package:logging/logging.dart'; - -/// {@template simple_auth_token_service} -/// A minimal, dependency-free implementation of [AuthTokenService] for debugging. -/// -/// Generates simple, predictable tokens and validates them by checking a prefix -/// and fetching the user from the repository. Does not involve JWT logic. -/// {@endtemplate} -class SimpleAuthTokenService implements AuthTokenService { - /// {@macro simple_auth_token_service} - const SimpleAuthTokenService({ - required HtDataRepository userRepository, - required Logger log, - }) : _userRepository = userRepository, - _log = log; - - final HtDataRepository _userRepository; - final Logger _log; - static const String _tokenPrefix = 'valid-token-for-user-id:'; - - @override - Future generateToken(User user) async { - _log.info('Generating token for user ${user.id}'); - final token = '$_tokenPrefix${user.id}'; - _log.finer('Generated token: $token'); - // Simulate async operation if needed, though not strictly necessary here - await Future.delayed(Duration.zero); - return token; - } - - @override - Future validateToken(String token) async { - _log.finer('Attempting to validate token: $token'); - if (!token.startsWith(_tokenPrefix)) { - _log.warning('Validation failed: Invalid prefix.'); - // Mimic JWT behavior by throwing Unauthorized for invalid format - throw const UnauthorizedException('Invalid token format.'); - } - - final userId = token.substring(_tokenPrefix.length); - _log.finer('Extracted user ID: $userId'); - - if (userId.isEmpty) { - _log.warning('Validation failed: Empty user ID.'); - throw const UnauthorizedException('Invalid token: Empty user ID.'); - } - - try { - _log.finer('Attempting to read user from repository...'); - final user = await _userRepository.read(id: userId); - _log.info('User read successful: ${user.id}'); - return user; - } on NotFoundException { - _log.warning('Validation failed: User ID $userId not found.'); - // Return null if user not found, mimicking successful validation - // of a token for a non-existent user. The middleware handles this. - return null; - } on HtHttpException catch (e, s) { - // Handle other potential repository errors - _log.warning('Validation failed: Repository error', e, s); - // Re-throw other client/repo exceptions - rethrow; - } catch (e, s) { - // Catch unexpected errors during validation - _log.severe('Unexpected validation error', e, s); - throw OperationFailedException( - 'Simple token validation failed unexpectedly: $e', - ); - } - } - - @override - Future invalidateToken(String token) async { - // This service uses simple prefixed tokens, not JWTs with JTI. - // True invalidation/blacklisting isn't applicable here. - // This method is implemented to satisfy the AuthTokenService interface. - _log.info( - 'Received request to invalidate token: $token. ' - 'No server-side invalidation is performed for simple tokens.', - ); - // Simulate async operation - await Future.delayed(Duration.zero); - // No specific exceptions thrown here. - } -} diff --git a/lib/src/services/verification_code_storage_service.dart b/lib/src/services/verification_code_storage_service.dart index ef21f89..a832009 100644 --- a/lib/src/services/verification_code_storage_service.dart +++ b/lib/src/services/verification_code_storage_service.dart @@ -67,118 +67,3 @@ abstract class VerificationCodeStorageService { /// Disposes of any resources used by the service (e.g., timers for cleanup). void dispose(); } - -/// {@template in_memory_verification_code_storage_service} -/// An in-memory implementation of [VerificationCodeStorageService]. -/// -/// Stores verification codes in memory. Not suitable for production if -/// persistence across server restarts is required. -/// {@endtemplate} -class InMemoryVerificationCodeStorageService - implements VerificationCodeStorageService { - /// {@macro in_memory_verification_code_storage_service} - InMemoryVerificationCodeStorageService({ - Duration cleanupInterval = _defaultCleanupInterval, - this.codeExpiryDuration = _defaultCodeExpiryDuration, - }) { - _cleanupTimer = Timer.periodic(cleanupInterval, (_) async { - try { - await cleanupExpiredCodes(); - } catch (e) { - print( - '[InMemoryVerificationCodeStorageService] Error during scheduled cleanup: $e', - ); - } - }); - print( - '[InMemoryVerificationCodeStorageService] Initialized with cleanup interval: ' - '$cleanupInterval and code expiry: $codeExpiryDuration', - ); - } - - /// Duration for which generated codes are considered valid. - final Duration codeExpiryDuration; - - /// Store for standard sign-in codes: Key is email. - @visibleForTesting - final Map signInCodesStore = {}; - - Timer? _cleanupTimer; - bool _isDisposed = false; - final Random _random = Random(); - - String _generateNumericCode({int length = 6}) { - final buffer = StringBuffer(); - for (var i = 0; i < length; i++) { - buffer.write(_random.nextInt(10).toString()); - } - return buffer.toString(); - } - - @override - Future generateAndStoreSignInCode(String email) async { - if (_isDisposed) { - throw const OperationFailedException('Service is disposed.'); - } - await Future.delayed(Duration.zero); // Simulate async - final code = _generateNumericCode(); - final expiresAt = DateTime.now().add(codeExpiryDuration); - signInCodesStore[email] = _SignInCodeEntry(code, expiresAt); - print( - '[InMemoryVerificationCodeStorageService] Stored sign-in code: $code for $email (expires: $expiresAt)', - ); - return code; - } - - @override - Future validateSignInCode(String email, String code) async { - if (_isDisposed) return false; - await Future.delayed(Duration.zero); // Simulate async - final entry = signInCodesStore[email]; - if (entry == null || entry.isExpired || entry.code != code) { - return false; - } - return true; - } - - @override - Future clearSignInCode(String email) async { - if (_isDisposed) return; - await Future.delayed(Duration.zero); // Simulate async - signInCodesStore.remove(email); - print( - '[InMemoryVerificationCodeStorageService] Cleared sign-in code for $email', - ); - } - - @override - Future cleanupExpiredCodes() async { - if (_isDisposed) return; - await Future.delayed(Duration.zero); // Simulate async - var cleanedCount = 0; - - signInCodesStore.removeWhere((key, entry) { - if (entry.isExpired) { - cleanedCount++; - return true; - } - return false; - }); - - if (cleanedCount > 0) { - print( - '[InMemoryVerificationCodeStorageService] Cleaned up $cleanedCount expired codes.', - ); - } - } - - @override - void dispose() { - if (!_isDisposed) { - _isDisposed = true; - _cleanupTimer?.cancel(); - signInCodesStore.clear(); - print('[InMemoryVerificationCodeStorageService] Disposed.'); - } - } -} diff --git a/routes/api/v1/auth/anonymous.dart b/routes/api/v1/auth/anonymous.dart index 7827b36..8007d7a 100644 --- a/routes/api/v1/auth/anonymous.dart +++ b/routes/api/v1/auth/anonymous.dart @@ -4,6 +4,10 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/helpers/response_helper.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('anonymous_handler'); /// Handles POST requests to `/api/v1/auth/anonymous`. /// @@ -37,9 +41,9 @@ Future onRequest(RequestContext context) async { } on HtHttpException catch (_) { // Let the central errorHandler middleware handle known exceptions rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the service layer - print('Unexpected error in /anonymous handler: $e'); + _logger.severe('Unexpected error in /anonymous handler', e, s); // Let the central errorHandler handle this as a 500 throw const OperationFailedException( 'An unexpected error occurred during anonymous sign-in.', diff --git a/routes/api/v1/auth/delete-account.dart b/routes/api/v1/auth/delete-account.dart index 6e7eacc..310e885 100644 --- a/routes/api/v1/auth/delete-account.dart +++ b/routes/api/v1/auth/delete-account.dart @@ -3,6 +3,10 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_shared/ht_shared.dart'; // For User and exceptions +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('delete_account_handler'); /// Handles DELETE requests to `/api/v1/auth/delete-account`. /// @@ -38,10 +42,12 @@ Future onRequest(RequestContext context) async { } on HtHttpException catch (_) { // Let the central errorHandler middleware handle known exceptions rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the service layer - print( - 'Unexpected error in /delete-account handler for user ${user.id}: $e', + _logger.severe( + 'Unexpected error in /delete-account handler for user ${user.id}', + e, + s, ); // Let the central errorHandler handle this as a 500 throw const OperationFailedException( diff --git a/routes/api/v1/auth/request-code.dart b/routes/api/v1/auth/request-code.dart index 11b6706..f809454 100644 --- a/routes/api/v1/auth/request-code.dart +++ b/routes/api/v1/auth/request-code.dart @@ -3,6 +3,10 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_shared/ht_shared.dart'; // For exceptions +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('request_code_handler'); /// Handles POST requests to `/api/v1/auth/request-code`. /// @@ -79,9 +83,9 @@ Future onRequest(RequestContext context) async { } on HtHttpException catch (_) { // Let the central errorHandler middleware handle known exceptions rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the service layer - print('Unexpected error in /request-code handler: $e'); + _logger.severe('Unexpected error in /request-code handler', e, s); // Let the central errorHandler handle this as a 500 throw const OperationFailedException( 'An unexpected error occurred while requesting the sign-in code.', diff --git a/routes/api/v1/auth/sign-out.dart b/routes/api/v1/auth/sign-out.dart index 302edcc..47eb73e 100644 --- a/routes/api/v1/auth/sign-out.dart +++ b/routes/api/v1/auth/sign-out.dart @@ -3,6 +3,10 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_shared/ht_shared.dart'; // For User and exceptions +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('sign_out_handler'); /// Handles POST requests to `/api/v1/auth/sign-out`. /// @@ -34,8 +38,8 @@ Future onRequest(RequestContext context) async { // Although authentication middleware should ensure a token is present, // this check acts as a safeguard. if (token == null || token.isEmpty) { - print( - 'Error: Could not extract Bearer token for user ${user.id} in sign-out handler.', + _logger.severe( + 'Could not extract Bearer token for user ${user.id} in sign-out handler.', ); throw const OperationFailedException( 'Internal error: Unable to retrieve authentication token for sign-out.', @@ -55,9 +59,13 @@ Future onRequest(RequestContext context) async { } on HtHttpException catch (_) { // Let the central errorHandler middleware handle known exceptions rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the service layer - print('Unexpected error in /sign-out handler for user ${user.id}: $e'); + _logger.severe( + 'Unexpected error in /sign-out handler for user ${user.id}', + e, + s, + ); // Let the central errorHandler handle this as a 500 throw const OperationFailedException( 'An unexpected error occurred during sign-out.', diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index 1d464b6..c4bf7bc 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -5,6 +5,10 @@ import 'package:ht_api/src/helpers/response_helper.dart'; import 'package:ht_api/src/services/auth_service.dart'; // Import exceptions, User, SuccessApiResponse, AND AuthSuccessResponse import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('verify_code_handler'); /// Handles POST requests to `/api/v1/auth/verify-code`. /// @@ -105,9 +109,9 @@ Future onRequest(RequestContext context) async { // Let the central errorHandler middleware handle known exceptions // (e.g., InvalidInputException if code is wrong/expired) rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the service layer - print('Unexpected error in /verify-code handler: $e'); + _logger.severe('Unexpected error in /verify-code handler', e, s); // Let the central errorHandler handle this as a 500 throw const OperationFailedException( 'An unexpected error occurred while verifying the sign-in code.', diff --git a/routes/api/v1/data/[id]/index.dart b/routes/api/v1/data/[id]/index.dart index b390ebe..05ada7c 100644 --- a/routes/api/v1/data/[id]/index.dart +++ b/routes/api/v1/data/[id]/index.dart @@ -8,6 +8,10 @@ import 'package:ht_api/src/services/dashboard_summary_service.dart'; import 'package:ht_api/src/services/user_preference_limit_service.dart'; // Import UserPreferenceLimitService import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; + +// Create a logger for this file. +final _logger = Logger('data_item_handler'); /// Handles requests for the /api/v1/data/[id] endpoint. /// Dispatches requests to specific handlers based on the HTTP method. @@ -136,7 +140,7 @@ Future _handleGet( !permissionService.isAdmin(authenticatedUser)) { // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + _logger.severe( 'Configuration Error: Model "$modelName" requires ' 'ownership check for GET item but getOwnerId is not provided.', ); @@ -192,9 +196,9 @@ Future _handlePut( dynamic itemToUpdate; try { itemToUpdate = modelConfig.fromJson(requestBody); - } on TypeError catch (e) { + } on TypeError catch (e, s) { // Catch errors during deserialization (e.g., missing required fields) - print('Deserialization TypeError in PUT /data/[id]: $e'); + _logger.warning('Deserialization TypeError in PUT /data/[id]', e, s); // Throw BadRequestException to be caught by the errorHandler throw const BadRequestException( 'Invalid request body: Missing or invalid required field(s).', @@ -214,7 +218,7 @@ Future _handlePut( } catch (e) { // Ignore if getId throws, means ID might not be in the body, // which is acceptable depending on the model/client. - print('Warning: Could not get ID from PUT body: $e'); + _logger.info('Could not get ID from PUT body: $e'); } // --- Handler-Level Limit Check (for UserContentPreferences PUT) --- @@ -224,7 +228,7 @@ Future _handlePut( try { // Ensure the itemToUpdate is the correct type for the limit service if (itemToUpdate is! UserContentPreferences) { - print( + _logger.severe( 'Type Error: Expected UserContentPreferences ' 'for limit check, but got ${itemToUpdate.runtimeType}.', ); @@ -239,11 +243,13 @@ Future _handlePut( } on HtHttpException { // Propagate known exceptions from the limit service (e.g., ForbiddenException) rethrow; - } catch (e) { + } catch (e, s) { // Catch unexpected errors from the limit service - print( + _logger.severe( 'Unexpected error during limit check for ' - 'UserContentPreferences PUT: $e', + 'UserContentPreferences PUT', + e, + s, ); throw const OperationFailedException( 'An unexpected error occurred during limit check.', @@ -358,7 +364,7 @@ Future _handlePut( !permissionService.isAdmin(authenticatedUser)) { // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + _logger.severe( 'Configuration Error: Model "$modelName" requires ' 'ownership check for PUT but getOwnerId is not provided.', ); @@ -374,7 +380,7 @@ Future _handlePut( if (itemOwnerId != authenticatedUser.id) { // This scenario should ideally not happen if the repository correctly // enforced ownership during the update call when userId was passed. - print( + _logger.warning( 'Ownership check failed AFTER PUT for item $id. ' 'Item owner: $itemOwnerId, User: ${authenticatedUser.id}', ); @@ -424,7 +430,7 @@ Future _handleDelete( !permissionService.isAdmin(authenticatedUser)) { // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + _logger.severe( 'Configuration Error: Model "$modelName" requires ' 'ownership check for DELETE but getOwnerId is not provided.', ); @@ -461,15 +467,15 @@ Future _handleDelete( final repo = context.read>(); itemToDelete = await repo.read( id: id, - userId: userIdForRepoCall, - ); // userId should be null for AppConfig - default: - print( - 'Error: Unsupported model type "$modelName" reached _handleDelete ownership check.', - ); - // Throw an exception to be caught by the errorHandler - throw OperationFailedException( - 'Unsupported model type "$modelName" reached handler.', + userId: userIdForRepoCall, + ); // userId should be null for AppConfig + default: + _logger.severe( + 'Unsupported model type "$modelName" reached _handleDelete ownership check.', + ); + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', ); } @@ -534,8 +540,8 @@ Future _handleDelete( default: // This case should ideally be caught by the data/_middleware.dart, // but added for safety. - print( - 'Error: Unsupported model type "$modelName" reached _handleDelete.', + _logger.severe( + 'Unsupported model type "$modelName" reached _handleDelete.', ); // Throw an exception to be caught by the errorHandler throw OperationFailedException(