api_keys 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +393 -0
- data/Rakefile +32 -0
- data/api_keys_dashboard.webp +0 -0
- data/api_keys_permissions.webp +0 -0
- data/api_keys_token.webp +0 -0
- data/app/controllers/api_keys/application_controller.rb +62 -0
- data/app/controllers/api_keys/keys_controller.rb +129 -0
- data/app/controllers/api_keys/security_controller.rb +16 -0
- data/app/views/api_keys/keys/_form.html.erb +106 -0
- data/app/views/api_keys/keys/_key_row.html.erb +72 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +52 -0
- data/app/views/api_keys/keys/_show_token.html.erb +88 -0
- data/app/views/api_keys/keys/edit.html.erb +5 -0
- data/app/views/api_keys/keys/index.html.erb +26 -0
- data/app/views/api_keys/keys/new.html.erb +5 -0
- data/app/views/api_keys/keys/show.html.erb +12 -0
- data/app/views/api_keys/security/best_practices.html.erb +70 -0
- data/app/views/layouts/api_keys/application.html.erb +115 -0
- data/config/routes.rb +18 -0
- data/lib/api_keys/authentication.rb +160 -0
- data/lib/api_keys/configuration.rb +125 -0
- data/lib/api_keys/controller.rb +47 -0
- data/lib/api_keys/engine.rb +76 -0
- data/lib/api_keys/jobs/callbacks_job.rb +69 -0
- data/lib/api_keys/jobs/update_stats_job.rb +58 -0
- data/lib/api_keys/logging.rb +42 -0
- data/lib/api_keys/models/api_key.rb +209 -0
- data/lib/api_keys/models/concerns/has_api_keys.rb +144 -0
- data/lib/api_keys/services/authenticator.rb +255 -0
- data/lib/api_keys/services/digestor.rb +68 -0
- data/lib/api_keys/services/token_generator.rb +32 -0
- data/lib/api_keys/tenant_resolution.rb +40 -0
- data/lib/api_keys/version.rb +5 -0
- data/lib/api_keys.rb +49 -0
- data/lib/generators/api_keys/install_generator.rb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +100 -0
- data/lib/generators/api_keys/templates/initializer.rb +160 -0
- metadata +184 -0
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
require_relative "../services/token_generator"
|
5
|
+
require_relative "../services/digestor"
|
6
|
+
|
7
|
+
module ApiKeys
|
8
|
+
# The core ActiveRecord model representing an API key.
|
9
|
+
class ApiKey < ActiveRecord::Base
|
10
|
+
self.table_name = "api_keys"
|
11
|
+
|
12
|
+
# == Concerns ==
|
13
|
+
# TODO: Potentially extract token generation/hashing logic into concerns
|
14
|
+
|
15
|
+
# == Associations ==
|
16
|
+
belongs_to :owner, polymorphic: true, optional: true
|
17
|
+
|
18
|
+
# == Attributes & Serialization ==
|
19
|
+
# Expose the plaintext token only immediately after creation
|
20
|
+
attr_reader :token
|
21
|
+
|
22
|
+
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
23
|
+
# using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
|
24
|
+
|
25
|
+
# == Validations ==
|
26
|
+
validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
|
27
|
+
validates :prefix, presence: true
|
28
|
+
validates :digest_algorithm, presence: true
|
29
|
+
validates :last4, presence: true, length: { is: 4 }
|
30
|
+
# validates :scopes, presence: true # Default handled by attribute def
|
31
|
+
# validates :metadata, presence: true # Default handled by attribute def
|
32
|
+
validates :name,
|
33
|
+
length: { maximum: 60 },
|
34
|
+
# Allow letters, numbers, underscores, hyphens, and spaces. No leading/trailing spaces.
|
35
|
+
format: { with: /\A[a-zA-Z0-9_ -]+\z/, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
|
36
|
+
allow_blank: true # Apply length and format only if name is present
|
37
|
+
|
38
|
+
validates :name, presence: true, if: :name_required? # Only require presence conditionally
|
39
|
+
validate :within_quota, on: :create, if: -> { owner.present? && (owner_configured? || ApiKeys.configuration.default_max_keys_per_owner.present?) }
|
40
|
+
|
41
|
+
# TODO: Add validation for expires_at > Time.current if present
|
42
|
+
validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
|
43
|
+
|
44
|
+
# TODO: Add validation for scope string format
|
45
|
+
# TODO: Add validation for prefix format (e.g., must end with _)
|
46
|
+
|
47
|
+
# == Callbacks ==
|
48
|
+
before_validation :set_defaults, on: :create
|
49
|
+
# Generate digest BEFORE validation runs
|
50
|
+
before_validation :generate_token_and_digest, on: :create
|
51
|
+
|
52
|
+
# == Scopes ==
|
53
|
+
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
54
|
+
scope :revoked, -> { where.not(revoked_at: nil) }
|
55
|
+
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
56
|
+
scope :inactive, -> { revoked.or(expired) }
|
57
|
+
scope :for_prefix, ->(prefix) { where(prefix: prefix) }
|
58
|
+
scope :for_owner, ->(owner) { where(owner: owner) }
|
59
|
+
# TODO: Add more scopes as needed (e.g., for_owner)
|
60
|
+
|
61
|
+
# == Instance Methods ==
|
62
|
+
|
63
|
+
def revoke!
|
64
|
+
update!(revoked_at: Time.current)
|
65
|
+
end
|
66
|
+
|
67
|
+
def revoked?
|
68
|
+
revoked_at.present?
|
69
|
+
end
|
70
|
+
|
71
|
+
def expired?
|
72
|
+
expires_at? && expires_at <= Time.current
|
73
|
+
end
|
74
|
+
|
75
|
+
def active?
|
76
|
+
!revoked? && !expired?
|
77
|
+
end
|
78
|
+
|
79
|
+
# Basic scope check. Assumes scopes are stored as an array of strings.
|
80
|
+
# Returns true if the key has no specific scopes (allowing all) or includes the required scope.
|
81
|
+
def allows_scope?(required_scope)
|
82
|
+
# Type casting for scopes/metadata happens via the attribute definition in the engine.
|
83
|
+
# Ensure the attribute is loaded/defined before using it.
|
84
|
+
# Check if the attribute method exists before calling .blank? or .include?
|
85
|
+
return true unless respond_to?(:scopes) # Guard clause if loaded before attribute definition
|
86
|
+
scopes.blank? || scopes.include?(required_scope.to_s)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
|
90
|
+
# Requires the plaintext token to be available (only right after creation).
|
91
|
+
def masked_token
|
92
|
+
# return "[Token not available]" unless token # No longer needed
|
93
|
+
# Show prefix, 4 bullets, last 4 chars of the random part
|
94
|
+
# random_part = token.delete_prefix(prefix) # No longer needed
|
95
|
+
# "#{prefix}••••#{random_part.last(4)}" # No longer needed
|
96
|
+
|
97
|
+
# Use the stored prefix and last4 attributes
|
98
|
+
return "[Invalid Key Data]" unless prefix.present? && last4.present?
|
99
|
+
"#{prefix}••••#{last4}"
|
100
|
+
end
|
101
|
+
|
102
|
+
# == Class Methods ==
|
103
|
+
# Most creation logic is handled by standard ActiveRecord methods + callbacks
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
# Set defaults for attributes not handled by the `attribute` API in the engine.
|
108
|
+
def set_defaults
|
109
|
+
# NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
|
110
|
+
|
111
|
+
# Determine the prefix: owner-specific setting > global config
|
112
|
+
# Note: `owner` might not be set yet if called outside normal AR flow.
|
113
|
+
owner_prefix_config = nil
|
114
|
+
if owner.present? && owner.class.respond_to?(:api_keys_settings)
|
115
|
+
owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
|
116
|
+
end
|
117
|
+
|
118
|
+
# Use owner setting if present, otherwise fall back to global config
|
119
|
+
prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
|
120
|
+
|
121
|
+
# Evaluate the prefix config (it might be a Proc)
|
122
|
+
# Ensure `self.prefix` is only set if it's not already present.
|
123
|
+
self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
|
124
|
+
|
125
|
+
# Removed default scopes logic here. It's correctly handled in the
|
126
|
+
# HasApiKeys#create_api_key! helper method, which is the intended
|
127
|
+
# way to create keys with proper default scope application.
|
128
|
+
end
|
129
|
+
|
130
|
+
# Generates the secure token, hashes it, and sets relevant attributes.
|
131
|
+
# Called before validation on create.
|
132
|
+
def generate_token_and_digest
|
133
|
+
# Generate token only if digest isn't already set (allows creating records with pre-hashed keys if needed)
|
134
|
+
return if token_digest.present?
|
135
|
+
|
136
|
+
# Ensure prefix default is set if needed (e.g., if validation skipped or called directly)
|
137
|
+
set_defaults unless self.prefix.present?
|
138
|
+
|
139
|
+
# Use the configured generator
|
140
|
+
generated_token = ApiKeys::Services::TokenGenerator.call(prefix: self.prefix)
|
141
|
+
@token = generated_token # Store plaintext temporarily in instance var for display
|
142
|
+
|
143
|
+
# Safety check: Ensure generated token starts with the expected prefix
|
144
|
+
unless @token.start_with?(self.prefix)
|
145
|
+
raise ApiKeys::Error, "Generated token '#{@token}' does not match expected prefix '#{self.prefix}'. Check TokenGenerator."
|
146
|
+
end
|
147
|
+
|
148
|
+
# Use the configured digestor
|
149
|
+
digest_result = ApiKeys::Services::Digestor.digest(token: @token)
|
150
|
+
|
151
|
+
self.token_digest = digest_result[:digest]
|
152
|
+
self.digest_algorithm = digest_result[:algorithm]
|
153
|
+
|
154
|
+
# Extract and store the last 4 chars of the random part
|
155
|
+
random_part = @token.delete_prefix(self.prefix)
|
156
|
+
self.last4 = random_part.last(4) # Store last4
|
157
|
+
|
158
|
+
# Set default expiration if configured globally and not set individually
|
159
|
+
# Needs to happen here since it relies on Time.current
|
160
|
+
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
|
161
|
+
self.expires_at = ApiKeys.configuration.expire_after.from_now
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# == Validation Helpers ==
|
166
|
+
|
167
|
+
def owner_present_and_configured?
|
168
|
+
owner.present? && owner_configured?
|
169
|
+
end
|
170
|
+
|
171
|
+
def owner_configured?
|
172
|
+
owner.class.respond_to?(:api_keys_settings)
|
173
|
+
end
|
174
|
+
|
175
|
+
def name_required?
|
176
|
+
if owner_configured?
|
177
|
+
owner.class.api_keys_settings[:require_name]
|
178
|
+
else
|
179
|
+
ApiKeys.configuration.require_key_name
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def within_quota
|
184
|
+
# Determine the applicable limit: owner-specific setting first, then global config.
|
185
|
+
limit = if owner_configured?
|
186
|
+
owner.class.api_keys_settings[:max_keys]
|
187
|
+
else
|
188
|
+
ApiKeys.configuration.default_max_keys_per_owner
|
189
|
+
end
|
190
|
+
|
191
|
+
# Only validate if a limit is actually set (either per-owner or globally).
|
192
|
+
return unless limit.present?
|
193
|
+
|
194
|
+
# Count only *active* keys for the quota check.
|
195
|
+
# Ensure `owner` association is loaded if needed, or use SQL count.
|
196
|
+
# Note: Ensure the owner association is set before this validation runs.
|
197
|
+
current_active_keys = owner.api_keys.active.count
|
198
|
+
|
199
|
+
if current_active_keys >= limit
|
200
|
+
errors.add(:base, "exceeds maximum allowed API keys (#{limit}) for this owner")
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def expiration_date_cannot_be_in_the_past
|
205
|
+
errors.add(:expires_at, "can't be in the past") if expires_at.present? && expires_at < Time.current
|
206
|
+
end
|
207
|
+
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module ApiKeys
|
6
|
+
module Models
|
7
|
+
module Concerns
|
8
|
+
# Concern to add API key capabilities to an owner model (e.g., User, Organization).
|
9
|
+
# This module provides the `has_api_keys` class method when extended onto ActiveRecord::Base.
|
10
|
+
module HasApiKeys
|
11
|
+
extend ActiveSupport::Concern
|
12
|
+
|
13
|
+
# Module containing class methods to be extended onto ActiveRecord::Base
|
14
|
+
module ClassMethods
|
15
|
+
# Defines the association and allows configuration for the specific owner model.
|
16
|
+
#
|
17
|
+
# Example:
|
18
|
+
# class User < ApplicationRecord
|
19
|
+
# # Using keyword arguments:
|
20
|
+
# has_api_keys max_keys: 5, require_name: true
|
21
|
+
#
|
22
|
+
# # Or using a block:
|
23
|
+
# has_api_keys do
|
24
|
+
# max_keys 10
|
25
|
+
# require_name false
|
26
|
+
# default_scopes %w[read write]
|
27
|
+
# end
|
28
|
+
# end
|
29
|
+
def has_api_keys(**options, &block)
|
30
|
+
# Include the concern's instance methods into the calling class (e.g., User)
|
31
|
+
# Ensures any instance-level helpers in HasApiKeys are available on the owner.
|
32
|
+
include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
|
33
|
+
|
34
|
+
# Define the core association on the specific class calling this method
|
35
|
+
has_many :api_keys,
|
36
|
+
class_name: "ApiKeys::ApiKey",
|
37
|
+
as: :owner,
|
38
|
+
dependent: :destroy # Consider :nullify based on requirements
|
39
|
+
|
40
|
+
# Define class_attribute for settings if not already defined.
|
41
|
+
# This ensures inheritance works correctly (subclasses get their own copy).
|
42
|
+
unless respond_to?(:api_keys_settings)
|
43
|
+
class_attribute :api_keys_settings, instance_writer: false, default: {}
|
44
|
+
end
|
45
|
+
|
46
|
+
# Initialize settings for this specific class, merging defaults and options
|
47
|
+
current_settings = {
|
48
|
+
# Default to global config values first
|
49
|
+
max_keys: ApiKeys.configuration&.default_max_keys_per_owner,
|
50
|
+
require_name: ApiKeys.configuration&.require_key_name,
|
51
|
+
default_scopes: ApiKeys.configuration&.default_scopes || []
|
52
|
+
}.merge(options) # Merge keyword arguments first
|
53
|
+
|
54
|
+
# Apply DSL block if provided, allowing overrides
|
55
|
+
if block_given?
|
56
|
+
dsl = DslProvider.new(current_settings)
|
57
|
+
dsl.instance_eval(&block)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Assign the final settings hash to the class attribute for this class
|
61
|
+
self.api_keys_settings = current_settings
|
62
|
+
|
63
|
+
# TODO: Add validation hook to check key limit on create?
|
64
|
+
# validates_with ApiKeys::Validators::MaxKeysValidator, on: :create, if: -> { api_keys_settings[:max_keys].present? }
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# DSL provider class to handle the block configuration
|
69
|
+
class DslProvider # Keep nested or move to a separate file if it grows
|
70
|
+
def initialize(settings)
|
71
|
+
@settings = settings # Operates directly on the hash passed in
|
72
|
+
end
|
73
|
+
|
74
|
+
def max_keys(value)
|
75
|
+
@settings[:max_keys] = value
|
76
|
+
end
|
77
|
+
|
78
|
+
def require_name(value)
|
79
|
+
@settings[:require_name] = value
|
80
|
+
end
|
81
|
+
|
82
|
+
def default_scopes(value)
|
83
|
+
@settings[:default_scopes] = Array(value)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Placeholder for future scope definitions
|
87
|
+
# def define_scope(name, description:)
|
88
|
+
# # In v1, this might just store metadata for documentation/future use
|
89
|
+
# # Could store in a separate class attribute or within settings hash.
|
90
|
+
# @settings[:defined_scopes] ||= {}
|
91
|
+
# @settings[:defined_scopes][name.to_s] = description
|
92
|
+
# end
|
93
|
+
end
|
94
|
+
|
95
|
+
# --- Instance Methods ---
|
96
|
+
# Methods included in the owner model (e.g., User).
|
97
|
+
|
98
|
+
# Creates a new API key for this owner instance and returns the ApiKey instance.
|
99
|
+
# Raises ActiveRecord::RecordInvalid if creation fails.
|
100
|
+
#
|
101
|
+
# @param name [String] The name for the new API key (required).
|
102
|
+
# @param scopes [Array<String>, nil] Scopes for the key. Defaults to owner/global settings.
|
103
|
+
# @param expires_at [Time, nil] Optional expiration timestamp.
|
104
|
+
# @param metadata [Hash, nil] Optional metadata hash.
|
105
|
+
# @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
|
106
|
+
# is available via the `#token` attribute on this instance
|
107
|
+
# *only until it's reloaded*.
|
108
|
+
def create_api_key!(name: nil, scopes: nil, expires_at: nil, metadata: nil)
|
109
|
+
# Fetch default scopes from this owner class's settings, falling back to global config.
|
110
|
+
owner_settings = self.class.api_keys_settings
|
111
|
+
default_scopes = owner_settings&.[](:default_scopes) || ApiKeys.configuration.default_scopes || []
|
112
|
+
|
113
|
+
# Use provided scopes if given, otherwise use the calculated defaults.
|
114
|
+
key_scopes = scopes.nil? ? default_scopes : Array(scopes)
|
115
|
+
|
116
|
+
# Create the key using the association, letting AR handle owner_id/type.
|
117
|
+
api_key = self.api_keys.create!(
|
118
|
+
name: name,
|
119
|
+
scopes: key_scopes,
|
120
|
+
expires_at: expires_at,
|
121
|
+
metadata: metadata || {} # Ensure metadata is at least an empty hash
|
122
|
+
# prefix, token_digest, digest_algorithm are set by ApiKey callbacks
|
123
|
+
)
|
124
|
+
|
125
|
+
# Return the ApiKey instance itself.
|
126
|
+
# The plaintext token is available via `api_key.token` immediately after this.
|
127
|
+
api_key
|
128
|
+
end
|
129
|
+
|
130
|
+
# Example: Check if the owner has reached their API key limit.
|
131
|
+
# def reached_api_key_limit?
|
132
|
+
# limit = self.class.api_keys_settings[:max_keys]
|
133
|
+
# # Ensure api_keys association is loaded or query count
|
134
|
+
# limit && api_keys.count >= limit # Or use a counter cache
|
135
|
+
# end
|
136
|
+
|
137
|
+
# Example: Get the specific settings for this owner instance's class.
|
138
|
+
# def api_keys_config
|
139
|
+
# self.class.api_keys_settings
|
140
|
+
# end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,255 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/cache"
|
4
|
+
require "active_support/core_ext/object/blank"
|
5
|
+
require "digest"
|
6
|
+
require_relative "../models/api_key"
|
7
|
+
require_relative "../services/digestor"
|
8
|
+
require_relative "../logging"
|
9
|
+
|
10
|
+
module ApiKeys
|
11
|
+
module Services
|
12
|
+
# Authenticates an incoming request by extracting and verifying an API key.
|
13
|
+
class Authenticator
|
14
|
+
extend ApiKeys::Logging
|
15
|
+
|
16
|
+
# Result object for authentication attempts.
|
17
|
+
Result = Struct.new(:success?, :api_key, :error_code, :message, keyword_init: true) do
|
18
|
+
def self.success(api_key)
|
19
|
+
new(success?: true, api_key: api_key)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.failure(error_code:, message:)
|
23
|
+
new(success?: false, error_code: error_code, message: message)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Authenticates the request.
|
28
|
+
#
|
29
|
+
# @param request [ActionDispatch::Request] The incoming request object.
|
30
|
+
# @return [ApiKeys::Services::Authenticator::Result] The result of the authentication attempt.
|
31
|
+
def self.call(request)
|
32
|
+
log_debug "[ApiKeys Auth] Authenticator.call started for request: #{request.uuid}"
|
33
|
+
config = ApiKeys.configuration
|
34
|
+
config.before_authentication&.call(request)
|
35
|
+
|
36
|
+
# === HTTPS Check (Production Only) ===
|
37
|
+
if defined?(Rails.env) && Rails.env.production? && config.https_only_production
|
38
|
+
if request.protocol == "http://"
|
39
|
+
warning_message = "[ApiKeys Security] API key authentication attempted over insecure HTTP connection in production."
|
40
|
+
log_warn warning_message
|
41
|
+
if config.https_strict_mode
|
42
|
+
log_warn "[ApiKeys Security] Strict mode enabled: Aborting authentication."
|
43
|
+
result = Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.")
|
44
|
+
config.after_authentication&.call(result)
|
45
|
+
return result # Halt execution due to strict mode
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
# === End HTTPS Check ===
|
50
|
+
|
51
|
+
token = extract_token(request, config)
|
52
|
+
|
53
|
+
unless token
|
54
|
+
log_debug "[ApiKeys Auth] Token extraction failed."
|
55
|
+
result = Result.failure(error_code: :missing_token, message: "API token is missing")
|
56
|
+
config.after_authentication&.call(result)
|
57
|
+
return result
|
58
|
+
end
|
59
|
+
|
60
|
+
log_debug "[ApiKeys Auth] Token extracted successfully. Verifying..."
|
61
|
+
# Pass the original token AND config to find_and_verify_key
|
62
|
+
api_key = find_and_verify_key(token, config)
|
63
|
+
|
64
|
+
result = if api_key&.active?
|
65
|
+
log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
|
66
|
+
# TODO: Optionally update last_used_at and requests_count
|
67
|
+
Result.success(api_key)
|
68
|
+
elsif api_key&.revoked?
|
69
|
+
log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
|
70
|
+
Result.failure(error_code: :revoked_key, message: "API key has been revoked")
|
71
|
+
elsif api_key&.expired?
|
72
|
+
log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
|
73
|
+
Result.failure(error_code: :expired_key, message: "API key has expired")
|
74
|
+
else # Not found, mismatch, or inactive
|
75
|
+
log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
|
76
|
+
Result.failure(error_code: :invalid_token, message: "API token is invalid")
|
77
|
+
end
|
78
|
+
|
79
|
+
log_debug "[ApiKeys Auth] Authenticator.call finished. Result: #{result.inspect}"
|
80
|
+
config.after_authentication&.call(result)
|
81
|
+
result
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
# Extracts the token string from the request headers or query parameters.
|
87
|
+
def self.extract_token(request, config)
|
88
|
+
# Check header first (preferred)
|
89
|
+
if config.header.present?
|
90
|
+
header_value = request.headers[config.header]
|
91
|
+
log_debug "[ApiKeys Auth] Checking header '#{config.header}': '#{header_value}'"
|
92
|
+
if header_value
|
93
|
+
# Handle "Bearer <token>" scheme
|
94
|
+
match = header_value.match(/^Bearer\s+(.*)$/i)
|
95
|
+
if match
|
96
|
+
log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
|
97
|
+
return match[1]
|
98
|
+
end
|
99
|
+
# Fallback: return the raw header value if no Bearer scheme
|
100
|
+
log_debug "[ApiKeys Auth] No Bearer scheme, using raw header value as token."
|
101
|
+
return header_value
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Check query parameter as fallback (if configured)
|
106
|
+
if config.query_param.present?
|
107
|
+
param_value = request.query_parameters[config.query_param]
|
108
|
+
log_debug "[ApiKeys Auth] Checking query param '#{config.query_param}': '#{param_value}'"
|
109
|
+
if param_value.present?
|
110
|
+
log_debug "[ApiKeys Auth] Extracted token from query parameter."
|
111
|
+
return param_value
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
log_debug "[ApiKeys Auth] No token found in headers or query parameters."
|
116
|
+
nil # No token found
|
117
|
+
end
|
118
|
+
|
119
|
+
# Finds the ApiKey record corresponding to the token and verifies it securely.
|
120
|
+
# Uses caching if enabled.
|
121
|
+
# @param token [String] The plaintext token from the request.
|
122
|
+
# @param config [ApiKeys::Configuration] The current configuration.
|
123
|
+
# @return [ApiKeys::ApiKey, nil] The verified ApiKey instance or nil.
|
124
|
+
def self.find_and_verify_key(token, config)
|
125
|
+
cache_key = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash
|
126
|
+
cache_ttl = config.cache_ttl.to_i
|
127
|
+
log_debug "[ApiKeys Auth] Verifying token. Cache key: #{cache_key}, TTL: #{cache_ttl}"
|
128
|
+
|
129
|
+
if cache_ttl > 0
|
130
|
+
cached_result = rails_cache&.read(cache_key)
|
131
|
+
log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}"
|
132
|
+
# Return cached result ONLY if it's a valid ApiKey instance (a true cache hit)
|
133
|
+
if cached_result.is_a?(ApiKeys::ApiKey)
|
134
|
+
log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}"
|
135
|
+
return cached_result
|
136
|
+
elsif cached_result.nil?
|
137
|
+
log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup."
|
138
|
+
# Continue execution if it's a cache miss (nil)
|
139
|
+
else
|
140
|
+
# Handle unexpected cache values (e.g., old symbol :not_found)
|
141
|
+
log_warn "[ApiKeys Auth] Invalid cache value found: #{cached_result.inspect}. Proceeding to DB lookup."
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# --- Cache miss or TTL=0: Perform DB lookup & verification ---
|
146
|
+
log_debug "[ApiKeys Auth] Performing DB lookup and verification."
|
147
|
+
|
148
|
+
# 1. Determine the expected hashing strategy (assuming single strategy for now)
|
149
|
+
strategy = config.hash_strategy.to_sym
|
150
|
+
log_debug "[ApiKeys Auth] Using strategy: #{strategy}"
|
151
|
+
|
152
|
+
# 2. Find and verify the key based on the strategy.
|
153
|
+
verified_key = nil
|
154
|
+
if strategy == :bcrypt
|
155
|
+
# Optimization: Check against the *configured* prefix first.
|
156
|
+
configured_prefix = config.token_prefix.call
|
157
|
+
matched_prefix = nil
|
158
|
+
|
159
|
+
if token.start_with?(configured_prefix)
|
160
|
+
log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}"
|
161
|
+
matched_prefix = configured_prefix
|
162
|
+
else
|
163
|
+
# Fallback: If no match, check against all known prefixes (cached).
|
164
|
+
log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes."
|
165
|
+
known_prefixes = fetch_known_prefixes(config)
|
166
|
+
# Sort by length descending to find the longest match first
|
167
|
+
matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) }
|
168
|
+
log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}"
|
169
|
+
end
|
170
|
+
|
171
|
+
possible_keys_scope = if matched_prefix
|
172
|
+
ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt')
|
173
|
+
else
|
174
|
+
# This path is now less likely but covers cases where token matches no known prefix.
|
175
|
+
log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup."
|
176
|
+
ApiKeys::ApiKey.none # Return an empty relation
|
177
|
+
end
|
178
|
+
|
179
|
+
log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql)
|
180
|
+
possible_keys = possible_keys_scope.to_a
|
181
|
+
log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt."
|
182
|
+
|
183
|
+
# Securely compare the provided token against the digests of potential keys
|
184
|
+
verified_key = possible_keys.find do |key|
|
185
|
+
match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt)
|
186
|
+
log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}"
|
187
|
+
match_result
|
188
|
+
end
|
189
|
+
|
190
|
+
elsif strategy == :sha256
|
191
|
+
# For sha256, we hash the incoming token and look for an exact match
|
192
|
+
# Note: Prefix lookup isn't useful here as the full hash is needed for the query.
|
193
|
+
token_digest = Digest::SHA256.hexdigest(token)
|
194
|
+
log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}"
|
195
|
+
|
196
|
+
# Find the key directly by the calculated digest and algorithm
|
197
|
+
verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256')
|
198
|
+
|
199
|
+
if verified_key
|
200
|
+
log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}"
|
201
|
+
else
|
202
|
+
log_debug "[ApiKeys Auth] No key found matching the SHA256 digest."
|
203
|
+
end
|
204
|
+
|
205
|
+
else
|
206
|
+
# Log unsupported strategy
|
207
|
+
log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}"
|
208
|
+
end
|
209
|
+
|
210
|
+
log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}"
|
211
|
+
# --- End DB Lookup ---
|
212
|
+
|
213
|
+
# Cache the result (either the found ApiKey instance or nil for a miss)
|
214
|
+
if cache_ttl > 0 && rails_cache
|
215
|
+
log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}"
|
216
|
+
rails_cache.write(cache_key, verified_key, expires_in: cache_ttl)
|
217
|
+
end
|
218
|
+
|
219
|
+
verified_key
|
220
|
+
end
|
221
|
+
|
222
|
+
# Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
|
223
|
+
def self.fetch_known_prefixes(config)
|
224
|
+
cache_key = "api_keys:known_prefixes"
|
225
|
+
cache_ttl = config.cache_ttl.to_i # Use the same TTL as key lookup for consistency
|
226
|
+
|
227
|
+
if cache_ttl > 0
|
228
|
+
cached_prefixes = rails_cache&.read(cache_key)
|
229
|
+
return cached_prefixes if cached_prefixes.is_a?(Array)
|
230
|
+
log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB."
|
231
|
+
end
|
232
|
+
|
233
|
+
# Fetch distinct, non-null prefixes from the database
|
234
|
+
prefixes = ApiKeys::ApiKey.distinct.pluck(:prefix).compact
|
235
|
+
|
236
|
+
if cache_ttl > 0 && rails_cache
|
237
|
+
log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}"
|
238
|
+
rails_cache.write(cache_key, prefixes, expires_in: cache_ttl)
|
239
|
+
end
|
240
|
+
|
241
|
+
prefixes
|
242
|
+
end
|
243
|
+
|
244
|
+
# Helper for accessing Rails cache safely
|
245
|
+
def self.rails_cache
|
246
|
+
defined?(Rails) ? Rails.cache : nil
|
247
|
+
end
|
248
|
+
|
249
|
+
# NOTE: Removing the incorrect private `find_key_by_token` method.
|
250
|
+
# def find_key_by_token(token)
|
251
|
+
# ...
|
252
|
+
# end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bcrypt"
|
4
|
+
require "digest"
|
5
|
+
|
6
|
+
module ApiKeys
|
7
|
+
module Services
|
8
|
+
# Handles hashing (digesting) and verifying tokens based on configured strategy.
|
9
|
+
class Digestor
|
10
|
+
# Creates a digest of the given token using the configured strategy.
|
11
|
+
#
|
12
|
+
# @param token [String] The plaintext token.
|
13
|
+
# @param strategy [Symbol] The hashing strategy (:bcrypt or :sha256).
|
14
|
+
# @return [Hash] A hash containing the digest and the algorithm used.
|
15
|
+
# e.g., { digest: "...", algorithm: "bcrypt" }
|
16
|
+
def self.digest(token:, strategy: ApiKeys.configuration.hash_strategy)
|
17
|
+
case strategy
|
18
|
+
when :bcrypt
|
19
|
+
# BCrypt handles salt generation internally
|
20
|
+
digest = BCrypt::Password.create(token, cost: BCrypt::Engine.cost)
|
21
|
+
{ digest: digest.to_s, algorithm: "bcrypt" }
|
22
|
+
when :sha256
|
23
|
+
# Note: Simple SHA256 without salt/pepper. Consider enhancing if needed.
|
24
|
+
# BCrypt is generally preferred for password/token hashing.
|
25
|
+
digest = Digest::SHA256.hexdigest(token)
|
26
|
+
{ digest: digest, algorithm: "sha256" }
|
27
|
+
else
|
28
|
+
raise ArgumentError, "Unsupported hash strategy: #{strategy}. Use :bcrypt or :sha256."
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Securely compares a plaintext token against a stored digest.
|
33
|
+
# Uses the configured secure comparison proc and hash strategy.
|
34
|
+
#
|
35
|
+
# @param token [String] The plaintext token provided by the user/client.
|
36
|
+
# @param stored_digest [String] The hashed digest stored in the database.
|
37
|
+
# @param strategy [Symbol] The hashing strategy used to create the stored_digest.
|
38
|
+
# @param comparison_proc [Proc] The secure comparison function.
|
39
|
+
# @return [Boolean] True if the token matches the digest, false otherwise.
|
40
|
+
def self.match?(token:, stored_digest:, strategy: ApiKeys.configuration.hash_strategy, comparison_proc: ApiKeys.configuration.secure_compare_proc)
|
41
|
+
return false if token.blank? || stored_digest.blank?
|
42
|
+
|
43
|
+
case strategy
|
44
|
+
when :bcrypt
|
45
|
+
begin
|
46
|
+
bcrypt_object = BCrypt::Password.new(stored_digest)
|
47
|
+
# BCrypt's `==` operator is designed for secure comparison
|
48
|
+
bcrypt_object == token
|
49
|
+
rescue BCrypt::Errors::InvalidHash
|
50
|
+
# If the stored digest isn't a valid BCrypt hash, comparison fails
|
51
|
+
false
|
52
|
+
end
|
53
|
+
when :sha256
|
54
|
+
# Directly compare the SHA256 hash of the input token with the stored digest
|
55
|
+
comparison_proc.call(stored_digest, Digest::SHA256.hexdigest(token))
|
56
|
+
else
|
57
|
+
# Strategy mismatch or unsupported strategy should fail comparison safely
|
58
|
+
Rails.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check." if defined?(Rails.logger)
|
59
|
+
false
|
60
|
+
end
|
61
|
+
rescue ArgumentError => e
|
62
|
+
# Catch potential errors from Digest or comparison proc
|
63
|
+
Rails.logger.error "[ApiKeys] Digestor comparison error: #{e.message}" if defined?(Rails.logger)
|
64
|
+
false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|