otto 2.0.0.pre3 → 2.0.0.pre7
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 +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +15 -20
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +2 -2
- data/lib/otto/core/router.rb +61 -8
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +2 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +61 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +8 -3
- data/lib/otto/helpers/response.rb +2 -2
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +2 -0
- data/lib/otto/locale/config.rb +2 -2
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +2 -0
- data/lib/otto/privacy/config.rb +2 -0
- data/lib/otto/privacy/geo_resolver.rb +199 -29
- data/lib/otto/privacy/ip_privacy.rb +2 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
- data/lib/otto/privacy.rb +2 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -0
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +23 -6
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +2 -2
- data/lib/otto/security/authentication/auth_strategy.rb +11 -4
- data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +2 -2
- data/lib/otto/security/configurator.rb +17 -2
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +3 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +174 -14
- data/otto.gemspec +7 -3
- metadata +24 -15
- data/benchmark_middleware_wrap.rb +0 -163
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route_handlers/lambda.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
|
|
6
6
|
require_relative 'base'
|
|
@@ -10,6 +10,7 @@ class Otto
|
|
|
10
10
|
# Custom handler for lambda/proc definitions (future extension)
|
|
11
11
|
class LambdaHandler < BaseHandler
|
|
12
12
|
def call(env, extra_params = {})
|
|
13
|
+
start_time = Otto::Utils.now_in_μs
|
|
13
14
|
req = Rack::Request.new(env)
|
|
14
15
|
res = Rack::Response.new
|
|
15
16
|
|
|
@@ -31,25 +32,12 @@ class Otto
|
|
|
31
32
|
request: req,
|
|
32
33
|
})
|
|
33
34
|
rescue StandardError => e
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
res.status = 500
|
|
39
|
-
res.headers['content-type'] = 'text/plain'
|
|
35
|
+
# Store handler context in env for centralized error handler
|
|
36
|
+
handler_name = "Lambda[#{route_definition.klass_name}]"
|
|
37
|
+
env['otto.handler'] = handler_name
|
|
38
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
|
|
43
|
-
else
|
|
44
|
-
res.write 'An error occurred. Please try again later.'
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Add security headers if available
|
|
48
|
-
if otto_instance.respond_to?(:security_config) && otto_instance.security_config
|
|
49
|
-
otto_instance.security_config.security_headers.each do |header, value|
|
|
50
|
-
res.headers[header] = value
|
|
51
|
-
end
|
|
52
|
-
end
|
|
40
|
+
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
53
41
|
end
|
|
54
42
|
|
|
55
43
|
res.finish
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route_handlers/logic_class.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
|
|
@@ -13,6 +13,7 @@ class Otto
|
|
|
13
13
|
# Logic classes use signature: initialize(context, params, locale)
|
|
14
14
|
class LogicClassHandler < BaseHandler
|
|
15
15
|
def call(env, extra_params = {})
|
|
16
|
+
start_time = Otto::Utils.now_in_μs
|
|
16
17
|
req = Rack::Request.new(env)
|
|
17
18
|
res = Rack::Response.new
|
|
18
19
|
|
|
@@ -30,7 +31,21 @@ class Otto
|
|
|
30
31
|
json_data = JSON.parse(req.body.read)
|
|
31
32
|
logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
|
|
32
33
|
rescue JSON::ParserError => e
|
|
33
|
-
|
|
34
|
+
# Base context pattern: create once, reuse for correlation
|
|
35
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
36
|
+
|
|
37
|
+
Otto.structured_log(:error, "JSON parsing error",
|
|
38
|
+
base_context.merge(
|
|
39
|
+
handler: "#{target_class}#call",
|
|
40
|
+
error: e.message,
|
|
41
|
+
error_class: e.class.name,
|
|
42
|
+
duration: Otto::Utils.now_in_μs - start_time
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Otto::LoggingHelpers.log_backtrace(e,
|
|
47
|
+
base_context.merge(handler: "#{target_class}#call")
|
|
48
|
+
)
|
|
34
49
|
end
|
|
35
50
|
end
|
|
36
51
|
|
|
@@ -58,9 +73,11 @@ class Otto
|
|
|
58
73
|
# In integrated context, let Otto's centralized error handler manage the response
|
|
59
74
|
# In direct testing context, handle errors locally for unit testing
|
|
60
75
|
if otto_instance
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
# Store handler context in env for centralized error handler
|
|
77
|
+
handler_name = "#{target_class}#call"
|
|
78
|
+
env['otto.handler'] = handler_name
|
|
79
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
80
|
+
|
|
64
81
|
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
65
82
|
else
|
|
66
83
|
# Direct handler testing context - handle errors locally with security improvements
|
data/lib/otto/route_handlers.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/authentication/auth_strategy.rb
|
|
4
2
|
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
5
|
# Base class for all authentication strategies in Otto framework
|
|
6
6
|
# Provides pluggable authentication patterns that can be customized per application
|
|
7
7
|
|
|
@@ -13,7 +13,9 @@ 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,
|
|
17
|
+
# Otto::Security::Authentication::AuthFailure]
|
|
18
|
+
# StrategyResult for success, AuthFailure for failure
|
|
17
19
|
def authenticate(env, requirement)
|
|
18
20
|
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
|
19
21
|
end
|
|
@@ -21,12 +23,17 @@ class Otto
|
|
|
21
23
|
protected
|
|
22
24
|
|
|
23
25
|
# Helper to create successful strategy result
|
|
26
|
+
#
|
|
27
|
+
# NOTE: strategy_name will be injected by RouteAuthWrapper after strategy execution.
|
|
28
|
+
# Strategies don't know their registered name, so we pass nil here and let the wrapper
|
|
29
|
+
# set it based on how the strategy was registered via add_auth_strategy(name, strategy).
|
|
24
30
|
def success(user:, session: {}, auth_method: nil, **metadata)
|
|
25
31
|
Otto::Security::Authentication::StrategyResult.new(
|
|
26
32
|
session: session,
|
|
27
33
|
user: user,
|
|
28
34
|
auth_method: auth_method || self.class.name.split('::').last,
|
|
29
|
-
metadata: metadata
|
|
35
|
+
metadata: metadata,
|
|
36
|
+
strategy_name: nil # Will be set by RouteAuthWrapper
|
|
30
37
|
)
|
|
31
38
|
end
|
|
32
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/authentication/route_auth_wrapper.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
class Otto
|
|
6
6
|
module Security
|
|
@@ -15,7 +15,7 @@ class Otto
|
|
|
15
15
|
# 1. Route matched (route_definition available)
|
|
16
16
|
# 2. RouteAuthWrapper#call invoked
|
|
17
17
|
# 3. Execute auth strategy based on route's auth_requirement
|
|
18
|
-
# 4. Set env['otto.strategy_result']
|
|
18
|
+
# 4. Set env['otto.strategy_result']
|
|
19
19
|
# 5. If auth fails, return 401 or redirect
|
|
20
20
|
# 6. If auth succeeds, call wrapped handler
|
|
21
21
|
#
|
|
@@ -39,132 +39,231 @@ class Otto
|
|
|
39
39
|
#
|
|
40
40
|
# For routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
41
41
|
# For routes WITH auth requirement: Enforces authentication
|
|
42
|
+
# Supports multi-strategy with OR logic: auth=session,apikey,oauth
|
|
42
43
|
#
|
|
43
44
|
# @param env [Hash] Rack environment
|
|
44
45
|
# @param extra_params [Hash] Additional parameters
|
|
45
46
|
# @return [Array] Rack response array
|
|
46
47
|
def call(env, extra_params = {})
|
|
47
|
-
|
|
48
|
+
auth_requirements = route_definition.auth_requirements
|
|
48
49
|
|
|
49
50
|
# Routes without auth requirement get anonymous StrategyResult
|
|
50
|
-
|
|
51
|
+
if auth_requirements.empty?
|
|
51
52
|
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
52
53
|
metadata = { ip: env['REMOTE_ADDR'] }
|
|
53
|
-
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
54
|
+
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
54
55
|
|
|
55
|
-
result = StrategyResult.anonymous(metadata: metadata)
|
|
56
|
+
result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
|
|
56
57
|
env['otto.strategy_result'] = result
|
|
57
|
-
env['otto.user'] = nil
|
|
58
|
-
env['otto.user_context'] = result.user_context
|
|
59
58
|
return wrapped_handler.call(env, extra_params)
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
# Routes WITH auth
|
|
63
|
-
strategy = get_strategy(auth_requirement)
|
|
61
|
+
# Routes WITH auth requirements: Try each strategy in order (first success wins)
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
# Validate all strategies exist before executing any (fail-fast)
|
|
64
|
+
auth_requirements.each do |requirement|
|
|
65
|
+
strategy, _strategy_name = get_strategy(requirement)
|
|
66
|
+
unless strategy
|
|
67
|
+
error_msg = "Authentication strategy not configured: '#{requirement}'"
|
|
68
|
+
Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
|
|
69
|
+
return unauthorized_response(env, error_msg)
|
|
70
|
+
end
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
last_failure = nil
|
|
74
|
+
failed_strategies = []
|
|
75
|
+
total_start_time = Otto::Utils.now_in_μs
|
|
76
|
+
|
|
77
|
+
auth_requirements.each do |requirement|
|
|
78
|
+
strategy, strategy_name = get_strategy(requirement)
|
|
79
|
+
|
|
80
|
+
# Log strategy execution start
|
|
81
|
+
Otto.structured_log(:debug, "Auth strategy executing",
|
|
82
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
83
|
+
strategy: strategy_name,
|
|
84
|
+
requirement: requirement,
|
|
85
|
+
strategy_position: auth_requirements.index(requirement) + 1,
|
|
86
|
+
total_strategies: auth_requirements.size
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Execute the strategy
|
|
91
|
+
start_time = Otto::Utils.now_in_μs
|
|
92
|
+
result = strategy.authenticate(env, requirement)
|
|
93
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
94
|
+
|
|
95
|
+
# Inject strategy_name into result (Data.define objects are immutable, use #with for updates)
|
|
96
|
+
if result.is_a?(StrategyResult)
|
|
97
|
+
result = result.with(strategy_name: strategy_name)
|
|
98
|
+
end
|
|
72
99
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
# Handle authentication success (both authenticated and anonymous results) - return immediately
|
|
101
|
+
if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
|
|
102
|
+
total_duration = Otto::Utils.now_in_μs - total_start_time
|
|
103
|
+
|
|
104
|
+
# Log authentication success
|
|
105
|
+
Otto.structured_log(:info, "Auth strategy result",
|
|
106
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
107
|
+
strategy: strategy_name,
|
|
108
|
+
success: true,
|
|
109
|
+
user_id: result.user_id,
|
|
110
|
+
duration: duration,
|
|
111
|
+
total_duration: total_duration,
|
|
112
|
+
strategies_attempted: failed_strategies.size + 1
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Set environment variables for controllers/logic on success
|
|
117
|
+
env['otto.strategy_result'] = result
|
|
118
|
+
|
|
119
|
+
# SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
|
|
120
|
+
# We must ensure env['rack.session'] and strategy_result.session reference
|
|
121
|
+
# the SAME object so that:
|
|
122
|
+
# 1. Logic classes write to strategy_result.session
|
|
123
|
+
# 2. Rack's session middleware persists env['rack.session']
|
|
124
|
+
# 3. Changes from (1) are included in (2)
|
|
125
|
+
#
|
|
126
|
+
# Using merge! instead would break this - the objects must be identical.
|
|
127
|
+
env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
|
|
128
|
+
|
|
129
|
+
# Layer 1 Authorization: Check role requirements (route-level)
|
|
130
|
+
role_requirements = route_definition.role_requirements
|
|
131
|
+
unless role_requirements.empty?
|
|
132
|
+
user_roles = extract_user_roles(result)
|
|
133
|
+
|
|
134
|
+
# OR logic: user needs ANY of the required roles
|
|
135
|
+
unless (user_roles & role_requirements).any?
|
|
136
|
+
Otto.structured_log(:warn, "Role authorization failed",
|
|
137
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
138
|
+
required_roles: role_requirements,
|
|
139
|
+
user_roles: user_roles,
|
|
140
|
+
user_id: result.user_id
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return forbidden_response(env,
|
|
145
|
+
"Access denied: requires one of roles: #{role_requirements.join(', ')}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Otto.structured_log(:debug, "Role authorization succeeded",
|
|
149
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
150
|
+
required_roles: role_requirements,
|
|
151
|
+
user_roles: user_roles,
|
|
152
|
+
matched_roles: user_roles & role_requirements
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Authentication and authorization succeeded - call wrapped handler
|
|
158
|
+
return wrapped_handler.call(env, extra_params)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Handle authentication failure - continue to next strategy
|
|
162
|
+
if result.is_a?(AuthFailure)
|
|
163
|
+
# Log authentication failure
|
|
164
|
+
Otto.structured_log(:info, "Auth strategy result",
|
|
165
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
166
|
+
strategy: strategy_name,
|
|
167
|
+
success: false,
|
|
168
|
+
failure_reason: result.failure_reason,
|
|
169
|
+
duration: duration,
|
|
170
|
+
remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
|
|
175
|
+
last_failure = result
|
|
176
|
+
end
|
|
177
|
+
end
|
|
83
178
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
179
|
+
# All strategies failed - return 401
|
|
180
|
+
total_duration = Otto::Utils.now_in_μs - total_start_time
|
|
181
|
+
|
|
182
|
+
# Log comprehensive failure
|
|
183
|
+
Otto.structured_log(:warn, "All auth strategies failed",
|
|
184
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
185
|
+
strategies_attempted: failed_strategies.map { |f| f[:strategy] },
|
|
186
|
+
total_duration: total_duration,
|
|
187
|
+
failure_count: failed_strategies.size
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Create anonymous result with comprehensive failure info
|
|
192
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
193
|
+
metadata = {
|
|
194
|
+
ip: env['REMOTE_ADDR'],
|
|
195
|
+
auth_failure: "All authentication strategies failed",
|
|
196
|
+
attempted_strategies: failed_strategies.map { |f| f[:strategy] },
|
|
197
|
+
failure_reasons: failed_strategies.map { |f| f[:reason] }
|
|
198
|
+
}
|
|
199
|
+
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
200
|
+
|
|
201
|
+
# Use 'multi-strategy-failure' only for actual multi-strategy failures
|
|
202
|
+
# For single-strategy failures, use the actual strategy name
|
|
203
|
+
failure_strategy_name = if auth_requirements.size > 1
|
|
204
|
+
'multi-strategy-failure'
|
|
205
|
+
elsif failed_strategies.any?
|
|
206
|
+
failed_strategies.first[:strategy]
|
|
207
|
+
else
|
|
208
|
+
auth_requirements.first
|
|
88
209
|
end
|
|
89
210
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
211
|
+
env['otto.strategy_result'] = StrategyResult.anonymous(
|
|
212
|
+
metadata: metadata,
|
|
213
|
+
strategy_name: failure_strategy_name
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
auth_failure_response(env, last_failure || AuthFailure.new(
|
|
217
|
+
failure_reason: "Authentication required",
|
|
218
|
+
auth_method: failure_strategy_name
|
|
219
|
+
))
|
|
107
220
|
end
|
|
108
221
|
|
|
109
222
|
private
|
|
110
223
|
|
|
111
|
-
# Get strategy from auth_config hash with
|
|
224
|
+
# Get strategy from auth_config hash with pattern matching
|
|
112
225
|
#
|
|
113
226
|
# Supports:
|
|
114
227
|
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
115
|
-
# - Prefix match: '
|
|
116
|
-
# - Fallback: 'role:*' → creates default RoleStrategy
|
|
117
|
-
# - Fallback: 'permission:*' → creates default PermissionStrategy
|
|
228
|
+
# - Prefix match: 'custom:value' → looks up 'custom' strategy
|
|
118
229
|
#
|
|
119
230
|
# Results are cached to avoid repeated lookups for the same requirement.
|
|
120
231
|
#
|
|
232
|
+
# NOTE: Role-based authorization should use route option `role=admin` instead of `auth=role:admin`
|
|
233
|
+
# to properly separate authentication from authorization concerns.
|
|
234
|
+
#
|
|
121
235
|
# @param requirement [String] Auth requirement from route
|
|
122
|
-
# @return [AuthStrategy, nil]
|
|
236
|
+
# @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
|
|
123
237
|
def get_strategy(requirement)
|
|
124
|
-
return nil unless auth_config && auth_config[:auth_strategies]
|
|
238
|
+
return [nil, nil] unless auth_config && auth_config[:auth_strategies]
|
|
125
239
|
|
|
126
|
-
# Check cache first
|
|
240
|
+
# Check cache first (cache stores [strategy, name] tuples)
|
|
127
241
|
return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
|
|
128
242
|
|
|
129
243
|
# Try exact match first - this has highest priority
|
|
130
244
|
strategy = auth_config[:auth_strategies][requirement]
|
|
131
245
|
if strategy
|
|
132
|
-
|
|
133
|
-
|
|
246
|
+
result = [strategy, requirement]
|
|
247
|
+
@strategy_cache[requirement] = result
|
|
248
|
+
return result
|
|
134
249
|
end
|
|
135
250
|
|
|
136
|
-
# For colon-separated requirements like "
|
|
251
|
+
# For colon-separated requirements like "custom:value", try prefix match
|
|
137
252
|
if requirement.include?(':')
|
|
138
253
|
prefix = requirement.split(':', 2).first
|
|
139
254
|
|
|
140
255
|
# Check if we have a strategy registered for the prefix
|
|
141
256
|
prefix_strategy = auth_config[:auth_strategies][prefix]
|
|
142
257
|
if prefix_strategy
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
258
|
+
result = [prefix_strategy, prefix]
|
|
259
|
+
@strategy_cache[requirement] = result
|
|
260
|
+
return result
|
|
162
261
|
end
|
|
163
262
|
end
|
|
164
263
|
|
|
165
264
|
# Cache nil results too to avoid repeated failed lookups
|
|
166
|
-
@strategy_cache[requirement] = nil
|
|
167
|
-
nil
|
|
265
|
+
@strategy_cache[requirement] = [nil, nil]
|
|
266
|
+
[nil, nil]
|
|
168
267
|
end
|
|
169
268
|
|
|
170
269
|
# Generate 401 response for authentication failure
|
|
@@ -246,6 +345,59 @@ class Otto
|
|
|
246
345
|
end
|
|
247
346
|
end
|
|
248
347
|
|
|
348
|
+
# Generate 403 Forbidden response for role authorization failure
|
|
349
|
+
#
|
|
350
|
+
# @param env [Hash] Rack environment
|
|
351
|
+
# @param message [String] Error message
|
|
352
|
+
# @return [Array] Rack response array
|
|
353
|
+
def forbidden_response(env, message)
|
|
354
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
355
|
+
wants_json = accept_header.include?('application/json')
|
|
356
|
+
|
|
357
|
+
if wants_json
|
|
358
|
+
body = { error: 'Forbidden', message: message }.to_json
|
|
359
|
+
headers = {
|
|
360
|
+
'content-type' => 'application/json',
|
|
361
|
+
'content-length' => body.bytesize.to_s
|
|
362
|
+
}
|
|
363
|
+
merge_security_headers!(headers)
|
|
364
|
+
[403, headers, [body]]
|
|
365
|
+
else
|
|
366
|
+
headers = { 'content-type' => 'text/plain' }
|
|
367
|
+
merge_security_headers!(headers)
|
|
368
|
+
[403, headers, [message]]
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Extract user roles from authentication result
|
|
373
|
+
#
|
|
374
|
+
# Supports multiple role sources in order of precedence:
|
|
375
|
+
# 1. result.user_roles (Array)
|
|
376
|
+
# 2. result.user[:roles] (Array)
|
|
377
|
+
# 3. result.user['roles'] (Array)
|
|
378
|
+
# 4. result.metadata[:user_roles] (Array)
|
|
379
|
+
#
|
|
380
|
+
# @param result [StrategyResult] Authentication result
|
|
381
|
+
# @return [Array<String>] Array of role strings
|
|
382
|
+
def extract_user_roles(result)
|
|
383
|
+
# Try direct user_roles accessor (e.g., from RoleStrategy)
|
|
384
|
+
return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
|
|
385
|
+
|
|
386
|
+
# Try user hash/object with roles
|
|
387
|
+
if result.user
|
|
388
|
+
roles = result.user[:roles] || result.user['roles']
|
|
389
|
+
return Array(roles) if roles
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Try metadata
|
|
393
|
+
if result.metadata && result.metadata[:user_roles]
|
|
394
|
+
return Array(result.metadata[:user_roles])
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# No roles found
|
|
398
|
+
[]
|
|
399
|
+
end
|
|
400
|
+
|
|
249
401
|
# Merge security headers into response headers
|
|
250
402
|
#
|
|
251
403
|
# @param headers [Hash] Response headers hash to merge into
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/authentication/strategy_result.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# StrategyResult is an immutable data structure that holds the result of an
|
|
6
6
|
# authentication strategy. It contains session, user, and metadata needed by
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
class Otto
|
|
22
22
|
module Security
|
|
23
23
|
module Authentication
|
|
24
|
-
StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
|
|
24
|
+
StrategyResult = Data.define(:session, :user, :auth_method, :metadata, :strategy_name) do
|
|
25
25
|
# =====================================================================
|
|
26
26
|
# USAGE PATTERNS - READ THIS FIRST
|
|
27
27
|
# =====================================================================
|
|
@@ -110,12 +110,13 @@ class Otto
|
|
|
110
110
|
#
|
|
111
111
|
# @param metadata [Hash] Optional metadata (IP, user agent, etc.)
|
|
112
112
|
# @return [StrategyResult] Anonymous result with nil user
|
|
113
|
-
def self.anonymous(metadata: {})
|
|
113
|
+
def self.anonymous(metadata: {}, strategy_name: 'anonymous')
|
|
114
114
|
new(
|
|
115
115
|
session: {},
|
|
116
116
|
user: nil,
|
|
117
117
|
auth_method: 'anonymous',
|
|
118
|
-
metadata: metadata
|
|
118
|
+
metadata: metadata,
|
|
119
|
+
strategy_name: strategy_name
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|