strongroom-rails 0.1.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 +7 -0
- data/CHANGELOG.md +1 -0
- data/LICENSE +28 -0
- data/README.md +415 -0
- data/lib/strongroom/client.rb +207 -0
- data/lib/strongroom/errors.rb +21 -0
- data/lib/strongroom/net_http_transport.rb +207 -0
- data/lib/strongroom/rails/account.rb +43 -0
- data/lib/strongroom/rails/authentication.rb +114 -0
- data/lib/strongroom/rails/railtie.rb +39 -0
- data/lib/strongroom/rails/registry.rb +66 -0
- data/lib/strongroom/rails/resolver.rb +78 -0
- data/lib/strongroom/rails/version.rb +7 -0
- data/lib/strongroom/rails/warden_strategy.rb +27 -0
- data/lib/strongroom/rails.rb +10 -0
- data/lib/strongroom/session.rb +24 -0
- data/lib/strongroom/token.rb +53 -0
- data/lib/strongroom.rb +8 -0
- metadata +233 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Strongroom
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class InvalidRequest < Error; end
|
|
7
|
+
class Unauthorized < Error; end
|
|
8
|
+
class ProtocolError < Error; end
|
|
9
|
+
class Unavailable < Error; end
|
|
10
|
+
|
|
11
|
+
class RateLimited < Error
|
|
12
|
+
attr_reader :retry_after
|
|
13
|
+
|
|
14
|
+
def initialize(retry_after: nil)
|
|
15
|
+
@retry_after = retry_after
|
|
16
|
+
super("authentication rate limited")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Busy < RateLimited; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "ipaddr"
|
|
7
|
+
|
|
8
|
+
module Strongroom
|
|
9
|
+
TransportResponse = Data.define(:status, :headers, :body) do
|
|
10
|
+
def initialize(status:, headers:, body:)
|
|
11
|
+
code = if status.is_a?(Integer)
|
|
12
|
+
status
|
|
13
|
+
elsif status.is_a?(String) && status.match?(/\A[1-5][0-9]{2}\z/)
|
|
14
|
+
Integer(status, 10)
|
|
15
|
+
end
|
|
16
|
+
raise ProtocolError, "invalid authentication response status" unless code && (100..599).cover?(code)
|
|
17
|
+
normalized = headers.to_h do |key, values|
|
|
18
|
+
[key.downcase.freeze, Array(values).map { |value| value.dup.freeze }.freeze]
|
|
19
|
+
end
|
|
20
|
+
super(status: code, headers: normalized.freeze, body: body.dup.freeze)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def inspect = "#<Strongroom::TransportResponse status=#{status} [FILTERED]>"
|
|
24
|
+
alias to_s inspect
|
|
25
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
26
|
+
def as_json(*) = {status: status}
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
redacted = headers.to_h do |key, values|
|
|
30
|
+
[key, %w[set-cookie cookie authorization].include?(key) ? ["[FILTERED]"] : values]
|
|
31
|
+
end
|
|
32
|
+
{status: status, headers: redacted, body: "[FILTERED]"}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def encode_with(coder)
|
|
36
|
+
coder.map = to_h.transform_keys(&:to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def init_with(_coder)
|
|
40
|
+
raise TypeError, "transport responses must not be deserialized"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def marshal_dump
|
|
44
|
+
raise TypeError, "transport responses must not be serialized"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class NetHTTPTransport
|
|
49
|
+
class Connection < Net::HTTP
|
|
50
|
+
module DeadlineReads
|
|
51
|
+
attr_accessor :connection
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def rbuf_fill
|
|
56
|
+
self.read_timeout = connection.budget(connection.read_timeout)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_accessor :expires
|
|
62
|
+
|
|
63
|
+
def budget(limit)
|
|
64
|
+
remaining = expires - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
raise Net::ReadTimeout unless remaining.positive?
|
|
66
|
+
[remaining, limit].min
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def on_connect
|
|
72
|
+
@socket.extend(DeadlineReads)
|
|
73
|
+
@socket.connection = self
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
MAX_RESPONSE_BYTES = 65_536
|
|
78
|
+
REQUESTS = {get: Net::HTTP::Get, post: Net::HTTP::Post, delete: Net::HTTP::Delete}.freeze
|
|
79
|
+
FAILURES = [IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError,
|
|
80
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError].freeze
|
|
81
|
+
attr_reader :endpoint
|
|
82
|
+
|
|
83
|
+
def self.endpoint(value)
|
|
84
|
+
unless value.is_a?(String) && value.bytesize <= 2048 && !value.match?(/[[:space:]\\@]/)
|
|
85
|
+
raise ConfigurationError, "invalid authentication endpoint"
|
|
86
|
+
end
|
|
87
|
+
uri = URI.parse(value)
|
|
88
|
+
unless %w[https http].include?(uri.scheme) && uri.hostname && !uri.hostname.include?("*") &&
|
|
89
|
+
(1..65_535).cover?(uri.port) && !uri.userinfo &&
|
|
90
|
+
uri.query.nil? && uri.fragment.nil? && ["", "/"].include?(uri.path) &&
|
|
91
|
+
(uri.scheme == "https" || loopback?(uri.hostname))
|
|
92
|
+
raise ConfigurationError, "HTTPS required (HTTP is loopback-only)"
|
|
93
|
+
end
|
|
94
|
+
uri.normalize.to_s.delete_suffix("/").freeze
|
|
95
|
+
rescue URI::InvalidURIError, ArgumentError, TypeError
|
|
96
|
+
raise ConfigurationError, "invalid authentication endpoint", cause: nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.loopback?(hostname)
|
|
100
|
+
hostname == "localhost" || IPAddr.new(hostname).loopback?
|
|
101
|
+
rescue IPAddr::Error
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def initialize(endpoint:, open_timeout: 2, read_timeout: 5, write_timeout: 5, deadline: 15,
|
|
106
|
+
keep_alive: false, keep_alive_timeout: 2, max_idle: 4)
|
|
107
|
+
@endpoint = self.class.endpoint(endpoint)
|
|
108
|
+
@uri = URI(@endpoint)
|
|
109
|
+
@open_timeout, @read_timeout, @write_timeout = [open_timeout, read_timeout, write_timeout].map { |value| seconds(value, 30) }
|
|
110
|
+
@deadline = seconds(deadline, 60)
|
|
111
|
+
raise ConfigurationError, "keep_alive must be true or false" unless [true, false].include?(keep_alive)
|
|
112
|
+
raise ConfigurationError, "max_idle must be an integer from 1 to 64" unless max_idle.is_a?(Integer) && (1..64).cover?(max_idle)
|
|
113
|
+
@keep_alive, @keep_alive_timeout, @max_idle = keep_alive, seconds(keep_alive_timeout, 30), max_idle
|
|
114
|
+
@lock, @idle, @pid = Mutex.new, [], Process.pid
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def keep_alive? = @keep_alive
|
|
118
|
+
|
|
119
|
+
def call(method, path, headers:, body: nil)
|
|
120
|
+
request = REQUESTS.fetch(method).new(path, headers)
|
|
121
|
+
request.body = body if body
|
|
122
|
+
connection = checkout(clock + @deadline)
|
|
123
|
+
response = exchange(connection, request)
|
|
124
|
+
checkin(connection)
|
|
125
|
+
connection = nil
|
|
126
|
+
response
|
|
127
|
+
rescue *FAILURES
|
|
128
|
+
raise Unavailable, "authentication service unavailable", cause: nil
|
|
129
|
+
ensure
|
|
130
|
+
discard(connection)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def close
|
|
134
|
+
idle = @lock.synchronize { @pid == Process.pid ? @idle.slice!(0..) : [] }
|
|
135
|
+
idle.each { |connection| discard(connection) }
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def checkout(expires)
|
|
142
|
+
connection = reusable || build
|
|
143
|
+
connection.expires = expires
|
|
144
|
+
connection.open_timeout = connection.budget(@open_timeout)
|
|
145
|
+
connection.started? ? connection : connection.start
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def reusable
|
|
149
|
+
return unless @keep_alive
|
|
150
|
+
@lock.synchronize do
|
|
151
|
+
@idle, @pid = [], Process.pid unless @pid == Process.pid
|
|
152
|
+
@idle.pop
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def checkin(connection)
|
|
157
|
+
kept = @keep_alive && @lock.synchronize do
|
|
158
|
+
@pid == Process.pid && @idle.size < @max_idle && @idle.push(connection)
|
|
159
|
+
end
|
|
160
|
+
discard(connection) unless kept
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build
|
|
164
|
+
http = Connection.new(@uri.hostname, @uri.port, nil)
|
|
165
|
+
http.read_timeout, http.write_timeout = @read_timeout, @write_timeout
|
|
166
|
+
http.keep_alive_timeout = @keep_alive_timeout
|
|
167
|
+
http.max_retries = 0
|
|
168
|
+
http.use_ssl = @uri.scheme == "https"
|
|
169
|
+
if http.use_ssl?
|
|
170
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
171
|
+
http.verify_hostname = true
|
|
172
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
173
|
+
end
|
|
174
|
+
http
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def exchange(connection, request)
|
|
178
|
+
connection.write_timeout = connection.budget(@write_timeout)
|
|
179
|
+
result = nil
|
|
180
|
+
connection.request(request) do |response|
|
|
181
|
+
bytes = +""
|
|
182
|
+
response.read_body do |chunk|
|
|
183
|
+
raise ProtocolError, "authentication response too large" if bytes.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
184
|
+
bytes << chunk
|
|
185
|
+
end
|
|
186
|
+
result = TransportResponse.new(status: response.code, headers: response.to_hash, body: bytes)
|
|
187
|
+
end
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def discard(connection)
|
|
192
|
+
connection.finish if connection&.started?
|
|
193
|
+
rescue *FAILURES
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def seconds(value, limit)
|
|
198
|
+
number = Float(value)
|
|
199
|
+
raise ArgumentError unless number.finite? && number.positive? && number <= limit
|
|
200
|
+
number
|
|
201
|
+
rescue ArgumentError, TypeError
|
|
202
|
+
raise ConfigurationError, "timeouts must be positive and at most #{limit} seconds", cause: nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Strongroom
|
|
4
|
+
module Rails
|
|
5
|
+
class Account
|
|
6
|
+
attr_reader :client, :table, :model, :register, :cookie_name
|
|
7
|
+
|
|
8
|
+
def initialize(client:, table:, model:, resolve:, eligible:, register: false)
|
|
9
|
+
raise ConfigurationError, "client must be a Strongroom::Client" unless client.is_a?(Client)
|
|
10
|
+
unless table.is_a?(String) && table.match?(/\A[a-z_][a-z0-9_]{0,62}\z/)
|
|
11
|
+
raise ConfigurationError, "table must be a lowercase SQL identifier of at most 63 characters"
|
|
12
|
+
end
|
|
13
|
+
raise ConfigurationError, "table #{table} is a PostgreSQL system name (pg_*)" if table.start_with?("pg_")
|
|
14
|
+
unless model.is_a?(Class) && model.respond_to?(:table_name)
|
|
15
|
+
raise ConfigurationError, "model must be a class responding to table_name, not #{model.inspect}"
|
|
16
|
+
end
|
|
17
|
+
unless model.table_name == table
|
|
18
|
+
raise ConfigurationError, "#{model}.table_name is #{model.table_name.inspect}, not #{table.inspect}"
|
|
19
|
+
end
|
|
20
|
+
raise ConfigurationError, "resolve must be callable (subject -> record or nil)" unless resolve.respond_to?(:call)
|
|
21
|
+
raise ConfigurationError, "eligible must be callable (record -> true)" unless eligible.respond_to?(:call)
|
|
22
|
+
raise ConfigurationError, "register must be true or false" unless [true, false].include?(register)
|
|
23
|
+
@client, @model, @table = client, model, table.dup.freeze
|
|
24
|
+
@resolver, @eligible, @register = resolve, eligible, register
|
|
25
|
+
@cookie_name = "strongroom_#{client.namespace}".freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve(session)
|
|
30
|
+
subject = client.subject(session)
|
|
31
|
+
record = @resolver.call(subject)
|
|
32
|
+
unless record.is_a?(model) && record.class.table_name == table && @eligible.call(record).equal?(true)
|
|
33
|
+
raise Unauthorized, "authentication failed"
|
|
34
|
+
end
|
|
35
|
+
record
|
|
36
|
+
rescue Error
|
|
37
|
+
raise
|
|
38
|
+
rescue StandardError
|
|
39
|
+
raise Unavailable, "local principal lookup unavailable", cause: nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "action_controller/metal/request_forgery_protection"
|
|
5
|
+
|
|
6
|
+
module Strongroom
|
|
7
|
+
module Rails
|
|
8
|
+
module Authentication
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
INTENT_HEADER = "HTTP_X_STRONGROOM_REQUEST"
|
|
12
|
+
|
|
13
|
+
def self.origin(value)
|
|
14
|
+
NetHTTPTransport.endpoint(value)
|
|
15
|
+
rescue ConfigurationError
|
|
16
|
+
raise ConfigurationError, "trusted origins must be canonical HTTPS origins (HTTP only on loopback)", cause: nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
prepend_before_action :strongroom_no_store!
|
|
21
|
+
helper_method :strongroom_current_account if respond_to?(:helper_method)
|
|
22
|
+
class_attribute :strongroom_trusted_origins, instance_accessor: false, default: [].freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class_methods do
|
|
26
|
+
def strongroom_trust_origins(*origins)
|
|
27
|
+
self.strongroom_trusted_origins = origins.flatten.map { |origin| Authentication.origin(origin) }.uniq.freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def strongroom_login(namespace:, identity:, password:, identity_kind: "email")
|
|
32
|
+
strongroom_verify_mutation!(:post)
|
|
33
|
+
account = strongroom_registry.fetch(namespace)
|
|
34
|
+
issued = account.client.login(identity: identity, password: password, identity_kind: identity_kind)
|
|
35
|
+
strongroom_publish(account, issued)
|
|
36
|
+
ensure
|
|
37
|
+
strongroom_resolver.forget(namespace)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def strongroom_current_account(namespace:)
|
|
41
|
+
strongroom_resolver.account(namespace)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def strongroom_rotate(namespace:)
|
|
45
|
+
strongroom_verify_mutation!(:post)
|
|
46
|
+
account = strongroom_registry.fetch(namespace)
|
|
47
|
+
token = strongroom_resolver.credential(namespace) or raise Unauthorized, "authentication failed"
|
|
48
|
+
strongroom_publish(account, account.client.rotate(token: token))
|
|
49
|
+
ensure
|
|
50
|
+
strongroom_resolver.forget(namespace)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def strongroom_logout(namespace:)
|
|
54
|
+
strongroom_verify_mutation!(:delete)
|
|
55
|
+
account = strongroom_registry.fetch(namespace)
|
|
56
|
+
resolver = strongroom_resolver
|
|
57
|
+
presented, cookie = resolver.credential(namespace), resolver.cookie_credential(namespace)
|
|
58
|
+
if presented.nil? && cookie.nil? && !request.get_header("HTTP_AUTHORIZATION").nil?
|
|
59
|
+
raise Unauthorized, "no revocable session credential"
|
|
60
|
+
end
|
|
61
|
+
[presented, cookie].compact.uniq(&:reveal).each { |token| account.client.logout(token: token) }
|
|
62
|
+
request.cookie_jar.delete(account.cookie_name, path: "/", secure: true, httponly: true, same_site: :lax)
|
|
63
|
+
nil
|
|
64
|
+
ensure
|
|
65
|
+
strongroom_resolver.forget(namespace)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def strongroom_no_store!
|
|
71
|
+
response.set_header("Cache-Control", "no-store")
|
|
72
|
+
response.set_header("Vary", [response.get_header("Vary"), "Cookie", "Authorization"].compact.join(", "))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def strongroom_registry = Registry.current
|
|
76
|
+
def strongroom_resolver = Resolver.new(request)
|
|
77
|
+
|
|
78
|
+
def strongroom_verify_mutation!(verb)
|
|
79
|
+
unless request.request_method == verb.to_s.upcase && strongroom_mutation_intended?
|
|
80
|
+
raise ::ActionController::InvalidAuthenticityToken, "verified mutation intent required"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def strongroom_mutation_intended?
|
|
85
|
+
if respond_to?(:verified_request?, true)
|
|
86
|
+
protect_against_forgery? && verified_request?
|
|
87
|
+
else
|
|
88
|
+
strongroom_declared_intent?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def strongroom_declared_intent?
|
|
93
|
+
return false unless request.get_header(INTENT_HEADER) == "1"
|
|
94
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
95
|
+
return request.get_header("HTTP_SEC_FETCH_SITE").nil? if origin.nil?
|
|
96
|
+
origin == request.base_url || self.class.strongroom_trusted_origins.include?(origin)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def strongroom_publish(account, issued)
|
|
100
|
+
begin
|
|
101
|
+
record = account.resolve(issued.session)
|
|
102
|
+
rescue Error => error
|
|
103
|
+
account.client.discard(token: issued.token)
|
|
104
|
+
raise error, cause: nil
|
|
105
|
+
end
|
|
106
|
+
request.cookie_jar.encrypted[account.cookie_name] = {
|
|
107
|
+
value: issued.token.reveal, expires: issued.session.expires_at,
|
|
108
|
+
secure: true, httponly: true, same_site: :lax, path: "/"
|
|
109
|
+
}
|
|
110
|
+
record
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Strongroom
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
config.strongroom = ActiveSupport::OrderedOptions.new
|
|
7
|
+
config.strongroom.verify_on_boot = true
|
|
8
|
+
|
|
9
|
+
config.action_dispatch.rescue_responses.merge!(
|
|
10
|
+
"Strongroom::InvalidRequest" => :bad_request,
|
|
11
|
+
"Strongroom::Unauthorized" => :unauthorized,
|
|
12
|
+
"Strongroom::RateLimited" => :too_many_requests,
|
|
13
|
+
"Strongroom::Busy" => :service_unavailable,
|
|
14
|
+
"Strongroom::ProtocolError" => :service_unavailable,
|
|
15
|
+
"Strongroom::Unavailable" => :service_unavailable
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
initializer "strongroom.filter_credentials" do |app|
|
|
19
|
+
app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
config.after_initialize do |app|
|
|
23
|
+
require_relative "warden_strategy" if defined?(::Warden::Strategies)
|
|
24
|
+
if app.config.strongroom.verify_on_boot
|
|
25
|
+
Railtie.verify!(app)
|
|
26
|
+
app.reloader.to_prepare { Railtie.verify!(app) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.verify!(app)
|
|
31
|
+
Registry.current(app)
|
|
32
|
+
unless app.config.middleware.include?(ActionDispatch::Cookies)
|
|
33
|
+
raise ConfigurationError, "ActionDispatch::Cookies middleware is required for Strongroom session cookies"
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Strongroom
|
|
4
|
+
module Rails
|
|
5
|
+
class Registry
|
|
6
|
+
def self.current(app = ::Rails.application)
|
|
7
|
+
raise ConfigurationError, "no Rails application is defined" unless app
|
|
8
|
+
registry = app.config.x.strongroom_accounts
|
|
9
|
+
return registry if registry.is_a?(self)
|
|
10
|
+
if registry.nil? || (registry.is_a?(ActiveSupport::OrderedOptions) && registry.empty?)
|
|
11
|
+
raise ConfigurationError, "config.x.strongroom_accounts is not set: assign a Strongroom::Rails::Registry in a to_prepare block"
|
|
12
|
+
end
|
|
13
|
+
raise ConfigurationError, "config.x.strongroom_accounts must be a Strongroom::Rails::Registry, not #{registry.class}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(*accounts, warden_scopes: {})
|
|
17
|
+
raise ConfigurationError, "configure one to sixteen accounts, got #{accounts.size}" unless (1..16).cover?(accounts.size)
|
|
18
|
+
unless accounts.all?(Account)
|
|
19
|
+
stray = accounts.find { |account| !account.is_a?(Account) }
|
|
20
|
+
raise ConfigurationError, "registry entries must be Strongroom::Rails::Account, not #{stray.class}"
|
|
21
|
+
end
|
|
22
|
+
@accounts = {}
|
|
23
|
+
accounts.each do |account|
|
|
24
|
+
client = account.client
|
|
25
|
+
raise ConfigurationError, "namespace #{client.namespace} is configured twice" if @accounts.key?(client.namespace)
|
|
26
|
+
@accounts.each_value do |other|
|
|
27
|
+
if other.table == account.table
|
|
28
|
+
raise ConfigurationError, "namespaces #{other.client.namespace} and #{client.namespace} share table #{account.table}"
|
|
29
|
+
end
|
|
30
|
+
if (other.client.issuer == client.issuer || other.client.endpoint == client.endpoint) &&
|
|
31
|
+
(other.client.org_id == client.org_id || other.client.app_id == client.app_id)
|
|
32
|
+
raise ConfigurationError,
|
|
33
|
+
"namespaces #{other.client.namespace} and #{client.namespace} share an org or app on the same issuer or endpoint"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
@accounts[client.namespace] = account
|
|
37
|
+
end
|
|
38
|
+
@accounts.freeze
|
|
39
|
+
@scopes = scopes(warden_scopes)
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fetch(namespace)
|
|
44
|
+
@accounts.fetch(namespace.to_s) { raise ConfigurationError, "unknown account namespace" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def namespaces = @accounts.keys
|
|
48
|
+
|
|
49
|
+
def for_scope(scope)
|
|
50
|
+
@scopes.fetch(scope.to_s) { fetch(scope) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def scopes(mapping)
|
|
56
|
+
raise ConfigurationError, "warden_scopes must map Warden scopes to namespaces" unless mapping.is_a?(Hash)
|
|
57
|
+
mapping.to_h do |scope, namespace|
|
|
58
|
+
unless (scope.is_a?(Symbol) || scope.is_a?(String)) && @accounts.key?(namespace.to_s)
|
|
59
|
+
raise ConfigurationError, "Warden scope #{scope.inspect} must map to a configured namespace"
|
|
60
|
+
end
|
|
61
|
+
[scope.to_s.freeze, @accounts.fetch(namespace.to_s)]
|
|
62
|
+
end.freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_dispatch"
|
|
4
|
+
require "action_dispatch/middleware/cookies"
|
|
5
|
+
|
|
6
|
+
module Strongroom
|
|
7
|
+
module Rails
|
|
8
|
+
class Resolver
|
|
9
|
+
MEMO = "strongroom.rails.resolved"
|
|
10
|
+
attr_reader :request
|
|
11
|
+
|
|
12
|
+
def initialize(request, registry: nil)
|
|
13
|
+
@request = case request
|
|
14
|
+
when ActionDispatch::Request then request
|
|
15
|
+
when Hash then ActionDispatch::Request.new(request)
|
|
16
|
+
else ActionDispatch::Request.new(request.env)
|
|
17
|
+
end
|
|
18
|
+
@registry = registry
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def account(namespace)
|
|
22
|
+
remember(:account, namespace) do
|
|
23
|
+
current = session(namespace)
|
|
24
|
+
current && registry.fetch(namespace).resolve(current)
|
|
25
|
+
rescue Unauthorized
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subject(namespace)
|
|
31
|
+
current = session(namespace) or return nil
|
|
32
|
+
registry.fetch(namespace).client.subject(current)
|
|
33
|
+
rescue Unauthorized
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def session(namespace)
|
|
38
|
+
remember(:session, namespace) do
|
|
39
|
+
token = credential(namespace)
|
|
40
|
+
token && registry.fetch(namespace).client.session(token: token)
|
|
41
|
+
rescue Unauthorized
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def forget(namespace)
|
|
47
|
+
name = namespace.to_s
|
|
48
|
+
memo.delete_if { |(_, key), _| key == name }
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def credential(namespace)
|
|
53
|
+
authorization = request.get_header("HTTP_AUTHORIZATION")
|
|
54
|
+
return Token.from_authorization(authorization) unless authorization.nil?
|
|
55
|
+
cookie_credential(namespace)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cookie_credential(namespace)
|
|
59
|
+
value = request.cookie_jar.encrypted[registry.fetch(namespace).cookie_name]
|
|
60
|
+
Token.new(value) unless value.nil?
|
|
61
|
+
rescue Unauthorized
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def registry = @registry ||= Registry.current
|
|
68
|
+
def memo = request.get_header(MEMO) || request.set_header(MEMO, {})
|
|
69
|
+
|
|
70
|
+
def remember(kind, namespace)
|
|
71
|
+
key = [kind, registry.fetch(namespace).client.namespace]
|
|
72
|
+
cache = memo
|
|
73
|
+
return cache[key] if cache.key?(key)
|
|
74
|
+
cache[key] = yield
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "warden"
|
|
4
|
+
|
|
5
|
+
module Strongroom
|
|
6
|
+
module Rails
|
|
7
|
+
class WardenStrategy < ::Warden::Strategies::Base
|
|
8
|
+
def valid?
|
|
9
|
+
!resolver.credential(namespace).nil?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def authenticate!
|
|
13
|
+
record = resolver.account(namespace)
|
|
14
|
+
record ? success!(record) : fail!("authentication failed")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def store? = false
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def namespace = @namespace ||= Registry.current.for_scope(scope).client.namespace
|
|
22
|
+
def resolver = @resolver ||= Resolver.new(env)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
::Warden::Strategies.add(:strongroom, Strongroom::Rails::WardenStrategy) unless ::Warden::Strategies[:strongroom]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strongroom"
|
|
4
|
+
require "rails"
|
|
5
|
+
require "action_controller"
|
|
6
|
+
require_relative "rails/account"
|
|
7
|
+
require_relative "rails/registry"
|
|
8
|
+
require_relative "rails/resolver"
|
|
9
|
+
require_relative "rails/authentication"
|
|
10
|
+
require_relative "rails/railtie"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Strongroom
|
|
6
|
+
Session = Data.define(:session_id, :user_id, :org_id, :app_id, :expires_at)
|
|
7
|
+
Subject = Data.define(:issuer, :namespace, :org_id, :app_id, :user_id)
|
|
8
|
+
|
|
9
|
+
class IssuedSession
|
|
10
|
+
attr_reader :session, :token
|
|
11
|
+
|
|
12
|
+
def initialize(session:, token:)
|
|
13
|
+
@session, @token = session, token
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def inspect = "#<Strongroom::IssuedSession session_id=#{session.session_id} token=[FILTERED]>"
|
|
18
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
19
|
+
|
|
20
|
+
def as_json(*)
|
|
21
|
+
raise TypeError, "issued credentials must not be serialized"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|