otto 1.5.0 → 2.0.0.pre1
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 +44 -5
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -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 +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- 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 +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- 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 +28 -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 +9 -9
- data/lib/otto/route_definition.rb +30 -33
- 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 +29 -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 -376
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -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 +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +15 -11
- 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 +38 -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 +16 -0
- data/lib/otto/security/validator.rb +8 -292
- 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 +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- 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
@@ -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 PublicStrategy < 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,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,223 @@
|
|
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
|
+
# Create an anonymous (unauthenticated) result
|
26
|
+
# @return [StrategyResult] Anonymous result with empty session and nil user
|
27
|
+
def self.anonymous(metadata: {})
|
28
|
+
new(
|
29
|
+
session: {},
|
30
|
+
user: nil, # Changed from {} to nil - clearer semantics
|
31
|
+
auth_method: 'anonymous',
|
32
|
+
metadata: metadata
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Check if the request is authenticated (has a user)
|
37
|
+
# @return [Boolean] True if user is present, false otherwise
|
38
|
+
def authenticated?
|
39
|
+
!user.nil?
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check if the request is anonymous (no user)
|
43
|
+
# @return [Boolean] True if not authenticated
|
44
|
+
def anonymous?
|
45
|
+
user.nil?
|
46
|
+
end
|
47
|
+
|
48
|
+
# Success/failure methods for compatibility
|
49
|
+
def success?
|
50
|
+
true # If we have a StrategyResult, authentication succeeded
|
51
|
+
end
|
52
|
+
|
53
|
+
def failure?
|
54
|
+
false # Failures return nil, not a StrategyResult
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
# Check if the user has a specific role
|
59
|
+
# @param role [String, Symbol] Role to check
|
60
|
+
# @return [Boolean] True if user has the role
|
61
|
+
def has_role?(role)
|
62
|
+
return false unless authenticated?
|
63
|
+
|
64
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
65
|
+
if user.respond_to?(:role)
|
66
|
+
user.role.to_s == role.to_s
|
67
|
+
elsif user.respond_to?(:has_role?)
|
68
|
+
user.has_role?(role)
|
69
|
+
elsif user.is_a?(Hash)
|
70
|
+
user_role = user[:role] || user['role']
|
71
|
+
user_role.to_s == role.to_s
|
72
|
+
else
|
73
|
+
false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Check if the user has a specific permission
|
78
|
+
# @param permission [String, Symbol] Permission to check
|
79
|
+
# @return [Boolean] True if user has the permission
|
80
|
+
def has_permission?(permission)
|
81
|
+
return false unless authenticated?
|
82
|
+
|
83
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
84
|
+
if user.respond_to?(:has_permission?)
|
85
|
+
user.has_permission?(permission)
|
86
|
+
elsif user.respond_to?(:permissions)
|
87
|
+
permissions = user.permissions || []
|
88
|
+
permissions = [permissions] unless permissions.is_a?(Array)
|
89
|
+
permissions.map(&:to_s).include?(permission.to_s)
|
90
|
+
elsif user.is_a?(Hash)
|
91
|
+
permissions = user[:permissions] || user['permissions'] || []
|
92
|
+
permissions = [permissions] unless permissions.is_a?(Array)
|
93
|
+
permissions.map(&:to_s).include?(permission.to_s)
|
94
|
+
else
|
95
|
+
false
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Check if the user has any of the specified roles
|
100
|
+
# @param roles [Array<String, Symbol>] Roles to check
|
101
|
+
# @return [Boolean] True if user has any of the roles
|
102
|
+
def has_any_role?(*roles)
|
103
|
+
roles.flatten.any? { |role| has_role?(role) }
|
104
|
+
end
|
105
|
+
|
106
|
+
# Check if the user has any of the specified permissions
|
107
|
+
# @param permissions [Array<String, Symbol>] Permissions to check
|
108
|
+
# @return [Boolean] True if user has any of the permissions
|
109
|
+
def has_any_permission?(*permissions)
|
110
|
+
permissions.flatten.any? { |permission| has_permission?(permission) }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Get user ID from various possible locations
|
114
|
+
# @return [String, Integer, nil] User ID or nil
|
115
|
+
def user_id
|
116
|
+
return nil unless authenticated?
|
117
|
+
|
118
|
+
# Try user model methods first, fall back to hash access and session
|
119
|
+
if user.respond_to?(:id)
|
120
|
+
user.id
|
121
|
+
elsif user.respond_to?(:user_id)
|
122
|
+
user.user_id
|
123
|
+
elsif user.is_a?(Hash)
|
124
|
+
user[:id] || user['id'] || user[:user_id] || user['user_id']
|
125
|
+
end || session[:user_id] || session['user_id']
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get user name from various possible locations
|
129
|
+
# @return [String, nil] User name or nil
|
130
|
+
def user_name
|
131
|
+
return nil unless authenticated?
|
132
|
+
|
133
|
+
# Try user model methods first, fall back to hash access
|
134
|
+
if user.respond_to?(:name)
|
135
|
+
user.name
|
136
|
+
elsif user.respond_to?(:username)
|
137
|
+
user.username
|
138
|
+
elsif user.is_a?(Hash)
|
139
|
+
user[:name] || user['name'] || user[:username] || user['username']
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Get session ID from various possible locations
|
144
|
+
# @return [String, nil] Session ID or nil
|
145
|
+
def session_id
|
146
|
+
session[:id] || session['id'] || session[:session_id] || session['session_id']
|
147
|
+
end
|
148
|
+
|
149
|
+
# Get all user roles as an array
|
150
|
+
# @return [Array<String>] Array of roles (empty if none)
|
151
|
+
def roles
|
152
|
+
return [] unless authenticated?
|
153
|
+
|
154
|
+
roles_data = user[:roles] || user['roles']
|
155
|
+
if roles_data.is_a?(Array)
|
156
|
+
roles_data.map(&:to_s)
|
157
|
+
elsif roles_data
|
158
|
+
[roles_data.to_s]
|
159
|
+
else
|
160
|
+
role = user[:role] || user['role']
|
161
|
+
role ? [role.to_s] : []
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# Get all user permissions as an array
|
166
|
+
# @return [Array<String>] Array of permissions (empty if none)
|
167
|
+
def permissions
|
168
|
+
return [] unless authenticated?
|
169
|
+
|
170
|
+
perms = user[:permissions] || user['permissions'] || []
|
171
|
+
perms = [perms] unless perms.is_a?(Array)
|
172
|
+
perms.map(&:to_s)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Create a string representation for debugging
|
176
|
+
# @return [String] Debug representation
|
177
|
+
def inspect
|
178
|
+
if authenticated?
|
179
|
+
"#<StrategyResult authenticated user=#{user_name || user_id} roles=#{roles} method=#{auth_method}>"
|
180
|
+
else
|
181
|
+
"#<StrategyResult anonymous method=#{auth_method}>"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Get user context - a hash containing user-specific information and metadata
|
186
|
+
# @return [Hash] User context hash
|
187
|
+
def user_context
|
188
|
+
if authenticated?
|
189
|
+
case auth_method
|
190
|
+
when 'session'
|
191
|
+
{ user_id: user_id, session: session }
|
192
|
+
else
|
193
|
+
metadata
|
194
|
+
end
|
195
|
+
else
|
196
|
+
case auth_method
|
197
|
+
when 'anonymous'
|
198
|
+
{}
|
199
|
+
else
|
200
|
+
metadata
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Create a hash representation
|
206
|
+
# @return [Hash] Hash representation of the context
|
207
|
+
def to_h
|
208
|
+
{
|
209
|
+
session: session,
|
210
|
+
user: user,
|
211
|
+
auth_method: auth_method,
|
212
|
+
metadata: metadata,
|
213
|
+
authenticated: authenticated?,
|
214
|
+
user_id: user_id,
|
215
|
+
user_name: user_name,
|
216
|
+
roles: roles,
|
217
|
+
permissions: permissions
|
218
|
+
}
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
@@ -1,289 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# lib/otto/security/authentication.rb
|
2
4
|
#
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
6
|
-
# Usage:
|
7
|
-
# otto = Otto.new('routes.txt', {
|
8
|
-
# auth_strategies: {
|
9
|
-
# 'publically' => PublicStrategy.new,
|
10
|
-
# 'authenticated' => SessionStrategy.new,
|
11
|
-
# 'role:admin' => RoleStrategy.new(['admin']),
|
12
|
-
# 'api_key' => APIKeyStrategy.new
|
13
|
-
# }
|
14
|
-
# })
|
15
|
-
|
16
|
-
class Otto
|
17
|
-
module Security
|
18
|
-
# Base class for all authentication strategies
|
19
|
-
class AuthStrategy
|
20
|
-
# Check if the request meets the authentication requirements
|
21
|
-
# @param env [Hash] Rack environment
|
22
|
-
# @param requirement [String] Authentication requirement string
|
23
|
-
# @return [AuthResult] Result containing success status and context
|
24
|
-
def authenticate(env, requirement)
|
25
|
-
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
26
|
-
end
|
27
|
-
|
28
|
-
# Optional: Extract user context for authenticated requests
|
29
|
-
# @param env [Hash] Rack environment
|
30
|
-
# @return [Hash] User context hash
|
31
|
-
def user_context(env)
|
32
|
-
{}
|
33
|
-
end
|
34
|
-
|
35
|
-
protected
|
36
|
-
|
37
|
-
# Helper to create successful auth result
|
38
|
-
def success(user_context = {})
|
39
|
-
AuthResult.new(true, user_context)
|
40
|
-
end
|
41
|
-
|
42
|
-
# Helper to create failed auth result
|
43
|
-
def failure(reason = 'Authentication failed')
|
44
|
-
AuthResult.new(false, {}, reason)
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
# Result object for authentication attempts
|
49
|
-
class AuthResult
|
50
|
-
attr_reader :user_context, :failure_reason
|
51
|
-
|
52
|
-
def initialize(success, user_context = {}, failure_reason = nil)
|
53
|
-
@success = success
|
54
|
-
@user_context = user_context
|
55
|
-
@failure_reason = failure_reason
|
56
|
-
end
|
57
|
-
|
58
|
-
def success?
|
59
|
-
@success
|
60
|
-
end
|
61
|
-
|
62
|
-
def failure?
|
63
|
-
!@success
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
# Public access strategy - always allows access
|
68
|
-
class PublicStrategy < AuthStrategy
|
69
|
-
def authenticate(env, requirement)
|
70
|
-
success
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
# Session-based authentication strategy
|
75
|
-
class SessionStrategy < AuthStrategy
|
76
|
-
def initialize(session_key: 'user_id', session_store: nil)
|
77
|
-
@session_key = session_key
|
78
|
-
@session_store = session_store
|
79
|
-
end
|
80
|
-
|
81
|
-
def authenticate(env, requirement)
|
82
|
-
session = env['rack.session']
|
83
|
-
return failure('No session available') unless session
|
84
|
-
|
85
|
-
user_id = session[@session_key]
|
86
|
-
return failure('Not authenticated') unless user_id
|
87
|
-
|
88
|
-
success(user_id: user_id, session: session)
|
89
|
-
end
|
90
|
-
|
91
|
-
def user_context(env)
|
92
|
-
session = env['rack.session']
|
93
|
-
return {} unless session
|
94
|
-
|
95
|
-
user_id = session[@session_key]
|
96
|
-
user_id ? { user_id: user_id } : {}
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
# Role-based authentication strategy
|
101
|
-
class RoleStrategy < AuthStrategy
|
102
|
-
def initialize(allowed_roles, session_key: 'user_roles')
|
103
|
-
@allowed_roles = Array(allowed_roles)
|
104
|
-
@session_key = session_key
|
105
|
-
end
|
106
|
-
|
107
|
-
def authenticate(env, requirement)
|
108
|
-
session = env['rack.session']
|
109
|
-
return failure('No session available') unless session
|
110
|
-
|
111
|
-
user_roles = session[@session_key] || []
|
112
|
-
user_roles = Array(user_roles)
|
113
|
-
|
114
|
-
# For requirements like "role:admin", extract the role part
|
115
|
-
if requirement.include?(':')
|
116
|
-
required_role = requirement.split(':', 2).last
|
117
|
-
if user_roles.include?(required_role)
|
118
|
-
success(user_roles: user_roles, required_role: required_role)
|
119
|
-
else
|
120
|
-
failure("Insufficient privileges - requires role: #{required_role}")
|
121
|
-
end
|
122
|
-
else
|
123
|
-
# For direct strategy matches, check if user has any of the allowed roles
|
124
|
-
matching_roles = user_roles & @allowed_roles
|
125
|
-
if matching_roles.any?
|
126
|
-
success(user_roles: user_roles, allowed_roles: @allowed_roles, matching_roles: matching_roles)
|
127
|
-
else
|
128
|
-
failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
|
129
|
-
end
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
def user_context(env)
|
134
|
-
session = env['rack.session']
|
135
|
-
return {} unless session
|
136
|
-
|
137
|
-
user_roles = session[@session_key] || []
|
138
|
-
{ user_roles: Array(user_roles) }
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
# API key authentication strategy
|
143
|
-
class APIKeyStrategy < AuthStrategy
|
144
|
-
def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
|
145
|
-
@api_keys = Array(api_keys)
|
146
|
-
@header_name = header_name
|
147
|
-
@param_name = param_name
|
148
|
-
end
|
149
|
-
|
150
|
-
def authenticate(env, requirement)
|
151
|
-
# Try header first, then query parameter
|
152
|
-
api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
|
5
|
+
# Index file for Otto authentication module
|
6
|
+
# Requires all authentication-related components for backward compatibility
|
153
7
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
8
|
+
require_relative 'authentication/auth_strategy'
|
9
|
+
require_relative 'authentication/strategy_result'
|
10
|
+
require_relative 'authentication/failure_result'
|
11
|
+
require_relative 'authentication/authentication_middleware'
|
158
12
|
|
159
|
-
|
13
|
+
# Load all strategies
|
14
|
+
require_relative 'authentication/strategies/public_strategy'
|
15
|
+
require_relative 'authentication/strategies/session_strategy'
|
16
|
+
require_relative 'authentication/strategies/role_strategy'
|
17
|
+
require_relative 'authentication/strategies/api_key_strategy'
|
18
|
+
require_relative 'authentication/strategies/permission_strategy'
|
160
19
|
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
def initialize(required_permissions, session_key: 'user_permissions')
|
172
|
-
@required_permissions = Array(required_permissions)
|
173
|
-
@session_key = session_key
|
174
|
-
end
|
175
|
-
|
176
|
-
def authenticate(env, requirement)
|
177
|
-
session = env['rack.session']
|
178
|
-
return failure('No session available') unless session
|
179
|
-
|
180
|
-
user_permissions = session[@session_key] || []
|
181
|
-
user_permissions = Array(user_permissions)
|
182
|
-
|
183
|
-
# Extract permission from requirement (e.g., "permission:write" -> "write")
|
184
|
-
required_permission = requirement.split(':', 2).last
|
185
|
-
|
186
|
-
if user_permissions.include?(required_permission)
|
187
|
-
success(user_permissions: user_permissions, required_permission: required_permission)
|
188
|
-
else
|
189
|
-
failure("Insufficient privileges - requires permission: #{required_permission}")
|
190
|
-
end
|
191
|
-
end
|
192
|
-
|
193
|
-
def user_context(env)
|
194
|
-
session = env['rack.session']
|
195
|
-
return {} unless session
|
196
|
-
|
197
|
-
user_permissions = session[@session_key] || []
|
198
|
-
{ user_permissions: Array(user_permissions) }
|
199
|
-
end
|
200
|
-
end
|
201
|
-
|
202
|
-
# Authentication middleware that enforces route-level auth requirements
|
203
|
-
class AuthenticationMiddleware
|
204
|
-
def initialize(app, config = {})
|
205
|
-
@app = app
|
206
|
-
@config = config
|
207
|
-
@strategies = config[:auth_strategies] || {}
|
208
|
-
@default_strategy = config[:default_auth_strategy] || 'publically'
|
209
|
-
|
210
|
-
# Add default public strategy if not provided
|
211
|
-
@strategies['publically'] ||= PublicStrategy.new
|
212
|
-
end
|
213
|
-
|
214
|
-
def call(env)
|
215
|
-
# Check if this route has auth requirements
|
216
|
-
route_definition = env['otto.route_definition']
|
217
|
-
return @app.call(env) unless route_definition
|
218
|
-
|
219
|
-
auth_requirement = route_definition.auth_requirement
|
220
|
-
return @app.call(env) unless auth_requirement
|
221
|
-
|
222
|
-
# Find appropriate strategy
|
223
|
-
strategy = find_strategy(auth_requirement)
|
224
|
-
unless strategy
|
225
|
-
return auth_error_response("Unknown authentication strategy: #{auth_requirement}")
|
226
|
-
end
|
227
|
-
|
228
|
-
# Perform authentication
|
229
|
-
auth_result = strategy.authenticate(env, auth_requirement)
|
230
|
-
|
231
|
-
if auth_result.success?
|
232
|
-
# Add user context to environment for handlers to use
|
233
|
-
env['otto.user_context'] = auth_result.user_context
|
234
|
-
env['otto.auth_result'] = auth_result
|
235
|
-
@app.call(env)
|
236
|
-
else
|
237
|
-
auth_error_response(auth_result.failure_reason)
|
238
|
-
end
|
239
|
-
end
|
240
|
-
|
241
|
-
private
|
242
|
-
|
243
|
-
def find_strategy(requirement)
|
244
|
-
# Try exact match first - this has highest priority
|
245
|
-
return @strategies[requirement] if @strategies[requirement]
|
246
|
-
|
247
|
-
# For colon-separated requirements like "role:admin", try prefix match
|
248
|
-
if requirement.include?(':')
|
249
|
-
prefix = requirement.split(':', 2).first
|
250
|
-
|
251
|
-
# Check if we have a strategy registered for the prefix
|
252
|
-
prefix_strategy = @strategies[prefix]
|
253
|
-
return prefix_strategy if prefix_strategy
|
254
|
-
|
255
|
-
# Try fallback patterns for role: and permission: requirements
|
256
|
-
if requirement.start_with?('role:')
|
257
|
-
return @strategies['role'] || RoleStrategy.new([])
|
258
|
-
elsif requirement.start_with?('permission:')
|
259
|
-
return @strategies['permission'] || PermissionStrategy.new([])
|
260
|
-
end
|
261
|
-
end
|
262
|
-
|
263
|
-
nil
|
264
|
-
end
|
265
|
-
|
266
|
-
def auth_error_response(message)
|
267
|
-
body = JSON.generate({
|
268
|
-
error: 'Authentication Required',
|
269
|
-
message: message,
|
270
|
-
timestamp: Time.now.to_i
|
271
|
-
})
|
272
|
-
|
273
|
-
headers = {
|
274
|
-
'Content-Type' => 'application/json',
|
275
|
-
'Content-Length' => body.bytesize.to_s
|
276
|
-
}
|
277
|
-
|
278
|
-
# Add security headers if available from config hash or Otto instance
|
279
|
-
if @config.is_a?(Hash) && @config[:security_headers]
|
280
|
-
headers.merge!(@config[:security_headers])
|
281
|
-
elsif @config.respond_to?(:security_config) && @config.security_config
|
282
|
-
headers.merge!(@config.security_config.security_headers)
|
283
|
-
end
|
284
|
-
|
285
|
-
[401, headers, [body]]
|
286
|
-
end
|
287
|
-
end
|
20
|
+
class Otto
|
21
|
+
module Security
|
22
|
+
# Backward compatibility aliases for the old namespace
|
23
|
+
AuthStrategy = Authentication::AuthStrategy
|
24
|
+
PublicStrategy = Authentication::Strategies::PublicStrategy
|
25
|
+
SessionStrategy = Authentication::Strategies::SessionStrategy
|
26
|
+
RoleStrategy = Authentication::Strategies::RoleStrategy
|
27
|
+
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
28
|
+
PermissionStrategy = Authentication::Strategies::PermissionStrategy
|
29
|
+
AuthenticationMiddleware = Authentication::AuthenticationMiddleware
|
288
30
|
end
|
31
|
+
|
32
|
+
# Top-level backward compatibility aliases
|
33
|
+
StrategyResult = Security::Authentication::StrategyResult
|
34
|
+
FailureResult = Security::Authentication::FailureResult
|
289
35
|
end
|