otto 1.3.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.
@@ -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