aris 1.4.2 → 1.5.0
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/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- metadata +31 -3
data/lib/aris/plugins/csrf.rb
CHANGED
|
@@ -1,48 +1,89 @@
|
|
|
1
|
+
# lib/aris/plugins/csrf.rb
|
|
2
|
+
#
|
|
3
|
+
# Session-backed CSRF protection (synchronizer token pattern).
|
|
4
|
+
#
|
|
5
|
+
# Requires the Session plugin earlier in the same `use:` list:
|
|
6
|
+
#
|
|
7
|
+
# use: [:session, :csrf]
|
|
8
|
+
#
|
|
9
|
+
# The generator makes sure the session holds a token and exposes it as
|
|
10
|
+
# `request.csrf_token`. The protection step rejects POST/PUT/PATCH/DELETE
|
|
11
|
+
# requests whose token (form field `_csrf`, or header `X-CSRF-Token`) does
|
|
12
|
+
# not match the one in the session.
|
|
13
|
+
#
|
|
14
|
+
# In a form: input type: "hidden", name: "_csrf", value: request.csrf_token
|
|
15
|
+
# With fetch: headers: { 'X-CSRF-Token': token }
|
|
1
16
|
require 'securerandom'
|
|
17
|
+
require 'rack/utils'
|
|
18
|
+
|
|
2
19
|
module Aris
|
|
3
20
|
module Plugins
|
|
4
21
|
module CsrfUtility
|
|
5
22
|
extend self
|
|
23
|
+
|
|
24
|
+
SESSION_KEY = :_csrf_token
|
|
25
|
+
FIELD_NAME = '_csrf'
|
|
26
|
+
HEADER_NAME = 'HTTP_X_CSRF_TOKEN'
|
|
27
|
+
|
|
6
28
|
def generate_token
|
|
7
29
|
SecureRandom.urlsafe_base64(32)
|
|
8
30
|
end
|
|
31
|
+
|
|
9
32
|
def validate_token(expected, provided)
|
|
10
|
-
|
|
33
|
+
return false if expected.nil? || provided.nil?
|
|
34
|
+
expected = expected.to_s
|
|
35
|
+
provided = provided.to_s
|
|
36
|
+
return false if expected.empty? || provided.empty?
|
|
37
|
+
::Rack::Utils.secure_compare(expected, provided)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The token the client sent, wherever it put it.
|
|
41
|
+
def provided_token(request)
|
|
42
|
+
header = request.respond_to?(:env) && request.env.respond_to?(:[]) ? request.env[HEADER_NAME] : nil
|
|
43
|
+
header ||= request.headers[HEADER_NAME] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
|
|
44
|
+
return header if header && !header.to_s.empty?
|
|
45
|
+
|
|
46
|
+
%i[form_params multipart_params params].each do |reader|
|
|
47
|
+
next unless request.respond_to?(reader)
|
|
48
|
+
value = request.public_send(reader)
|
|
49
|
+
token = value[FIELD_NAME] if value.respond_to?(:[])
|
|
50
|
+
return token if token && !token.to_s.empty?
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
11
53
|
end
|
|
12
54
|
end
|
|
13
|
-
|
|
14
|
-
CSRF_THREAD_KEY = :aris_csrf_token
|
|
55
|
+
|
|
15
56
|
FORM_METHODS = %w[POST PUT PATCH DELETE].freeze
|
|
16
|
-
|
|
57
|
+
|
|
17
58
|
class CsrfTokenGenerator
|
|
18
59
|
def self.call(request, response)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Thread.current[CSRF_THREAD_KEY] = token
|
|
60
|
+
unless request.respond_to?(:session)
|
|
61
|
+
raise ArgumentError, "CSRF protection needs the Session plugin before it: use: [:session, :csrf]"
|
|
22
62
|
end
|
|
63
|
+
request.session[CsrfUtility::SESSION_KEY] ||= CsrfUtility.generate_token
|
|
64
|
+
token = request.session[CsrfUtility::SESSION_KEY]
|
|
65
|
+
request.define_singleton_method(:csrf_token) { token }
|
|
23
66
|
nil # Continue pipeline
|
|
24
67
|
end
|
|
25
68
|
end
|
|
26
|
-
|
|
69
|
+
|
|
27
70
|
class CsrfProtection
|
|
28
71
|
def self.call(request, response)
|
|
29
|
-
return nil unless FORM_METHODS.include?(request.method)
|
|
30
|
-
expected =
|
|
31
|
-
provided = request
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
nil
|
|
72
|
+
return nil unless FORM_METHODS.include?(request.method.to_s.upcase)
|
|
73
|
+
expected = request.respond_to?(:session) ? request.session[CsrfUtility::SESSION_KEY] : nil
|
|
74
|
+
provided = CsrfUtility.provided_token(request)
|
|
75
|
+
return nil if CsrfUtility.validate_token(expected, provided)
|
|
76
|
+
|
|
77
|
+
response.status = 403
|
|
78
|
+
response.headers['content-type'] = 'text/plain'
|
|
79
|
+
response.body = ['CSRF token validation failed']
|
|
80
|
+
response
|
|
40
81
|
end
|
|
41
82
|
end
|
|
42
|
-
|
|
43
83
|
end
|
|
44
84
|
end
|
|
45
|
-
|
|
85
|
+
|
|
86
|
+
Aris.register_plugin(:csrf,
|
|
46
87
|
generator: Aris::Plugins::CsrfTokenGenerator,
|
|
47
88
|
protection: Aris::Plugins::CsrfProtection
|
|
48
|
-
)
|
|
89
|
+
)
|
data/lib/aris/plugins/flash.rb
CHANGED
|
@@ -3,43 +3,64 @@ require 'rack/utils'
|
|
|
3
3
|
|
|
4
4
|
module Aris
|
|
5
5
|
module Plugins
|
|
6
|
+
# Parses application/x-www-form-urlencoded bodies.
|
|
7
|
+
#
|
|
8
|
+
# use: [:form_parser]
|
|
9
|
+
#
|
|
10
|
+
# Handlers read fields from `request.form_params` (form fields only) or
|
|
11
|
+
# `request.params` (query string merged with form fields).
|
|
6
12
|
class FormParser
|
|
7
13
|
attr_reader :config
|
|
8
|
-
|
|
14
|
+
|
|
9
15
|
PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
|
|
10
|
-
|
|
16
|
+
CONTENT_TYPE = 'application/x-www-form-urlencoded'.freeze
|
|
17
|
+
|
|
11
18
|
def initialize(**config)
|
|
12
19
|
@config = config
|
|
13
20
|
end
|
|
14
|
-
|
|
15
|
-
def self.call(request, response)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
|
|
21
|
+
|
|
22
|
+
def self.call(request, response)
|
|
23
|
+
return nil unless PARSEABLE_METHODS.include?(request.method.to_s.upcase)
|
|
24
|
+
return nil unless form_request?(request)
|
|
25
|
+
|
|
26
|
+
raw_body = request.body
|
|
27
|
+
return nil if raw_body.nil? || raw_body.empty?
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
data = ::Rack::Utils.parse_nested_query(raw_body)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
response.status = 400
|
|
33
|
+
response.headers['content-type'] = 'text/plain'
|
|
34
|
+
response.body = ['Invalid form data']
|
|
35
|
+
return response
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
request.instance_variable_set(:@parsed_form_data, data)
|
|
39
|
+
request.define_singleton_method(:form_params) { @parsed_form_data || {} }
|
|
40
|
+
|
|
41
|
+
# Merge into params so `request.params['field']` works for forms too.
|
|
42
|
+
if request.respond_to?(:params)
|
|
43
|
+
merged = request.params.merge(data)
|
|
44
|
+
request.instance_variable_set(:@params, merged)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Works with both adapters: Rack keeps CONTENT_TYPE in env, the Mock
|
|
51
|
+
# adapter keeps it in headers.
|
|
52
|
+
def self.form_request?(request)
|
|
53
|
+
content_type = nil
|
|
54
|
+
content_type = request.env['CONTENT_TYPE'] if request.respond_to?(:env) && request.env.respond_to?(:[])
|
|
55
|
+
content_type ||= request.headers['CONTENT_TYPE'] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
|
|
56
|
+
content_type.to_s.include?(CONTENT_TYPE)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Instances (from .build) behave exactly like the class.
|
|
60
|
+
def call(request, response)
|
|
61
|
+
self.class.call(request, response)
|
|
62
|
+
end
|
|
63
|
+
|
|
43
64
|
def self.build(**config)
|
|
44
65
|
new(**config)
|
|
45
66
|
end
|
|
@@ -16,10 +16,23 @@ module Aris
|
|
|
16
16
|
@allowed_extensions = config[:allowed_extensions] # nil = all allowed
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Works as a class (`use: [:multipart]`, defaults) or an instance
|
|
20
|
+
# (`use: [Multipart.build(max_file_size: ..., allowed_extensions: [...])]`).
|
|
21
|
+
def self.call(request, response)
|
|
22
|
+
(@default_instance ||= new).call(request, response)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.content_type_of(request)
|
|
26
|
+
content_type = nil
|
|
27
|
+
content_type = request.env['CONTENT_TYPE'] if request.respond_to?(:env) && request.env.respond_to?(:[])
|
|
28
|
+
content_type ||= request.headers['CONTENT_TYPE'] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
|
|
29
|
+
content_type
|
|
30
|
+
end
|
|
31
|
+
|
|
19
32
|
def call(request, response)
|
|
20
|
-
return nil unless PARSEABLE_METHODS.include?(request.method)
|
|
33
|
+
return nil unless PARSEABLE_METHODS.include?(request.method.to_s.upcase)
|
|
21
34
|
|
|
22
|
-
content_type = request
|
|
35
|
+
content_type = self.class.content_type_of(request)
|
|
23
36
|
return nil unless content_type&.include?('multipart/form-data')
|
|
24
37
|
|
|
25
38
|
# Extract boundary from content type
|
|
@@ -69,6 +82,13 @@ module Aris
|
|
|
69
82
|
|
|
70
83
|
# Attach parsed data to request
|
|
71
84
|
request.instance_variable_set(:@multipart_data, parts)
|
|
85
|
+
fields = parts.each_with_object({}) { |p, h| h[p[:name]] = p[:data] if p[:type] == :field }
|
|
86
|
+
request.define_singleton_method(:multipart_data) { @multipart_data || [] }
|
|
87
|
+
request.define_singleton_method(:multipart_params) { fields }
|
|
88
|
+
request.define_singleton_method(:multipart_files) { (@multipart_data || []).select { |p| p[:type] == :file } }
|
|
89
|
+
if request.respond_to?(:params)
|
|
90
|
+
request.instance_variable_set(:@params, request.params.merge(fields))
|
|
91
|
+
end
|
|
72
92
|
|
|
73
93
|
rescue => e
|
|
74
94
|
response.status = 400
|
|
@@ -38,6 +38,13 @@ module Aris
|
|
|
38
38
|
def self.build(**config)
|
|
39
39
|
new(**config)
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
# `use: [:request_logger]` (the bare symbol) runs an instance with default options.
|
|
43
|
+
def self.call(request, response)
|
|
44
|
+
(@default_instance ||= new).call(request, response)
|
|
45
|
+
end
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
|
-
end
|
|
48
|
+
end
|
|
49
|
+
# Self-register so `use: [:request_logger]` works without an explicit register_plugin call.
|
|
50
|
+
Aris.register_plugin(:request_logger, plugin_class: Aris::Plugins::RequestLogger)
|
|
@@ -30,6 +30,11 @@ module Aris
|
|
|
30
30
|
def self.build(**config)
|
|
31
31
|
new(**config)
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
# `use: [:security_headers]` (the bare symbol) runs an instance with default options.
|
|
35
|
+
def self.call(request, response)
|
|
36
|
+
(@default_instance ||= new).call(request, response)
|
|
37
|
+
end
|
|
33
38
|
|
|
34
39
|
private
|
|
35
40
|
|
|
@@ -96,4 +101,6 @@ def build_headers(config)
|
|
|
96
101
|
end
|
|
97
102
|
end
|
|
98
103
|
end
|
|
99
|
-
end
|
|
104
|
+
end
|
|
105
|
+
# Self-register so `use: [:security_headers]` works without an explicit register_plugin call.
|
|
106
|
+
Aris.register_plugin(:security_headers, plugin_class: Aris::Plugins::SecurityHeaders)
|
data/lib/aris/plugins/session.rb
CHANGED
|
@@ -1,168 +1,219 @@
|
|
|
1
1
|
# lib/aris/plugins/session.rb
|
|
2
|
+
#
|
|
3
|
+
# Cookie sessions that can carry authentication.
|
|
4
|
+
#
|
|
5
|
+
# The cookie holds an AES-256-GCM encrypted, authenticated blob keyed from
|
|
6
|
+
# Aris::Config.secret_key_base. A client cannot read the session contents
|
|
7
|
+
# and cannot forge or modify them: any tampering, wrong key, or expired
|
|
8
|
+
# session simply loads as an empty session.
|
|
9
|
+
#
|
|
10
|
+
# Aris.configure { |c| c.secret_key_base = ENV.fetch('SECRET_KEY_BASE') }
|
|
11
|
+
#
|
|
12
|
+
# Aris.routes({
|
|
13
|
+
# "example.com": {
|
|
14
|
+
# use: [:session],
|
|
15
|
+
# "/login": { post: { to: ->(req, res, prm) { req.session[:user_id] = 1; res.redirect('/') } } },
|
|
16
|
+
# "/logout": { post: { to: ->(req, res, prm) { req.session.destroy; res.redirect('/') } } },
|
|
17
|
+
# }
|
|
18
|
+
# })
|
|
19
|
+
#
|
|
20
|
+
# Options (Session.default_config, or Session.build(**opts) for a per-scope
|
|
21
|
+
# instance): key, expire_after (seconds), secret, secure, same_site, path.
|
|
2
22
|
require 'json'
|
|
3
23
|
require 'base64'
|
|
4
24
|
require 'openssl'
|
|
25
|
+
require 'securerandom'
|
|
5
26
|
|
|
6
27
|
module Aris
|
|
7
28
|
module Plugins
|
|
8
29
|
class Session
|
|
30
|
+
CIPHER = 'aes-256-gcm'
|
|
31
|
+
IV_BYTES = 12
|
|
32
|
+
TAG_BYTES = 16
|
|
33
|
+
MIN_SECRET_BYTES = 32
|
|
34
|
+
|
|
9
35
|
@default_config = {
|
|
10
36
|
enabled: true,
|
|
11
37
|
store: :cookie,
|
|
12
38
|
key: '_aris_session',
|
|
13
39
|
expire_after: 14 * 24 * 3600, # 2 weeks in seconds
|
|
14
|
-
secret: nil
|
|
40
|
+
secret: nil,
|
|
41
|
+
secure: nil, # nil = Config.cookie_options[:secure], or true in production
|
|
42
|
+
same_site: :lax,
|
|
43
|
+
path: '/'
|
|
15
44
|
}
|
|
16
|
-
|
|
45
|
+
|
|
17
46
|
class << self
|
|
18
47
|
attr_accessor :default_config
|
|
19
|
-
|
|
48
|
+
|
|
49
|
+
# Plugin protocol (class used directly: `use: [:session]`)
|
|
20
50
|
def call(request, response)
|
|
21
|
-
load_session(request)
|
|
51
|
+
load_session(request, effective_config)
|
|
22
52
|
nil
|
|
23
53
|
end
|
|
24
|
-
|
|
54
|
+
|
|
25
55
|
def call_response(request, response)
|
|
26
56
|
return unless request.respond_to?(:session)
|
|
27
|
-
|
|
28
|
-
store_session(request, response)
|
|
57
|
+
store_session(request, response, effective_config)
|
|
29
58
|
end
|
|
30
|
-
|
|
59
|
+
|
|
60
|
+
# `use: [Aris::Plugins::Session.build(key: '_myapp', expire_after: 3600)]`
|
|
31
61
|
def build(**config)
|
|
32
|
-
|
|
33
|
-
config[:secret] ||= Aris::Config.secret_key_base
|
|
34
|
-
new(config)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def load_session(request)
|
|
40
|
-
session_data = load_from_store(request)
|
|
41
|
-
|
|
42
|
-
request.define_singleton_method(:session) do
|
|
43
|
-
@session ||= SessionData.new(session_data)
|
|
44
|
-
end
|
|
62
|
+
new(default_config.merge(config))
|
|
45
63
|
end
|
|
46
|
-
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
|
|
65
|
+
def effective_config
|
|
66
|
+
default_config
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- implementation shared by the class and instances ---------------
|
|
70
|
+
|
|
71
|
+
def load_session(request, config)
|
|
72
|
+
data = load_from_cookie(request, config)
|
|
73
|
+
request.define_singleton_method(:session) { @session ||= SessionData.new(data) }
|
|
55
74
|
end
|
|
56
|
-
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
|
|
76
|
+
def store_session(request, response, config)
|
|
77
|
+
session = request.session
|
|
78
|
+
return unless session.changed? || session.destroyed?
|
|
79
|
+
if session.destroyed? || session.to_hash.empty?
|
|
80
|
+
response.delete_cookie(config[:key], path: config[:path])
|
|
61
81
|
else
|
|
62
|
-
|
|
82
|
+
response.set_cookie(config[:key], encrypt(session.to_hash, config), cookie_options(config))
|
|
63
83
|
end
|
|
64
84
|
end
|
|
65
|
-
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
85
|
+
|
|
86
|
+
def load_from_cookie(request, config)
|
|
87
|
+
return {} unless request.respond_to?(:cookies)
|
|
88
|
+
raw = request.cookies[config[:key]]
|
|
89
|
+
return {} if raw.nil? || raw.empty?
|
|
90
|
+
decrypt(raw, config) || {}
|
|
71
91
|
end
|
|
72
|
-
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
|
|
93
|
+
def cookie_options(config)
|
|
94
|
+
secure = config[:secure]
|
|
95
|
+
secure = (Aris::Config.cookie_options || {})[:secure] if secure.nil?
|
|
96
|
+
secure = (ENV['RACK_ENV'] == 'production') if secure.nil?
|
|
97
|
+
{
|
|
98
|
+
httponly: true,
|
|
99
|
+
secure: secure ? true : false,
|
|
100
|
+
same_site: config[:same_site],
|
|
101
|
+
path: config[:path],
|
|
102
|
+
max_age: config[:expire_after]
|
|
103
|
+
}
|
|
78
104
|
end
|
|
79
|
-
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
begin
|
|
87
|
-
# For encrypted sessions
|
|
88
|
-
decrypt_session(cookie_value)
|
|
89
|
-
rescue
|
|
90
|
-
{} # Invalid session, start fresh
|
|
105
|
+
|
|
106
|
+
def secret_for(config)
|
|
107
|
+
secret = config[:secret] || Aris::Config.secret_key_base
|
|
108
|
+
if secret.nil? || secret.to_s.bytesize < MIN_SECRET_BYTES
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"Aris sessions need Aris::Config.secret_key_base (or session secret:) of at least #{MIN_SECRET_BYTES} bytes"
|
|
91
111
|
end
|
|
112
|
+
secret.to_s
|
|
92
113
|
end
|
|
93
|
-
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
|
|
115
|
+
def derived_key(config)
|
|
116
|
+
OpenSSL::HMAC.digest('SHA256', secret_for(config), 'aris.session.v1')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encrypt(data, config)
|
|
120
|
+
payload = data.merge('_exp' => Time.now.to_i + config[:expire_after].to_i)
|
|
121
|
+
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
|
|
122
|
+
cipher.key = derived_key(config)
|
|
123
|
+
iv = cipher.random_iv
|
|
124
|
+
cipher.auth_data = ''
|
|
125
|
+
ciphertext = cipher.update(JSON.generate(payload)) + cipher.final
|
|
126
|
+
Base64.urlsafe_encode64(iv + cipher.auth_tag + ciphertext, padding: false)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def decrypt(raw, config)
|
|
130
|
+
blob = Base64.urlsafe_decode64(raw)
|
|
131
|
+
return nil if blob.bytesize <= IV_BYTES + TAG_BYTES
|
|
132
|
+
iv = blob.byteslice(0, IV_BYTES)
|
|
133
|
+
tag = blob.byteslice(IV_BYTES, TAG_BYTES)
|
|
134
|
+
ciphertext = blob.byteslice(IV_BYTES + TAG_BYTES, blob.bytesize)
|
|
135
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
136
|
+
cipher.key = derived_key(config)
|
|
137
|
+
cipher.iv = iv
|
|
138
|
+
cipher.auth_tag = tag
|
|
139
|
+
cipher.auth_data = ''
|
|
140
|
+
json = cipher.update(ciphertext) + cipher.final
|
|
141
|
+
payload = JSON.parse(json, symbolize_names: true)
|
|
142
|
+
return nil unless payload.is_a?(Hash)
|
|
143
|
+
exp = payload.delete(:_exp)
|
|
144
|
+
return nil if exp && Time.now.to_i > exp.to_i
|
|
145
|
+
payload
|
|
146
|
+
rescue ArgumentError, OpenSSL::Cipher::CipherError, JSON::ParserError, TypeError
|
|
147
|
+
nil # bad base64, tampered, wrong key, or garbage: start a fresh session
|
|
119
148
|
end
|
|
120
149
|
end
|
|
121
|
-
|
|
150
|
+
|
|
122
151
|
def initialize(config)
|
|
123
152
|
@config = config
|
|
124
153
|
end
|
|
125
|
-
|
|
154
|
+
|
|
155
|
+
attr_reader :config
|
|
156
|
+
|
|
157
|
+
# Plugin protocol (instance from Session.build)
|
|
158
|
+
def call(request, response)
|
|
159
|
+
self.class.load_session(request, @config)
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def call_response(request, response)
|
|
164
|
+
return unless request.respond_to?(:session)
|
|
165
|
+
self.class.store_session(request, response, @config)
|
|
166
|
+
end
|
|
167
|
+
|
|
126
168
|
# Session data container
|
|
127
169
|
class SessionData
|
|
128
170
|
def initialize(initial_data = {})
|
|
129
|
-
@data = initial_data || {}
|
|
171
|
+
@data = (initial_data || {}).transform_keys { |k| k.to_sym }
|
|
130
172
|
@changed = false
|
|
131
173
|
@destroyed = false
|
|
132
174
|
end
|
|
133
|
-
|
|
175
|
+
|
|
134
176
|
def [](key)
|
|
135
177
|
@data[key.to_sym]
|
|
136
178
|
end
|
|
137
|
-
|
|
179
|
+
|
|
138
180
|
def []=(key, value)
|
|
139
181
|
@data[key.to_sym] = value
|
|
140
182
|
@changed = true
|
|
141
183
|
end
|
|
142
|
-
|
|
184
|
+
|
|
185
|
+
def fetch(key, *default, &block)
|
|
186
|
+
@data.fetch(key.to_sym, *default, &block)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def key?(key)
|
|
190
|
+
@data.key?(key.to_sym)
|
|
191
|
+
end
|
|
192
|
+
|
|
143
193
|
def delete(key)
|
|
144
|
-
@data.delete(key.to_sym)
|
|
145
194
|
@changed = true
|
|
195
|
+
@data.delete(key.to_sym)
|
|
146
196
|
end
|
|
147
|
-
|
|
197
|
+
|
|
148
198
|
def clear
|
|
149
199
|
@data.clear
|
|
150
200
|
@changed = true
|
|
151
201
|
end
|
|
152
|
-
|
|
202
|
+
|
|
153
203
|
def destroy
|
|
154
204
|
clear
|
|
155
205
|
@destroyed = true
|
|
156
206
|
end
|
|
157
|
-
|
|
207
|
+
|
|
158
208
|
def to_hash
|
|
159
209
|
@data.dup
|
|
160
210
|
end
|
|
161
|
-
|
|
211
|
+
alias to_h to_hash
|
|
212
|
+
|
|
162
213
|
def changed?
|
|
163
214
|
@changed
|
|
164
215
|
end
|
|
165
|
-
|
|
216
|
+
|
|
166
217
|
def destroyed?
|
|
167
218
|
@destroyed
|
|
168
219
|
end
|
|
@@ -172,4 +223,4 @@ module Aris
|
|
|
172
223
|
end
|
|
173
224
|
|
|
174
225
|
# Register the plugin
|
|
175
|
-
Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
|
|
226
|
+
Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# lib/aris/response_helpers.rb
|
|
2
2
|
require 'json'
|
|
3
|
+
require 'time'
|
|
3
4
|
|
|
4
5
|
module Aris
|
|
5
6
|
module ResponseHelpers
|
|
@@ -73,6 +74,37 @@ module Aris
|
|
|
73
74
|
redirect(path, status: status)
|
|
74
75
|
end
|
|
75
76
|
|
|
77
|
+
# --- Cookies -----------------------------------------------------------
|
|
78
|
+
# Available on every response (Rack and Mock). Emits Rack 3 compliant
|
|
79
|
+
# headers: the key is lowercase `set-cookie`, and several cookies become
|
|
80
|
+
# an Array of values instead of a comma-joined string (which browsers
|
|
81
|
+
# cannot parse).
|
|
82
|
+
#
|
|
83
|
+
# res.set_cookie('theme', 'dark', max_age: 3600)
|
|
84
|
+
# res.set_cookie('sid', token, httponly: true, secure: true, same_site: :lax)
|
|
85
|
+
# res.delete_cookie('sid')
|
|
86
|
+
#
|
|
87
|
+
# Defaults come from Aris::Config.cookie_options.
|
|
88
|
+
def set_cookie(name, value, options = {})
|
|
89
|
+
merged = (Aris::Config.cookie_options || {}).merge(options)
|
|
90
|
+
parts = ["#{name}=#{value}"]
|
|
91
|
+
parts << "Domain=#{merged[:domain]}" if merged[:domain]
|
|
92
|
+
parts << "Path=#{merged[:path]}" if merged[:path]
|
|
93
|
+
parts << "Max-Age=#{merged[:max_age]}" if merged.key?(:max_age) && !merged[:max_age].nil?
|
|
94
|
+
parts << "Expires=#{merged[:expires].httpdate}" if merged[:expires].respond_to?(:httpdate)
|
|
95
|
+
parts << "HttpOnly" if merged[:httponly]
|
|
96
|
+
parts << "Secure" if merged[:secure]
|
|
97
|
+
if merged[:same_site]
|
|
98
|
+
parts << "SameSite=#{merged[:same_site].to_s.capitalize}"
|
|
99
|
+
end
|
|
100
|
+
add_set_cookie_header(parts.join('; '))
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete_cookie(name, options = {})
|
|
105
|
+
set_cookie(name, '', options.merge(max_age: 0, expires: Time.at(0).utc))
|
|
106
|
+
end
|
|
107
|
+
|
|
76
108
|
# No content response
|
|
77
109
|
def no_content
|
|
78
110
|
self.status = 204
|
|
@@ -115,6 +147,15 @@ module Aris
|
|
|
115
147
|
end
|
|
116
148
|
|
|
117
149
|
private
|
|
150
|
+
|
|
151
|
+
def add_set_cookie_header(cookie_string)
|
|
152
|
+
existing = headers['set-cookie']
|
|
153
|
+
headers['set-cookie'] = case existing
|
|
154
|
+
when nil then cookie_string
|
|
155
|
+
when Array then existing + [cookie_string]
|
|
156
|
+
else [existing, cookie_string]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
118
159
|
|
|
119
160
|
def detect_content_type(file_path)
|
|
120
161
|
# Handle Tempfile paths that may have random extensions
|