otto 1.4.0 → 1.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/.gitignore +1 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +3 -3
- data/docs/.gitignore +2 -0
- data/lib/otto/response_handlers.rb +141 -0
- data/lib/otto/route.rb +120 -54
- data/lib/otto/route_definition.rb +187 -0
- data/lib/otto/route_handlers.rb +383 -0
- data/lib/otto/security/authentication.rb +289 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +70 -3
- metadata +6 -1
@@ -0,0 +1,383 @@
|
|
1
|
+
# lib/otto/route_handlers.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
# Pluggable Route Handler Factory (Phase 4)
|
5
|
+
# Enables different execution patterns while maintaining backward compatibility
|
6
|
+
module RouteHandlers
|
7
|
+
|
8
|
+
# Factory for creating appropriate handlers based on route definitions
|
9
|
+
class HandlerFactory
|
10
|
+
# Create a handler for the given route definition
|
11
|
+
# @param route_definition [Otto::RouteDefinition] The route definition
|
12
|
+
# @param otto_instance [Otto] The Otto instance for configuration access
|
13
|
+
# @return [BaseHandler] Appropriate handler for the route
|
14
|
+
def self.create_handler(route_definition, otto_instance = nil)
|
15
|
+
case route_definition.kind
|
16
|
+
when :logic
|
17
|
+
LogicClassHandler.new(route_definition, otto_instance)
|
18
|
+
when :instance
|
19
|
+
InstanceMethodHandler.new(route_definition, otto_instance)
|
20
|
+
when :class
|
21
|
+
ClassMethodHandler.new(route_definition, otto_instance)
|
22
|
+
else
|
23
|
+
raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Base class for all route handlers
|
29
|
+
# Provides common functionality and interface
|
30
|
+
class BaseHandler
|
31
|
+
attr_reader :route_definition, :otto_instance
|
32
|
+
|
33
|
+
def initialize(route_definition, otto_instance = nil)
|
34
|
+
@route_definition = route_definition
|
35
|
+
@otto_instance = otto_instance
|
36
|
+
end
|
37
|
+
|
38
|
+
# Execute the route handler
|
39
|
+
# @param env [Hash] Rack environment
|
40
|
+
# @param extra_params [Hash] Additional parameters
|
41
|
+
# @return [Array] Rack response array
|
42
|
+
def call(env, extra_params = {})
|
43
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
44
|
+
end
|
45
|
+
|
46
|
+
protected
|
47
|
+
|
48
|
+
# Get the target class, loading it safely
|
49
|
+
# @return [Class] The target class
|
50
|
+
def target_class
|
51
|
+
@target_class ||= safe_const_get(route_definition.klass_name)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Setup request and response with the same extensions and processing as Route#call
|
55
|
+
# @param req [Rack::Request] Request object
|
56
|
+
# @param res [Rack::Response] Response object
|
57
|
+
# @param env [Hash] Rack environment
|
58
|
+
# @param extra_params [Hash] Additional parameters
|
59
|
+
def setup_request_response(req, res, env, extra_params)
|
60
|
+
# Apply the same extensions as original Route#call
|
61
|
+
req.extend Otto::RequestHelpers
|
62
|
+
res.extend Otto::ResponseHelpers
|
63
|
+
res.request = req
|
64
|
+
|
65
|
+
# Make security config available to response helpers
|
66
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
67
|
+
env['otto.security_config'] = otto_instance.security_config
|
68
|
+
end
|
69
|
+
|
70
|
+
# Make route definition and options available to middleware and handlers
|
71
|
+
env['otto.route_definition'] = route_definition
|
72
|
+
env['otto.route_options'] = route_definition.options
|
73
|
+
|
74
|
+
# Process parameters through security layer
|
75
|
+
req.params.merge! extra_params
|
76
|
+
req.params.replace Otto::Static.indifferent_params(req.params)
|
77
|
+
|
78
|
+
# Add security headers
|
79
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
80
|
+
otto_instance.security_config.security_headers.each do |header, value|
|
81
|
+
res.headers[header] = value
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Setup class extensions if target_class is available
|
86
|
+
if target_class
|
87
|
+
target_class.extend Otto::Route::ClassMethods
|
88
|
+
target_class.otto = otto_instance if otto_instance
|
89
|
+
end
|
90
|
+
|
91
|
+
# Add security helpers if CSRF is enabled
|
92
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config&.csrf_enabled?
|
93
|
+
res.extend Otto::Security::CSRFHelpers
|
94
|
+
end
|
95
|
+
|
96
|
+
# Add validation helpers
|
97
|
+
res.extend Otto::Security::ValidationHelpers
|
98
|
+
end
|
99
|
+
|
100
|
+
# Finalize response with the same processing as Route#call
|
101
|
+
# @param res [Rack::Response] Response object
|
102
|
+
# @return [Array] Rack response array
|
103
|
+
def finalize_response(res)
|
104
|
+
res.body = [res.body] unless res.body.respond_to?(:each)
|
105
|
+
res.finish
|
106
|
+
end
|
107
|
+
|
108
|
+
# Handle response using appropriate response handler
|
109
|
+
# @param result [Object] Result from route execution
|
110
|
+
# @param response [Rack::Response] Response object
|
111
|
+
# @param context [Hash] Additional context for response handling
|
112
|
+
def handle_response(result, response, context = {})
|
113
|
+
response_type = route_definition.response_type
|
114
|
+
|
115
|
+
# Get the appropriate response handler
|
116
|
+
handler_class = case response_type
|
117
|
+
when 'json' then Otto::ResponseHandlers::JSONHandler
|
118
|
+
when 'redirect' then Otto::ResponseHandlers::RedirectHandler
|
119
|
+
when 'view' then Otto::ResponseHandlers::ViewHandler
|
120
|
+
when 'auto' then Otto::ResponseHandlers::AutoHandler
|
121
|
+
else Otto::ResponseHandlers::DefaultHandler
|
122
|
+
end
|
123
|
+
|
124
|
+
handler_class.handle(result, response, context)
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
# Safely get a constant from a string name
|
130
|
+
# @param name [String] Class name
|
131
|
+
# @return [Class] The class
|
132
|
+
def safe_const_get(name)
|
133
|
+
name.split('::').inject(Object) do |scope, const_name|
|
134
|
+
scope.const_get(const_name)
|
135
|
+
end
|
136
|
+
rescue NameError => e
|
137
|
+
raise NameError, "Unknown class: #{name} (#{e})"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Handler for Logic classes (new in Otto Framework Enhancement)
|
142
|
+
# Supports the OneTime Secret Logic class pattern
|
143
|
+
class LogicClassHandler < BaseHandler
|
144
|
+
def call(env, extra_params = {})
|
145
|
+
req = Rack::Request.new(env)
|
146
|
+
res = Rack::Response.new
|
147
|
+
|
148
|
+
begin
|
149
|
+
# Get authentication context if available
|
150
|
+
auth_result = env['otto.auth_result']
|
151
|
+
|
152
|
+
# Initialize Logic class with standard parameters
|
153
|
+
# Logic classes expect: session, user, params, locale
|
154
|
+
logic_params = req.params.merge(extra_params)
|
155
|
+
locale = env['otto.locale'] || 'en'
|
156
|
+
|
157
|
+
logic = if target_class.instance_method(:initialize).arity == 4
|
158
|
+
# Standard Logic class constructor
|
159
|
+
target_class.new(
|
160
|
+
auth_result&.session,
|
161
|
+
auth_result&.user,
|
162
|
+
logic_params,
|
163
|
+
locale
|
164
|
+
)
|
165
|
+
else
|
166
|
+
# Fallback for custom constructors
|
167
|
+
target_class.new(req, res)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Execute standard Logic class lifecycle
|
171
|
+
if logic.respond_to?(:raise_concerns)
|
172
|
+
logic.raise_concerns
|
173
|
+
end
|
174
|
+
|
175
|
+
result = if logic.respond_to?(:process)
|
176
|
+
logic.process
|
177
|
+
else
|
178
|
+
logic.call || logic
|
179
|
+
end
|
180
|
+
|
181
|
+
# Handle response with Logic instance context
|
182
|
+
handle_response(result, res, {
|
183
|
+
logic_instance: logic,
|
184
|
+
request: req,
|
185
|
+
status_code: logic.respond_to?(:status_code) ? logic.status_code : nil
|
186
|
+
})
|
187
|
+
|
188
|
+
rescue => e
|
189
|
+
# Error handling - return 500 with proper headers like main Otto error handler
|
190
|
+
error_id = SecureRandom.hex(8)
|
191
|
+
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
192
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
193
|
+
|
194
|
+
res.status = 500
|
195
|
+
res.headers['content-type'] = 'text/plain'
|
196
|
+
|
197
|
+
if Otto.env?(:dev, :development)
|
198
|
+
res.write "Server error (ID: #{error_id}). Check logs for details."
|
199
|
+
else
|
200
|
+
res.write "An error occurred. Please try again later."
|
201
|
+
end
|
202
|
+
|
203
|
+
# Add security headers if available
|
204
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
205
|
+
otto_instance.security_config.security_headers.each do |header, value|
|
206
|
+
res.headers[header] = value
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
res.finish
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Handler for instance methods (existing Otto pattern)
|
216
|
+
# Maintains backward compatibility for Controller#action patterns
|
217
|
+
class InstanceMethodHandler < BaseHandler
|
218
|
+
def call(env, extra_params = {})
|
219
|
+
req = Rack::Request.new(env)
|
220
|
+
res = Rack::Response.new
|
221
|
+
|
222
|
+
begin
|
223
|
+
# Apply the same extensions and processing as original Route#call
|
224
|
+
setup_request_response(req, res, env, extra_params)
|
225
|
+
|
226
|
+
# Create instance and call method (existing Otto behavior)
|
227
|
+
instance = target_class.new(req, res)
|
228
|
+
result = instance.send(route_definition.method_name)
|
229
|
+
|
230
|
+
# Only handle response if response_type is not default
|
231
|
+
if route_definition.response_type != 'default'
|
232
|
+
handle_response(result, res, {
|
233
|
+
instance: instance,
|
234
|
+
request: req
|
235
|
+
})
|
236
|
+
end
|
237
|
+
|
238
|
+
rescue => e
|
239
|
+
# Error handling - return 500 with proper headers like main Otto error handler
|
240
|
+
error_id = SecureRandom.hex(8)
|
241
|
+
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
242
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
243
|
+
|
244
|
+
res.status = 500
|
245
|
+
res.headers['content-type'] = 'text/plain'
|
246
|
+
|
247
|
+
if Otto.env?(:dev, :development)
|
248
|
+
res.write "Server error (ID: #{error_id}). Check logs for details."
|
249
|
+
else
|
250
|
+
res.write "An error occurred. Please try again later."
|
251
|
+
end
|
252
|
+
|
253
|
+
# Add security headers if available
|
254
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
255
|
+
otto_instance.security_config.security_headers.each do |header, value|
|
256
|
+
res.headers[header] = value
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
finalize_response(res)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# Handler for class methods (existing Otto pattern)
|
266
|
+
# Maintains backward compatibility for Controller.action patterns
|
267
|
+
class ClassMethodHandler < BaseHandler
|
268
|
+
def call(env, extra_params = {})
|
269
|
+
req = Rack::Request.new(env)
|
270
|
+
res = Rack::Response.new
|
271
|
+
|
272
|
+
begin
|
273
|
+
# Apply the same extensions and processing as original Route#call
|
274
|
+
setup_request_response(req, res, env, extra_params)
|
275
|
+
|
276
|
+
# Call class method directly (existing Otto behavior)
|
277
|
+
result = target_class.send(route_definition.method_name, req, res)
|
278
|
+
|
279
|
+
# Only handle response if response_type is not default
|
280
|
+
if route_definition.response_type != 'default'
|
281
|
+
handle_response(result, res, {
|
282
|
+
class: target_class,
|
283
|
+
request: req
|
284
|
+
})
|
285
|
+
end
|
286
|
+
|
287
|
+
rescue => e
|
288
|
+
# Error handling - return 500 with proper headers like main Otto error handler
|
289
|
+
error_id = SecureRandom.hex(8)
|
290
|
+
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
291
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
292
|
+
|
293
|
+
res.status = 500
|
294
|
+
|
295
|
+
# Content negotiation for error response
|
296
|
+
accept_header = env['HTTP_ACCEPT'].to_s
|
297
|
+
if accept_header.include?('application/json')
|
298
|
+
res.headers['content-type'] = 'application/json'
|
299
|
+
error_data = if Otto.env?(:dev, :development)
|
300
|
+
{
|
301
|
+
error: 'Internal Server Error',
|
302
|
+
message: 'Server error occurred. Check logs for details.',
|
303
|
+
error_id: error_id,
|
304
|
+
}
|
305
|
+
else
|
306
|
+
{
|
307
|
+
error: 'Internal Server Error',
|
308
|
+
message: 'An error occurred. Please try again later.',
|
309
|
+
}
|
310
|
+
end
|
311
|
+
res.write JSON.generate(error_data)
|
312
|
+
else
|
313
|
+
res.headers['content-type'] = 'text/plain'
|
314
|
+
if Otto.env?(:dev, :development)
|
315
|
+
res.write "Server error (ID: #{error_id}). Check logs for details."
|
316
|
+
else
|
317
|
+
res.write "An error occurred. Please try again later."
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# Add security headers if available
|
322
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
323
|
+
otto_instance.security_config.security_headers.each do |header, value|
|
324
|
+
res.headers[header] = value
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
finalize_response(res)
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# Custom handler for lambda/proc definitions (future extension)
|
334
|
+
class LambdaHandler < BaseHandler
|
335
|
+
def call(env, extra_params = {})
|
336
|
+
req = Rack::Request.new(env)
|
337
|
+
res = Rack::Response.new
|
338
|
+
|
339
|
+
begin
|
340
|
+
# Security: Lambda handlers require pre-configured procs from Otto instance
|
341
|
+
# This prevents code injection via eval and maintains security
|
342
|
+
handler_name = route_definition.klass_name
|
343
|
+
lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
|
344
|
+
|
345
|
+
lambda_proc = lambda_registry[handler_name]
|
346
|
+
unless lambda_proc.respond_to?(:call)
|
347
|
+
raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
|
348
|
+
end
|
349
|
+
|
350
|
+
result = lambda_proc.call(req, res, extra_params)
|
351
|
+
|
352
|
+
handle_response(result, res, {
|
353
|
+
lambda: lambda_proc,
|
354
|
+
request: req
|
355
|
+
})
|
356
|
+
|
357
|
+
rescue => e
|
358
|
+
error_id = SecureRandom.hex(8)
|
359
|
+
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
360
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
361
|
+
|
362
|
+
res.status = 500
|
363
|
+
res.headers['content-type'] = 'text/plain'
|
364
|
+
|
365
|
+
if Otto.env?(:dev, :development)
|
366
|
+
res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
|
367
|
+
else
|
368
|
+
res.write "An error occurred. Please try again later."
|
369
|
+
end
|
370
|
+
|
371
|
+
# Add security headers if available
|
372
|
+
if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
|
373
|
+
otto_instance.security_config.security_headers.each do |header, value|
|
374
|
+
res.headers[header] = value
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
res.finish
|
380
|
+
end
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
# lib/otto/security/authentication.rb
|
2
|
+
#
|
3
|
+
# Configurable authentication strategy system for Otto framework
|
4
|
+
# Provides pluggable authentication patterns that can be customized per application
|
5
|
+
#
|
6
|
+
# Usage:
|
7
|
+
# otto = Otto.new('routes.txt', {
|
8
|
+
# auth_strategies: {
|
9
|
+
# 'publically' => PublicStrategy.new,
|
10
|
+
# 'authenticated' => SessionStrategy.new,
|
11
|
+
# 'role:admin' => RoleStrategy.new(['admin']),
|
12
|
+
# 'api_key' => APIKeyStrategy.new
|
13
|
+
# }
|
14
|
+
# })
|
15
|
+
|
16
|
+
class Otto
|
17
|
+
module Security
|
18
|
+
# Base class for all authentication strategies
|
19
|
+
class AuthStrategy
|
20
|
+
# Check if the request meets the authentication requirements
|
21
|
+
# @param env [Hash] Rack environment
|
22
|
+
# @param requirement [String] Authentication requirement string
|
23
|
+
# @return [AuthResult] Result containing success status and context
|
24
|
+
def authenticate(env, requirement)
|
25
|
+
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
26
|
+
end
|
27
|
+
|
28
|
+
# Optional: Extract user context for authenticated requests
|
29
|
+
# @param env [Hash] Rack environment
|
30
|
+
# @return [Hash] User context hash
|
31
|
+
def user_context(env)
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
|
35
|
+
protected
|
36
|
+
|
37
|
+
# Helper to create successful auth result
|
38
|
+
def success(user_context = {})
|
39
|
+
AuthResult.new(true, user_context)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Helper to create failed auth result
|
43
|
+
def failure(reason = 'Authentication failed')
|
44
|
+
AuthResult.new(false, {}, reason)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Result object for authentication attempts
|
49
|
+
class AuthResult
|
50
|
+
attr_reader :user_context, :failure_reason
|
51
|
+
|
52
|
+
def initialize(success, user_context = {}, failure_reason = nil)
|
53
|
+
@success = success
|
54
|
+
@user_context = user_context
|
55
|
+
@failure_reason = failure_reason
|
56
|
+
end
|
57
|
+
|
58
|
+
def success?
|
59
|
+
@success
|
60
|
+
end
|
61
|
+
|
62
|
+
def failure?
|
63
|
+
!@success
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Public access strategy - always allows access
|
68
|
+
class PublicStrategy < AuthStrategy
|
69
|
+
def authenticate(env, requirement)
|
70
|
+
success
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Session-based authentication strategy
|
75
|
+
class SessionStrategy < AuthStrategy
|
76
|
+
def initialize(session_key: 'user_id', session_store: nil)
|
77
|
+
@session_key = session_key
|
78
|
+
@session_store = session_store
|
79
|
+
end
|
80
|
+
|
81
|
+
def authenticate(env, requirement)
|
82
|
+
session = env['rack.session']
|
83
|
+
return failure('No session available') unless session
|
84
|
+
|
85
|
+
user_id = session[@session_key]
|
86
|
+
return failure('Not authenticated') unless user_id
|
87
|
+
|
88
|
+
success(user_id: user_id, session: session)
|
89
|
+
end
|
90
|
+
|
91
|
+
def user_context(env)
|
92
|
+
session = env['rack.session']
|
93
|
+
return {} unless session
|
94
|
+
|
95
|
+
user_id = session[@session_key]
|
96
|
+
user_id ? { user_id: user_id } : {}
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Role-based authentication strategy
|
101
|
+
class RoleStrategy < AuthStrategy
|
102
|
+
def initialize(allowed_roles, session_key: 'user_roles')
|
103
|
+
@allowed_roles = Array(allowed_roles)
|
104
|
+
@session_key = session_key
|
105
|
+
end
|
106
|
+
|
107
|
+
def authenticate(env, requirement)
|
108
|
+
session = env['rack.session']
|
109
|
+
return failure('No session available') unless session
|
110
|
+
|
111
|
+
user_roles = session[@session_key] || []
|
112
|
+
user_roles = Array(user_roles)
|
113
|
+
|
114
|
+
# For requirements like "role:admin", extract the role part
|
115
|
+
if requirement.include?(':')
|
116
|
+
required_role = requirement.split(':', 2).last
|
117
|
+
if user_roles.include?(required_role)
|
118
|
+
success(user_roles: user_roles, required_role: required_role)
|
119
|
+
else
|
120
|
+
failure("Insufficient privileges - requires role: #{required_role}")
|
121
|
+
end
|
122
|
+
else
|
123
|
+
# For direct strategy matches, check if user has any of the allowed roles
|
124
|
+
matching_roles = user_roles & @allowed_roles
|
125
|
+
if matching_roles.any?
|
126
|
+
success(user_roles: user_roles, allowed_roles: @allowed_roles, matching_roles: matching_roles)
|
127
|
+
else
|
128
|
+
failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def user_context(env)
|
134
|
+
session = env['rack.session']
|
135
|
+
return {} unless session
|
136
|
+
|
137
|
+
user_roles = session[@session_key] || []
|
138
|
+
{ user_roles: Array(user_roles) }
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# API key authentication strategy
|
143
|
+
class APIKeyStrategy < AuthStrategy
|
144
|
+
def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
|
145
|
+
@api_keys = Array(api_keys)
|
146
|
+
@header_name = header_name
|
147
|
+
@param_name = param_name
|
148
|
+
end
|
149
|
+
|
150
|
+
def authenticate(env, requirement)
|
151
|
+
# Try header first, then query parameter
|
152
|
+
api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
|
153
|
+
|
154
|
+
if api_key.nil?
|
155
|
+
request = Rack::Request.new(env)
|
156
|
+
api_key = request.params[@param_name]
|
157
|
+
end
|
158
|
+
|
159
|
+
return failure('No API key provided') unless api_key
|
160
|
+
|
161
|
+
if @api_keys.empty? || @api_keys.include?(api_key)
|
162
|
+
success(api_key: api_key)
|
163
|
+
else
|
164
|
+
failure('Invalid API key')
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Permission-based authentication strategy
|
170
|
+
class PermissionStrategy < AuthStrategy
|
171
|
+
def initialize(required_permissions, session_key: 'user_permissions')
|
172
|
+
@required_permissions = Array(required_permissions)
|
173
|
+
@session_key = session_key
|
174
|
+
end
|
175
|
+
|
176
|
+
def authenticate(env, requirement)
|
177
|
+
session = env['rack.session']
|
178
|
+
return failure('No session available') unless session
|
179
|
+
|
180
|
+
user_permissions = session[@session_key] || []
|
181
|
+
user_permissions = Array(user_permissions)
|
182
|
+
|
183
|
+
# Extract permission from requirement (e.g., "permission:write" -> "write")
|
184
|
+
required_permission = requirement.split(':', 2).last
|
185
|
+
|
186
|
+
if user_permissions.include?(required_permission)
|
187
|
+
success(user_permissions: user_permissions, required_permission: required_permission)
|
188
|
+
else
|
189
|
+
failure("Insufficient privileges - requires permission: #{required_permission}")
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def user_context(env)
|
194
|
+
session = env['rack.session']
|
195
|
+
return {} unless session
|
196
|
+
|
197
|
+
user_permissions = session[@session_key] || []
|
198
|
+
{ user_permissions: Array(user_permissions) }
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Authentication middleware that enforces route-level auth requirements
|
203
|
+
class AuthenticationMiddleware
|
204
|
+
def initialize(app, config = {})
|
205
|
+
@app = app
|
206
|
+
@config = config
|
207
|
+
@strategies = config[:auth_strategies] || {}
|
208
|
+
@default_strategy = config[:default_auth_strategy] || 'publically'
|
209
|
+
|
210
|
+
# Add default public strategy if not provided
|
211
|
+
@strategies['publically'] ||= PublicStrategy.new
|
212
|
+
end
|
213
|
+
|
214
|
+
def call(env)
|
215
|
+
# Check if this route has auth requirements
|
216
|
+
route_definition = env['otto.route_definition']
|
217
|
+
return @app.call(env) unless route_definition
|
218
|
+
|
219
|
+
auth_requirement = route_definition.auth_requirement
|
220
|
+
return @app.call(env) unless auth_requirement
|
221
|
+
|
222
|
+
# Find appropriate strategy
|
223
|
+
strategy = find_strategy(auth_requirement)
|
224
|
+
unless strategy
|
225
|
+
return auth_error_response("Unknown authentication strategy: #{auth_requirement}")
|
226
|
+
end
|
227
|
+
|
228
|
+
# Perform authentication
|
229
|
+
auth_result = strategy.authenticate(env, auth_requirement)
|
230
|
+
|
231
|
+
if auth_result.success?
|
232
|
+
# Add user context to environment for handlers to use
|
233
|
+
env['otto.user_context'] = auth_result.user_context
|
234
|
+
env['otto.auth_result'] = auth_result
|
235
|
+
@app.call(env)
|
236
|
+
else
|
237
|
+
auth_error_response(auth_result.failure_reason)
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
private
|
242
|
+
|
243
|
+
def find_strategy(requirement)
|
244
|
+
# Try exact match first - this has highest priority
|
245
|
+
return @strategies[requirement] if @strategies[requirement]
|
246
|
+
|
247
|
+
# For colon-separated requirements like "role:admin", try prefix match
|
248
|
+
if requirement.include?(':')
|
249
|
+
prefix = requirement.split(':', 2).first
|
250
|
+
|
251
|
+
# Check if we have a strategy registered for the prefix
|
252
|
+
prefix_strategy = @strategies[prefix]
|
253
|
+
return prefix_strategy if prefix_strategy
|
254
|
+
|
255
|
+
# Try fallback patterns for role: and permission: requirements
|
256
|
+
if requirement.start_with?('role:')
|
257
|
+
return @strategies['role'] || RoleStrategy.new([])
|
258
|
+
elsif requirement.start_with?('permission:')
|
259
|
+
return @strategies['permission'] || PermissionStrategy.new([])
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
nil
|
264
|
+
end
|
265
|
+
|
266
|
+
def auth_error_response(message)
|
267
|
+
body = JSON.generate({
|
268
|
+
error: 'Authentication Required',
|
269
|
+
message: message,
|
270
|
+
timestamp: Time.now.to_i
|
271
|
+
})
|
272
|
+
|
273
|
+
headers = {
|
274
|
+
'Content-Type' => 'application/json',
|
275
|
+
'Content-Length' => body.bytesize.to_s
|
276
|
+
}
|
277
|
+
|
278
|
+
# Add security headers if available from config hash or Otto instance
|
279
|
+
if @config.is_a?(Hash) && @config[:security_headers]
|
280
|
+
headers.merge!(@config[:security_headers])
|
281
|
+
elsif @config.respond_to?(:security_config) && @config.security_config
|
282
|
+
headers.merge!(@config.security_config.security_headers)
|
283
|
+
end
|
284
|
+
|
285
|
+
[401, headers, [body]]
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
data/lib/otto/version.rb
CHANGED