otto 2.0.0.pre7 → 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/code-smells.yml +3 -6
- data/CHANGELOG.rst +68 -2
- data/Gemfile.lock +1 -1
- data/lib/otto/route_handlers/class_method.rb +8 -1
- data/lib/otto/route_handlers/instance_method.rb +8 -1
- data/lib/otto/route_handlers/logic_class.rb +10 -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/version.rb +1 -1
- metadata +4 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b094c2fc179b84631bf53b1e4b7f3cbcd661f83e1f8a88ab6c1f22d3e8b11cbd
|
|
4
|
+
data.tar.gz: 70a7588c86b8b3f31968b577a298f05c09cad5ef5d9fcf028f0feece2963e8fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9e8f2f46cf51bc0799dd6030e52a48c1665b9978efb8bc686977be722e61b3c553f17db91d9fd795447b5b4882ac95af404a71e14f9b65d3935a992ecb768f1
|
|
7
|
+
data.tar.gz: d0c6f98edf2f468297dcb19bb9743b1a2858f2497cf40d2fc4332550a883542f2b7e1a1de8ac4b9f9d352b40c53e974d6c99aecee2679abc6b38868c02ae5bd6
|
|
@@ -27,15 +27,12 @@ jobs:
|
|
|
27
27
|
uses: ruby/setup-ruby@v1
|
|
28
28
|
with:
|
|
29
29
|
ruby-version: 3.4
|
|
30
|
-
bundler-cache: true
|
|
31
30
|
|
|
32
|
-
- name:
|
|
31
|
+
- name: Install dependencies with optional groups
|
|
33
32
|
run: |
|
|
34
33
|
bundle config set --local path 'vendor/bundle'
|
|
35
|
-
bundle config set --local
|
|
36
|
-
|
|
37
|
-
- name: Install dependencies
|
|
38
|
-
run: bundle install --jobs 4 --retry 3
|
|
34
|
+
bundle config set --local with 'development test'
|
|
35
|
+
bundle install --jobs 4 --retry 3
|
|
39
36
|
|
|
40
37
|
- name: Run Reek analysis
|
|
41
38
|
run: |
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,10 +7,76 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
|
|
|
7
7
|
|
|
8
8
|
<!--scriv-insert-here-->
|
|
9
9
|
|
|
10
|
+
.. _changelog-2.0.0.pre8:
|
|
11
|
+
|
|
12
|
+
2.0.0.pre8 — 2025-11-27
|
|
13
|
+
=======================
|
|
14
|
+
|
|
15
|
+
Fixed
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Routes declaring ``response=json`` now return 401 JSON errors instead of 302 redirects when authentication fails, regardless of Accept header. The route's explicit configuration takes precedence over content negotiation.
|
|
19
|
+
|
|
20
|
+
.. _changelog-2.0.0.pre7:
|
|
21
|
+
|
|
22
|
+
2.0.0.pre7 — 2025-11-24
|
|
23
|
+
=======================
|
|
24
|
+
|
|
25
|
+
Added
|
|
26
|
+
-----
|
|
27
|
+
|
|
28
|
+
- Error handler registration system for expected business logic errors. Register handlers with ``otto.register_error_handler(ErrorClass, status: 404, log_level: :info)`` to return proper HTTP status codes and avoid logging expected errors as 500s with backtraces. Supports custom response handlers via blocks for complete control over error responses.
|
|
29
|
+
|
|
30
|
+
Changed
|
|
31
|
+
-------
|
|
32
|
+
|
|
33
|
+
- Backtrace logging now always logs at ERROR level (was DEBUG) with sanitized file paths for security. Backtraces for unhandled 500 errors are always logged regardless of ``OTTO_DEBUG`` setting, with paths sanitized to prevent exposing system information (project files show relative paths, gems show ``[GEM] name-version/path``, Ruby stdlib shows ``[RUBY] filename``).
|
|
34
|
+
- Increased backtrace limit from 10 to 20 lines for critical errors to provide better debugging context.
|
|
35
|
+
|
|
36
|
+
AI Assistance
|
|
37
|
+
-------------
|
|
38
|
+
|
|
39
|
+
- Implemented error handler registration architecture with comprehensive test coverage (17 test cases) using sequential thinking to work through security implications and design decisions. AI assisted with path sanitization strategy, error classification patterns, and ensuring backward compatibility with existing error handling.
|
|
40
|
+
|
|
41
|
+
Improved backtrace sanitization security and readability
|
|
42
|
+
--------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
**Security Enhancements:**
|
|
45
|
+
|
|
46
|
+
- Fixed bundler gem path detection to correctly sanitize git-based gems
|
|
47
|
+
- Now properly handles nested gem paths like ``/gems/3.4.0/bundler/gems/otto-abc123/``
|
|
48
|
+
- Strips git hash suffixes from bundler gems (``otto-abc123def456`` → ``otto``)
|
|
49
|
+
- Removes version numbers from regular gems (``rack-3.2.4`` → ``rack``)
|
|
50
|
+
- Prevents exposure of absolute paths, usernames, and project names in logs
|
|
51
|
+
|
|
52
|
+
**Improvements:**
|
|
53
|
+
|
|
54
|
+
- Bundler gems now show as ``[GEM] otto/lib/otto/route.rb:142`` instead of ``[GEM] 3.4.0/bundler/gems/...``
|
|
55
|
+
- Regular gems show cleaner output: ``[GEM] rack/lib/rack.rb:20`` instead of ``[GEM] rack-3.2.4/lib/rack.rb:20``
|
|
56
|
+
- Multi-hyphenated gem names handled correctly (``active-record-import-1.5.0`` → ``active-record-import``)
|
|
57
|
+
- Better handling of version-only directory names in gem paths
|
|
58
|
+
|
|
59
|
+
**Documentation:**
|
|
60
|
+
|
|
61
|
+
- Added comprehensive backtrace sanitization section to CLAUDE.md
|
|
62
|
+
- Documented security guarantees and sanitization rules
|
|
63
|
+
- Added examples showing before/after path transformations
|
|
64
|
+
- Created comprehensive test suite for backtrace sanitization
|
|
65
|
+
|
|
66
|
+
**Rationale:**
|
|
67
|
+
|
|
68
|
+
Raw backtraces expose sensitive information:
|
|
69
|
+
- Usernames (``/Users/alice/``, ``/home/admin/``)
|
|
70
|
+
- Project structure and internal organization
|
|
71
|
+
- Gem installation paths and Ruby versions
|
|
72
|
+
- System architecture details
|
|
73
|
+
|
|
74
|
+
This improvement ensures all backtraces are sanitized automatically, preventing accidental leakage of sensitive system information while maintaining readability for debugging.
|
|
75
|
+
|
|
10
76
|
.. _changelog-2.0.0.pre6:
|
|
11
77
|
|
|
12
|
-
2.0.0.pre6
|
|
13
|
-
|
|
78
|
+
2.0.0.pre6
|
|
79
|
+
==========
|
|
14
80
|
|
|
15
81
|
Changed
|
|
16
82
|
-------
|
data/Gemfile.lock
CHANGED
|
@@ -10,7 +10,14 @@ require_relative 'base'
|
|
|
10
10
|
class Otto
|
|
11
11
|
module RouteHandlers
|
|
12
12
|
# Handler for class methods (existing Otto pattern)
|
|
13
|
-
#
|
|
13
|
+
# Route syntax: Controller.action
|
|
14
|
+
#
|
|
15
|
+
# Class methods receive full Rack request/response access:
|
|
16
|
+
# - Method signature: def self.action(request, response)
|
|
17
|
+
# - Direct access to sessions, cookies, headers, and the raw env
|
|
18
|
+
#
|
|
19
|
+
# Use this handler for endpoints requiring request-level control (logout,
|
|
20
|
+
# session management, cookie manipulation, custom header handling).
|
|
14
21
|
class ClassMethodHandler < BaseHandler
|
|
15
22
|
def call(env, extra_params = {})
|
|
16
23
|
start_time = Otto::Utils.now_in_μs
|
|
@@ -8,7 +8,14 @@ require_relative 'base'
|
|
|
8
8
|
class Otto
|
|
9
9
|
module RouteHandlers
|
|
10
10
|
# Handler for instance methods (existing Otto pattern)
|
|
11
|
-
#
|
|
11
|
+
# Route syntax: Controller#action
|
|
12
|
+
#
|
|
13
|
+
# Controller instances receive full Rack request/response access:
|
|
14
|
+
# - initialize(request, response) with Rack::Request and Rack::Response
|
|
15
|
+
# - Direct access to sessions, cookies, headers, and the raw env
|
|
16
|
+
#
|
|
17
|
+
# Use this handler for endpoints requiring request-level control (logout,
|
|
18
|
+
# session management, cookie manipulation, custom header handling).
|
|
12
19
|
class InstanceMethodHandler < BaseHandler
|
|
13
20
|
def call(env, extra_params = {})
|
|
14
21
|
start_time = Otto::Utils.now_in_μs
|
|
@@ -9,8 +9,16 @@ require_relative 'base'
|
|
|
9
9
|
class Otto
|
|
10
10
|
module RouteHandlers
|
|
11
11
|
# Handler for Logic classes (new in Otto Framework Enhancement)
|
|
12
|
-
#
|
|
13
|
-
# Logic classes use signature: initialize(context, params, locale)
|
|
12
|
+
#
|
|
13
|
+
# Logic classes use a constrained signature: initialize(context, params, locale)
|
|
14
|
+
# - context: The authentication strategy result (user info, session data)
|
|
15
|
+
# - params: Merged request parameters (URL params + body + extra_params)
|
|
16
|
+
# - locale: The locale string from env['otto.locale']
|
|
17
|
+
#
|
|
18
|
+
# IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
|
|
19
|
+
# This is intentional - Logic classes work with clean, authenticated contexts.
|
|
20
|
+
# For endpoints requiring direct request access (sessions, cookies, headers,
|
|
21
|
+
# or logout flows), use controller handlers (Controller#action or Controller.action).
|
|
14
22
|
class LogicClassHandler < BaseHandler
|
|
15
23
|
def call(env, extra_params = {})
|
|
16
24
|
start_time = Otto::Utils.now_in_μs
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Builds HTTP error responses for authentication/authorization failures
|
|
8
|
+
#
|
|
9
|
+
# Handles content negotiation (JSON vs HTML) and applies security headers.
|
|
10
|
+
# Route's declared response_type takes precedence over Accept header.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# builder = ResponseBuilder.new(route_definition, auth_config, security_config)
|
|
14
|
+
# response = builder.unauthorized(env, "Invalid token")
|
|
15
|
+
# response = builder.forbidden(env, "Admin role required")
|
|
16
|
+
# response = builder.auth_failure(env, auth_failure_result)
|
|
17
|
+
#
|
|
18
|
+
class ResponseBuilder
|
|
19
|
+
# @param route_definition [RouteDefinition] Route with response_type info
|
|
20
|
+
# @param auth_config [Hash] Auth config with :login_path for HTML redirects
|
|
21
|
+
# @param security_config [SecurityConfig, nil] Optional security config for headers
|
|
22
|
+
def initialize(route_definition, auth_config, security_config = nil)
|
|
23
|
+
@route_definition = route_definition
|
|
24
|
+
@auth_config = auth_config
|
|
25
|
+
@security_config = security_config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate response for authentication failure
|
|
29
|
+
#
|
|
30
|
+
# @param env [Hash] Rack environment
|
|
31
|
+
# @param result [AuthFailure] Failure result from strategy
|
|
32
|
+
# @return [Array] Rack response array
|
|
33
|
+
def auth_failure(env, result)
|
|
34
|
+
wants_json?(env) ? json_auth_error(result) : html_auth_error(result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generate 401 Unauthorized response
|
|
38
|
+
#
|
|
39
|
+
# @param env [Hash] Rack environment
|
|
40
|
+
# @param message [String] Error message
|
|
41
|
+
# @return [Array] Rack response array
|
|
42
|
+
def unauthorized(env, message)
|
|
43
|
+
if wants_json?(env)
|
|
44
|
+
json_response(401, error: message)
|
|
45
|
+
else
|
|
46
|
+
text_response(401, message)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generate 403 Forbidden response
|
|
51
|
+
#
|
|
52
|
+
# @param env [Hash] Rack environment
|
|
53
|
+
# @param message [String] Error message
|
|
54
|
+
# @return [Array] Rack response array
|
|
55
|
+
def forbidden(env, message)
|
|
56
|
+
if wants_json?(env)
|
|
57
|
+
json_response(403, error: 'Forbidden', message: message)
|
|
58
|
+
else
|
|
59
|
+
text_response(403, message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Determine if response should be JSON based on route config and Accept header
|
|
66
|
+
#
|
|
67
|
+
# Route's declared response type takes precedence over Accept header.
|
|
68
|
+
# This ensures API routes (response=json) always get JSON errors.
|
|
69
|
+
#
|
|
70
|
+
# @param env [Hash] Rack environment
|
|
71
|
+
# @return [Boolean] true if response should be JSON
|
|
72
|
+
def wants_json?(env)
|
|
73
|
+
return true if @route_definition.response_type == 'json'
|
|
74
|
+
|
|
75
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
76
|
+
accept_header.include?('application/json')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Generate JSON 401 response for auth failure
|
|
80
|
+
def json_auth_error(result)
|
|
81
|
+
json_response(401,
|
|
82
|
+
error: 'Authentication Required',
|
|
83
|
+
message: result.failure_reason || 'Not authenticated',
|
|
84
|
+
timestamp: Time.now.to_i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Generate HTML 401 response (redirect to login)
|
|
88
|
+
def html_auth_error(_result)
|
|
89
|
+
login_path = @auth_config[:login_path] || '/signin'
|
|
90
|
+
headers = { 'location' => login_path }
|
|
91
|
+
merge_security_headers!(headers)
|
|
92
|
+
[302, headers, ["Redirecting to #{login_path}"]]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build a JSON response with security headers
|
|
96
|
+
def json_response(status, body_hash)
|
|
97
|
+
body = body_hash.to_json
|
|
98
|
+
headers = {
|
|
99
|
+
'content-type' => 'application/json',
|
|
100
|
+
'content-length' => body.bytesize.to_s,
|
|
101
|
+
}
|
|
102
|
+
merge_security_headers!(headers)
|
|
103
|
+
[status, headers, [body]]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build a plain text response with security headers
|
|
107
|
+
def text_response(status, message)
|
|
108
|
+
headers = { 'content-type' => 'text/plain' }
|
|
109
|
+
merge_security_headers!(headers)
|
|
110
|
+
[status, headers, [message]]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Merge security headers into response headers
|
|
114
|
+
def merge_security_headers!(headers)
|
|
115
|
+
return unless @security_config
|
|
116
|
+
|
|
117
|
+
headers.merge!(@security_config.security_headers)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Handles Layer 1 (route-level) role-based authorization
|
|
8
|
+
#
|
|
9
|
+
# Extracts user roles from authentication results and checks against
|
|
10
|
+
# route requirements using OR logic (user needs ANY of the required roles).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# authorizer = RoleAuthorization.new(route_definition)
|
|
14
|
+
# authorizer.check!(strategy_result, env) # raises or returns true
|
|
15
|
+
#
|
|
16
|
+
# @note This is Layer 1 authorization only. Layer 2 (resource-level)
|
|
17
|
+
# authorization should be handled in Logic classes via raise_concerns.
|
|
18
|
+
#
|
|
19
|
+
class RoleAuthorization
|
|
20
|
+
# @param route_definition [RouteDefinition] Route with role requirements
|
|
21
|
+
def initialize(route_definition)
|
|
22
|
+
@route_definition = route_definition
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if authentication result satisfies role requirements
|
|
26
|
+
#
|
|
27
|
+
# @param result [StrategyResult] Authentication result
|
|
28
|
+
# @param env [Hash] Rack environment (for logging)
|
|
29
|
+
# @return [true] if authorized
|
|
30
|
+
# @return [Hash] failure info if not authorized: { authorized: false, required: [...], actual: [...] }
|
|
31
|
+
def check(result, env)
|
|
32
|
+
role_requirements = @route_definition.role_requirements
|
|
33
|
+
return true if role_requirements.empty?
|
|
34
|
+
|
|
35
|
+
user_roles = extract_roles(result)
|
|
36
|
+
|
|
37
|
+
# OR logic: user needs ANY of the required roles
|
|
38
|
+
if (user_roles & role_requirements).any?
|
|
39
|
+
log_success(env, role_requirements, user_roles)
|
|
40
|
+
true
|
|
41
|
+
else
|
|
42
|
+
log_failure(env, role_requirements, user_roles, result)
|
|
43
|
+
{
|
|
44
|
+
authorized: false,
|
|
45
|
+
required: role_requirements,
|
|
46
|
+
actual: user_roles,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check authorization, returning boolean
|
|
52
|
+
#
|
|
53
|
+
# @param result [StrategyResult] Authentication result
|
|
54
|
+
# @return [Boolean] true if authorized
|
|
55
|
+
def authorized?(result)
|
|
56
|
+
role_requirements = @route_definition.role_requirements
|
|
57
|
+
return true if role_requirements.empty?
|
|
58
|
+
|
|
59
|
+
user_roles = extract_roles(result)
|
|
60
|
+
(user_roles & role_requirements).any?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get the role requirements for error messages
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>] Required roles
|
|
66
|
+
def requirements
|
|
67
|
+
@route_definition.role_requirements
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Extract user roles from authentication result
|
|
73
|
+
#
|
|
74
|
+
# Supports multiple role sources in order of precedence:
|
|
75
|
+
# 1. result.user_roles (Array)
|
|
76
|
+
# 2. result.user[:roles] (Array)
|
|
77
|
+
# 3. result.user['roles'] (Array)
|
|
78
|
+
# 4. result.metadata[:user_roles] (Array)
|
|
79
|
+
#
|
|
80
|
+
# @param result [StrategyResult] Authentication result
|
|
81
|
+
# @return [Array<String>] Array of role strings
|
|
82
|
+
def extract_roles(result)
|
|
83
|
+
# Try direct user_roles accessor (e.g., from RoleStrategy)
|
|
84
|
+
return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
|
|
85
|
+
|
|
86
|
+
# Try user hash/object with roles
|
|
87
|
+
if result.user
|
|
88
|
+
roles = result.user[:roles] || result.user['roles']
|
|
89
|
+
return Array(roles) if roles
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Try metadata
|
|
93
|
+
return Array(result.metadata[:user_roles]) if result.metadata && result.metadata[:user_roles]
|
|
94
|
+
|
|
95
|
+
# No roles found
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log_success(env, required_roles, user_roles)
|
|
100
|
+
Otto.structured_log(:debug, 'Role authorization succeeded',
|
|
101
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
102
|
+
required_roles: required_roles,
|
|
103
|
+
user_roles: user_roles,
|
|
104
|
+
matched_roles: user_roles & required_roles
|
|
105
|
+
))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_failure(env, required_roles, user_roles, result)
|
|
109
|
+
Otto.structured_log(:warn, 'Role authorization failed',
|
|
110
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
111
|
+
required_roles: required_roles,
|
|
112
|
+
user_roles: user_roles,
|
|
113
|
+
user_id: result.user_id
|
|
114
|
+
))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Resolves authentication strategy names to strategy instances
|
|
8
|
+
#
|
|
9
|
+
# Handles strategy lookup with caching and pattern matching:
|
|
10
|
+
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
11
|
+
# - Prefix match: 'custom:value' → looks up 'custom' strategy
|
|
12
|
+
#
|
|
13
|
+
# Results are cached to avoid repeated lookups for the same requirement.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# resolver = StrategyResolver.new(auth_config)
|
|
17
|
+
# strategy, name = resolver.resolve('session')
|
|
18
|
+
# strategy, name = resolver.resolve('oauth:google') # prefix match
|
|
19
|
+
#
|
|
20
|
+
class StrategyResolver
|
|
21
|
+
# @param auth_config [Hash] Auth configuration with :auth_strategies key
|
|
22
|
+
def initialize(auth_config)
|
|
23
|
+
@auth_config = auth_config
|
|
24
|
+
@cache = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve a requirement string to a strategy instance
|
|
28
|
+
#
|
|
29
|
+
# @param requirement [String] Auth requirement from route (e.g., 'session', 'oauth:google')
|
|
30
|
+
# @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
|
|
31
|
+
def resolve(requirement)
|
|
32
|
+
return [nil, nil] unless @auth_config && @auth_config[:auth_strategies]
|
|
33
|
+
|
|
34
|
+
# Check cache first
|
|
35
|
+
return @cache[requirement] if @cache.key?(requirement)
|
|
36
|
+
|
|
37
|
+
result = find_strategy(requirement)
|
|
38
|
+
@cache[requirement] = result
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clear the strategy cache
|
|
43
|
+
def clear_cache
|
|
44
|
+
@cache.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def find_strategy(requirement)
|
|
50
|
+
strategies = @auth_config[:auth_strategies]
|
|
51
|
+
|
|
52
|
+
# Try exact match first - highest priority
|
|
53
|
+
strategy = strategies[requirement]
|
|
54
|
+
return [strategy, requirement] if strategy
|
|
55
|
+
|
|
56
|
+
# For colon-separated requirements like "custom:value", try prefix match
|
|
57
|
+
if requirement.include?(':')
|
|
58
|
+
prefix = requirement.split(':', 2).first
|
|
59
|
+
prefix_strategy = strategies[prefix]
|
|
60
|
+
return [prefix_strategy, prefix] if prefix_strategy
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[nil, nil]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -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
|
data/lib/otto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.0.
|
|
4
|
+
version: 2.0.0.pre8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -169,8 +169,6 @@ files:
|
|
|
169
169
|
- LICENSE.txt
|
|
170
170
|
- README.md
|
|
171
171
|
- bin/rspec
|
|
172
|
-
- changelog.d/20251103_235431_delano_86_improve_error_logging.rst
|
|
173
|
-
- changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst
|
|
174
172
|
- changelog.d/README.md
|
|
175
173
|
- changelog.d/scriv.ini
|
|
176
174
|
- docs/.gitignore
|
|
@@ -289,6 +287,9 @@ files:
|
|
|
289
287
|
- lib/otto/security/authentication/auth_failure.rb
|
|
290
288
|
- lib/otto/security/authentication/auth_strategy.rb
|
|
291
289
|
- lib/otto/security/authentication/route_auth_wrapper.rb
|
|
290
|
+
- lib/otto/security/authentication/route_auth_wrapper/response_builder.rb
|
|
291
|
+
- lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb
|
|
292
|
+
- lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb
|
|
292
293
|
- lib/otto/security/authentication/strategies/api_key_strategy.rb
|
|
293
294
|
- lib/otto/security/authentication/strategies/noauth_strategy.rb
|
|
294
295
|
- lib/otto/security/authentication/strategies/permission_strategy.rb
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
Added
|
|
2
|
-
-----
|
|
3
|
-
|
|
4
|
-
- Error handler registration system for expected business logic errors. Register handlers with ``otto.register_error_handler(ErrorClass, status: 404, log_level: :info)`` to return proper HTTP status codes and avoid logging expected errors as 500s with backtraces. Supports custom response handlers via blocks for complete control over error responses.
|
|
5
|
-
|
|
6
|
-
Changed
|
|
7
|
-
-------
|
|
8
|
-
|
|
9
|
-
- Backtrace logging now always logs at ERROR level (was DEBUG) with sanitized file paths for security. Backtraces for unhandled 500 errors are always logged regardless of ``OTTO_DEBUG`` setting, with paths sanitized to prevent exposing system information (project files show relative paths, gems show ``[GEM] name-version/path``, Ruby stdlib shows ``[RUBY] filename``).
|
|
10
|
-
- Increased backtrace limit from 10 to 20 lines for critical errors to provide better debugging context.
|
|
11
|
-
|
|
12
|
-
AI Assistance
|
|
13
|
-
-------------
|
|
14
|
-
|
|
15
|
-
- Implemented error handler registration architecture with comprehensive test coverage (17 test cases) using sequential thinking to work through security implications and design decisions. AI assisted with path sanitization strategy, error classification patterns, and ensuring backward compatibility with existing error handling.
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
.. Changed in: otto
|
|
2
|
-
.. Fixes issue:
|
|
3
|
-
|
|
4
|
-
Improved backtrace sanitization security and readability
|
|
5
|
-
---------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
**Security Enhancements:**
|
|
8
|
-
|
|
9
|
-
- Fixed bundler gem path detection to correctly sanitize git-based gems
|
|
10
|
-
- Now properly handles nested gem paths like ``/gems/3.4.0/bundler/gems/otto-abc123/``
|
|
11
|
-
- Strips git hash suffixes from bundler gems (``otto-abc123def456`` → ``otto``)
|
|
12
|
-
- Removes version numbers from regular gems (``rack-3.2.4`` → ``rack``)
|
|
13
|
-
- Prevents exposure of absolute paths, usernames, and project names in logs
|
|
14
|
-
|
|
15
|
-
**Improvements:**
|
|
16
|
-
|
|
17
|
-
- Bundler gems now show as ``[GEM] otto/lib/otto/route.rb:142`` instead of ``[GEM] 3.4.0/bundler/gems/...``
|
|
18
|
-
- Regular gems show cleaner output: ``[GEM] rack/lib/rack.rb:20`` instead of ``[GEM] rack-3.2.4/lib/rack.rb:20``
|
|
19
|
-
- Multi-hyphenated gem names handled correctly (``active-record-import-1.5.0`` → ``active-record-import``)
|
|
20
|
-
- Better handling of version-only directory names in gem paths
|
|
21
|
-
|
|
22
|
-
**Documentation:**
|
|
23
|
-
|
|
24
|
-
- Added comprehensive backtrace sanitization section to CLAUDE.md
|
|
25
|
-
- Documented security guarantees and sanitization rules
|
|
26
|
-
- Added examples showing before/after path transformations
|
|
27
|
-
- Created comprehensive test suite for backtrace sanitization
|
|
28
|
-
|
|
29
|
-
**Rationale:**
|
|
30
|
-
|
|
31
|
-
Raw backtraces expose sensitive information:
|
|
32
|
-
- Usernames (``/Users/alice/``, ``/home/admin/``)
|
|
33
|
-
- Project structure and internal organization
|
|
34
|
-
- Gem installation paths and Ruby versions
|
|
35
|
-
- System architecture details
|
|
36
|
-
|
|
37
|
-
This improvement ensures all backtraces are sanitized automatically, preventing accidental leakage of sensitive system information while maintaining readability for debugging.
|