|
| 1 | +import 'dart:async'; |
| 2 | + |
| 3 | +import 'package:ht_api/src/services/token_blacklist_service.dart'; |
| 4 | +import 'package:ht_data_mongodb/ht_data_mongodb.dart'; |
| 5 | +import 'package:ht_shared/ht_shared.dart'; |
| 6 | +import 'package:logging/logging.dart'; |
| 7 | +import 'package:mongo_dart/mongo_dart.dart'; |
| 8 | + |
| 9 | +/// The name of the MongoDB collection used for storing blacklisted tokens. |
| 10 | +const String kBlacklistedTokensCollection = 'blacklisted_tokens'; |
| 11 | + |
| 12 | +/// {@template mongodb_token_blacklist_service} |
| 13 | +/// A MongoDB-backed implementation of [TokenBlacklistService]. |
| 14 | +/// |
| 15 | +/// Stores blacklisted JWT IDs (jti) in a dedicated MongoDB collection. |
| 16 | +/// It leverages a TTL (Time-To-Live) index on the `expiry` field to have |
| 17 | +/// MongoDB automatically purge expired tokens, ensuring efficient cleanup. |
| 18 | +/// {@endtemplate} |
| 19 | +class MongoDbTokenBlacklistService implements TokenBlacklistService { |
| 20 | + /// {@macro mongodb_token_blacklist_service} |
| 21 | + MongoDbTokenBlacklistService({ |
| 22 | + required MongoDbConnectionManager connectionManager, |
| 23 | + required Logger log, |
| 24 | + }) : _connectionManager = connectionManager, |
| 25 | + _log = log { |
| 26 | + // Fire-and-forget initialization. Errors are logged internally. |
| 27 | + _init(); |
| 28 | + } |
| 29 | + |
| 30 | + final MongoDbConnectionManager _connectionManager; |
| 31 | + final Logger _log; |
| 32 | + |
| 33 | + DbCollection get _collection => |
| 34 | + _connectionManager.db.collection(kBlacklistedTokensCollection); |
| 35 | + |
| 36 | + /// Initializes the service by ensuring the TTL index exists. |
| 37 | + /// This is idempotent and safe to call multiple times. |
| 38 | + Future<void> _init() async { |
| 39 | + try { |
| 40 | + _log.info('Ensuring TTL index exists for blacklist collection...'); |
| 41 | + // Using a raw command is more robust against client library changes. |
| 42 | + final command = { |
| 43 | + 'createIndexes': kBlacklistedTokensCollection, |
| 44 | + 'indexes': [ |
| 45 | + { |
| 46 | + 'key': {'expiry': 1}, |
| 47 | + 'name': 'expiry_ttl_index', |
| 48 | + 'expireAfterSeconds': 0, |
| 49 | + } |
| 50 | + ] |
| 51 | + }; |
| 52 | + await _connectionManager.db.runCommand(command); |
| 53 | + _log.info('Blacklist TTL index is set up correctly.'); |
| 54 | + } catch (e, s) { |
| 55 | + _log.severe( |
| 56 | + 'Failed to create TTL index for blacklist collection. ' |
| 57 | + 'Failed to create TTL index for blacklist collection. ' |
| 58 | + 'Automatic cleanup of expired tokens will not work.', |
| 59 | + e, |
| 60 | + s, |
| 61 | + ); |
| 62 | + // Rethrow the exception to halt startup if the index is critical. |
| 63 | + rethrow; |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + @override |
| 68 | + Future<void> blacklist(String jti, DateTime expiry) async { |
| 69 | + try { |
| 70 | + // The document structure is simple: the JTI is the primary key (_id) |
| 71 | + // and `expiry` is the TTL-indexed field. |
| 72 | + await _collection.insertOne({ |
| 73 | + '_id': jti, |
| 74 | + 'expiry': expiry, |
| 75 | + }); |
| 76 | + _log.info('Blacklisted jti: $jti (expires: $expiry)'); |
| 77 | + } on MongoDartError catch (e) { |
| 78 | + // Handle the specific case of a duplicate key error, which means the |
| 79 | + // token is already blacklisted. This is not a failure condition. |
| 80 | + // We check the message because the error type may not be specific enough. |
| 81 | + if (e.message.contains('duplicate key')) { |
| 82 | + _log.warning( |
| 83 | + 'Attempted to blacklist an already blacklisted jti: $jti', |
| 84 | + ); |
| 85 | + // Swallow the exception as the desired state is already achieved. |
| 86 | + return; |
| 87 | + } |
| 88 | + // For other database errors, rethrow as a standard exception. |
| 89 | + _log.severe('MongoDartError while blacklisting jti $jti: $e'); |
| 90 | + throw OperationFailedException('Failed to blacklist token: $e'); |
| 91 | + } catch (e) { |
| 92 | + _log.severe('Unexpected error while blacklisting jti $jti: $e'); |
| 93 | + throw OperationFailedException('Failed to blacklist token: $e'); |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + @override |
| 98 | + Future<bool> isBlacklisted(String jti) async { |
| 99 | + try { |
| 100 | + // We only need to check for the existence of the document. |
| 101 | + // The TTL index handles removal of expired tokens automatically, |
| 102 | + // so if a document exists, it is considered blacklisted. |
| 103 | + final result = await _collection.findOne(where.eq('_id', jti)); |
| 104 | + return result != null; |
| 105 | + } catch (e) { |
| 106 | + _log.severe('Error checking blacklist for jti $jti: $e'); |
| 107 | + throw OperationFailedException('Failed to check token blacklist: $e'); |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + @override |
| 112 | + Future<void> cleanupExpired() async { |
| 113 | + // This is a no-op because the TTL index on the MongoDB collection |
| 114 | + // handles the cleanup automatically on the server side. |
| 115 | + _log.finer( |
| 116 | + 'cleanupExpired() called, but no action is needed due to TTL index.', |
| 117 | + ); |
| 118 | + return Future.value(); |
| 119 | + } |
| 120 | + |
| 121 | + @override |
| 122 | + void dispose() { |
| 123 | + // This is a no-op because the underlying database connection is managed |
| 124 | + // by the injected MongoDbConnectionManager, which has its own lifecycle |
| 125 | + // managed by AppDependencies. |
| 126 | + _log.finer('dispose() called, no action needed.'); |
| 127 | + } |
| 128 | +} |
0 commit comments