sqrl-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 +18 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/app/controllers/sqrl/rails/application_controller.rb +70 -0
- data/app/controllers/sqrl/rails/assets_controller.rb +22 -0
- data/app/controllers/sqrl/rails/callback_controller.rb +101 -0
- data/app/controllers/sqrl/rails/cli_controller.rb +18 -0
- data/app/controllers/sqrl/rails/identities_controller.rb +57 -0
- data/app/controllers/sqrl/rails/logins_controller.rb +24 -0
- data/app/controllers/sqrl/rails/refresh_controller.rb +22 -0
- data/app/controllers/sqrl/rails/status_controller.rb +21 -0
- data/app/helpers/sqrl/rails/login_helper.rb +85 -0
- data/app/models/sqrl/rails/identity.rb +48 -0
- data/app/models/sqrl/rails/nut.rb +13 -0
- data/app/models/sqrl/rails/pending_login.rb +15 -0
- data/app/views/layouts/sqrl/rails/application.html.erb +18 -0
- data/app/views/sqrl/rails/identities/index.html.erb +27 -0
- data/app/views/sqrl/rails/logins/new.html.erb +3 -0
- data/config/locales/en.yml +11 -0
- data/config/routes.rb +12 -0
- data/lib/generators/sqrl/install/install_generator.rb +131 -0
- data/lib/generators/sqrl/install/templates/create_sqrl_tables.rb.tt +56 -0
- data/lib/generators/sqrl/install/templates/initializer.rb.tt +91 -0
- data/lib/sqrl/rails/configuration.rb +145 -0
- data/lib/sqrl/rails/controller_helpers.rb +37 -0
- data/lib/sqrl/rails/engine.rb +42 -0
- data/lib/sqrl/rails/password_policy.rb +120 -0
- data/lib/sqrl/rails/rate_limited.rb +62 -0
- data/lib/sqrl/rails/sign_in.rb +80 -0
- data/lib/sqrl/rails/stores/active_record.rb +279 -0
- data/lib/sqrl/rails/stores/cache.rb +311 -0
- data/lib/sqrl/rails/user_factory.rb +41 -0
- data/lib/sqrl/rails.rb +220 -0
- data/lib/sqrl-rails.rb +3 -0
- data/lib/tasks/sqrl.rake +12 -0
- metadata +129 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Store implementations shipped with the Rails integration.
|
|
6
|
+
module Stores
|
|
7
|
+
# The three {SQRL::Server} store interfaces on an +ActiveSupport::Cache::Store+, for
|
|
8
|
+
# applications that keep short-lived login state in Redis or Memcached rather than
|
|
9
|
+
# in the database:
|
|
10
|
+
#
|
|
11
|
+
# config.sqrl.stores = :cache # uses Rails.cache
|
|
12
|
+
# config.sqrl.cache_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
|
|
13
|
+
#
|
|
14
|
+
# Single use is implemented as read-then-+delete+: every supported store deletes a
|
|
15
|
+
# key atomically and reports whether it existed, so concurrent consumers of one nut
|
|
16
|
+
# or one CPS token see exactly one winner. Nuts and pending logins expire through
|
|
17
|
+
# the cache's own TTL, so +prune!+ has nothing to do.
|
|
18
|
+
#
|
|
19
|
+
# Identities are durable account data and are kept here too (they are what a
|
|
20
|
+
# cache store makes you lose on eviction), so this set is meant for sites whose
|
|
21
|
+
# cache is persistent (Redis with persistence) or that pair it with their own
|
|
22
|
+
# identity store:
|
|
23
|
+
#
|
|
24
|
+
# stores = SQRL::Rails::Stores::Cache.stores(Rails.cache)
|
|
25
|
+
# stores.identities = SQRL::Rails::Stores::ActiveRecord::IdentityStore.new
|
|
26
|
+
# config.sqrl.stores = stores
|
|
27
|
+
#
|
|
28
|
+
# +FileStore+, +MemoryStore+ and +NullStore+ cannot share state between processes
|
|
29
|
+
# or delete atomically across them, so they are refused in production unless
|
|
30
|
+
# +allow_unsafe: true+ is passed.
|
|
31
|
+
module Cache
|
|
32
|
+
# Cache store classes without an atomic delete; refused in production.
|
|
33
|
+
UNSAFE_STORES = %w[ActiveSupport::Cache::FileStore ActiveSupport::Cache::MemoryStore
|
|
34
|
+
ActiveSupport::Cache::NullStore].freeze
|
|
35
|
+
|
|
36
|
+
# @param cache [ActiveSupport::Cache::Store]
|
|
37
|
+
# @param namespace [String] key prefix
|
|
38
|
+
# @param identity_ttl [Integer, nil] seconds identities live (nil: never expire)
|
|
39
|
+
# @param allow_unsafe [Boolean] accept process-local stores in production
|
|
40
|
+
# @return [SQRL::Server::Stores]
|
|
41
|
+
def self.stores(cache = ::Rails.cache, namespace: "sqrl", identity_ttl: nil, allow_unsafe: false)
|
|
42
|
+
check!(cache, allow_unsafe: allow_unsafe)
|
|
43
|
+
SQRL::Server::Stores.new(nuts: NutStore.new(cache, namespace: namespace),
|
|
44
|
+
identities: IdentityStore.new(cache, namespace: namespace, ttl: identity_ttl),
|
|
45
|
+
pending_logins: PendingLoginStore.new(cache, namespace: namespace))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.check!(cache, allow_unsafe: false)
|
|
49
|
+
raise Error, "config.sqrl.cache_store must be an ActiveSupport::Cache::Store" unless cache.respond_to?(:read) && cache.respond_to?(:delete)
|
|
50
|
+
return if allow_unsafe || !defined?(::Rails) || !::Rails.respond_to?(:env) || !::Rails.env.production?
|
|
51
|
+
return unless UNSAFE_STORES.include?(cache.class.name)
|
|
52
|
+
|
|
53
|
+
raise Error, "#{cache.class.name} is process-local and cannot back SQRL stores in production; " \
|
|
54
|
+
"use a Redis or Memcached cache store (or pass allow_unsafe: true)"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Serialization shared by the stores: binary fields are base64url strings, times
|
|
58
|
+
# are integers, so any cache serializer (Marshal, JSON, MessagePack) works.
|
|
59
|
+
module Codec
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# @return [String, nil] binary bytes as base64url
|
|
63
|
+
def bin(bytes) = bytes.nil? ? nil : SQRL::Util.b64u_encode(bytes.b)
|
|
64
|
+
# @return [String, nil] binary bytes from base64url
|
|
65
|
+
def unbin(text) = text.nil? ? nil : SQRL::Util.b64u_decode(text)
|
|
66
|
+
# @return [Integer, nil] a Time as epoch seconds
|
|
67
|
+
def time(value) = value&.to_i
|
|
68
|
+
# @return [Time, nil] a Time from epoch seconds
|
|
69
|
+
def untime(value) = value.nil? ? nil : Time.at(value.to_i)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Common plumbing.
|
|
73
|
+
class Base
|
|
74
|
+
def initialize(cache, namespace:)
|
|
75
|
+
@cache = cache
|
|
76
|
+
@namespace = namespace
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def key(*parts)
|
|
82
|
+
([@namespace] + parts).join(":")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Seconds until +expires_at+ (at least 1), or nil.
|
|
86
|
+
def ttl_until(expires_at, now)
|
|
87
|
+
return nil if expires_at.nil?
|
|
88
|
+
|
|
89
|
+
[(expires_at.to_i - now.to_i), 1].max
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @see SQRL::Server::NutStore
|
|
94
|
+
class NutStore < Base
|
|
95
|
+
# Issue on the cache store; same contract as +SQRL::Server::NutStore#issue+.
|
|
96
|
+
def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
|
|
97
|
+
record = SQRL::Server::NutRecord.new(nut: nut, pending_login_id: pending_login_id,
|
|
98
|
+
expected_server: expected_server, ip: ip, bound_idk: bound_idk,
|
|
99
|
+
expires_at: expires_at, used_at: nil)
|
|
100
|
+
@cache.write(key("nut", nut), dump(record), expires_in: ttl_until(expires_at, Time.now))
|
|
101
|
+
record
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Consume on the cache store; same contract as +SQRL::Server::NutStore#consume+.
|
|
105
|
+
def consume(nut, now: Time.now)
|
|
106
|
+
return nil if nut.nil? || nut.empty?
|
|
107
|
+
|
|
108
|
+
data = @cache.read(key("nut", nut))
|
|
109
|
+
return nil unless data
|
|
110
|
+
# Deleting is the single-use guarantee: only one caller gets +true+.
|
|
111
|
+
return nil unless @cache.delete(key("nut", nut))
|
|
112
|
+
|
|
113
|
+
record = load(data)
|
|
114
|
+
return nil if record.expires_at && record.expires_at <= now
|
|
115
|
+
|
|
116
|
+
record.used_at = now
|
|
117
|
+
record
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Entries expire through the cache TTL.
|
|
121
|
+
def prune!(_now = Time.now)
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def dump(r)
|
|
128
|
+
{ "nut" => r.nut, "pending_login_id" => r.pending_login_id, "expected_server" => r.expected_server,
|
|
129
|
+
"ip" => r.ip, "bound_idk" => Codec.bin(r.bound_idk), "expires_at" => Codec.time(r.expires_at) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def load(h)
|
|
133
|
+
SQRL::Server::NutRecord.new(nut: h["nut"], pending_login_id: h["pending_login_id"],
|
|
134
|
+
expected_server: h["expected_server"], ip: h["ip"],
|
|
135
|
+
bound_idk: Codec.unbin(h["bound_idk"]),
|
|
136
|
+
expires_at: Codec.untime(h["expires_at"]), used_at: nil)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @see SQRL::Server::IdentityStore
|
|
141
|
+
class IdentityStore < Base
|
|
142
|
+
def initialize(cache, namespace:, ttl: nil)
|
|
143
|
+
super(cache, namespace: namespace)
|
|
144
|
+
@ttl = ttl
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Find on the cache store; same contract as +SQRL::Server::IdentityStore#find+.
|
|
148
|
+
def find(idk)
|
|
149
|
+
data = @cache.read(key("id", Codec.bin(idk)))
|
|
150
|
+
data && load(data)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def create(**attrs)
|
|
154
|
+
identity = SQRL::Server::Identity.new(**attrs)
|
|
155
|
+
k = key("id", Codec.bin(identity.idk))
|
|
156
|
+
raise ArgumentError, "identity already exists" if @cache.exist?(k)
|
|
157
|
+
|
|
158
|
+
write(identity)
|
|
159
|
+
identity
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Update on the cache store; same contract as +SQRL::Server::IdentityStore#update+.
|
|
163
|
+
def update(idk, **attrs)
|
|
164
|
+
identity = find(idk)
|
|
165
|
+
return nil unless identity
|
|
166
|
+
|
|
167
|
+
attrs.each { |name, value| identity[name] = value }
|
|
168
|
+
write(identity)
|
|
169
|
+
identity
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Rekey on the cache store; same contract as +SQRL::Server::IdentityStore#rekey+.
|
|
173
|
+
def rekey(old_idk, new_idk, suk:, vuk:, ins:)
|
|
174
|
+
identity = find(old_idk)
|
|
175
|
+
return nil unless identity
|
|
176
|
+
|
|
177
|
+
identity.idk = new_idk
|
|
178
|
+
identity.suk = suk
|
|
179
|
+
identity.vuk = vuk
|
|
180
|
+
identity.ins = ins
|
|
181
|
+
write(identity)
|
|
182
|
+
@cache.delete(key("id", Codec.bin(old_idk)))
|
|
183
|
+
@cache.write(key("superseded", Codec.bin(old_idk)), Codec.bin(new_idk), expires_in: @ttl)
|
|
184
|
+
identity
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def superseded?(idk)
|
|
188
|
+
@cache.exist?(key("superseded", Codec.bin(idk)))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Remove on the cache store; same contract as +SQRL::Server::IdentityStore#remove+.
|
|
192
|
+
def remove(idk)
|
|
193
|
+
identity = find(idk)
|
|
194
|
+
return nil unless identity
|
|
195
|
+
|
|
196
|
+
@cache.delete(key("id", Codec.bin(idk)))
|
|
197
|
+
identity
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def write(identity)
|
|
203
|
+
@cache.write(key("id", Codec.bin(identity.idk)), dump(identity), expires_in: @ttl)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def dump(i)
|
|
207
|
+
{ "idk" => Codec.bin(i.idk), "suk" => Codec.bin(i.suk), "vuk" => Codec.bin(i.vuk),
|
|
208
|
+
"disabled" => i.disabled == true, "sqrlonly" => i.sqrlonly == true, "hardlock" => i.hardlock == true,
|
|
209
|
+
"ins" => i.ins, "logins" => i.logins.to_i, "user_ref" => i.user_ref }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def load(h)
|
|
213
|
+
SQRL::Server::Identity.new(idk: Codec.unbin(h["idk"]), suk: Codec.unbin(h["suk"]), vuk: Codec.unbin(h["vuk"]),
|
|
214
|
+
disabled: h["disabled"], sqrlonly: h["sqrlonly"], hardlock: h["hardlock"],
|
|
215
|
+
ins: h["ins"], logins: h["logins"].to_i, user_ref: h["user_ref"])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @see SQRL::Server::PendingLoginStore
|
|
220
|
+
class PendingLoginStore < Base
|
|
221
|
+
# Create on the cache store; same contract as +SQRL::Server::PendingLoginStore#create+.
|
|
222
|
+
def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
|
|
223
|
+
login = SQRL::Server::PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
|
|
224
|
+
link_user_ref: link_user_ref, created_at: created_at)
|
|
225
|
+
write(login)
|
|
226
|
+
login
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Find on the cache store; same contract as +SQRL::Server::IdentityStore#find+.
|
|
230
|
+
def find(id)
|
|
231
|
+
return nil if id.nil?
|
|
232
|
+
|
|
233
|
+
data = @cache.read(key("login", id))
|
|
234
|
+
return nil unless data
|
|
235
|
+
|
|
236
|
+
login = load(data)
|
|
237
|
+
# The cache TTL has a one-second floor; honour the exact expiry ourselves.
|
|
238
|
+
login.expires_at && login.expires_at <= Time.now ? nil : login
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Update on the cache store; same contract as +SQRL::Server::IdentityStore#update+.
|
|
242
|
+
def update(id, **attrs)
|
|
243
|
+
login = find(id)
|
|
244
|
+
return nil unless login
|
|
245
|
+
|
|
246
|
+
old_token = login.cps_token
|
|
247
|
+
attrs.each { |name, value| login[name] = value }
|
|
248
|
+
write(login)
|
|
249
|
+
if attrs.key?(:cps_token)
|
|
250
|
+
@cache.delete(key("cps", old_token)) if old_token && old_token != login.cps_token
|
|
251
|
+
if login.cps_token
|
|
252
|
+
@cache.write(key("cps", login.cps_token), id, expires_in: ttl_until(login.cps_token_expires_at, Time.now))
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
login
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Claim cps token on the cache store; same contract as +SQRL::Server::PendingLoginStore#claim_cps_token+.
|
|
259
|
+
def claim_cps_token(token, now: Time.now)
|
|
260
|
+
return nil if token.nil? || token.empty?
|
|
261
|
+
|
|
262
|
+
id = @cache.read(key("cps", token))
|
|
263
|
+
return nil unless id
|
|
264
|
+
# The index entry is the single-use guard.
|
|
265
|
+
return nil unless @cache.delete(key("cps", token))
|
|
266
|
+
|
|
267
|
+
login = find(id)
|
|
268
|
+
return nil unless login && login.cps_token == token
|
|
269
|
+
|
|
270
|
+
expired = login.cps_token_expires_at && login.cps_token_expires_at <= now
|
|
271
|
+
login.cps_token = nil
|
|
272
|
+
login.cps_token_expires_at = nil
|
|
273
|
+
write(login)
|
|
274
|
+
expired ? nil : login
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Entries expire through the cache TTL.
|
|
278
|
+
def prune!(_now = Time.now)
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def write(login)
|
|
285
|
+
@cache.write(key("login", login.id), dump(login), expires_in: ttl_until(login.expires_at, Time.now))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def dump(l)
|
|
289
|
+
{ "id" => l.id, "authenticated_idk" => Codec.bin(l.authenticated_idk),
|
|
290
|
+
"authenticated_at" => Codec.time(l.authenticated_at), "btn" => l.btn, "asked" => l.asked == true,
|
|
291
|
+
"last_command" => l.last_command&.to_s, "cps_token" => l.cps_token,
|
|
292
|
+
"cps_token_expires_at" => Codec.time(l.cps_token_expires_at), "ip" => l.ip, "return_to" => l.return_to,
|
|
293
|
+
"link_user_ref" => l.link_user_ref, "created_at" => Codec.time(l.created_at),
|
|
294
|
+
"expires_at" => Codec.time(l.expires_at), "ip_matched" => l.ip_matched == true }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def load(h)
|
|
298
|
+
SQRL::Server::PendingLogin.new(id: h["id"], authenticated_idk: Codec.unbin(h["authenticated_idk"]),
|
|
299
|
+
authenticated_at: Codec.untime(h["authenticated_at"]), btn: h["btn"],
|
|
300
|
+
asked: h["asked"], last_command: h["last_command"]&.to_sym,
|
|
301
|
+
cps_token: h["cps_token"],
|
|
302
|
+
cps_token_expires_at: Codec.untime(h["cps_token_expires_at"]),
|
|
303
|
+
ip: h["ip"], return_to: h["return_to"], link_user_ref: h["link_user_ref"],
|
|
304
|
+
created_at: Codec.untime(h["created_at"]),
|
|
305
|
+
expires_at: Codec.untime(h["expires_at"]), ip_matched: h["ip_matched"])
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Rails
|
|
7
|
+
# Default +create_user+: builds a user record for a brand-new SQRL identity using
|
|
8
|
+
# only the columns the model has. SQRL carries no profile data, so placeholders are
|
|
9
|
+
# generated: a random password when the model has +password_digest+ (so
|
|
10
|
+
# +has_secure_password+ validations pass) and a non-routable email when it has an
|
|
11
|
+
# +email+ or +email_address+ column. Applications that need real data set
|
|
12
|
+
# +config.sqrl.create_user+ or use +on_unknown_identity = :link+.
|
|
13
|
+
module UserFactory
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param identity [SQRL::Server::Identity]
|
|
17
|
+
# @param model [Class] the user model
|
|
18
|
+
# @return [Object] the saved user
|
|
19
|
+
def call(identity, model = SQRL::Rails.config.user_model)
|
|
20
|
+
model.create!(attributes_for(identity, model))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash] placeholder attributes matching the columns and writers the user model has
|
|
24
|
+
def attributes_for(identity, model)
|
|
25
|
+
columns = model.respond_to?(:column_names) ? model.column_names : []
|
|
26
|
+
handle = identity.idk_b64[0, 16].downcase.tr("_-", "xy")
|
|
27
|
+
attrs = {}
|
|
28
|
+
attrs[:email_address] = "sqrl-#{handle}@sqrl.invalid" if columns.include?("email_address")
|
|
29
|
+
attrs[:email] = "sqrl-#{handle}@sqrl.invalid" if columns.include?("email")
|
|
30
|
+
attrs[:name] = "SQRL user #{handle[0, 8]}" if columns.include?("name")
|
|
31
|
+
attrs[:username] = "sqrl-#{handle}" if columns.include?("username")
|
|
32
|
+
if model.method_defined?(:password=)
|
|
33
|
+
password = SecureRandom.hex(32)
|
|
34
|
+
attrs[:password] = password
|
|
35
|
+
attrs[:password_confirmation] = password if model.method_defined?(:password_confirmation=)
|
|
36
|
+
end
|
|
37
|
+
attrs
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/sqrl/rails.rb
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "sqrl/core"
|
|
5
|
+
require "sqrl/rack"
|
|
6
|
+
|
|
7
|
+
# SQRL namespace, reopened for the Rails integration.
|
|
8
|
+
module SQRL
|
|
9
|
+
# SQRL login for Rails: an isolated engine serving the SQRL endpoint, status poll,
|
|
10
|
+
# CPS callback and browser script; ActiveRecord-backed stores; a view helper that
|
|
11
|
+
# renders the login block; and a sign-in adapter that plugs into the authentication
|
|
12
|
+
# stack generated by `bin/rails generate authentication` (Rails 8) or into any custom
|
|
13
|
+
# session scheme.
|
|
14
|
+
#
|
|
15
|
+
# # Gemfile
|
|
16
|
+
# gem "sqrl-rails"
|
|
17
|
+
# # then
|
|
18
|
+
# bin/rails generate sqrl:install
|
|
19
|
+
# bin/rails db:migrate
|
|
20
|
+
module Rails
|
|
21
|
+
# Same as SQRL::VERSION; the gems release in lockstep.
|
|
22
|
+
VERSION = SQRL::VERSION
|
|
23
|
+
|
|
24
|
+
# Raised for configuration problems.
|
|
25
|
+
class Error < SQRL::Error; end
|
|
26
|
+
|
|
27
|
+
# Paths of the engine's routes, relative to the mount point.
|
|
28
|
+
ENGINE_PATHS = { cli: "/cli", status: "/status", callback: "/callback", login: "/login",
|
|
29
|
+
script: "/sqrl.js", identities: "/identities", refresh: "/refresh" }.freeze
|
|
30
|
+
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@host_resolver = nil
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# The application-wide configuration (+Rails.application.config.sqrl+).
|
|
36
|
+
#
|
|
37
|
+
# @return [Configuration]
|
|
38
|
+
def config
|
|
39
|
+
::Rails.application.config.sqrl
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The configured stores, resolved once.
|
|
43
|
+
#
|
|
44
|
+
# @return [Server::Stores]
|
|
45
|
+
def stores
|
|
46
|
+
@stores ||= resolve_stores(config.stores)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Forget memoized handlers, endpoints and stores (tests, configuration changes).
|
|
50
|
+
def reset!
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@host_resolver = nil
|
|
53
|
+
@stores = nil
|
|
54
|
+
@mount_path = nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The {SQRL::Rack::HostResolver} deciding which host and scheme SQRL URLs are issued
|
|
59
|
+
# for, built from +config.host+, +config.scheme+, +config.allowed_hosts+ and
|
|
60
|
+
# +config.host_cache_limit+. It also bounds the per-host endpoint cache.
|
|
61
|
+
#
|
|
62
|
+
# @return [SQRL::Rack::HostResolver]
|
|
63
|
+
def host_resolver
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
scheme = config.scheme.presence || (config.insecure_http ? "qrl" : nil)
|
|
66
|
+
@host_resolver ||= SQRL::Rack::HostResolver.new(host: config.host.presence, scheme: scheme,
|
|
67
|
+
allowed_hosts: config.allowed_hosts,
|
|
68
|
+
limit: config.host_cache_limit,
|
|
69
|
+
logger: config.logger || ::Rails.logger)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Path the engine is mounted at in the host application (+/sqrl+ by default).
|
|
74
|
+
def mount_path
|
|
75
|
+
config.mount_path || (@mount_path ||= detect_mount_path) || "/sqrl"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Public (host-level) path of an engine route, for example +path_for(:status)+.
|
|
79
|
+
def path_for(name, script_name: "")
|
|
80
|
+
"#{script_name}#{mount_path}#{ENGINE_PATHS.fetch(name)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The {SQRL::Rack::Endpoint} for a request's host and scheme, built lazily and
|
|
84
|
+
# memoized per [host, scheme] in a bounded cache.
|
|
85
|
+
#
|
|
86
|
+
# @param request [ActionDispatch::Request, Rack::Request]
|
|
87
|
+
# @return [SQRL::Rack::Endpoint]
|
|
88
|
+
# @raise [SQRL::Rack::HostNotAllowed] when the request host may not be used
|
|
89
|
+
def endpoint_for(request)
|
|
90
|
+
resolver = host_resolver
|
|
91
|
+
host, scheme = resolver.resolve(request)
|
|
92
|
+
resolver.fetch([host, scheme]) { build_endpoint(host, scheme) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The {Server::Handler} for a request (see {endpoint_for}).
|
|
96
|
+
def handler_for(request)
|
|
97
|
+
endpoint_for(request).handler
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether any active SQRL identity of +user+ asked the site to disable every other
|
|
101
|
+
# (non-SQRL) way of authenticating (+opt=sqrlonly+). Password logins for such a
|
|
102
|
+
# user must be refused; see {PasswordPolicy}.
|
|
103
|
+
#
|
|
104
|
+
# @param user [Object, nil]
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def sqrl_only?(user)
|
|
107
|
+
return false if user.nil?
|
|
108
|
+
|
|
109
|
+
Identity.active.for_user(user).where(sqrlonly: true).exists?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether any active SQRL identity of +user+ asked the site to disable every other
|
|
113
|
+
# (non-SQRL) way of recovering the account (+opt=hardlock+). Password resets for
|
|
114
|
+
# such a user must be refused; see {PasswordPolicy}.
|
|
115
|
+
#
|
|
116
|
+
# @param user [Object, nil]
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def hard_locked?(user)
|
|
119
|
+
return false if user.nil?
|
|
120
|
+
|
|
121
|
+
Identity.active.for_user(user).where(hardlock: true).exists?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Opaque reference stored on SQRL identities to point at the application's user:
|
|
125
|
+
# +"User:42"+.
|
|
126
|
+
def user_ref(user)
|
|
127
|
+
return nil if user.nil?
|
|
128
|
+
return user if user.is_a?(String)
|
|
129
|
+
|
|
130
|
+
type = user.class.respond_to?(:polymorphic_name) ? user.class.polymorphic_name : user.class.name
|
|
131
|
+
"#{type}:#{user.id}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Resolve a reference made by {user_ref} back to the record, or nil.
|
|
135
|
+
def user_from_ref(ref)
|
|
136
|
+
return nil if ref.blank?
|
|
137
|
+
|
|
138
|
+
type, id = ref.to_s.split(":", 2)
|
|
139
|
+
return nil if type.blank? || id.blank?
|
|
140
|
+
|
|
141
|
+
klass = type.safe_constantize
|
|
142
|
+
return nil unless klass.respond_to?(:find_by)
|
|
143
|
+
|
|
144
|
+
klass.find_by(klass.primary_key => id)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The host application's +Authentication+ concern from `rails generate
|
|
148
|
+
# authentication`, when present.
|
|
149
|
+
#
|
|
150
|
+
# @return [Module, nil]
|
|
151
|
+
def host_authentication_concern
|
|
152
|
+
return nil unless Object.const_defined?(:Authentication)
|
|
153
|
+
|
|
154
|
+
concern = Object.const_get(:Authentication)
|
|
155
|
+
if (concern.is_a?(Module) && concern.method_defined?(:start_new_session_for, false)) ||
|
|
156
|
+
(concern.is_a?(Module) && concern.private_method_defined?(:start_new_session_for, false))
|
|
157
|
+
concern
|
|
158
|
+
end
|
|
159
|
+
rescue NameError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def resolve_stores(value)
|
|
166
|
+
case value
|
|
167
|
+
when nil, :active_record, "active_record" then Stores::ActiveRecord.stores
|
|
168
|
+
when :memory, "memory" then Server::Memory.stores
|
|
169
|
+
when :cache, "cache" then Stores::Cache.stores(config.cache_store || ::Rails.cache)
|
|
170
|
+
when Server::Stores then value
|
|
171
|
+
else
|
|
172
|
+
raise Error, "config.sqrl.stores must be :active_record, :cache, :memory or an SQRL::Server::Stores"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_endpoint(host, scheme)
|
|
177
|
+
mount = mount_path
|
|
178
|
+
server_config = Server::Config.new(**config.server_options, host: host, scheme: scheme,
|
|
179
|
+
path: "#{mount}#{ENGINE_PATHS[:cli]}",
|
|
180
|
+
callback_path: "#{mount}#{ENGINE_PATHS[:callback]}")
|
|
181
|
+
handler = Server::Handler.new(config: server_config, stores: stores)
|
|
182
|
+
SQRL::Rack::Endpoint.new(handler: handler, session_key: config.session_key, identity_key: config.identity_key,
|
|
183
|
+
status_path: ENGINE_PATHS[:status], callback_path: ENGINE_PATHS[:callback],
|
|
184
|
+
script_path: ENGINE_PATHS[:script], refresh_path: ENGINE_PATHS[:refresh],
|
|
185
|
+
poll_requires_ip_match: config.poll_requires_ip_match ? true : false,
|
|
186
|
+
ip: config.ip || method(:client_ip))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def client_ip(request)
|
|
190
|
+
request.respond_to?(:remote_ip) ? request.remote_ip : request.ip
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Find where the host application mounted the engine.
|
|
194
|
+
def detect_mount_path
|
|
195
|
+
::Rails.application.routes.routes.each do |route|
|
|
196
|
+
app = route.app
|
|
197
|
+
# A mount is wrapped in ActionDispatch::Routing::Mapper::Constraints; plain
|
|
198
|
+
# dispatchers return themselves from #app.
|
|
199
|
+
app = app.app if app.respond_to?(:app) && !app.equal?(app.app)
|
|
200
|
+
next unless app == Engine
|
|
201
|
+
|
|
202
|
+
return route.path.spec.to_s.sub(/\(\.:format\)\z/, "").sub(%r{/\z}, "")
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
require_relative "rails/configuration"
|
|
213
|
+
require_relative "rails/stores/active_record"
|
|
214
|
+
require_relative "rails/stores/cache"
|
|
215
|
+
require_relative "rails/user_factory"
|
|
216
|
+
require_relative "rails/sign_in"
|
|
217
|
+
require_relative "rails/controller_helpers"
|
|
218
|
+
require_relative "rails/password_policy"
|
|
219
|
+
require_relative "rails/rate_limited"
|
|
220
|
+
require_relative "rails/engine"
|
data/lib/sqrl-rails.rb
ADDED
data/lib/tasks/sqrl.rake
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :sqrl do
|
|
4
|
+
desc "Delete expired SQRL nuts and pending logins"
|
|
5
|
+
task prune: :environment do
|
|
6
|
+
stores = SQRL::Rails.stores
|
|
7
|
+
now = Time.current
|
|
8
|
+
stores.nuts.prune!(now)
|
|
9
|
+
stores.pending_logins.prune!(now)
|
|
10
|
+
puts "SQRL: pruned expired nuts and pending logins"
|
|
11
|
+
end
|
|
12
|
+
end
|