Class: Otto

Inherits:
Object
  • Object
show all
Extended by:
ClassMethods
Defined in:
lib/otto.rb,
lib/otto/route.rb,
lib/otto/static.rb,
lib/otto/version.rb,
lib/otto/helpers/base.rb,
lib/otto/design_system.rb,
lib/otto/security/csrf.rb,
lib/otto/route_handlers.rb,
lib/otto/helpers/request.rb,
lib/otto/security/config.rb,
lib/otto/helpers/response.rb,
lib/otto/route_definition.rb,
lib/otto/response_handlers.rb,
lib/otto/security/validator.rb,
lib/otto/security/authentication.rb

Overview

lib/otto/security/authentication.rb

Configurable authentication strategy system for Otto framework Provides pluggable authentication patterns that can be customized per application

Usage:

otto = Otto.new('routes.txt', {
  auth_strategies: {
    'publically' => PublicStrategy.new,
    'authenticated' => SessionStrategy.new,
    'role:admin' => RoleStrategy.new(['admin']),
    'api_key' => APIKeyStrategy.new
  }
})

Defined Under Namespace

Modules: BaseHelpers, ClassMethods, DesignSystem, RequestHelpers, ResponseHandlers, ResponseHelpers, RouteHandlers, Security, Static Classes: Route, RouteDefinition

Constant Summary collapse

LIB_HOME =
__dir__
VERSION =
'1.5.0'.freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ClassMethods

default, env?, path

Constructor Details

#initialize(path = nil, opts = {}) ⇒ Otto

Returns a new instance of Otto.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/otto.rb', line 66

def initialize(path = nil, opts = {})
  @routes_static     = { GET: {} }
  @routes            = { GET: [] }
  @routes_literal    = { GET: {} }
  @route_definitions = {}
  @option            = {
    public: nil,
    locale: 'en',
  }.merge(opts)
  @security_config   = Otto::Security::Config.new
  @middleware_stack  = []
  @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory

  # Configure locale support (merge global config with instance options)
  configure_locale(opts)

  # Configure security based on options
  configure_security(opts)

  # Configure authentication based on options
  configure_authentication(opts)

  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
  load(path) unless path.nil?
  super()
end

Class Attribute Details

.debugObject

Returns the value of attribute debug.



610
611
612
# File 'lib/otto.rb', line 610

def debug
  @debug
end

.loggerObject

Returns the value of attribute logger.



610
611
612
# File 'lib/otto.rb', line 610

def logger
  @logger
end

Instance Attribute Details

#auth_configObject (readonly)

Returns the value of attribute auth_config.



63
64
65
# File 'lib/otto.rb', line 63

def auth_config
  @auth_config
end

#locale_configObject (readonly)

Returns the value of attribute locale_config.



63
64
65
# File 'lib/otto.rb', line 63

def locale_config
  @locale_config
end

#middleware_stackObject

Returns the value of attribute middleware_stack.



64
65
66
# File 'lib/otto.rb', line 64

def middleware_stack
  @middleware_stack
end

#not_foundObject

Returns the value of attribute not_found.



64
65
66
# File 'lib/otto.rb', line 64

def not_found
  @not_found
end

#optionObject (readonly) Also known as: options

Returns the value of attribute option.



63
64
65
# File 'lib/otto.rb', line 63

def option
  @option
end

#route_definitionsObject (readonly)

Returns the value of attribute route_definitions.



63
64
65
# File 'lib/otto.rb', line 63

def route_definitions
  @route_definitions
end

#route_handler_factoryObject (readonly)

Returns the value of attribute route_handler_factory.



63
64
65
# File 'lib/otto.rb', line 63

def route_handler_factory
  @route_handler_factory
end

#routesObject (readonly)

Returns the value of attribute routes.



63
64
65
# File 'lib/otto.rb', line 63

def routes
  @routes
end

#routes_literalObject (readonly)

Returns the value of attribute routes_literal.



63
64
65
# File 'lib/otto.rb', line 63

def routes_literal
  @routes_literal
end

#routes_staticObject (readonly)

Returns the value of attribute routes_static.



63
64
65
# File 'lib/otto.rb', line 63

def routes_static
  @routes_static
end

#security_configObject (readonly)

Returns the value of attribute security_config.



63
64
65
# File 'lib/otto.rb', line 63

def security_config
  @security_config
end

#server_errorObject

Returns the value of attribute server_error.



64
65
66
# File 'lib/otto.rb', line 64

def server_error
  @server_error
end

#static_routeObject (readonly)

Returns the value of attribute static_route.



63
64
65
# File 'lib/otto.rb', line 63

def static_route
  @static_route
end

Class Method Details

.configure {|config| ... } ⇒ Object

Global configuration for all Otto instances

Yields:

  • (config)


53
54
55
56
57
# File 'lib/otto.rb', line 53

def self.configure
  config = OpenStruct.new(@global_config)
  yield config
  @global_config = config.to_h
end

.global_configObject



59
60
61
# File 'lib/otto.rb', line 59

def self.global_config
  @global_config
end

Instance Method Details

#add_auth_strategy(name, strategy) ⇒ Object

Add a single authentication strategy

Examples:

otto.add_auth_strategy('custom', MyCustomStrategy.new)

Parameters:



462
463
464
465
466
467
# File 'lib/otto.rb', line 462

def add_auth_strategy(name, strategy)
  @auth_config ||= { auth_strategies: {}, default_auth_strategy: 'publically' }
  @auth_config[:auth_strategies][name] = strategy

  enable_authentication!
end

#add_static_path(path) ⇒ Object



159
160
161
162
163
164
165
166
167
168
# File 'lib/otto.rb', line 159

def add_static_path(path)
  return unless safe_file?(path)

  base_path                      = File.split(path).first
  # Files in the root directory can refer to themselves
  base_path                      = path if base_path == '/'
  File.join(option[:public], base_path)
  Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
  routes_static[:GET][base_path] = base_path
end

#add_trusted_proxy(proxy) ⇒ Object

Add a trusted proxy server for accurate client IP detection. Only requests from trusted proxies will have their forwarded headers honored.

Examples:

otto.add_trusted_proxy('10.0.0.0/8')
otto.add_trusted_proxy(/^172\.16\./)

Parameters:

  • proxy (String, Regexp)

    IP address, CIDR range, or regex pattern



353
354
355
# File 'lib/otto.rb', line 353

def add_trusted_proxy(proxy)
  @security_config.add_trusted_proxy(proxy)
end

#call(env) ⇒ Object



170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/otto.rb', line 170

def call(env)
  # Apply middleware stack
  app = ->(e) { handle_request(e) }
  @middleware_stack.reverse_each do |middleware|
    app = middleware.new(app, @security_config)
  end

  begin
    app.call(env)
  rescue StandardError => ex
    handle_error(ex, env)
  end
end

#configure(available_locales: nil, default_locale: nil) ⇒ Object

Configure locale settings for the application

Examples:

otto.configure(
  available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
  default_locale: 'en'
)

Parameters:

  • available_locales (Hash) (defaults to: nil)

    Hash of available locales (e.g., { ‘en’ => ‘English’, ‘es’ => ‘Spanish’ })

  • default_locale (String) (defaults to: nil)

    Default locale to use as fallback



420
421
422
423
424
# File 'lib/otto.rb', line 420

def configure(available_locales: nil, default_locale: nil)
  @locale_config ||= {}
  @locale_config[:available_locales] = available_locales if available_locales
  @locale_config[:default_locale] = default_locale if default_locale
end

#configure_auth_strategies(strategies, default_strategy: 'publically') ⇒ Object

Configure authentication strategies for route-level access control.

Examples:

otto.configure_auth_strategies({
  'publically' => Otto::Security::PublicStrategy.new,
  'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
  'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
  'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
})

Parameters:

  • strategies (Hash)

    Hash mapping strategy names to strategy instances

  • default_strategy (String) (defaults to: 'publically')

    Default strategy to use when none specified



448
449
450
451
452
453
454
# File 'lib/otto.rb', line 448

def configure_auth_strategies(strategies, default_strategy: 'publically')
  @auth_config ||= {}
  @auth_config[:auth_strategies] = strategies
  @auth_config[:default_auth_strategy] = default_strategy

  enable_authentication! unless strategies.empty?
end

#determine_locale(env) ⇒ Object



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/otto.rb', line 294

def determine_locale(env)
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
  accept_langs = option[:locale] if accept_langs.to_s.empty?
  locales      = []
  unless accept_langs.empty?
    locales = accept_langs.split(',').map do |l|
      l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
      l.split(';q=')
    end.sort_by do |_locale, qvalue|
      qvalue.to_f
    end.collect do |locale, _qvalue|
      locale
    end.reverse
  end
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
  locales.empty? ? nil : locales
end

#enable_authentication!Object

Enable authentication middleware for route-level access control. This will automatically check route auth parameters and enforce authentication.

Examples:

otto.enable_authentication!


431
432
433
434
435
# File 'lib/otto.rb', line 431

def enable_authentication!
  return if middleware_enabled?(Otto::Security::AuthenticationMiddleware)

  use Otto::Security::AuthenticationMiddleware, @auth_config
end

#enable_csp!(policy = "default-src 'self'") ⇒ Object

Enable Content Security Policy (CSP) header to prevent XSS attacks. The default policy only allows resources from the same origin.

Examples:

otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")

Parameters:

  • policy (String) (defaults to: "default-src 'self'")

    CSP policy string (default: “default-src ‘self’”)



388
389
390
# File 'lib/otto.rb', line 388

def enable_csp!(policy = "default-src 'self'")
  @security_config.enable_csp!(policy)
end

#enable_csp_with_nonce!(debug: false) ⇒ Object

Enable Content Security Policy (CSP) with nonce support for dynamic header generation. This enables the res.send_csp_headers response helper method.

Examples:

otto.enable_csp_with_nonce!(debug: true)

Parameters:

  • debug (Boolean) (defaults to: false)

    Enable debug logging for CSP headers (default: false)



407
408
409
# File 'lib/otto.rb', line 407

def enable_csp_with_nonce!(debug: false)
  @security_config.enable_csp_with_nonce!(debug: debug)
end

#enable_csrf_protection!Object

Enable CSRF protection for POST, PUT, DELETE, and PATCH requests. This will automatically add CSRF tokens to HTML forms and validate them on unsafe HTTP methods.

Examples:

otto.enable_csrf_protection!


327
328
329
330
331
332
# File 'lib/otto.rb', line 327

def enable_csrf_protection!
  return if middleware_enabled?(Otto::Security::CSRFMiddleware)

  @security_config.enable_csrf_protection!
  use Otto::Security::CSRFMiddleware
end

#enable_frame_protection!(option = 'SAMEORIGIN') ⇒ Object

Enable X-Frame-Options header to prevent clickjacking attacks.

Examples:

otto.enable_frame_protection!('DENY')

Parameters:

  • option (String) (defaults to: 'SAMEORIGIN')

    Frame options: ‘DENY’, ‘SAMEORIGIN’, or ‘ALLOW-FROM uri’



397
398
399
# File 'lib/otto.rb', line 397

def enable_frame_protection!(option = 'SAMEORIGIN')
  @security_config.enable_frame_protection!(option)
end

#enable_hsts!(max_age: 31_536_000, include_subdomains: true) ⇒ Object

Enable HTTP Strict Transport Security (HSTS) header. WARNING: This can make your domain inaccessible if HTTPS is not properly configured. Only enable this when you’re certain HTTPS is working correctly.

Examples:

otto.enable_hsts!(max_age: 86400, include_subdomains: false)

Parameters:

  • max_age (Integer) (defaults to: 31_536_000)

    Maximum age in seconds (default: 1 year)

  • include_subdomains (Boolean) (defaults to: true)

    Apply to all subdomains (default: true)



378
379
380
# File 'lib/otto.rb', line 378

def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
  @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
end

#enable_request_validation!Object

Enable request validation including input sanitization, size limits, and protection against XSS and SQL injection attacks.

Examples:

otto.enable_request_validation!


339
340
341
342
343
344
# File 'lib/otto.rb', line 339

def enable_request_validation!
  return if middleware_enabled?(Otto::Security::ValidationMiddleware)

  @security_config.input_validation = true
  use Otto::Security::ValidationMiddleware
end

#handle_request(env) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/otto.rb', line 184

def handle_request(env)
  locale             = determine_locale env
  env['rack.locale'] = locale
  env['otto.locale_config'] = @locale_config if @locale_config
  @static_route    ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
  path_info          = Rack::Utils.unescape(env['PATH_INFO'])
  path_info          = '/' if path_info.to_s.empty?

  begin
    path_info_clean = path_info
      .encode(
        'UTF-8', # Target encoding
        invalid: :replace, # Replace invalid byte sequences
        undef: :replace,   # Replace characters undefined in UTF-8
        replace: '',        # Use empty string for replacement
      )
      .gsub(%r{/$}, '') # Remove trailing slash, if present
  rescue ArgumentError => ex
    # Log the error but don't expose details
    Otto.logger.error '[Otto.handle_request] Path encoding error'
    Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
    # Set a default value or use the original path_info
    path_info_clean = path_info
  end

  base_path      = File.split(path_info).first
  # Files in the root directory can refer to themselves
  base_path      = path_info if base_path == '/'
  http_verb      = env['REQUEST_METHOD'].upcase.to_sym
  literal_routes = routes_literal[http_verb] || {}
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
    # Otto.logger.debug " request: #{path_info} (static)"
    static_route.call(env)
  elsif literal_routes.has_key?(path_info_clean)
    route = literal_routes[path_info_clean]
    # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
    route.call(env)
  elsif static_route && http_verb == :GET && safe_file?(path_info)
    Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
    routes_static[:GET][base_path] = base_path
    static_route.call(env)
  else
    extra_params  = {}
    found_route   = nil
    valid_routes  = routes[http_verb] || []
    valid_routes.push(*routes[:GET]) if http_verb == :HEAD
    valid_routes.each do |route|
      # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
      next unless (match = route.pattern.match(path_info))

      values       = match.captures.to_a
      # The first capture returned is the entire matched string b/c
      # we wrapped the entire regex in parens. We don't need it to
      # the full match.
      values.shift
      extra_params =
        if route.keys.any?
          route.keys.zip(values).each_with_object({}) do |(k, v), hash|
            if k == 'splat'
              (hash[k] ||= []) << v
            else
              hash[k] = v
            end
          end
        elsif values.any?
          { 'captures' => values }
        else
          {}
        end
      found_route  = route
      break
    end
    found_route ||= literal_routes['/404']
    if found_route
      found_route.call env, extra_params
    else
      @not_found || Otto::Static.not_found
    end
  end
end

#load(path) ⇒ Object

Raises:

  • (ArgumentError)


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/otto.rb', line 94

def load(path)
  path = File.expand_path(path)
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)

  raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
  raw.each do |entry|
    # Enhanced parsing: split only on first two whitespace boundaries
    # This preserves parameters in the definition part
    parts = entry.split(/\s+/, 3)
    verb, path, definition = parts[0], parts[1], parts[2]
    route                                   = Otto::Route.new verb, path, definition
    route.otto                              = self
    path_clean                              = path.gsub(%r{/$}, '')
    @route_definitions[route.definition]    = route
    Otto.logger.debug "route: #{route.pattern}" if Otto.debug
    @routes[route.verb]                   ||= []
    @routes[route.verb] << route
    @routes_literal[route.verb]           ||= {}
    @routes_literal[route.verb][path_clean] = route
  rescue StandardError
    Otto.logger.error "Bad route in #{path}: #{entry}"
  end
  self
end

#safe_dir?(path) ⇒ Boolean

Returns:

  • (Boolean)


144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/otto.rb', line 144

def safe_dir?(path)
  return false if path.nil? || path.empty?

  # Clean and expand the path
  clean_path = path.delete("\0").strip
  return false if clean_path.empty?

  expanded_path = File.expand_path(clean_path)

  # Check directory exists, is readable, and has proper ownership
  File.directory?(expanded_path) &&
    File.readable?(expanded_path) &&
    (File.owned?(expanded_path) || File.grpowned?(expanded_path))
end

#safe_file?(path) ⇒ Boolean

Returns:

  • (Boolean)


119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/otto.rb', line 119

def safe_file?(path)
  return false if option[:public].nil? || option[:public].empty?
  return false if path.nil? || path.empty?

  # Normalize and resolve the public directory path
  public_dir = File.expand_path(option[:public])
  return false unless File.directory?(public_dir)

  # Clean the requested path - remove null bytes and normalize
  clean_path = path.delete("\0").strip
  return false if clean_path.empty?

  # Join and expand to get the full resolved path
  requested_path = File.expand_path(File.join(public_dir, clean_path))

  # Ensure the resolved path is within the public directory (prevents path traversal)
  return false unless requested_path.start_with?(public_dir + File::SEPARATOR)

  # Check file exists, is readable, and is not a directory
  File.exist?(requested_path) &&
    File.readable?(requested_path) &&
    !File.directory?(requested_path) &&
    (File.owned?(requested_path) || File.grpowned?(requested_path))
end

#set_security_headers(headers) ⇒ Object

Set custom security headers that will be added to all responses. These merge with the default security headers.

Examples:

otto.set_security_headers({
  'content-security-policy' => "default-src 'self'",
  'strict-transport-security' => 'max-age=31536000'
})

Parameters:

  • headers (Hash)

    Hash of header name => value pairs



366
367
368
# File 'lib/otto.rb', line 366

def set_security_headers(headers)
  @security_config.security_headers.merge!(headers)
end

#uri(route_definition, params = {}) ⇒ Object

Return the URI path for the given route_definition e.g.

Otto.default.path 'YourClass.somemethod'  #=> /some/path


271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/otto.rb', line 271

def uri(route_definition, params = {})
  # raise RuntimeError, "Not working"
  route = @route_definitions[route_definition]
  return if route.nil?

  local_params = params.clone
  local_path   = route.path.clone

  local_params.each_pair do |k, v|
    next unless local_path.match(":#{k}")

    local_path.gsub!(":#{k}", v.to_s)
    local_params.delete(k)
  end

  uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
  unless local_params.empty?
    query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
    uri.query    = query_string
  end
  uri.to_s
end

#use(middleware) ⇒ Object

Add middleware to the stack

Parameters:

  • middleware (Class)

    The middleware class to add

  • args (Array)

    Additional arguments for the middleware

  • block (Proc)

    Optional block for middleware configuration



317
318
319
# File 'lib/otto.rb', line 317

def use(middleware, *, &)
  @middleware_stack << middleware
end