otto 2.0.0.pre1 → 2.0.0.pre3
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/.github/workflows/ci.yml +2 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- data/lib/otto/security/authentication/failure_result.rb +0 -36
data/lib/otto/route.rb
CHANGED
|
@@ -45,10 +45,10 @@ class Otto
|
|
|
45
45
|
# "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
|
|
46
46
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
|
47
47
|
def initialize(verb, path, definition)
|
|
48
|
-
|
|
48
|
+
pattern, keys = *compile(path)
|
|
49
49
|
|
|
50
50
|
# Create immutable route definition
|
|
51
|
-
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern:
|
|
51
|
+
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
|
|
52
52
|
|
|
53
53
|
# Resolve the class
|
|
54
54
|
@klass = safe_const_get(@route_definition.klass_name)
|
|
@@ -87,52 +87,6 @@ class Otto
|
|
|
87
87
|
@route_definition.options
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
# Safely resolve a class name using Object.const_get with security validations
|
|
93
|
-
# This replaces the previous eval() usage to prevent code injection attacks.
|
|
94
|
-
#
|
|
95
|
-
# Security features:
|
|
96
|
-
# - Validates class name format (must start with capital letter)
|
|
97
|
-
# - Prevents access to dangerous system classes
|
|
98
|
-
# - Blocks relative class references (starting with ::)
|
|
99
|
-
# - Provides clear error messages for debugging
|
|
100
|
-
#
|
|
101
|
-
# @param class_name [String] The class name to resolve
|
|
102
|
-
# @return [Class] The resolved class
|
|
103
|
-
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
|
104
|
-
def safe_const_get(class_name)
|
|
105
|
-
# Validate class name format
|
|
106
|
-
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
|
107
|
-
raise ArgumentError, "Invalid class name format: #{class_name}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Prevent dangerous class names
|
|
111
|
-
forbidden_classes = %w[
|
|
112
|
-
Kernel Module Class Object BasicObject
|
|
113
|
-
File Dir IO Process System
|
|
114
|
-
Binding Proc Method UnboundMethod
|
|
115
|
-
Thread ThreadGroup Fiber
|
|
116
|
-
ObjectSpace GC
|
|
117
|
-
]
|
|
118
|
-
|
|
119
|
-
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
|
120
|
-
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
begin
|
|
124
|
-
Object.const_get(class_name)
|
|
125
|
-
rescue NameError => e
|
|
126
|
-
raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
public
|
|
131
|
-
|
|
132
|
-
def pattern_regexp
|
|
133
|
-
Regexp.new(@path.gsub('/*', '/.+'))
|
|
134
|
-
end
|
|
135
|
-
|
|
136
90
|
# Execute the route by calling the associated class method
|
|
137
91
|
#
|
|
138
92
|
# This method handles the complete request/response cycle with built-in security:
|
|
@@ -218,6 +172,48 @@ class Otto
|
|
|
218
172
|
|
|
219
173
|
private
|
|
220
174
|
|
|
175
|
+
# Safely resolve a class name using Object.const_get with security validations
|
|
176
|
+
# This replaces the previous eval() usage to prevent code injection attacks.
|
|
177
|
+
#
|
|
178
|
+
# Security features:
|
|
179
|
+
# - Validates class name format (must start with capital letter)
|
|
180
|
+
# - Prevents access to dangerous system classes
|
|
181
|
+
# - Blocks relative class references (starting with ::)
|
|
182
|
+
# - Provides clear error messages for debugging
|
|
183
|
+
#
|
|
184
|
+
# @param class_name [String] The class name to resolve
|
|
185
|
+
# @return [Class] The resolved class
|
|
186
|
+
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
|
187
|
+
def safe_const_get(class_name)
|
|
188
|
+
# Validate class name format
|
|
189
|
+
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
|
190
|
+
raise ArgumentError, "Invalid class name format: #{class_name}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Remove any leading :: then add exactly one
|
|
194
|
+
fq_class_name = "::#{class_name.sub(/^::+/, '')}"
|
|
195
|
+
|
|
196
|
+
# Prevent dangerous class names
|
|
197
|
+
forbidden_classes = %w[
|
|
198
|
+
Kernel Module Class Object BasicObject
|
|
199
|
+
File Dir IO Process System
|
|
200
|
+
Binding Proc Method UnboundMethod
|
|
201
|
+
Thread ThreadGroup Fiber
|
|
202
|
+
ObjectSpace GC
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
|
206
|
+
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
# Always guarantee exactly two leading colons
|
|
211
|
+
Object.const_get(fq_class_name)
|
|
212
|
+
rescue NameError => e
|
|
213
|
+
raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
221
217
|
def compile(path)
|
|
222
218
|
keys = []
|
|
223
219
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# lib/otto/route_handlers/factory.rb
|
|
4
4
|
|
|
5
5
|
require_relative 'base'
|
|
6
|
+
require_relative '../security/authentication/route_auth_wrapper'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module RouteHandlers
|
|
@@ -13,16 +14,30 @@ class Otto
|
|
|
13
14
|
# @param otto_instance [Otto] The Otto instance for configuration access
|
|
14
15
|
# @return [BaseHandler] Appropriate handler for the route
|
|
15
16
|
def self.create_handler(route_definition, otto_instance = nil)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
# Create base handler based on route kind
|
|
18
|
+
handler_class = case route_definition.kind
|
|
19
|
+
when :logic then LogicClassHandler
|
|
20
|
+
when :instance then InstanceMethodHandler
|
|
21
|
+
when :class then ClassMethodHandler
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
handler = handler_class.new(route_definition, otto_instance)
|
|
27
|
+
|
|
28
|
+
# Always wrap with RouteAuthWrapper to ensure env['otto.strategy_result'] is set
|
|
29
|
+
# - Routes WITH auth requirement: Enforces authentication
|
|
30
|
+
# - Routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
31
|
+
if otto_instance&.auth_config
|
|
32
|
+
handler = Otto::Security::Authentication::RouteAuthWrapper.new(
|
|
33
|
+
handler,
|
|
34
|
+
route_definition,
|
|
35
|
+
otto_instance.auth_config,
|
|
36
|
+
otto_instance.security_config
|
|
37
|
+
)
|
|
25
38
|
end
|
|
39
|
+
|
|
40
|
+
handler
|
|
26
41
|
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
@@ -17,8 +17,8 @@ class Otto
|
|
|
17
17
|
res = Rack::Response.new
|
|
18
18
|
|
|
19
19
|
begin
|
|
20
|
-
# Get strategy result (guaranteed to exist from
|
|
21
|
-
strategy_result = env['otto.strategy_result']
|
|
20
|
+
# Get strategy result (guaranteed to exist from RouteAuthWrapper)
|
|
21
|
+
strategy_result = env['otto.strategy_result']
|
|
22
22
|
|
|
23
23
|
# Initialize Logic class with new signature: context, params, locale
|
|
24
24
|
logic_params = req.params.merge(extra_params)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/security/authentication/failure_result.rb
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
# Failure result for authentication failures
|
|
9
|
+
AuthFailure = Data.define(:failure_reason, :auth_method) do
|
|
10
|
+
# AuthFailure represents authentication failure
|
|
11
|
+
# Returned by strategies when authentication fails
|
|
12
|
+
# Contains failure reason for error messages
|
|
13
|
+
|
|
14
|
+
# Check if authenticated - always false for failures
|
|
15
|
+
#
|
|
16
|
+
# @return [Boolean] False (failures are never authenticated)
|
|
17
|
+
def authenticated?
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Check if anonymous - always true for failures
|
|
22
|
+
#
|
|
23
|
+
# @return [Boolean] True (failures have no user)
|
|
24
|
+
def anonymous?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get empty user context for failures
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash] Empty hash
|
|
31
|
+
def user_context
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Create a string representation for debugging
|
|
36
|
+
#
|
|
37
|
+
# @return [String] Debug representation
|
|
38
|
+
def inspect
|
|
39
|
+
"#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -13,7 +13,7 @@ class Otto
|
|
|
13
13
|
# Check if the request meets the authentication requirements
|
|
14
14
|
# @param env [Hash] Rack environment
|
|
15
15
|
# @param requirement [String] Authentication requirement string
|
|
16
|
-
# @return [Otto::Security::Authentication::StrategyResult,
|
|
16
|
+
# @return [Otto::Security::Authentication::StrategyResult, Otto::Security::Authentication::AuthFailure] StrategyResult for success, AuthFailure for failure
|
|
17
17
|
def authenticate(env, requirement)
|
|
18
18
|
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
|
19
19
|
end
|
|
@@ -30,10 +30,10 @@ class Otto
|
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Helper for authentication failure - return
|
|
33
|
+
# Helper for authentication failure - return AuthFailure
|
|
34
34
|
def failure(reason = nil)
|
|
35
35
|
Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
|
|
36
|
-
Otto::Security::Authentication::
|
|
36
|
+
Otto::Security::Authentication::AuthFailure.new(
|
|
37
37
|
failure_reason: reason || 'Authentication failed',
|
|
38
38
|
auth_method: self.class.name.split('::').last
|
|
39
39
|
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/security/authentication/route_auth_wrapper.rb
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
# Wraps route handlers to enforce authentication requirements
|
|
9
|
+
#
|
|
10
|
+
# This wrapper executes authentication strategies AFTER routing but BEFORE
|
|
11
|
+
# route handler execution. This solves the architectural issue where
|
|
12
|
+
# middleware-based authentication runs before routing (so can't access route info).
|
|
13
|
+
#
|
|
14
|
+
# Flow:
|
|
15
|
+
# 1. Route matched (route_definition available)
|
|
16
|
+
# 2. RouteAuthWrapper#call invoked
|
|
17
|
+
# 3. Execute auth strategy based on route's auth_requirement
|
|
18
|
+
# 4. Set env['otto.strategy_result'], env['otto.user']
|
|
19
|
+
# 5. If auth fails, return 401 or redirect
|
|
20
|
+
# 6. If auth succeeds, call wrapped handler
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# handler = InstanceMethodHandler.new(route_def, otto)
|
|
24
|
+
# wrapped = RouteAuthWrapper.new(handler, route_def, auth_config)
|
|
25
|
+
# wrapped.call(env, extra_params)
|
|
26
|
+
#
|
|
27
|
+
class RouteAuthWrapper
|
|
28
|
+
attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
|
|
29
|
+
|
|
30
|
+
def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
|
|
31
|
+
@wrapped_handler = wrapped_handler
|
|
32
|
+
@route_definition = route_definition
|
|
33
|
+
@auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
|
34
|
+
@security_config = security_config
|
|
35
|
+
@strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Execute authentication then call wrapped handler
|
|
39
|
+
#
|
|
40
|
+
# For routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
41
|
+
# For routes WITH auth requirement: Enforces authentication
|
|
42
|
+
#
|
|
43
|
+
# @param env [Hash] Rack environment
|
|
44
|
+
# @param extra_params [Hash] Additional parameters
|
|
45
|
+
# @return [Array] Rack response array
|
|
46
|
+
def call(env, extra_params = {})
|
|
47
|
+
auth_requirement = route_definition.auth_requirement
|
|
48
|
+
|
|
49
|
+
# Routes without auth requirement get anonymous StrategyResult
|
|
50
|
+
unless auth_requirement
|
|
51
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
52
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
53
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
54
|
+
|
|
55
|
+
result = StrategyResult.anonymous(metadata: metadata)
|
|
56
|
+
env['otto.strategy_result'] = result
|
|
57
|
+
env['otto.user'] = nil
|
|
58
|
+
env['otto.user_context'] = result.user_context
|
|
59
|
+
return wrapped_handler.call(env, extra_params)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Routes WITH auth requirement: Execute authentication strategy
|
|
63
|
+
strategy = get_strategy(auth_requirement)
|
|
64
|
+
|
|
65
|
+
unless strategy
|
|
66
|
+
Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
|
|
67
|
+
return unauthorized_response(env, "Authentication strategy not configured")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Execute the strategy
|
|
71
|
+
result = strategy.authenticate(env, auth_requirement)
|
|
72
|
+
|
|
73
|
+
# Handle authentication failure
|
|
74
|
+
if result.is_a?(AuthFailure)
|
|
75
|
+
# Create anonymous result with failure info for logging/auditing
|
|
76
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
77
|
+
metadata = {
|
|
78
|
+
ip: env['REMOTE_ADDR'],
|
|
79
|
+
auth_failure: result.failure_reason,
|
|
80
|
+
attempted_strategy: auth_requirement
|
|
81
|
+
}
|
|
82
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
83
|
+
|
|
84
|
+
env['otto.strategy_result'] = StrategyResult.anonymous(metadata: metadata)
|
|
85
|
+
env['otto.user'] = nil
|
|
86
|
+
env['otto.user_context'] = {}
|
|
87
|
+
return auth_failure_response(env, result)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Set environment variables for controllers/logic on success
|
|
91
|
+
env['otto.strategy_result'] = result
|
|
92
|
+
env['otto.user'] = result.user
|
|
93
|
+
env['otto.user_context'] = result.user_context
|
|
94
|
+
|
|
95
|
+
# SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
|
|
96
|
+
# We must ensure env['rack.session'] and strategy_result.session reference
|
|
97
|
+
# the SAME object so that:
|
|
98
|
+
# 1. Logic classes write to strategy_result.session
|
|
99
|
+
# 2. Rack's session middleware persists env['rack.session']
|
|
100
|
+
# 3. Changes from (1) are included in (2)
|
|
101
|
+
#
|
|
102
|
+
# Using merge! instead would break this - the objects must be identical.
|
|
103
|
+
env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
|
|
104
|
+
|
|
105
|
+
# Authentication succeeded - call wrapped handler
|
|
106
|
+
wrapped_handler.call(env, extra_params)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Get strategy from auth_config hash with sophisticated pattern matching
|
|
112
|
+
#
|
|
113
|
+
# Supports:
|
|
114
|
+
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
115
|
+
# - Prefix match: 'role:admin' → looks up 'role' strategy
|
|
116
|
+
# - Fallback: 'role:*' → creates default RoleStrategy
|
|
117
|
+
# - Fallback: 'permission:*' → creates default PermissionStrategy
|
|
118
|
+
#
|
|
119
|
+
# Results are cached to avoid repeated lookups for the same requirement.
|
|
120
|
+
#
|
|
121
|
+
# @param requirement [String] Auth requirement from route
|
|
122
|
+
# @return [AuthStrategy, nil] Strategy instance or nil
|
|
123
|
+
def get_strategy(requirement)
|
|
124
|
+
return nil unless auth_config && auth_config[:auth_strategies]
|
|
125
|
+
|
|
126
|
+
# Check cache first
|
|
127
|
+
return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
|
|
128
|
+
|
|
129
|
+
# Try exact match first - this has highest priority
|
|
130
|
+
strategy = auth_config[:auth_strategies][requirement]
|
|
131
|
+
if strategy
|
|
132
|
+
@strategy_cache[requirement] = strategy
|
|
133
|
+
return strategy
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# For colon-separated requirements like "role:admin", try prefix match
|
|
137
|
+
if requirement.include?(':')
|
|
138
|
+
prefix = requirement.split(':', 2).first
|
|
139
|
+
|
|
140
|
+
# Check if we have a strategy registered for the prefix
|
|
141
|
+
prefix_strategy = auth_config[:auth_strategies][prefix]
|
|
142
|
+
if prefix_strategy
|
|
143
|
+
@strategy_cache[requirement] = prefix_strategy
|
|
144
|
+
return prefix_strategy
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Try fallback patterns for role: and permission: requirements
|
|
148
|
+
if requirement.start_with?('role:')
|
|
149
|
+
# Cache the fallback strategy under 'role:' key to create it only once
|
|
150
|
+
strategy = @strategy_cache['role:'] ||= begin
|
|
151
|
+
auth_config[:auth_strategies]['role'] || Strategies::RoleStrategy.new([])
|
|
152
|
+
end
|
|
153
|
+
@strategy_cache[requirement] = strategy
|
|
154
|
+
return strategy
|
|
155
|
+
elsif requirement.start_with?('permission:')
|
|
156
|
+
# Cache the fallback strategy under 'permission:' key
|
|
157
|
+
strategy = @strategy_cache['permission:'] ||= begin
|
|
158
|
+
auth_config[:auth_strategies]['permission'] || Strategies::PermissionStrategy.new([])
|
|
159
|
+
end
|
|
160
|
+
@strategy_cache[requirement] = strategy
|
|
161
|
+
return strategy
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Cache nil results too to avoid repeated failed lookups
|
|
166
|
+
@strategy_cache[requirement] = nil
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Generate 401 response for authentication failure
|
|
171
|
+
#
|
|
172
|
+
# @param env [Hash] Rack environment
|
|
173
|
+
# @param result [AuthFailure] Failure result from strategy
|
|
174
|
+
# @return [Array] Rack response array
|
|
175
|
+
def auth_failure_response(env, result)
|
|
176
|
+
# Check if request wants JSON
|
|
177
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
178
|
+
wants_json = accept_header.include?('application/json')
|
|
179
|
+
|
|
180
|
+
if wants_json
|
|
181
|
+
json_auth_error(result)
|
|
182
|
+
else
|
|
183
|
+
html_auth_error(result)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Generate JSON 401 response
|
|
188
|
+
#
|
|
189
|
+
# @param result [AuthFailure] Failure result
|
|
190
|
+
# @return [Array] Rack response array
|
|
191
|
+
def json_auth_error(result)
|
|
192
|
+
body = {
|
|
193
|
+
error: 'Authentication Required',
|
|
194
|
+
message: result.failure_reason || 'Not authenticated',
|
|
195
|
+
timestamp: Time.now.to_i
|
|
196
|
+
}.to_json
|
|
197
|
+
|
|
198
|
+
headers = {
|
|
199
|
+
'content-type' => 'application/json',
|
|
200
|
+
'content-length' => body.bytesize.to_s
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Add security headers if available
|
|
204
|
+
merge_security_headers!(headers)
|
|
205
|
+
|
|
206
|
+
[401, headers, [body]]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Generate HTML 401 response or redirect
|
|
210
|
+
#
|
|
211
|
+
# @param result [AuthFailure] Failure result
|
|
212
|
+
# @return [Array] Rack response array
|
|
213
|
+
def html_auth_error(result)
|
|
214
|
+
# For HTML requests, redirect to login
|
|
215
|
+
login_path = auth_config[:login_path] || '/signin'
|
|
216
|
+
|
|
217
|
+
headers = { 'location' => login_path }
|
|
218
|
+
|
|
219
|
+
# Add security headers if available
|
|
220
|
+
merge_security_headers!(headers)
|
|
221
|
+
|
|
222
|
+
[302, headers, ["Redirecting to #{login_path}"]]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Generate generic unauthorized response
|
|
226
|
+
#
|
|
227
|
+
# @param env [Hash] Rack environment
|
|
228
|
+
# @param message [String] Error message
|
|
229
|
+
# @return [Array] Rack response array
|
|
230
|
+
def unauthorized_response(env, message)
|
|
231
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
232
|
+
wants_json = accept_header.include?('application/json')
|
|
233
|
+
|
|
234
|
+
if wants_json
|
|
235
|
+
body = { error: message }.to_json
|
|
236
|
+
headers = {
|
|
237
|
+
'content-type' => 'application/json',
|
|
238
|
+
'content-length' => body.bytesize.to_s
|
|
239
|
+
}
|
|
240
|
+
merge_security_headers!(headers)
|
|
241
|
+
[401, headers, [body]]
|
|
242
|
+
else
|
|
243
|
+
headers = { 'content-type' => 'text/plain' }
|
|
244
|
+
merge_security_headers!(headers)
|
|
245
|
+
[401, headers, [message]]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Merge security headers into response headers
|
|
250
|
+
#
|
|
251
|
+
# @param headers [Hash] Response headers hash to merge into
|
|
252
|
+
def merge_security_headers!(headers)
|
|
253
|
+
return unless security_config
|
|
254
|
+
|
|
255
|
+
headers.merge!(security_config.security_headers)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -8,9 +8,13 @@ class Otto
|
|
|
8
8
|
module Authentication
|
|
9
9
|
module Strategies
|
|
10
10
|
# Public access strategy - always allows access
|
|
11
|
-
class
|
|
11
|
+
class NoAuthStrategy < AuthStrategy
|
|
12
12
|
def authenticate(env, _requirement)
|
|
13
|
-
|
|
13
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
14
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
15
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
16
|
+
|
|
17
|
+
Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|