otto 2.0.0.pre3 → 2.0.0.pre8
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 +143 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +156 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- 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 +26 -26
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +16 -6
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +33 -8
- 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/response_builder.rb +123 -0
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
- data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
- 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 +25 -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,28 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'route_auth_wrapper/strategy_resolver'
|
|
4
|
+
require_relative 'route_auth_wrapper/response_builder'
|
|
5
|
+
require_relative 'route_auth_wrapper/role_authorization'
|
|
4
6
|
|
|
5
7
|
class Otto
|
|
6
8
|
module Security
|
|
7
9
|
module Authentication
|
|
8
|
-
# Wraps route handlers
|
|
10
|
+
# Wraps route handlers with authentication and authorization
|
|
9
11
|
#
|
|
10
|
-
# This
|
|
11
|
-
#
|
|
12
|
-
#
|
|
12
|
+
# This is the main orchestrator that:
|
|
13
|
+
# - Sets anonymous StrategyResult for unauthenticated routes
|
|
14
|
+
# - Enforces authentication for protected routes
|
|
15
|
+
# - Supports multi-strategy with OR logic (first success wins)
|
|
16
|
+
# - Performs Layer 1 (route-level) role authorization
|
|
13
17
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
|
|
20
|
+
# response = wrapper.call(env)
|
|
21
21
|
#
|
|
22
|
-
# @
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# wrapped.call(env, extra_params)
|
|
22
|
+
# @see RouteAuthWrapper::StrategyResolver for strategy lookup
|
|
23
|
+
# @see RouteAuthWrapper::ResponseBuilder for error responses
|
|
24
|
+
# @see RouteAuthWrapper::RoleAuthorization for role checking
|
|
26
25
|
#
|
|
27
26
|
class RouteAuthWrapper
|
|
28
27
|
attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
|
|
@@ -30,229 +29,220 @@ class Otto
|
|
|
30
29
|
def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
|
|
31
30
|
@wrapped_handler = wrapped_handler
|
|
32
31
|
@route_definition = route_definition
|
|
33
|
-
@auth_config = auth_config
|
|
32
|
+
@auth_config = auth_config
|
|
34
33
|
@security_config = security_config
|
|
35
|
-
|
|
34
|
+
|
|
35
|
+
# Initialize extracted components
|
|
36
|
+
@strategy_resolver = RouteAuthWrapperComponents::StrategyResolver.new(auth_config)
|
|
37
|
+
@response_builder = RouteAuthWrapperComponents::ResponseBuilder.new(route_definition, auth_config, security_config)
|
|
38
|
+
@role_authorizer = RouteAuthWrapperComponents::RoleAuthorization.new(route_definition)
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
# Execute authentication then call wrapped handler
|
|
39
42
|
#
|
|
40
|
-
# For routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
41
|
-
# For routes WITH auth requirement: Enforces authentication
|
|
42
|
-
#
|
|
43
43
|
# @param env [Hash] Rack environment
|
|
44
44
|
# @param extra_params [Hash] Additional parameters
|
|
45
45
|
# @return [Array] Rack response array
|
|
46
46
|
def call(env, extra_params = {})
|
|
47
|
-
|
|
47
|
+
auth_requirements = route_definition.auth_requirements
|
|
48
48
|
|
|
49
49
|
# Routes without auth requirement get anonymous StrategyResult
|
|
50
|
-
|
|
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
|
|
50
|
+
return handle_anonymous_route(env, extra_params) if auth_requirements.empty?
|
|
61
51
|
|
|
62
|
-
#
|
|
63
|
-
|
|
52
|
+
# Validate all strategies exist before executing any (fail-fast)
|
|
53
|
+
validation_error = validate_strategies(auth_requirements, env)
|
|
54
|
+
return validation_error if validation_error
|
|
64
55
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
end
|
|
56
|
+
# Try each strategy in order (first success wins)
|
|
57
|
+
authenticate_and_authorize(env, extra_params, auth_requirements)
|
|
58
|
+
end
|
|
69
59
|
|
|
70
|
-
|
|
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
|
|
60
|
+
private
|
|
89
61
|
|
|
90
|
-
|
|
62
|
+
# Handle routes without authentication requirements
|
|
63
|
+
def handle_anonymous_route(env, extra_params)
|
|
64
|
+
metadata = build_anonymous_metadata(env)
|
|
65
|
+
result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
|
|
91
66
|
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
67
|
wrapped_handler.call(env, extra_params)
|
|
107
68
|
end
|
|
108
69
|
|
|
109
|
-
|
|
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.
|
|
70
|
+
# Validate all strategies exist before executing
|
|
120
71
|
#
|
|
121
|
-
# @
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
strategy = auth_config[:auth_strategies][requirement]
|
|
131
|
-
if strategy
|
|
132
|
-
@strategy_cache[requirement] = strategy
|
|
133
|
-
return strategy
|
|
72
|
+
# @return [Array, nil] Error response if validation fails, nil otherwise
|
|
73
|
+
def validate_strategies(auth_requirements, env)
|
|
74
|
+
auth_requirements.each do |requirement|
|
|
75
|
+
strategy, _name = @strategy_resolver.resolve(requirement)
|
|
76
|
+
next if strategy
|
|
77
|
+
|
|
78
|
+
error_msg = "Authentication strategy not configured: '#{requirement}'"
|
|
79
|
+
Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
|
|
80
|
+
return @response_builder.unauthorized(env, error_msg)
|
|
134
81
|
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
135
84
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
85
|
+
# Main authentication and authorization flow
|
|
86
|
+
def authenticate_and_authorize(env, extra_params, auth_requirements)
|
|
87
|
+
failed_strategies = []
|
|
88
|
+
total_start_time = Otto::Utils.now_in_μs
|
|
139
89
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return prefix_strategy
|
|
145
|
-
end
|
|
90
|
+
auth_requirements.each do |requirement|
|
|
91
|
+
strategy, strategy_name = @strategy_resolver.resolve(requirement)
|
|
92
|
+
|
|
93
|
+
log_strategy_start(env, strategy_name, requirement, auth_requirements)
|
|
146
94
|
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
end
|
|
160
|
-
@strategy_cache[requirement] = strategy
|
|
161
|
-
return strategy
|
|
95
|
+
# Execute the strategy
|
|
96
|
+
start_time = Otto::Utils.now_in_μs
|
|
97
|
+
result = strategy.authenticate(env, requirement)
|
|
98
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
99
|
+
|
|
100
|
+
# Inject strategy_name into result
|
|
101
|
+
result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
|
|
102
|
+
|
|
103
|
+
# Handle authentication success
|
|
104
|
+
if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
|
|
105
|
+
return handle_auth_success(env, extra_params, result, strategy_name,
|
|
106
|
+
duration, total_start_time, failed_strategies)
|
|
162
107
|
end
|
|
108
|
+
|
|
109
|
+
# Handle authentication failure - continue to next strategy
|
|
110
|
+
next unless result.is_a?(AuthFailure)
|
|
111
|
+
|
|
112
|
+
log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
|
|
113
|
+
failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
|
|
163
114
|
end
|
|
164
115
|
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
nil
|
|
116
|
+
# All strategies failed
|
|
117
|
+
handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
|
|
168
118
|
end
|
|
169
119
|
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
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')
|
|
120
|
+
# Handle successful authentication
|
|
121
|
+
def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
|
|
122
|
+
total_duration = Otto::Utils.now_in_μs - total_start_time
|
|
179
123
|
|
|
180
|
-
|
|
181
|
-
json_auth_error(result)
|
|
182
|
-
else
|
|
183
|
-
html_auth_error(result)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
124
|
+
log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
|
|
186
125
|
|
|
187
|
-
|
|
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
|
-
}
|
|
126
|
+
# Set environment variables for controllers/logic
|
|
127
|
+
env['otto.strategy_result'] = result
|
|
202
128
|
|
|
203
|
-
#
|
|
204
|
-
|
|
129
|
+
# SESSION PERSISTENCE: Ensure env['rack.session'] and strategy_result.session
|
|
130
|
+
# reference the SAME object for proper session persistence
|
|
131
|
+
env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
|
|
205
132
|
|
|
206
|
-
|
|
207
|
-
|
|
133
|
+
# Layer 1 Authorization: Check role requirements
|
|
134
|
+
auth_check = @role_authorizer.check(result, env)
|
|
135
|
+
unless auth_check == true
|
|
136
|
+
return @response_builder.forbidden(env,
|
|
137
|
+
"Access denied: requires one of roles: #{auth_check[:required].join(', ')}")
|
|
138
|
+
end
|
|
208
139
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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'
|
|
140
|
+
# Authentication and authorization succeeded
|
|
141
|
+
wrapped_handler.call(env, extra_params)
|
|
142
|
+
end
|
|
216
143
|
|
|
217
|
-
|
|
144
|
+
# Handle case when all authentication strategies fail
|
|
145
|
+
def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
|
|
146
|
+
total_duration = Otto::Utils.now_in_μs - total_start_time
|
|
147
|
+
|
|
148
|
+
log_all_failed(env, failed_strategies, total_duration)
|
|
149
|
+
|
|
150
|
+
# Create anonymous result with failure info
|
|
151
|
+
metadata = build_failure_metadata(env, failed_strategies)
|
|
152
|
+
failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
|
|
153
|
+
|
|
154
|
+
env['otto.strategy_result'] = StrategyResult.anonymous(
|
|
155
|
+
metadata: metadata,
|
|
156
|
+
strategy_name: failure_strategy_name
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
last_failure = if failed_strategies.any?
|
|
160
|
+
AuthFailure.new(
|
|
161
|
+
failure_reason: failed_strategies.last[:reason],
|
|
162
|
+
auth_method: failed_strategies.last[:strategy]
|
|
163
|
+
)
|
|
164
|
+
else
|
|
165
|
+
AuthFailure.new(
|
|
166
|
+
failure_reason: 'Authentication required',
|
|
167
|
+
auth_method: auth_requirements.first
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@response_builder.auth_failure(env, last_failure)
|
|
172
|
+
end
|
|
218
173
|
|
|
219
|
-
|
|
220
|
-
|
|
174
|
+
# Build metadata for anonymous routes
|
|
175
|
+
def build_anonymous_metadata(env)
|
|
176
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
177
|
+
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
178
|
+
metadata
|
|
179
|
+
end
|
|
221
180
|
|
|
222
|
-
|
|
181
|
+
# Build metadata for failed authentication
|
|
182
|
+
def build_failure_metadata(env, failed_strategies)
|
|
183
|
+
metadata = {
|
|
184
|
+
ip: env['REMOTE_ADDR'],
|
|
185
|
+
auth_failure: 'All authentication strategies failed',
|
|
186
|
+
attempted_strategies: failed_strategies.map { |f| f[:strategy] },
|
|
187
|
+
failure_reasons: failed_strategies.map { |f| f[:reason] },
|
|
188
|
+
}
|
|
189
|
+
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
190
|
+
metadata
|
|
223
191
|
end
|
|
224
192
|
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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]]
|
|
193
|
+
# Determine strategy name for failure response
|
|
194
|
+
def determine_failure_strategy_name(auth_requirements, failed_strategies)
|
|
195
|
+
if auth_requirements.size > 1
|
|
196
|
+
'multi-strategy-failure'
|
|
197
|
+
elsif failed_strategies.any?
|
|
198
|
+
failed_strategies.first[:strategy]
|
|
242
199
|
else
|
|
243
|
-
|
|
244
|
-
merge_security_headers!(headers)
|
|
245
|
-
[401, headers, [message]]
|
|
200
|
+
auth_requirements.first
|
|
246
201
|
end
|
|
247
202
|
end
|
|
248
203
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
204
|
+
# Logging helpers
|
|
205
|
+
|
|
206
|
+
def log_strategy_start(env, strategy_name, requirement, auth_requirements)
|
|
207
|
+
Otto.structured_log(:debug, 'Auth strategy executing',
|
|
208
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
209
|
+
strategy: strategy_name,
|
|
210
|
+
requirement: requirement,
|
|
211
|
+
strategy_position: auth_requirements.index(requirement) + 1,
|
|
212
|
+
total_strategies: auth_requirements.size
|
|
213
|
+
))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
|
|
217
|
+
Otto.structured_log(:info, 'Auth strategy result',
|
|
218
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
219
|
+
strategy: strategy_name,
|
|
220
|
+
success: true,
|
|
221
|
+
user_id: result.user_id,
|
|
222
|
+
duration: duration,
|
|
223
|
+
total_duration: total_duration,
|
|
224
|
+
strategies_attempted: failed_strategies.size + 1
|
|
225
|
+
))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
|
|
229
|
+
Otto.structured_log(:info, 'Auth strategy result',
|
|
230
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
231
|
+
strategy: strategy_name,
|
|
232
|
+
success: false,
|
|
233
|
+
failure_reason: result.failure_reason,
|
|
234
|
+
duration: duration,
|
|
235
|
+
remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
|
|
236
|
+
))
|
|
237
|
+
end
|
|
254
238
|
|
|
255
|
-
|
|
239
|
+
def log_all_failed(env, failed_strategies, total_duration)
|
|
240
|
+
Otto.structured_log(:warn, 'All auth strategies failed',
|
|
241
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
242
|
+
strategies_attempted: failed_strategies.map { |f| f[:strategy] },
|
|
243
|
+
total_duration: total_duration,
|
|
244
|
+
failure_count: failed_strategies.size
|
|
245
|
+
))
|
|
256
246
|
end
|
|
257
247
|
end
|
|
258
248
|
end
|
|
@@ -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
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lib/otto/security/authorization_error.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
# Authorization error for resource-level access control failures
|
|
8
|
+
#
|
|
9
|
+
# This exception is designed to be raised from Logic classes when a user
|
|
10
|
+
# attempts to access a resource they don't have permission to access.
|
|
11
|
+
#
|
|
12
|
+
# Otto automatically registers this as a 403 Forbidden error during
|
|
13
|
+
# initialization, so raising this exception will return a 403 response
|
|
14
|
+
# instead of a 500 error.
|
|
15
|
+
#
|
|
16
|
+
# Two-Layer Authorization Pattern:
|
|
17
|
+
# - Layer 1 (Route-level): RouteAuthWrapper checks authentication/basic roles
|
|
18
|
+
# - Layer 2 (Resource-level): Logic classes raise AuthorizationError for ownership/permissions
|
|
19
|
+
#
|
|
20
|
+
# @example Ownership check in Logic class
|
|
21
|
+
# class PostEditLogic
|
|
22
|
+
# def raise_concerns
|
|
23
|
+
# @post = Post.find(params[:id])
|
|
24
|
+
#
|
|
25
|
+
# unless @post.user_id == @context.user_id
|
|
26
|
+
# raise Otto::Security::AuthorizationError, "Cannot edit another user's post"
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @example Multi-condition authorization
|
|
32
|
+
# class OrganizationDeleteLogic
|
|
33
|
+
# def raise_concerns
|
|
34
|
+
# @org = Organization.find(params[:id])
|
|
35
|
+
#
|
|
36
|
+
# unless @context.user_roles.include?('admin') || @org.owner_id == @context.user_id
|
|
37
|
+
# raise Otto::Security::AuthorizationError,
|
|
38
|
+
# "Requires admin role or organization ownership"
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
class AuthorizationError < StandardError
|
|
44
|
+
# Optional additional context for logging/debugging
|
|
45
|
+
attr_reader :resource, :action, :user_id
|
|
46
|
+
|
|
47
|
+
# Initialize authorization error with optional context
|
|
48
|
+
#
|
|
49
|
+
# @param message [String] Human-readable error message
|
|
50
|
+
# @param resource [String, nil] Resource type being accessed (e.g., 'Post', 'Organization')
|
|
51
|
+
# @param action [String, nil] Action being attempted (e.g., 'edit', 'delete')
|
|
52
|
+
# @param user_id [String, Integer, nil] ID of user attempting access
|
|
53
|
+
def initialize(message = 'Access denied', resource: nil, action: nil, user_id: nil)
|
|
54
|
+
super(message)
|
|
55
|
+
@resource = resource
|
|
56
|
+
@action = action
|
|
57
|
+
@user_id = user_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Generate structured log data for authorization failures
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] Hash suitable for structured logging
|
|
63
|
+
def to_log_data
|
|
64
|
+
{
|
|
65
|
+
error: message,
|
|
66
|
+
resource: resource,
|
|
67
|
+
action: action,
|
|
68
|
+
user_id: user_id,
|
|
69
|
+
}.compact
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/configurator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative 'middleware/csrf_middleware'
|
|
6
6
|
require_relative 'middleware/validation_middleware'
|
|
@@ -170,9 +170,24 @@ class Otto
|
|
|
170
170
|
|
|
171
171
|
# Add a single authentication strategy
|
|
172
172
|
#
|
|
173
|
+
# Part of the Security::Configurator facade for consolidated configuration.
|
|
174
|
+
# This delegates to the same storage as Otto#add_auth_strategy, allowing
|
|
175
|
+
# authentication to be configured alongside other security features.
|
|
176
|
+
#
|
|
177
|
+
# Prefer using Otto#add_auth_strategy directly for simpler cases, or use this
|
|
178
|
+
# when configuring multiple security features together via the security facade.
|
|
179
|
+
#
|
|
173
180
|
# @param name [String] Strategy name
|
|
174
181
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
182
|
+
# @example
|
|
183
|
+
# otto.security.add_auth_strategy('session', SessionStrategy.new)
|
|
184
|
+
# @raise [ArgumentError] if strategy name already registered
|
|
175
185
|
def add_auth_strategy(name, strategy)
|
|
186
|
+
# Strict mode: Detect strategy name collisions
|
|
187
|
+
if @auth_config[:auth_strategies].key?(name)
|
|
188
|
+
raise ArgumentError, "Authentication strategy '#{name}' is already registered"
|
|
189
|
+
end
|
|
190
|
+
|
|
176
191
|
@auth_config[:auth_strategies][name] = strategy
|
|
177
192
|
end
|
|
178
193
|
|