otto 1.6.0 → 2.0.0.pre2
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 +3 -2
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +26 -344
- data/CHANGELOG.rst +131 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +11 -4
- data/Gemfile.lock +38 -42
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +237 -0
- data/lib/otto/core/router.rb +184 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +5 -21
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +27 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
- data/lib/otto/mcp/server.rb +45 -22
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +34 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +51 -55
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +42 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
- data/lib/otto/security/authentication/failure_result.rb +44 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +337 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -23
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +141 -498
- data/otto.gemspec +4 -2
- metadata +99 -18
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/security/authentication/route_auth_wrapper.rb
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
# Wraps route handlers to enforce authentication requirements
|
|
9
|
+
#
|
|
10
|
+
# This wrapper executes authentication strategies AFTER routing but BEFORE
|
|
11
|
+
# route handler execution. This solves the architectural issue where
|
|
12
|
+
# middleware-based authentication runs before routing (so can't access route info).
|
|
13
|
+
#
|
|
14
|
+
# Flow:
|
|
15
|
+
# 1. Route matched (route_definition available)
|
|
16
|
+
# 2. RouteAuthWrapper#call invoked
|
|
17
|
+
# 3. Execute auth strategy based on route's auth_requirement
|
|
18
|
+
# 4. Set env['otto.strategy_result'], env['otto.user']
|
|
19
|
+
# 5. If auth fails, return 401 or redirect
|
|
20
|
+
# 6. If auth succeeds, call wrapped handler
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# handler = InstanceMethodHandler.new(route_def, otto)
|
|
24
|
+
# wrapped = RouteAuthWrapper.new(handler, route_def, auth_config)
|
|
25
|
+
# wrapped.call(env, extra_params)
|
|
26
|
+
#
|
|
27
|
+
class RouteAuthWrapper
|
|
28
|
+
attr_reader :wrapped_handler, :route_definition, :auth_config
|
|
29
|
+
|
|
30
|
+
def initialize(wrapped_handler, route_definition, auth_config)
|
|
31
|
+
@wrapped_handler = wrapped_handler
|
|
32
|
+
@route_definition = route_definition
|
|
33
|
+
@auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Execute authentication then call wrapped handler
|
|
37
|
+
#
|
|
38
|
+
# @param env [Hash] Rack environment
|
|
39
|
+
# @param extra_params [Hash] Additional parameters
|
|
40
|
+
# @return [Array] Rack response array
|
|
41
|
+
def call(env, extra_params = {})
|
|
42
|
+
# Execute authentication strategy for this route
|
|
43
|
+
auth_requirement = route_definition.auth_requirement
|
|
44
|
+
strategy = get_strategy(auth_requirement)
|
|
45
|
+
|
|
46
|
+
unless strategy
|
|
47
|
+
Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
|
|
48
|
+
return unauthorized_response(env, "Authentication strategy not configured")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Execute the strategy
|
|
52
|
+
result = strategy.authenticate(env, auth_requirement)
|
|
53
|
+
|
|
54
|
+
# Set environment variables for controllers/logic
|
|
55
|
+
env['otto.strategy_result'] = result
|
|
56
|
+
env['otto.user'] = result.user if result.is_a?(StrategyResult)
|
|
57
|
+
env['otto.user_context'] = result.user_context if result.is_a?(StrategyResult)
|
|
58
|
+
|
|
59
|
+
# Handle authentication failure
|
|
60
|
+
if result.is_a?(FailureResult)
|
|
61
|
+
return auth_failure_response(env, result)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Authentication succeeded - call wrapped handler
|
|
65
|
+
wrapped_handler.call(env, extra_params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Get strategy from auth_config hash
|
|
71
|
+
#
|
|
72
|
+
# @param requirement [String] Auth requirement from route
|
|
73
|
+
# @return [AuthStrategy, nil] Strategy instance or nil
|
|
74
|
+
def get_strategy(requirement)
|
|
75
|
+
return nil unless auth_config && auth_config[:auth_strategies]
|
|
76
|
+
|
|
77
|
+
auth_config[:auth_strategies][requirement]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Generate 401 response for authentication failure
|
|
81
|
+
#
|
|
82
|
+
# @param env [Hash] Rack environment
|
|
83
|
+
# @param result [FailureResult] Failure result from strategy
|
|
84
|
+
# @return [Array] Rack response array
|
|
85
|
+
def auth_failure_response(env, result)
|
|
86
|
+
# Check if request wants JSON
|
|
87
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
88
|
+
wants_json = accept_header.include?('application/json')
|
|
89
|
+
|
|
90
|
+
if wants_json
|
|
91
|
+
json_auth_error(result)
|
|
92
|
+
else
|
|
93
|
+
html_auth_error(result)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Generate JSON 401 response
|
|
98
|
+
#
|
|
99
|
+
# @param result [FailureResult] Failure result
|
|
100
|
+
# @return [Array] Rack response array
|
|
101
|
+
def json_auth_error(result)
|
|
102
|
+
body = {
|
|
103
|
+
error: 'Authentication Required',
|
|
104
|
+
message: result.failure_reason || 'Not authenticated',
|
|
105
|
+
timestamp: Time.now.to_i
|
|
106
|
+
}.to_json
|
|
107
|
+
|
|
108
|
+
[
|
|
109
|
+
401,
|
|
110
|
+
{ 'content-type' => 'application/json' },
|
|
111
|
+
[body]
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Generate HTML 401 response or redirect
|
|
116
|
+
#
|
|
117
|
+
# @param result [FailureResult] Failure result
|
|
118
|
+
# @return [Array] Rack response array
|
|
119
|
+
def html_auth_error(result)
|
|
120
|
+
# For HTML requests, redirect to login
|
|
121
|
+
login_path = auth_config[:login_path] || '/signin'
|
|
122
|
+
|
|
123
|
+
[
|
|
124
|
+
302,
|
|
125
|
+
{ 'location' => login_path },
|
|
126
|
+
["Redirecting to #{login_path}"]
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Generate generic unauthorized response
|
|
131
|
+
#
|
|
132
|
+
# @param env [Hash] Rack environment
|
|
133
|
+
# @param message [String] Error message
|
|
134
|
+
# @return [Array] Rack response array
|
|
135
|
+
def unauthorized_response(env, message)
|
|
136
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
137
|
+
wants_json = accept_header.include?('application/json')
|
|
138
|
+
|
|
139
|
+
if wants_json
|
|
140
|
+
body = { error: message }.to_json
|
|
141
|
+
[401, { 'content-type' => 'application/json' }, [body]]
|
|
142
|
+
else
|
|
143
|
+
[401, { 'content-type' => 'text/plain' }, [message]]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../auth_strategy'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
module Strategies
|
|
9
|
+
# API key authentication strategy
|
|
10
|
+
class APIKeyStrategy < AuthStrategy
|
|
11
|
+
def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
|
|
12
|
+
@api_keys = Array(api_keys)
|
|
13
|
+
@header_name = header_name
|
|
14
|
+
@param_name = param_name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authenticate(env, _requirement)
|
|
18
|
+
# Try header first, then query parameter
|
|
19
|
+
api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
|
|
20
|
+
|
|
21
|
+
if api_key.nil?
|
|
22
|
+
request = Rack::Request.new(env)
|
|
23
|
+
api_key = request.params[@param_name]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return failure('No API key provided') unless api_key
|
|
27
|
+
|
|
28
|
+
if @api_keys.empty? || @api_keys.include?(api_key)
|
|
29
|
+
# Create a simple user hash for API key authentication
|
|
30
|
+
user_data = { api_key: api_key }
|
|
31
|
+
success(user: user_data, api_key: api_key)
|
|
32
|
+
else
|
|
33
|
+
failure('Invalid API key')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../auth_strategy'
|
|
4
|
+
require_relative '../strategy_result'
|
|
5
|
+
|
|
6
|
+
class Otto
|
|
7
|
+
module Security
|
|
8
|
+
module Authentication
|
|
9
|
+
module Strategies
|
|
10
|
+
# Public access strategy - always allows access
|
|
11
|
+
class NoAuthStrategy < AuthStrategy
|
|
12
|
+
def authenticate(env, _requirement)
|
|
13
|
+
Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../auth_strategy'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
module Strategies
|
|
9
|
+
# Permission-based authentication strategy
|
|
10
|
+
class PermissionStrategy < AuthStrategy
|
|
11
|
+
def initialize(required_permissions, session_key: 'user_permissions')
|
|
12
|
+
@required_permissions = Array(required_permissions)
|
|
13
|
+
@session_key = session_key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authenticate(env, requirement)
|
|
17
|
+
session = env['rack.session']
|
|
18
|
+
return failure('No session available') unless session
|
|
19
|
+
|
|
20
|
+
user_permissions = session[@session_key] || []
|
|
21
|
+
user_permissions = Array(user_permissions)
|
|
22
|
+
|
|
23
|
+
# Create user data from session
|
|
24
|
+
user_data = { user_permissions: user_permissions, session: session }
|
|
25
|
+
|
|
26
|
+
# Extract permission from requirement (e.g., "permission:write" -> "write")
|
|
27
|
+
required_permission = requirement.split(':', 2).last
|
|
28
|
+
|
|
29
|
+
if user_permissions.include?(required_permission)
|
|
30
|
+
success(user: user_data, user_permissions: user_permissions, required_permission: required_permission)
|
|
31
|
+
else
|
|
32
|
+
failure("Insufficient privileges - requires permission: #{required_permission}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def user_context(env)
|
|
37
|
+
session = env['rack.session']
|
|
38
|
+
return {} unless session
|
|
39
|
+
|
|
40
|
+
user_permissions = session[@session_key] || []
|
|
41
|
+
{ user_permissions: Array(user_permissions) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../auth_strategy'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
module Strategies
|
|
9
|
+
# Role-based authentication strategy
|
|
10
|
+
class RoleStrategy < AuthStrategy
|
|
11
|
+
def initialize(allowed_roles, session_key: 'user_roles')
|
|
12
|
+
@allowed_roles = Array(allowed_roles)
|
|
13
|
+
@session_key = session_key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authenticate(env, requirement)
|
|
17
|
+
session = env['rack.session']
|
|
18
|
+
return failure('No session available') unless session
|
|
19
|
+
|
|
20
|
+
user_roles = session[@session_key] || []
|
|
21
|
+
user_roles = Array(user_roles)
|
|
22
|
+
|
|
23
|
+
# Create user data from session
|
|
24
|
+
user_data = { user_roles: user_roles, session: session }
|
|
25
|
+
|
|
26
|
+
# For requirements like "role:admin", extract the role part
|
|
27
|
+
if requirement.include?(':')
|
|
28
|
+
required_role = requirement.split(':', 2).last
|
|
29
|
+
if user_roles.include?(required_role)
|
|
30
|
+
success(user: user_data, user_roles: user_roles, required_role: required_role)
|
|
31
|
+
else
|
|
32
|
+
failure("Insufficient privileges - requires role: #{required_role}")
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
# For direct strategy matches, check if user has any of the allowed roles
|
|
36
|
+
matching_roles = user_roles & @allowed_roles
|
|
37
|
+
if matching_roles.any?
|
|
38
|
+
success(user: user_data, user_roles: user_roles, allowed_roles: @allowed_roles,
|
|
39
|
+
matching_roles: matching_roles)
|
|
40
|
+
else
|
|
41
|
+
failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def user_context(env)
|
|
47
|
+
session = env['rack.session']
|
|
48
|
+
return {} unless session
|
|
49
|
+
|
|
50
|
+
user_roles = session[@session_key] || []
|
|
51
|
+
{ user_roles: Array(user_roles) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../auth_strategy'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
module Strategies
|
|
9
|
+
# Session-based authentication strategy
|
|
10
|
+
class SessionStrategy < AuthStrategy
|
|
11
|
+
def initialize(session_key: 'user_id', session_store: nil)
|
|
12
|
+
@session_key = session_key
|
|
13
|
+
@session_store = session_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authenticate(env, _requirement)
|
|
17
|
+
session = env['rack.session']
|
|
18
|
+
return failure('No session available') unless session
|
|
19
|
+
|
|
20
|
+
user_id = session[@session_key]
|
|
21
|
+
return failure('Not authenticated') unless user_id
|
|
22
|
+
|
|
23
|
+
# Create a simple user hash for the generic strategy
|
|
24
|
+
user_data = { id: user_id, user_id: user_id }
|
|
25
|
+
success(session: session, user: user_data, auth_method: 'session')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def user_context(env)
|
|
29
|
+
session = env['rack.session']
|
|
30
|
+
return {} unless session
|
|
31
|
+
|
|
32
|
+
user_id = session[@session_key]
|
|
33
|
+
return {} unless user_id
|
|
34
|
+
|
|
35
|
+
{ user_id: user_id }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/security/authentication/strategy_result.rb
|
|
4
|
+
|
|
5
|
+
# StrategyResult is an immutable data structure that holds the result of an
|
|
6
|
+
# authentication strategy. It contains session, user, and metadata needed by
|
|
7
|
+
# Otto Logic classes.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# result = StrategyResult.new(
|
|
11
|
+
# session: { id: 'abc123', user_id: 1 },
|
|
12
|
+
# user: user_model_instance, # Actual user model, not a hash
|
|
13
|
+
# auth_method: 'token',
|
|
14
|
+
# metadata: { ip: '127.0.0.1' }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# result.authenticated? #=> true
|
|
18
|
+
# result.has_role?('admin') #=> true
|
|
19
|
+
# result.user.name #=> 'John' (assuming user model has name method)
|
|
20
|
+
#
|
|
21
|
+
class Otto
|
|
22
|
+
module Security
|
|
23
|
+
module Authentication
|
|
24
|
+
StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
|
|
25
|
+
# =====================================================================
|
|
26
|
+
# USAGE PATTERNS - READ THIS FIRST
|
|
27
|
+
# =====================================================================
|
|
28
|
+
#
|
|
29
|
+
# StrategyResult represents authentication state for a request.
|
|
30
|
+
# It serves TWO distinct purposes that must not be confused:
|
|
31
|
+
#
|
|
32
|
+
# 1. REQUEST STATE: Current session/user information
|
|
33
|
+
# - Use `authenticated?` to check if session has a user
|
|
34
|
+
# - Available on ALL requests (anonymous or authenticated)
|
|
35
|
+
#
|
|
36
|
+
# 2. AUTH ATTEMPT OUTCOME: Whether authentication just succeeded
|
|
37
|
+
# - Use `auth_attempt_succeeded?` to check if auth strategy ran
|
|
38
|
+
# - Only true when route had auth=... requirement AND succeeded
|
|
39
|
+
#
|
|
40
|
+
# CREATION PATTERNS
|
|
41
|
+
# -----------------
|
|
42
|
+
#
|
|
43
|
+
# StrategyResult should ONLY be created by:
|
|
44
|
+
#
|
|
45
|
+
# 1. Otto's AuthenticationMiddleware (automatic, route-based)
|
|
46
|
+
# - Routes WITH auth=...: Creates result from strategy execution
|
|
47
|
+
# - Routes WITHOUT auth=...: Creates anonymous result
|
|
48
|
+
#
|
|
49
|
+
# 2. Auth app router (manual, for Logic class compatibility)
|
|
50
|
+
# - Manually builds StrategyResult for Roda routes
|
|
51
|
+
# - Maintains same interface as Otto controllers
|
|
52
|
+
#
|
|
53
|
+
# APPLICATION CODE SHOULD NOT manually create StrategyResult!
|
|
54
|
+
# Instead, access session directly or rely on middleware.
|
|
55
|
+
#
|
|
56
|
+
# SESSION CONTRACT
|
|
57
|
+
# ----------------
|
|
58
|
+
#
|
|
59
|
+
# For multi-app architectures with shared session:
|
|
60
|
+
#
|
|
61
|
+
# Required session keys for authenticated state:
|
|
62
|
+
# session['authenticated'] # Boolean flag
|
|
63
|
+
# session['identity_id'] # User/customer ID
|
|
64
|
+
# session['authenticated_at'] # Timestamp
|
|
65
|
+
#
|
|
66
|
+
# Optional session keys:
|
|
67
|
+
# session['email'] # User email
|
|
68
|
+
# session['ip_address'] # Client IP
|
|
69
|
+
# session['user_agent'] # Client UA
|
|
70
|
+
# session['locale'] # User locale
|
|
71
|
+
#
|
|
72
|
+
# Advanced mode adds:
|
|
73
|
+
# session['account_external_id'] # Rodauth external_id
|
|
74
|
+
# session['advanced_account_id'] # Rodauth account ID
|
|
75
|
+
#
|
|
76
|
+
# EXAMPLES
|
|
77
|
+
# --------
|
|
78
|
+
#
|
|
79
|
+
# Check if user in session (registration flow):
|
|
80
|
+
# class CreateAccount
|
|
81
|
+
# def raise_concerns
|
|
82
|
+
# # Block registration if already logged in
|
|
83
|
+
# raise FormError, "Already signed up" if @context.authenticated?
|
|
84
|
+
# end
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# Check if auth just succeeded (post-login redirect):
|
|
88
|
+
# class LoginHandler
|
|
89
|
+
# def process
|
|
90
|
+
# if @context.auth_attempt_succeeded?
|
|
91
|
+
# redirect_to dashboard_path
|
|
92
|
+
# end
|
|
93
|
+
# end
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# Distinguish between the two:
|
|
97
|
+
# @context.authenticated? #=> true (user in session)
|
|
98
|
+
# @context.auth_attempt_succeeded? #=> false (no auth route)
|
|
99
|
+
#
|
|
100
|
+
# # vs route with auth=session:
|
|
101
|
+
# @context.authenticated? #=> true (user in session)
|
|
102
|
+
# @context.auth_attempt_succeeded? #=> true (strategy just ran)
|
|
103
|
+
#
|
|
104
|
+
# =====================================================================
|
|
105
|
+
|
|
106
|
+
# Create an anonymous (unauthenticated) result
|
|
107
|
+
#
|
|
108
|
+
# Used by middleware for routes without auth requirements
|
|
109
|
+
# and by PublicStrategy for publicly accessible routes.
|
|
110
|
+
#
|
|
111
|
+
# @param metadata [Hash] Optional metadata (IP, user agent, etc.)
|
|
112
|
+
# @return [StrategyResult] Anonymous result with nil user
|
|
113
|
+
def self.anonymous(metadata: {})
|
|
114
|
+
new(
|
|
115
|
+
session: {},
|
|
116
|
+
user: nil,
|
|
117
|
+
auth_method: 'anonymous',
|
|
118
|
+
metadata: metadata
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if the request has an authenticated user in session
|
|
123
|
+
#
|
|
124
|
+
# This checks REQUEST STATE, not auth attempt outcome.
|
|
125
|
+
# Returns true if session contains a user, regardless of
|
|
126
|
+
# whether authentication just occurred or was from a previous request.
|
|
127
|
+
#
|
|
128
|
+
# @return [Boolean] True if user is present in session
|
|
129
|
+
# @example
|
|
130
|
+
# # Block registration if user already logged in
|
|
131
|
+
# raise FormError if @context.authenticated?
|
|
132
|
+
def authenticated?
|
|
133
|
+
!user.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check if authentication strategy just executed and succeeded
|
|
137
|
+
#
|
|
138
|
+
# This checks AUTH ATTEMPT OUTCOME, not just session state.
|
|
139
|
+
# Returns true only when:
|
|
140
|
+
# 1. Route had an auth=... requirement (not anonymous/public)
|
|
141
|
+
# 2. Auth strategy executed
|
|
142
|
+
# 3. Authentication succeeded (user authenticated)
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean] True if auth strategy just succeeded
|
|
145
|
+
# @example
|
|
146
|
+
# # Redirect after successful login
|
|
147
|
+
# redirect_to dashboard if @context.auth_attempt_succeeded?
|
|
148
|
+
def auth_attempt_succeeded?
|
|
149
|
+
authenticated? && auth_method.to_s != 'anonymous'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check if the request is anonymous (no user in session)
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean] True if not authenticated
|
|
155
|
+
def anonymous?
|
|
156
|
+
user.nil?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Check if the user has a specific role
|
|
160
|
+
#
|
|
161
|
+
# @param role [String, Symbol] Role to check
|
|
162
|
+
# @return [Boolean] True if user has the role
|
|
163
|
+
def has_role?(role)
|
|
164
|
+
return false unless authenticated?
|
|
165
|
+
|
|
166
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
|
167
|
+
if user.respond_to?(:role)
|
|
168
|
+
user.role.to_s == role.to_s
|
|
169
|
+
elsif user.respond_to?(:has_role?)
|
|
170
|
+
user.has_role?(role)
|
|
171
|
+
elsif user.is_a?(Hash)
|
|
172
|
+
user_role = user[:role] || user['role']
|
|
173
|
+
user_role.to_s == role.to_s
|
|
174
|
+
else
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Check if the user has a specific permission
|
|
180
|
+
#
|
|
181
|
+
# @param permission [String, Symbol] Permission to check
|
|
182
|
+
# @return [Boolean] True if user has the permission
|
|
183
|
+
def has_permission?(permission)
|
|
184
|
+
return false unless authenticated?
|
|
185
|
+
|
|
186
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
|
187
|
+
if user.respond_to?(:has_permission?)
|
|
188
|
+
user.has_permission?(permission)
|
|
189
|
+
elsif user.respond_to?(:permissions)
|
|
190
|
+
permissions = user.permissions || []
|
|
191
|
+
permissions = [permissions] unless permissions.is_a?(Array)
|
|
192
|
+
permissions.map(&:to_s).include?(permission.to_s)
|
|
193
|
+
elsif user.is_a?(Hash)
|
|
194
|
+
permissions = user[:permissions] || user['permissions'] || []
|
|
195
|
+
permissions = [permissions] unless permissions.is_a?(Array)
|
|
196
|
+
permissions.map(&:to_s).include?(permission.to_s)
|
|
197
|
+
else
|
|
198
|
+
false
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check if the user has any of the specified roles
|
|
203
|
+
#
|
|
204
|
+
# @param roles [Array<String, Symbol>] Roles to check
|
|
205
|
+
# @return [Boolean] True if user has any of the roles
|
|
206
|
+
def has_any_role?(*roles)
|
|
207
|
+
roles.flatten.any? { |role| has_role?(role) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Check if the user has any of the specified permissions
|
|
211
|
+
#
|
|
212
|
+
# @param permissions [Array<String, Symbol>] Permissions to check
|
|
213
|
+
# @return [Boolean] True if user has any of the permissions
|
|
214
|
+
def has_any_permission?(*permissions)
|
|
215
|
+
permissions.flatten.any? { |permission| has_permission?(permission) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Get user ID from various possible locations
|
|
219
|
+
#
|
|
220
|
+
# @return [String, Integer, nil] User ID or nil
|
|
221
|
+
def user_id
|
|
222
|
+
return nil unless authenticated?
|
|
223
|
+
|
|
224
|
+
# Try user model methods first, fall back to hash access and session
|
|
225
|
+
if user.respond_to?(:id)
|
|
226
|
+
user.id
|
|
227
|
+
elsif user.respond_to?(:user_id)
|
|
228
|
+
user.user_id
|
|
229
|
+
elsif user.is_a?(Hash)
|
|
230
|
+
user[:id] || user['id'] || user[:user_id] || user['user_id']
|
|
231
|
+
end || session[:user_id] || session['user_id']
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Get user name from various possible locations
|
|
235
|
+
#
|
|
236
|
+
# @return [String, nil] User name or nil
|
|
237
|
+
def user_name
|
|
238
|
+
return nil unless authenticated?
|
|
239
|
+
|
|
240
|
+
# Try user model methods first, fall back to hash access
|
|
241
|
+
if user.respond_to?(:name)
|
|
242
|
+
user.name
|
|
243
|
+
elsif user.respond_to?(:username)
|
|
244
|
+
user.username
|
|
245
|
+
elsif user.is_a?(Hash)
|
|
246
|
+
user[:name] || user['name'] || user[:username] || user['username']
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Get session ID from various possible locations
|
|
251
|
+
#
|
|
252
|
+
# @return [String, nil] Session ID or nil
|
|
253
|
+
def session_id
|
|
254
|
+
session[:id] || session['id'] || session[:session_id] || session['session_id']
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Get all user roles as an array
|
|
258
|
+
#
|
|
259
|
+
# @return [Array<String>] Array of roles (empty if none)
|
|
260
|
+
def roles
|
|
261
|
+
return [] unless authenticated?
|
|
262
|
+
|
|
263
|
+
roles_data = user[:roles] || user['roles']
|
|
264
|
+
if roles_data.is_a?(Array)
|
|
265
|
+
roles_data.map(&:to_s)
|
|
266
|
+
elsif roles_data
|
|
267
|
+
[roles_data.to_s]
|
|
268
|
+
else
|
|
269
|
+
role = user[:role] || user['role']
|
|
270
|
+
role ? [role.to_s] : []
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Get all user permissions as an array
|
|
275
|
+
#
|
|
276
|
+
# @return [Array<String>] Array of permissions (empty if none)
|
|
277
|
+
def permissions
|
|
278
|
+
return [] unless authenticated?
|
|
279
|
+
|
|
280
|
+
perms = user[:permissions] || user['permissions'] || []
|
|
281
|
+
perms = [perms] unless perms.is_a?(Array)
|
|
282
|
+
perms.map(&:to_s)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Create a string representation for debugging
|
|
286
|
+
#
|
|
287
|
+
# @return [String] Debug representation
|
|
288
|
+
def inspect
|
|
289
|
+
if authenticated?
|
|
290
|
+
"#<StrategyResult authenticated user=#{user_name || user_id} roles=#{roles} method=#{auth_method}>"
|
|
291
|
+
else
|
|
292
|
+
"#<StrategyResult anonymous method=#{auth_method}>"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Get user context - a hash containing user-specific information and metadata
|
|
297
|
+
#
|
|
298
|
+
# @return [Hash] User context hash
|
|
299
|
+
def user_context
|
|
300
|
+
if authenticated?
|
|
301
|
+
case auth_method
|
|
302
|
+
when 'session'
|
|
303
|
+
{ user_id: user_id, session: session }
|
|
304
|
+
else
|
|
305
|
+
metadata
|
|
306
|
+
end
|
|
307
|
+
else
|
|
308
|
+
case auth_method
|
|
309
|
+
when 'anonymous'
|
|
310
|
+
{}
|
|
311
|
+
else
|
|
312
|
+
metadata
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Create a hash representation
|
|
318
|
+
#
|
|
319
|
+
# @return [Hash] Hash representation of the context
|
|
320
|
+
def to_h
|
|
321
|
+
{
|
|
322
|
+
session: session,
|
|
323
|
+
user: user,
|
|
324
|
+
auth_method: auth_method,
|
|
325
|
+
metadata: metadata,
|
|
326
|
+
authenticated: authenticated?,
|
|
327
|
+
auth_attempt_succeeded: auth_attempt_succeeded?,
|
|
328
|
+
user_id: user_id,
|
|
329
|
+
user_name: user_name,
|
|
330
|
+
roles: roles,
|
|
331
|
+
permissions: permissions
|
|
332
|
+
}
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|