otto 2.0.0.pre7 → 2.0.0.pre9
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/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +5 -8
- data/CHANGELOG.rst +87 -2
- data/Gemfile.lock +6 -6
- data/README.md +20 -0
- data/docs/.gitignore +2 -0
- data/docs/modern-authentication-authorization-landscape.md +558 -0
- data/docs/multi-strategy-authentication-design.md +1401 -0
- data/lib/otto/core/error_handler.rb +19 -8
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +1 -1
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route_handlers/base.rb +86 -1
- data/lib/otto/route_handlers/class_method.rb +17 -68
- data/lib/otto/route_handlers/instance_method.rb +18 -58
- data/lib/otto/route_handlers/logic_class.rb +95 -92
- data/lib/otto/security/authentication/auth_strategy.rb +2 -2
- 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 +167 -329
- data/lib/otto/security/authentication/strategy_result.rb +9 -9
- data/lib/otto/security/authorization_error.rb +1 -1
- data/lib/otto/security/config.rb +3 -3
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +47 -3
- metadata +7 -3
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +0 -15
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +0 -37
|
@@ -1,28 +1,27 @@
|
|
|
1
|
-
# lib/otto/security/authentication/route_auth_wrapper.rb
|
|
2
|
-
#
|
|
3
1
|
# frozen_string_literal: true
|
|
4
2
|
|
|
3
|
+
require_relative 'route_auth_wrapper/strategy_resolver'
|
|
4
|
+
require_relative 'route_auth_wrapper/response_builder'
|
|
5
|
+
require_relative 'route_auth_wrapper/role_authorization'
|
|
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']
|
|
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,17 +29,17 @@ 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
|
-
# Supports multi-strategy with OR logic: auth=session,apikey,oauth
|
|
43
|
-
#
|
|
44
43
|
# @param env [Hash] Rack environment
|
|
45
44
|
# @param extra_params [Hash] Additional parameters
|
|
46
45
|
# @return [Array] Rack response array
|
|
@@ -48,363 +47,202 @@ class Otto
|
|
|
48
47
|
auth_requirements = route_definition.auth_requirements
|
|
49
48
|
|
|
50
49
|
# Routes without auth requirement get anonymous StrategyResult
|
|
51
|
-
if auth_requirements.empty?
|
|
52
|
-
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
53
|
-
metadata = { ip: env['REMOTE_ADDR'] }
|
|
54
|
-
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
55
|
-
|
|
56
|
-
result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
|
|
57
|
-
env['otto.strategy_result'] = result
|
|
58
|
-
return wrapped_handler.call(env, extra_params)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Routes WITH auth requirements: Try each strategy in order (first success wins)
|
|
50
|
+
return handle_anonymous_route(env, extra_params) if auth_requirements.empty?
|
|
62
51
|
|
|
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
|
|
55
|
+
|
|
56
|
+
# Try each strategy in order (first success wins)
|
|
57
|
+
authenticate_and_authorize(env, extra_params, auth_requirements)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
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')
|
|
66
|
+
env['otto.strategy_result'] = result
|
|
67
|
+
wrapped_handler.call(env, extra_params)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Validate all strategies exist before executing
|
|
71
|
+
#
|
|
72
|
+
# @return [Array, nil] Error response if validation fails, nil otherwise
|
|
73
|
+
def validate_strategies(auth_requirements, env)
|
|
64
74
|
auth_requirements.each do |requirement|
|
|
65
|
-
strategy,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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)
|
|
71
81
|
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
72
84
|
|
|
73
|
-
|
|
85
|
+
# Main authentication and authorization flow
|
|
86
|
+
def authenticate_and_authorize(env, extra_params, auth_requirements)
|
|
74
87
|
failed_strategies = []
|
|
75
88
|
total_start_time = Otto::Utils.now_in_μs
|
|
76
89
|
|
|
77
90
|
auth_requirements.each do |requirement|
|
|
78
|
-
strategy, strategy_name =
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
)
|
|
91
|
+
strategy, strategy_name = @strategy_resolver.resolve(requirement)
|
|
92
|
+
|
|
93
|
+
log_strategy_start(env, strategy_name, requirement, auth_requirements)
|
|
89
94
|
|
|
90
95
|
# Execute the strategy
|
|
91
96
|
start_time = Otto::Utils.now_in_μs
|
|
92
97
|
result = strategy.authenticate(env, requirement)
|
|
93
98
|
duration = Otto::Utils.now_in_μs - start_time
|
|
94
99
|
|
|
95
|
-
# Inject strategy_name into result
|
|
96
|
-
if result.is_a?(StrategyResult)
|
|
97
|
-
result = result.with(strategy_name: strategy_name)
|
|
98
|
-
end
|
|
100
|
+
# Inject strategy_name into result
|
|
101
|
+
result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
|
|
99
102
|
|
|
100
|
-
# Handle authentication success
|
|
103
|
+
# Handle authentication success
|
|
101
104
|
if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
|
|
102
|
-
|
|
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)
|
|
105
|
+
return handle_auth_success(env, extra_params, result, strategy_name,
|
|
106
|
+
duration, total_start_time, failed_strategies)
|
|
159
107
|
end
|
|
160
108
|
|
|
161
109
|
# Handle authentication failure - continue to next strategy
|
|
162
|
-
|
|
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
|
|
178
|
-
|
|
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']
|
|
110
|
+
next unless result.is_a?(AuthFailure)
|
|
200
111
|
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
112
|
+
log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
|
|
113
|
+
failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
|
|
209
114
|
end
|
|
210
115
|
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
))
|
|
116
|
+
# All strategies failed
|
|
117
|
+
handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
|
|
220
118
|
end
|
|
221
119
|
|
|
222
|
-
|
|
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
|
|
223
123
|
|
|
224
|
-
|
|
225
|
-
#
|
|
226
|
-
# Supports:
|
|
227
|
-
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
228
|
-
# - Prefix match: 'custom:value' → looks up 'custom' strategy
|
|
229
|
-
#
|
|
230
|
-
# Results are cached to avoid repeated lookups for the same requirement.
|
|
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
|
-
#
|
|
235
|
-
# @param requirement [String] Auth requirement from route
|
|
236
|
-
# @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
|
|
237
|
-
def get_strategy(requirement)
|
|
238
|
-
return [nil, nil] unless auth_config && auth_config[:auth_strategies]
|
|
239
|
-
|
|
240
|
-
# Check cache first (cache stores [strategy, name] tuples)
|
|
241
|
-
return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
|
|
242
|
-
|
|
243
|
-
# Try exact match first - this has highest priority
|
|
244
|
-
strategy = auth_config[:auth_strategies][requirement]
|
|
245
|
-
if strategy
|
|
246
|
-
result = [strategy, requirement]
|
|
247
|
-
@strategy_cache[requirement] = result
|
|
248
|
-
return result
|
|
249
|
-
end
|
|
124
|
+
log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
|
|
250
125
|
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
prefix = requirement.split(':', 2).first
|
|
126
|
+
# Set environment variables for controllers/logic
|
|
127
|
+
env['otto.strategy_result'] = result
|
|
254
128
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
132
|
+
|
|
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(', ')}")
|
|
262
138
|
end
|
|
263
139
|
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
[nil, nil]
|
|
140
|
+
# Authentication and authorization succeeded
|
|
141
|
+
wrapped_handler.call(env, extra_params)
|
|
267
142
|
end
|
|
268
143
|
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
# @param result [AuthFailure] Failure result from strategy
|
|
273
|
-
# @return [Array] Rack response array
|
|
274
|
-
def auth_failure_response(env, result)
|
|
275
|
-
# Check if request wants JSON
|
|
276
|
-
accept_header = env['HTTP_ACCEPT'] || ''
|
|
277
|
-
wants_json = accept_header.include?('application/json')
|
|
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
|
|
278
147
|
|
|
279
|
-
|
|
280
|
-
json_auth_error(result)
|
|
281
|
-
else
|
|
282
|
-
html_auth_error(result)
|
|
283
|
-
end
|
|
284
|
-
end
|
|
148
|
+
log_all_failed(env, failed_strategies, total_duration)
|
|
285
149
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
# @return [Array] Rack response array
|
|
290
|
-
def json_auth_error(result)
|
|
291
|
-
body = {
|
|
292
|
-
error: 'Authentication Required',
|
|
293
|
-
message: result.failure_reason || 'Not authenticated',
|
|
294
|
-
timestamp: Time.now.to_i
|
|
295
|
-
}.to_json
|
|
296
|
-
|
|
297
|
-
headers = {
|
|
298
|
-
'content-type' => 'application/json',
|
|
299
|
-
'content-length' => body.bytesize.to_s
|
|
300
|
-
}
|
|
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)
|
|
301
153
|
|
|
302
|
-
|
|
303
|
-
|
|
154
|
+
env['otto.strategy_result'] = StrategyResult.anonymous(
|
|
155
|
+
metadata: metadata,
|
|
156
|
+
strategy_name: failure_strategy_name
|
|
157
|
+
)
|
|
304
158
|
|
|
305
|
-
|
|
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)
|
|
306
172
|
end
|
|
307
173
|
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
# For HTML requests, redirect to login
|
|
314
|
-
login_path = auth_config[:login_path] || '/signin'
|
|
315
|
-
|
|
316
|
-
headers = { 'location' => login_path }
|
|
317
|
-
|
|
318
|
-
# Add security headers if available
|
|
319
|
-
merge_security_headers!(headers)
|
|
320
|
-
|
|
321
|
-
[302, headers, ["Redirecting to #{login_path}"]]
|
|
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
|
|
322
179
|
end
|
|
323
180
|
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
body = { error: message }.to_json
|
|
335
|
-
headers = {
|
|
336
|
-
'content-type' => 'application/json',
|
|
337
|
-
'content-length' => body.bytesize.to_s
|
|
338
|
-
}
|
|
339
|
-
merge_security_headers!(headers)
|
|
340
|
-
[401, headers, [body]]
|
|
341
|
-
else
|
|
342
|
-
headers = { 'content-type' => 'text/plain' }
|
|
343
|
-
merge_security_headers!(headers)
|
|
344
|
-
[401, headers, [message]]
|
|
345
|
-
end
|
|
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
|
|
346
191
|
end
|
|
347
192
|
|
|
348
|
-
#
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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]]
|
|
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]
|
|
365
199
|
else
|
|
366
|
-
|
|
367
|
-
merge_security_headers!(headers)
|
|
368
|
-
[403, headers, [message]]
|
|
200
|
+
auth_requirements.first
|
|
369
201
|
end
|
|
370
202
|
end
|
|
371
203
|
|
|
372
|
-
#
|
|
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
|
|
204
|
+
# Logging helpers
|
|
391
205
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
396
215
|
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
))
|
|
399
226
|
end
|
|
400
227
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
406
238
|
|
|
407
|
-
|
|
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
|
+
))
|
|
408
246
|
end
|
|
409
247
|
end
|
|
410
248
|
end
|
|
@@ -320,16 +320,16 @@ class Otto
|
|
|
320
320
|
# @return [Hash] Hash representation of the context
|
|
321
321
|
def to_h
|
|
322
322
|
{
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
323
|
+
session: session,
|
|
324
|
+
user: user,
|
|
325
|
+
auth_method: auth_method,
|
|
326
|
+
metadata: metadata,
|
|
327
|
+
authenticated: authenticated?,
|
|
328
328
|
auth_attempt_succeeded: auth_attempt_succeeded?,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
user_id: user_id,
|
|
330
|
+
user_name: user_name,
|
|
331
|
+
roles: roles,
|
|
332
|
+
permissions: permissions,
|
|
333
333
|
}
|
|
334
334
|
end
|
|
335
335
|
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -436,12 +436,12 @@ class Otto
|
|
|
436
436
|
end
|
|
437
437
|
|
|
438
438
|
# Raised when a request exceeds the configured size limit
|
|
439
|
-
class RequestTooLargeError <
|
|
439
|
+
class RequestTooLargeError < Otto::PayloadTooLargeError; end
|
|
440
440
|
|
|
441
441
|
# Raised when CSRF token validation fails
|
|
442
|
-
class CSRFError <
|
|
442
|
+
class CSRFError < Otto::ForbiddenError; end
|
|
443
443
|
|
|
444
444
|
# Raised when input validation fails (XSS, SQL injection, etc.)
|
|
445
|
-
class ValidationError <
|
|
445
|
+
class ValidationError < Otto::BadRequestError; end
|
|
446
446
|
end
|
|
447
447
|
end
|
|
@@ -55,9 +55,13 @@ class Otto
|
|
|
55
55
|
'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
# Content negotiation for rate limit response
|
|
59
|
+
# Route's response_type takes precedence over Accept header
|
|
60
|
+
route_def = request.env['otto.route_definition']
|
|
61
|
+
wants_json = (route_def&.response_type == 'json') ||
|
|
62
|
+
request.env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
63
|
+
|
|
64
|
+
if wants_json
|
|
61
65
|
error_response = {
|
|
62
66
|
error: 'Rate limit exceeded',
|
|
63
67
|
message: 'Too many requests',
|
data/lib/otto/version.rb
CHANGED