api_keys 0.4.3 ā 0.5.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +154 -21
- data/app/controllers/api_keys/keys_controller.rb +39 -8
- data/app/views/api_keys/keys/_form.html.erb +45 -4
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +32 -0
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +49 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/models/api_key.rb +184 -10
- data/lib/api_keys/models/concerns/has_api_keys.rb +59 -10
- data/lib/api_keys/restrictions.rb +379 -0
- data/lib/api_keys/services/authenticator.rb +88 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +57 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +41 -6
- metadata +5 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ApiKeys
|
|
7
|
+
# Value object describing *where* an API key may be used from: a list of web
|
|
8
|
+
# origins (hosts, with optional `*.` subdomain wildcards) and a list of IP
|
|
9
|
+
# addresses or CIDR ranges.
|
|
10
|
+
#
|
|
11
|
+
# Restrictions are plain data stored in the `restrictions` JSON column:
|
|
12
|
+
#
|
|
13
|
+
# { "origins" => ["example.com", "*.example.com"],
|
|
14
|
+
# "ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }
|
|
15
|
+
#
|
|
16
|
+
# Matching semantics (normative):
|
|
17
|
+
#
|
|
18
|
+
# - Within a list: OR. Any entry that matches admits the request.
|
|
19
|
+
# - Across lists: AND. Every list that is present and non-empty must pass.
|
|
20
|
+
# - Empty (or absent) restrictions mean unrestricted. Presence is the toggle.
|
|
21
|
+
# - Every failure mode fails closed: a locked list plus an unreadable request
|
|
22
|
+
# context refuses the request.
|
|
23
|
+
#
|
|
24
|
+
# The object is immutable and has no Active Record dependency. Malformed
|
|
25
|
+
# persisted values are represented explicitly and deny authentication; model
|
|
26
|
+
# validations keep them out during ordinary writes.
|
|
27
|
+
class Restrictions
|
|
28
|
+
# The restriction kinds this gem understands. Anything else stored in the
|
|
29
|
+
# column is a validation error rather than a silently ignored key.
|
|
30
|
+
KINDS = %i[origins ips].freeze
|
|
31
|
+
KIND_NAMES = KINDS.map(&:to_s).freeze
|
|
32
|
+
|
|
33
|
+
# Entries are split on commas, whitespace, and newlines so that a single
|
|
34
|
+
# text field can hold a whole list ("example.com, *.example.com").
|
|
35
|
+
ENTRY_SEPARATOR = /[\s,;]+/
|
|
36
|
+
|
|
37
|
+
# A bare host, optionally prefixed with a `*.` subdomain wildcard.
|
|
38
|
+
# `*` alone is deliberately invalid: an empty list already means "anywhere".
|
|
39
|
+
DNS_LABEL = /[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/
|
|
40
|
+
ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/
|
|
41
|
+
|
|
42
|
+
attr_reader :origins, :ips, :extras
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Coerces anything into a Restrictions instance. Never raises.
|
|
46
|
+
#
|
|
47
|
+
# @param value [Restrictions, Hash, nil, Object] The stored column value,
|
|
48
|
+
# a hash of lists, or an existing instance.
|
|
49
|
+
# @return [ApiKeys::Restrictions]
|
|
50
|
+
def wrap(value)
|
|
51
|
+
return value if value.is_a?(self)
|
|
52
|
+
return none if value.nil?
|
|
53
|
+
return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
|
|
56
|
+
known = known.to_h { |key, entries| [key.to_s, entries] }
|
|
57
|
+
|
|
58
|
+
new(
|
|
59
|
+
origins: coerce_list(known["origins"]),
|
|
60
|
+
ips: coerce_list(known["ips"]),
|
|
61
|
+
extras: extras.to_h
|
|
62
|
+
)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
# Stored policy is untrusted input. Preserve the core invariant even if
|
|
65
|
+
# an exotic object raises while being coerced: malformed never means
|
|
66
|
+
# unrestricted.
|
|
67
|
+
new(origins: [], ips: [], extras: {}, malformed: true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The shared empty instance: no origins, no IPs, no restrictions at all.
|
|
71
|
+
# @return [ApiKeys::Restrictions]
|
|
72
|
+
def none
|
|
73
|
+
@none ||= new(origins: [], ips: [], extras: {}).freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Forgiving parser for the raw string a dashboard text field submits.
|
|
77
|
+
# Accepts full URLs, bare hosts, commas, newlines, and stray whitespace;
|
|
78
|
+
# returns bare lowercase hosts, de-duplicated, order preserved.
|
|
79
|
+
#
|
|
80
|
+
# normalize_origins("https://Shop.example/, *.app.example\n x")
|
|
81
|
+
# # => ["shop.example", "*.app.example", "x"]
|
|
82
|
+
#
|
|
83
|
+
# Non-string entries are preserved so validation can report malformed
|
|
84
|
+
# programmatic input instead of silently erasing a requested policy.
|
|
85
|
+
# @param value [String, Array, nil] Raw user input.
|
|
86
|
+
# @return [Array] Normalized origin entries.
|
|
87
|
+
def normalize_origins(value)
|
|
88
|
+
tokenize(value).map do |token|
|
|
89
|
+
next token unless token.is_a?(String)
|
|
90
|
+
|
|
91
|
+
origin_host(token) || token.strip.downcase
|
|
92
|
+
end.uniq
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Forgiving parser for IP/CIDR input. String entries are kept verbatim
|
|
96
|
+
# (lowercased) so validation, not the parser, reports malformed ranges.
|
|
97
|
+
# Non-string entries are likewise preserved for validation.
|
|
98
|
+
#
|
|
99
|
+
# @param value [String, Array, nil] Raw user input.
|
|
100
|
+
# @return [Array<String>] Normalized IP entries.
|
|
101
|
+
def normalize_ips(value)
|
|
102
|
+
tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Extracts the host the browser claims the request came from: the Origin
|
|
106
|
+
# header when present, the Referer header otherwise. Returns nil when
|
|
107
|
+
# neither is present or parseable, which callers must treat as a refusal.
|
|
108
|
+
#
|
|
109
|
+
# @param request [ActionDispatch::Request, #headers, nil]
|
|
110
|
+
# @return [String, nil] Bare lowercase host.
|
|
111
|
+
def extract_origin_host(request)
|
|
112
|
+
headers = request.headers if request.respond_to?(:headers)
|
|
113
|
+
return nil unless headers.respond_to?(:[])
|
|
114
|
+
|
|
115
|
+
origin = headers["Origin"]
|
|
116
|
+
unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?)
|
|
117
|
+
# Origin has precedence over Referer. A present-but-invalid Origin
|
|
118
|
+
# (including the browser's opaque `null` origin) must not be rescued
|
|
119
|
+
# by a friendlier Referer value.
|
|
120
|
+
return host_from_url(origin)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
host_from_url(headers["Referer"])
|
|
124
|
+
rescue StandardError
|
|
125
|
+
# A hostile or exotic request object must never take an endpoint down;
|
|
126
|
+
# an unreadable origin is simply an origin that matches nothing.
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Splits raw input into candidate entries without interpreting them.
|
|
131
|
+
# @api private
|
|
132
|
+
def tokenize(value)
|
|
133
|
+
entries = case value
|
|
134
|
+
when nil then []
|
|
135
|
+
when String then value.split(ENTRY_SEPARATOR)
|
|
136
|
+
when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] }
|
|
137
|
+
else [value]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
entries.filter_map do |entry|
|
|
141
|
+
next entry unless entry.is_a?(String)
|
|
142
|
+
|
|
143
|
+
trimmed = entry.strip
|
|
144
|
+
trimmed unless trimmed.empty?
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reduces a single user-supplied entry to a bare lowercase host.
|
|
149
|
+
# Full URLs give up their host; bare hosts keep everything before the
|
|
150
|
+
# first slash, colon, or question mark. Returns nil when nothing is left.
|
|
151
|
+
# @api private
|
|
152
|
+
def origin_host(entry)
|
|
153
|
+
candidate = entry.to_s.strip
|
|
154
|
+
return nil if candidate.empty?
|
|
155
|
+
|
|
156
|
+
if candidate.include?("//")
|
|
157
|
+
host = host_from_url(candidate)
|
|
158
|
+
return host
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if (address = parse_ip(candidate)) && !candidate.include?("/")
|
|
162
|
+
return address.to_s.downcase
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if candidate.start_with?("[")
|
|
166
|
+
host = host_from_url("http://#{candidate}")
|
|
167
|
+
return host if host
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
host = candidate.split(%r{[/?#]}).first.to_s
|
|
171
|
+
host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000").
|
|
172
|
+
host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals.
|
|
173
|
+
host = host.downcase
|
|
174
|
+
host.empty? ? nil : host
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Pulls the host out of a full URL, tolerating garbage.
|
|
178
|
+
# @api private
|
|
179
|
+
def host_from_url(value)
|
|
180
|
+
return nil unless value.is_a?(String)
|
|
181
|
+
|
|
182
|
+
trimmed = value.strip
|
|
183
|
+
return nil if trimmed.empty?
|
|
184
|
+
|
|
185
|
+
host = URI.parse(trimmed).host
|
|
186
|
+
return nil if host.nil? || host.empty?
|
|
187
|
+
|
|
188
|
+
host.delete_prefix("[").delete_suffix("]").downcase
|
|
189
|
+
rescue URI::Error, ArgumentError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Coerces one stored list into an array of entries, preserving anything
|
|
194
|
+
# that is not a string so validations can report it instead of the value
|
|
195
|
+
# disappearing silently.
|
|
196
|
+
# @api private
|
|
197
|
+
def coerce_list(value)
|
|
198
|
+
entries = case value
|
|
199
|
+
when nil then []
|
|
200
|
+
when String then value.split(ENTRY_SEPARATOR)
|
|
201
|
+
when Array then value
|
|
202
|
+
else [value]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
entries.filter_map do |entry|
|
|
206
|
+
next entry unless entry.is_a?(String)
|
|
207
|
+
|
|
208
|
+
trimmed = entry.strip.downcase
|
|
209
|
+
trimmed unless trimmed.empty?
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
entry
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whether a stored origin entry is shaped like a host or `*.host`.
|
|
216
|
+
# @api private
|
|
217
|
+
def valid_origin_entry?(entry)
|
|
218
|
+
return false unless entry.is_a?(String)
|
|
219
|
+
return true if !entry.include?("/") && parse_ip(entry)
|
|
220
|
+
|
|
221
|
+
entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN)
|
|
222
|
+
rescue ArgumentError
|
|
223
|
+
false
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Whether a stored IP entry is a single address or a CIDR range.
|
|
227
|
+
# @api private
|
|
228
|
+
def valid_ip_entry?(entry)
|
|
229
|
+
parse_ip(entry) ? true : false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Parses an address or range with stdlib IPAddr. A bare address is a /32
|
|
233
|
+
# (or /128), so `IPAddr#include?` answers exact matches and range matches
|
|
234
|
+
# through a single code path.
|
|
235
|
+
# @api private
|
|
236
|
+
def parse_ip(value)
|
|
237
|
+
return nil unless value.is_a?(String)
|
|
238
|
+
|
|
239
|
+
trimmed = value.strip
|
|
240
|
+
return nil if trimmed.empty?
|
|
241
|
+
|
|
242
|
+
address = IPAddr.new(trimmed)
|
|
243
|
+
address.ipv6? && address.ipv4_mapped? ? address.native : address
|
|
244
|
+
rescue IPAddr::Error
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @param origins [Array<String>] Already-coerced origin entries.
|
|
250
|
+
# @param ips [Array<String>] Already-coerced IP entries.
|
|
251
|
+
# @param extras [Hash] Unrecognized keys, preserved so validation sees them.
|
|
252
|
+
# @param malformed [Boolean] Whether coercion itself found an invalid shape.
|
|
253
|
+
def initialize(origins: [], ips: [], extras: {}, malformed: false)
|
|
254
|
+
@origins = deep_copy(origins, freeze_copy: true)
|
|
255
|
+
@ips = deep_copy(ips, freeze_copy: true)
|
|
256
|
+
@extras = deep_copy(extras, freeze_copy: true)
|
|
257
|
+
@malformed = malformed || @extras.any? ||
|
|
258
|
+
@origins.any? { |entry| !self.class.valid_origin_entry?(entry) } ||
|
|
259
|
+
@ips.any? { |entry| !self.class.valid_ip_entry?(entry) }
|
|
260
|
+
freeze
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Malformed data can only arrive through validation-bypassing writes or a
|
|
264
|
+
# damaged database. Authentication always denies it.
|
|
265
|
+
def malformed?
|
|
266
|
+
@malformed
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @return [Boolean] true when this key may be used from anywhere.
|
|
270
|
+
def unrestricted?
|
|
271
|
+
!malformed? && origins.empty? && ips.empty?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @return [Boolean] true when at least one list is locked.
|
|
275
|
+
def restricted?
|
|
276
|
+
!unrestricted?
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @return [Array<Symbol>] The restriction kinds actually in use.
|
|
280
|
+
def kinds
|
|
281
|
+
KINDS.select { |kind| public_send(kind).any? }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The storage shape: known lists that have entries, plus any unrecognized
|
|
285
|
+
# keys exactly as they were found.
|
|
286
|
+
# @return [Hash]
|
|
287
|
+
def to_h
|
|
288
|
+
hash = {}
|
|
289
|
+
hash["origins"] = deep_copy(origins) if origins.any?
|
|
290
|
+
hash["ips"] = deep_copy(ips) if ips.any?
|
|
291
|
+
hash.merge(deep_copy(extras))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
alias as_json to_h
|
|
295
|
+
|
|
296
|
+
# Does this request context satisfy every locked list?
|
|
297
|
+
#
|
|
298
|
+
# @param origin_host [String, nil] Host from Origin/Referer.
|
|
299
|
+
# @param ip [String, nil] Client IP address.
|
|
300
|
+
# @return [Boolean]
|
|
301
|
+
def allows?(origin_host: nil, ip: nil)
|
|
302
|
+
!malformed? && origin_allowed?(origin_host) && ip_allowed?(ip)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @param host [String, nil] Bare host to check.
|
|
306
|
+
# @return [Boolean] true when the origins list is empty or one entry matches.
|
|
307
|
+
# A locked list plus a nil/blank host refuses: fail closed.
|
|
308
|
+
def origin_allowed?(host)
|
|
309
|
+
return false if malformed?
|
|
310
|
+
return true if origins.empty?
|
|
311
|
+
|
|
312
|
+
candidate = host.to_s.strip.downcase
|
|
313
|
+
return false if candidate.empty?
|
|
314
|
+
candidate = self.class.parse_ip(candidate)&.to_s || candidate
|
|
315
|
+
|
|
316
|
+
origins.any? { |entry| origin_entry_matches?(entry, candidate) }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @param ip [String, nil] Client IP address.
|
|
320
|
+
# @return [Boolean] true when the IP list is empty or one entry contains it.
|
|
321
|
+
# A locked list plus an unparseable address refuses: fail closed.
|
|
322
|
+
def ip_allowed?(ip)
|
|
323
|
+
return false if malformed?
|
|
324
|
+
return true if ips.empty?
|
|
325
|
+
|
|
326
|
+
address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s)
|
|
327
|
+
return false unless address
|
|
328
|
+
|
|
329
|
+
ips.any? { |entry| ip_entry_matches?(entry, address) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def ==(other)
|
|
333
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
334
|
+
end
|
|
335
|
+
alias eql? ==
|
|
336
|
+
|
|
337
|
+
def hash
|
|
338
|
+
to_h.hash
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def inspect
|
|
342
|
+
"#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def deep_copy(value, freeze_copy: false)
|
|
348
|
+
copy = case value
|
|
349
|
+
when Hash
|
|
350
|
+
value.to_h do |key, entry|
|
|
351
|
+
[deep_copy(key, freeze_copy: freeze_copy), deep_copy(entry, freeze_copy: freeze_copy)]
|
|
352
|
+
end
|
|
353
|
+
when Array
|
|
354
|
+
value.map { |entry| deep_copy(entry, freeze_copy: freeze_copy) }
|
|
355
|
+
when String
|
|
356
|
+
value.dup
|
|
357
|
+
else
|
|
358
|
+
value
|
|
359
|
+
end
|
|
360
|
+
copy.freeze if freeze_copy
|
|
361
|
+
copy
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# `*.example.com` matches any subdomain at any depth, but never the apex ā
|
|
365
|
+
# Google's rule. List the apex separately when you want both.
|
|
366
|
+
def origin_entry_matches?(entry, host)
|
|
367
|
+
if entry.start_with?("*.")
|
|
368
|
+
suffix = entry.delete_prefix("*")
|
|
369
|
+
host.end_with?(suffix) && host.length > suffix.length
|
|
370
|
+
else
|
|
371
|
+
entry == host
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def ip_entry_matches?(entry, address)
|
|
376
|
+
self.class.parse_ip(entry).include?(address)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/blank"
|
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "../models/api_key"
|
|
7
7
|
require_relative "../services/digestor"
|
|
8
|
+
require_relative "../restrictions"
|
|
8
9
|
require_relative "../logging"
|
|
9
10
|
|
|
10
11
|
module ApiKeys
|
|
@@ -25,8 +26,13 @@ module ApiKeys
|
|
|
25
26
|
new(success?: true, api_key: api_key)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
# `api_key` is present when the key WAS identified and a policy check
|
|
30
|
+
# refused it (environment isolation, request restrictions): the
|
|
31
|
+
# after_authentication callback then reports WHICH key was refused,
|
|
32
|
+
# exactly as it already does for scope refusals. Lookup failures have
|
|
33
|
+
# no key to name, so they leave it nil.
|
|
34
|
+
def self.failure(error_code:, message:, api_key: nil)
|
|
35
|
+
new(success?: false, error_code: error_code, message: message, api_key: api_key)
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Do not delegate to Struct's default inspection: it recursively inspects
|
|
@@ -82,20 +88,25 @@ module ApiKeys
|
|
|
82
88
|
elsif api_key&.active?
|
|
83
89
|
log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
|
|
84
90
|
|
|
85
|
-
# Check environment isolation
|
|
91
|
+
# Check environment isolation, then the key's request restrictions.
|
|
92
|
+
# Both run on every request, including token-cache hits: the
|
|
93
|
+
# cache only shortcuts the lookup, and the row is reloaded fresh.
|
|
86
94
|
env_check_result = check_environment_isolation(api_key, config)
|
|
95
|
+
restriction_result = env_check_result ? nil : check_request_restrictions(api_key, request, config)
|
|
87
96
|
if env_check_result
|
|
88
97
|
env_check_result # Return failure result
|
|
98
|
+
elsif restriction_result
|
|
99
|
+
restriction_result # Return failure result
|
|
89
100
|
else
|
|
90
101
|
# TODO: Optionally update last_used_at and requests_count
|
|
91
102
|
Result.success(api_key)
|
|
92
103
|
end
|
|
93
104
|
elsif api_key&.revoked?
|
|
94
105
|
log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
|
|
95
|
-
Result.failure(error_code: :revoked_key, message: "API key has been revoked")
|
|
106
|
+
Result.failure(error_code: :revoked_key, message: "API key has been revoked", api_key: api_key)
|
|
96
107
|
elsif api_key&.expired?
|
|
97
108
|
log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
|
|
98
|
-
Result.failure(error_code: :expired_key, message: "API key has expired")
|
|
109
|
+
Result.failure(error_code: :expired_key, message: "API key has expired", api_key: api_key)
|
|
99
110
|
else # Not found, mismatch, or inactive
|
|
100
111
|
log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
|
|
101
112
|
Result.failure(error_code: :invalid_token, message: "API token is invalid")
|
|
@@ -357,7 +368,7 @@ module ApiKeys
|
|
|
357
368
|
return nil if configured
|
|
358
369
|
|
|
359
370
|
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its key type is not configured."
|
|
360
|
-
Result.failure(error_code: :unknown_key_type, message: "API key type is not configured")
|
|
371
|
+
Result.failure(error_code: :unknown_key_type, message: "API key type is not configured", api_key: api_key)
|
|
361
372
|
end
|
|
362
373
|
|
|
363
374
|
def self.check_environment_configuration(api_key, config)
|
|
@@ -371,7 +382,7 @@ module ApiKeys
|
|
|
371
382
|
return nil if configured
|
|
372
383
|
|
|
373
384
|
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its environment is not configured."
|
|
374
|
-
Result.failure(error_code: :unknown_environment, message: "API key environment is not configured")
|
|
385
|
+
Result.failure(error_code: :unknown_environment, message: "API key environment is not configured", api_key: api_key)
|
|
375
386
|
end
|
|
376
387
|
|
|
377
388
|
# Check if the API key's environment matches the current environment
|
|
@@ -386,7 +397,8 @@ module ApiKeys
|
|
|
386
397
|
if key_env.blank?
|
|
387
398
|
return Result.failure(
|
|
388
399
|
error_code: :environment_misconfigured,
|
|
389
|
-
message: "API key environment could not be verified"
|
|
400
|
+
message: "API key environment could not be verified",
|
|
401
|
+
api_key: api_key
|
|
390
402
|
)
|
|
391
403
|
end
|
|
392
404
|
|
|
@@ -398,7 +410,8 @@ module ApiKeys
|
|
|
398
410
|
log_warn "[ApiKeys Security] Current environment resolution failed (#{error.class})."
|
|
399
411
|
return Result.failure(
|
|
400
412
|
error_code: :environment_misconfigured,
|
|
401
|
-
message: "API key environment could not be verified"
|
|
413
|
+
message: "API key environment could not be verified",
|
|
414
|
+
api_key: api_key
|
|
402
415
|
)
|
|
403
416
|
end
|
|
404
417
|
|
|
@@ -412,7 +425,8 @@ module ApiKeys
|
|
|
412
425
|
log_warn "[ApiKeys Security] Strict environment isolation is enabled, but current_environment resolved to blank."
|
|
413
426
|
return Result.failure(
|
|
414
427
|
error_code: :environment_misconfigured,
|
|
415
|
-
message: "API key environment could not be verified"
|
|
428
|
+
message: "API key environment could not be verified",
|
|
429
|
+
api_key: api_key
|
|
416
430
|
)
|
|
417
431
|
end
|
|
418
432
|
|
|
@@ -420,13 +434,74 @@ module ApiKeys
|
|
|
420
434
|
log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}."
|
|
421
435
|
return Result.failure(
|
|
422
436
|
error_code: :environment_mismatch,
|
|
423
|
-
message: "API key cannot be used in this environment"
|
|
437
|
+
message: "API key cannot be used in this environment",
|
|
438
|
+
api_key: api_key
|
|
424
439
|
)
|
|
425
440
|
end
|
|
426
441
|
|
|
427
442
|
nil # Check passed
|
|
428
443
|
end
|
|
429
444
|
|
|
445
|
+
# Enforces the key's request restrictions: which web origins and which IP
|
|
446
|
+
# addresses may present it. Empty lists mean unrestricted, so every key
|
|
447
|
+
# written before this feature existed passes untouched.
|
|
448
|
+
#
|
|
449
|
+
# Every list that is present must pass (AND across kinds); within a list
|
|
450
|
+
# any entry admits the request (OR within a kind). A locked list plus an
|
|
451
|
+
# unreadable request context refuses: these checks fail closed.
|
|
452
|
+
#
|
|
453
|
+
# @return [ApiKeys::Services::Authenticator::Result, nil] Failure, or nil when the check passes.
|
|
454
|
+
def self.check_request_restrictions(api_key, request, config)
|
|
455
|
+
restrictions = api_key.restrictions
|
|
456
|
+
return nil if restrictions.unrestricted?
|
|
457
|
+
|
|
458
|
+
if restrictions.malformed?
|
|
459
|
+
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its stored request restrictions are malformed."
|
|
460
|
+
return Result.failure(
|
|
461
|
+
error_code: :restriction_misconfigured,
|
|
462
|
+
message: "This API key's request restrictions could not be verified",
|
|
463
|
+
api_key: api_key
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
if restrictions.origins.any?
|
|
468
|
+
origin_host = ApiKeys::Restrictions.extract_origin_host(request)
|
|
469
|
+
unless restrictions.origin_allowed?(origin_host)
|
|
470
|
+
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed."
|
|
471
|
+
return Result.failure(
|
|
472
|
+
error_code: :origin_not_allowed,
|
|
473
|
+
message: "This API key is restricted to specific web origins, and this request's origin is not allowed",
|
|
474
|
+
api_key: api_key
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
if restrictions.ips.any?
|
|
480
|
+
unless restrictions.ip_allowed?(resolve_client_ip(request, config))
|
|
481
|
+
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed."
|
|
482
|
+
return Result.failure(
|
|
483
|
+
error_code: :ip_not_allowed,
|
|
484
|
+
message: "This API key is restricted to specific IP addresses, and this request's address is not allowed",
|
|
485
|
+
api_key: api_key
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
nil # Check passed
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Resolves the client IP through the configured resolver. A resolver that
|
|
494
|
+
# blows up yields nil, which an IP-locked key treats as a refusal.
|
|
495
|
+
def self.resolve_client_ip(request, config)
|
|
496
|
+
resolver = config.client_ip_resolver
|
|
497
|
+
return nil unless resolver.respond_to?(:call)
|
|
498
|
+
|
|
499
|
+
resolver.call(request)
|
|
500
|
+
rescue StandardError => error
|
|
501
|
+
log_warn "[ApiKeys Security] Client IP resolution failed (#{error.class}); treating the address as unknown."
|
|
502
|
+
nil
|
|
503
|
+
end
|
|
504
|
+
|
|
430
505
|
private_class_method :extract_token, :find_and_verify_key, :find_sha256_key,
|
|
431
506
|
:find_bcrypt_key, :find_bcrypt_key_for_prefixes,
|
|
432
507
|
:find_bcrypt_key_by_last4, :find_verified_bcrypt_candidate,
|
|
@@ -437,7 +512,8 @@ module ApiKeys
|
|
|
437
512
|
:safe_find_by_id, :sanitize_prefixes, :valid_token?,
|
|
438
513
|
:production_environment?, :secure_request?,
|
|
439
514
|
:check_key_type_configuration, :check_environment_configuration,
|
|
440
|
-
:check_environment_isolation
|
|
515
|
+
:check_environment_isolation, :check_request_restrictions,
|
|
516
|
+
:resolve_client_ip
|
|
441
517
|
end
|
|
442
518
|
end
|
|
443
519
|
end
|
data/lib/api_keys/version.rb
CHANGED
data/lib/api_keys.rb
CHANGED
|
@@ -54,6 +54,7 @@ end
|
|
|
54
54
|
require "api_keys/version"
|
|
55
55
|
require "api_keys/configuration" # Defines the ApiKeys::Configuration class
|
|
56
56
|
require "api_keys/errors" # Error classes for key types feature
|
|
57
|
+
require "api_keys/restrictions" # Origin/IP request restrictions value object
|
|
57
58
|
|
|
58
59
|
# Files that might depend on ApiKeys.configuration being available
|
|
59
60
|
require "api_keys/controller" # This can lead to loading jobs, etc.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ApiKeys
|
|
7
|
+
module Generators
|
|
8
|
+
# Rails generator for adding the `restrictions` column to the api_keys table.
|
|
9
|
+
# This generator is for existing installations that want to lock keys to
|
|
10
|
+
# specific web origins or IP addresses. New installs get the column from the
|
|
11
|
+
# install generator, so they never need to run this.
|
|
12
|
+
class AddRestrictionsGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
# Implement the required interface for Rails::Generators::Migration.
|
|
18
|
+
def self.next_migration_number(dirname)
|
|
19
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
20
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Creates the migration file using the template.
|
|
24
|
+
def create_migration_file
|
|
25
|
+
migration_template "add_restrictions_to_api_keys.rb.erb",
|
|
26
|
+
File.join(db_migrate_path, "add_restrictions_to_api_keys.rb")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Displays helpful information to the user after installation.
|
|
30
|
+
def display_post_install_message
|
|
31
|
+
say "\nš Request restrictions migration created!", :green
|
|
32
|
+
say "\nNext steps:"
|
|
33
|
+
say " 1. Run `rails db:migrate` to add the restrictions column."
|
|
34
|
+
say "\n 2. Lock a key to the places it may be used from:"
|
|
35
|
+
say " user.create_api_key!(name: 'Widget key', allowed_origins: 'example.com, *.example.com')"
|
|
36
|
+
say " key.allowed_ips = '203.0.113.7, 10.0.0.0/8'"
|
|
37
|
+
say "\n Keys without restrictions keep working from anywhere; presence is the toggle."
|
|
38
|
+
say "\n 3. Optionally cap which restriction kinds each key type may carry:"
|
|
39
|
+
say " config.key_types = {"
|
|
40
|
+
say " publishable: { prefix: 'pk', permissions: %w[read], public: true,"
|
|
41
|
+
say " restrictions: [:origins] },"
|
|
42
|
+
say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }"
|
|
43
|
+
say " }"
|
|
44
|
+
say "\n 4. Behind a CDN or proxy, make sure the client IP is truthful:"
|
|
45
|
+
say " config.action_dispatch.trusted_proxies = ..."
|
|
46
|
+
say " # Trust a vendor header only when ingress blocks requests that bypass that vendor."
|
|
47
|
+
say "\nSee the api_keys README for detailed usage and examples.", :cyan
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def migration_version
|
|
53
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|