otto 2.0.0.pre2 → 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 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- 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 +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- 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/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- 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 +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- 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 +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -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 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- 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 +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- 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 +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- 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 +231 -0
- 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 +12 -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 +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -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
|
#
|
|
@@ -25,62 +25,251 @@ class Otto
|
|
|
25
25
|
# wrapped.call(env, extra_params)
|
|
26
26
|
#
|
|
27
27
|
class RouteAuthWrapper
|
|
28
|
-
attr_reader :wrapped_handler, :route_definition, :auth_config
|
|
28
|
+
attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
|
|
29
29
|
|
|
30
|
-
def initialize(wrapped_handler, route_definition, auth_config)
|
|
30
|
+
def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
|
|
31
31
|
@wrapped_handler = wrapped_handler
|
|
32
32
|
@route_definition = route_definition
|
|
33
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
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
# Execute authentication then call wrapped handler
|
|
37
39
|
#
|
|
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
|
+
#
|
|
38
44
|
# @param env [Hash] Rack environment
|
|
39
45
|
# @param extra_params [Hash] Additional parameters
|
|
40
46
|
# @return [Array] Rack response array
|
|
41
47
|
def call(env, extra_params = {})
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
auth_requirements = route_definition.auth_requirements
|
|
49
|
+
|
|
50
|
+
# 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']
|
|
45
55
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
56
|
+
result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
|
|
57
|
+
env['otto.strategy_result'] = result
|
|
58
|
+
return wrapped_handler.call(env, extra_params)
|
|
49
59
|
end
|
|
50
60
|
|
|
51
|
-
#
|
|
52
|
-
|
|
61
|
+
# Routes WITH auth requirements: Try each strategy in order (first success wins)
|
|
62
|
+
|
|
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
|
|
71
|
+
end
|
|
72
|
+
|
|
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
|
|
99
|
+
|
|
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)
|
|
53
133
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
)
|
|
58
143
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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']
|
|
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
|
|
62
209
|
end
|
|
63
210
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
))
|
|
66
220
|
end
|
|
67
221
|
|
|
68
222
|
private
|
|
69
223
|
|
|
70
|
-
# Get strategy from auth_config hash
|
|
224
|
+
# Get strategy from auth_config hash with pattern matching
|
|
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.
|
|
71
234
|
#
|
|
72
235
|
# @param requirement [String] Auth requirement from route
|
|
73
|
-
# @return [AuthStrategy, nil]
|
|
236
|
+
# @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
|
|
74
237
|
def get_strategy(requirement)
|
|
75
|
-
return nil unless auth_config && auth_config[:auth_strategies]
|
|
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
|
|
250
|
+
|
|
251
|
+
# For colon-separated requirements like "custom:value", try prefix match
|
|
252
|
+
if requirement.include?(':')
|
|
253
|
+
prefix = requirement.split(':', 2).first
|
|
76
254
|
|
|
77
|
-
|
|
255
|
+
# Check if we have a strategy registered for the prefix
|
|
256
|
+
prefix_strategy = auth_config[:auth_strategies][prefix]
|
|
257
|
+
if prefix_strategy
|
|
258
|
+
result = [prefix_strategy, prefix]
|
|
259
|
+
@strategy_cache[requirement] = result
|
|
260
|
+
return result
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Cache nil results too to avoid repeated failed lookups
|
|
265
|
+
@strategy_cache[requirement] = [nil, nil]
|
|
266
|
+
[nil, nil]
|
|
78
267
|
end
|
|
79
268
|
|
|
80
269
|
# Generate 401 response for authentication failure
|
|
81
270
|
#
|
|
82
271
|
# @param env [Hash] Rack environment
|
|
83
|
-
# @param result [
|
|
272
|
+
# @param result [AuthFailure] Failure result from strategy
|
|
84
273
|
# @return [Array] Rack response array
|
|
85
274
|
def auth_failure_response(env, result)
|
|
86
275
|
# Check if request wants JSON
|
|
@@ -96,7 +285,7 @@ class Otto
|
|
|
96
285
|
|
|
97
286
|
# Generate JSON 401 response
|
|
98
287
|
#
|
|
99
|
-
# @param result [
|
|
288
|
+
# @param result [AuthFailure] Failure result
|
|
100
289
|
# @return [Array] Rack response array
|
|
101
290
|
def json_auth_error(result)
|
|
102
291
|
body = {
|
|
@@ -105,26 +294,31 @@ class Otto
|
|
|
105
294
|
timestamp: Time.now.to_i
|
|
106
295
|
}.to_json
|
|
107
296
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
297
|
+
headers = {
|
|
298
|
+
'content-type' => 'application/json',
|
|
299
|
+
'content-length' => body.bytesize.to_s
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Add security headers if available
|
|
303
|
+
merge_security_headers!(headers)
|
|
304
|
+
|
|
305
|
+
[401, headers, [body]]
|
|
113
306
|
end
|
|
114
307
|
|
|
115
308
|
# Generate HTML 401 response or redirect
|
|
116
309
|
#
|
|
117
|
-
# @param result [
|
|
310
|
+
# @param result [AuthFailure] Failure result
|
|
118
311
|
# @return [Array] Rack response array
|
|
119
312
|
def html_auth_error(result)
|
|
120
313
|
# For HTML requests, redirect to login
|
|
121
314
|
login_path = auth_config[:login_path] || '/signin'
|
|
122
315
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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}"]]
|
|
128
322
|
end
|
|
129
323
|
|
|
130
324
|
# Generate generic unauthorized response
|
|
@@ -138,11 +332,80 @@ class Otto
|
|
|
138
332
|
|
|
139
333
|
if wants_json
|
|
140
334
|
body = { error: message }.to_json
|
|
141
|
-
|
|
335
|
+
headers = {
|
|
336
|
+
'content-type' => 'application/json',
|
|
337
|
+
'content-length' => body.bytesize.to_s
|
|
338
|
+
}
|
|
339
|
+
merge_security_headers!(headers)
|
|
340
|
+
[401, headers, [body]]
|
|
142
341
|
else
|
|
143
|
-
|
|
342
|
+
headers = { 'content-type' => 'text/plain' }
|
|
343
|
+
merge_security_headers!(headers)
|
|
344
|
+
[401, headers, [message]]
|
|
144
345
|
end
|
|
145
346
|
end
|
|
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
|
+
|
|
401
|
+
# Merge security headers into response headers
|
|
402
|
+
#
|
|
403
|
+
# @param headers [Hash] Response headers hash to merge into
|
|
404
|
+
def merge_security_headers!(headers)
|
|
405
|
+
return unless security_config
|
|
406
|
+
|
|
407
|
+
headers.merge!(security_config.security_headers)
|
|
408
|
+
end
|
|
146
409
|
end
|
|
147
410
|
end
|
|
148
411
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/authentication/strategies/noauth_strategy.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../auth_strategy'
|
|
@@ -10,7 +12,11 @@ class Otto
|
|
|
10
12
|
# Public access strategy - always allows access
|
|
11
13
|
class NoAuthStrategy < AuthStrategy
|
|
12
14
|
def authenticate(env, _requirement)
|
|
13
|
-
|
|
15
|
+
# Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
|
|
16
|
+
metadata = { ip: env['REMOTE_ADDR'] }
|
|
17
|
+
metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
|
|
18
|
+
|
|
19
|
+
Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
|
|
14
20
|
end
|
|
15
21
|
end
|
|
16
22
|
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
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/security/authentication.rb
|
|
4
2
|
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
5
|
# Index file for Otto authentication module
|
|
6
6
|
# Requires all authentication-related components for backward compatibility
|
|
7
7
|
|
|
8
8
|
require_relative 'authentication/auth_strategy'
|
|
9
9
|
require_relative 'authentication/strategy_result'
|
|
10
|
-
require_relative 'authentication/
|
|
11
|
-
require_relative 'authentication/
|
|
10
|
+
require_relative 'authentication/auth_failure'
|
|
11
|
+
require_relative 'authentication/route_auth_wrapper'
|
|
12
12
|
|
|
13
13
|
# Load all strategies
|
|
14
14
|
require_relative 'authentication/strategies/noauth_strategy'
|
|
@@ -26,10 +26,9 @@ class Otto
|
|
|
26
26
|
RoleStrategy = Authentication::Strategies::RoleStrategy
|
|
27
27
|
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
|
28
28
|
PermissionStrategy = Authentication::Strategies::PermissionStrategy
|
|
29
|
-
AuthenticationMiddleware = Authentication::AuthenticationMiddleware
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Top-level backward compatibility aliases
|
|
33
32
|
StrategyResult = Security::Authentication::StrategyResult
|
|
34
|
-
|
|
33
|
+
AuthFailure = Security::Authentication::AuthFailure
|
|
35
34
|
end
|
|
@@ -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
|