Class: ApiKeys::Services::Authenticator
- Inherits:
-
Object
- Object
- ApiKeys::Services::Authenticator
- Extended by:
- Logging
- Defined in:
- lib/api_keys/services/authenticator.rb
Overview
Authenticates an incoming request by extracting and verifying an API key.
Defined Under Namespace
Classes: Result
Class Method Summary collapse
-
.call(request) ⇒ ApiKeys::Services::Authenticator::Result
Authenticates the request.
-
.check_environment_isolation(api_key, config) ⇒ Object
Check if the API key's environment matches the current environment Returns a failure Result if there's a mismatch and strict isolation is enabled Returns nil if the check passes or is not applicable.
-
.extract_token(request, config) ⇒ Object
Extracts the token string from the request headers or query parameters.
-
.fetch_known_prefixes(config) ⇒ Object
Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
-
.find_and_verify_key(token, config) ⇒ ApiKeys::ApiKey?
Finds the ApiKey record corresponding to the token and verifies it securely.
-
.rails_cache ⇒ Object
Helper for accessing Rails cache safely.
Class Method Details
.call(request) ⇒ ApiKeys::Services::Authenticator::Result
Authenticates the request.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/api_keys/services/authenticator.rb', line 31 def self.call(request) log_debug "[ApiKeys Auth] Authenticator.call started for request: #{request.uuid}" config = ApiKeys.configuration config.before_authentication&.call(request) # === HTTPS Check (Production Only) === if defined?(Rails.env) && Rails.env.production? && config.https_only_production if request.protocol == "http://" = "[ApiKeys Security] API key authentication attempted over insecure HTTP connection in production." log_warn if config.https_strict_mode log_warn "[ApiKeys Security] Strict mode enabled: Aborting authentication." result = Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.") config.after_authentication&.call(result) return result # Halt execution due to strict mode end end end # === End HTTPS Check === token = extract_token(request, config) unless token log_debug "[ApiKeys Auth] Token extraction failed." result = Result.failure(error_code: :missing_token, message: "API token is missing") config.after_authentication&.call(result) return result end log_debug "[ApiKeys Auth] Token extracted successfully. Verifying..." # Pass the original token AND config to find_and_verify_key api_key = find_and_verify_key(token, config) result = if api_key&.active? log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}" # Check environment isolation if enabled env_check_result = check_environment_isolation(api_key, config) if env_check_result env_check_result # Return failure result else # TODO: Optionally update last_used_at and requests_count Result.success(api_key) end elsif api_key&.revoked? log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}" Result.failure(error_code: :revoked_key, message: "API key has been revoked") elsif api_key&.expired? log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}" Result.failure(error_code: :expired_key, message: "API key has expired") else # Not found, mismatch, or inactive log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found." Result.failure(error_code: :invalid_token, message: "API token is invalid") end log_debug "[ApiKeys Auth] Authenticator.call finished. Result: #{result.inspect}" config.after_authentication&.call(result) result end |
.check_environment_isolation(api_key, config) ⇒ Object
Check if the API key's environment matches the current environment Returns a failure Result if there's a mismatch and strict isolation is enabled Returns nil if the check passes or is not applicable
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/api_keys/services/authenticator.rb', line 259 def self.check_environment_isolation(api_key, config) return nil unless config.strict_environment_isolation # Skip check if key doesn't have an environment (legacy keys) key_env = api_key.environment return nil if key_env.blank? # Get current environment current_env_config = config.current_environment current_env = current_env_config.respond_to?(:call) ? current_env_config.call : current_env_config # Normalize to string first, then check if blank # This ensures consistent string comparison and prevents edge cases with empty strings current_env = current_env.to_s key_env = key_env.to_s # Skip isolation check if current environment is blank (not properly configured) if current_env.blank? log_debug "[ApiKeys Auth] Current environment is blank, skipping environment isolation check" return nil end if current_env != key_env log_debug "[ApiKeys Auth] Environment mismatch: Key environment '#{key_env}' does not match current environment '#{current_env}'" return Result.failure( error_code: :environment_mismatch, message: "API key environment (#{key_env}) does not match current environment (#{current_env})" ) end nil # Check passed end |
.extract_token(request, config) ⇒ Object
Extracts the token string from the request headers or query parameters.
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/api_keys/services/authenticator.rb', line 94 def self.extract_token(request, config) # Check header first (preferred) if config.header.present? header_value = request.headers[config.header] log_debug "[ApiKeys Auth] Checking header '#{config.header}': '#{header_value}'" if header_value # Handle "Bearer <token>" scheme match = header_value.match(/^Bearer\s+(.*)$/i) if match log_debug "[ApiKeys Auth] Extracted token from Bearer scheme." return match[1] end # Fallback: return the raw header value if no Bearer scheme log_debug "[ApiKeys Auth] No Bearer scheme, using raw header value as token." return header_value end end # Check query parameter as fallback (if configured) if config.query_param.present? param_value = request.query_parameters[config.query_param] log_debug "[ApiKeys Auth] Checking query param '#{config.query_param}': '#{param_value}'" if param_value.present? log_debug "[ApiKeys Auth] Extracted token from query parameter." return param_value end end log_debug "[ApiKeys Auth] No token found in headers or query parameters." nil # No token found end |
.fetch_known_prefixes(config) ⇒ Object
Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/api_keys/services/authenticator.rb', line 230 def self.fetch_known_prefixes(config) cache_key = "api_keys:known_prefixes" cache_ttl = config.cache_ttl.to_i # Use the same TTL as key lookup for consistency if cache_ttl > 0 cached_prefixes = rails_cache&.read(cache_key) return cached_prefixes if cached_prefixes.is_a?(Array) log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB." end # Fetch distinct, non-null prefixes from the database prefixes = ApiKeys::ApiKey.distinct.pluck(:prefix).compact if cache_ttl > 0 && rails_cache log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}" rails_cache.write(cache_key, prefixes, expires_in: cache_ttl) end prefixes end |
.find_and_verify_key(token, config) ⇒ ApiKeys::ApiKey?
Finds the ApiKey record corresponding to the token and verifies it securely. Uses caching if enabled.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/api_keys/services/authenticator.rb', line 131 def self.find_and_verify_key(token, config) cache_key = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash cache_ttl = config.cache_ttl.to_i log_debug "[ApiKeys Auth] Verifying token. Cache key: #{cache_key}, TTL: #{cache_ttl}" if cache_ttl > 0 cached_result = rails_cache&.read(cache_key) log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}" # Return cached result ONLY if it's a valid ApiKey instance (a true cache hit) if cached_result.is_a?(ApiKeys::ApiKey) log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}" return cached_result elsif cached_result.nil? log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup." # Continue execution if it's a cache miss (nil) else # Handle unexpected cache values (e.g., old symbol :not_found) log_warn "[ApiKeys Auth] Invalid cache value found: #{cached_result.inspect}. Proceeding to DB lookup." end end # --- Cache miss or TTL=0: Perform DB lookup & verification --- log_debug "[ApiKeys Auth] Performing DB lookup and verification." # 1. Determine the expected hashing strategy (assuming single strategy for now) strategy = config.hash_strategy.to_sym log_debug "[ApiKeys Auth] Using strategy: #{strategy}" # 2. Find and verify the key based on the strategy. verified_key = nil if strategy == :bcrypt # Optimization: Check against the *configured* prefix first. configured_prefix = config.token_prefix.call matched_prefix = nil if token.start_with?(configured_prefix) log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}" matched_prefix = configured_prefix else # Fallback: If no match, check against all known prefixes (cached). log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes." known_prefixes = fetch_known_prefixes(config) # Sort by length descending to find the longest match first matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) } log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}" end possible_keys_scope = if matched_prefix ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt') else # This path is now less likely but covers cases where token matches no known prefix. log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup." ApiKeys::ApiKey.none # Return an empty relation end log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql) possible_keys = possible_keys_scope.to_a log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt." # Securely compare the provided token against the digests of potential keys verified_key = possible_keys.find do |key| match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt) log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}" match_result end elsif strategy == :sha256 # For sha256, we hash the incoming token and look for an exact match # Note: Prefix lookup isn't useful here as the full hash is needed for the query. token_digest = Digest::SHA256.hexdigest(token) log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}" # Find the key directly by the calculated digest and algorithm verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256') if verified_key log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}" else log_debug "[ApiKeys Auth] No key found matching the SHA256 digest." end else # Log unsupported strategy log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}" end log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}" # --- End DB Lookup --- # Cache the result (either the found ApiKey instance or nil for a miss) if cache_ttl > 0 && rails_cache log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}" rails_cache.write(cache_key, verified_key, expires_in: cache_ttl) end verified_key end |
.rails_cache ⇒ Object
Helper for accessing Rails cache safely
252 253 254 |
# File 'lib/api_keys/services/authenticator.rb', line 252 def self.rails_cache defined?(Rails) ? Rails.cache : nil end |