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,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Enforces the two account-policy signals a SQRL client can send with +ident+:
|
|
6
|
+
#
|
|
7
|
+
# * +sqrlonly+ – "disable all non-SQRL authentication": password logins for the user
|
|
8
|
+
# must be refused ({Sessions}).
|
|
9
|
+
# * +hardlock+ – "disable all non-SQRL account recovery": password resets for the
|
|
10
|
+
# user must be refused ({Passwords}).
|
|
11
|
+
#
|
|
12
|
+
# The concerns target the controllers that `bin/rails generate authentication`
|
|
13
|
+
# produces (the generator adds the +include+ lines), but work in any controller that
|
|
14
|
+
# takes an email address or a password-reset token:
|
|
15
|
+
#
|
|
16
|
+
# class SessionsController < ApplicationController
|
|
17
|
+
# include SQRL::Rails::PasswordPolicy::Sessions # before_action on :create
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# class PasswordsController < ApplicationController
|
|
21
|
+
# include SQRL::Rails::PasswordPolicy::Passwords # before_action on :create, :edit, :update
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Override +sqrl_policy_user+ to change how the user is looked up, and
|
|
25
|
+
# +sqrl_policy_refused(reason)+ to change the response (the default redirects to the
|
|
26
|
+
# sign-in page with a flash alert from the +sqrl.password_policy+ translations).
|
|
27
|
+
module PasswordPolicy
|
|
28
|
+
# Shared lookup and response helpers.
|
|
29
|
+
module Base
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The user the current request is about: by +email_address+/+email+ parameter,
|
|
35
|
+
# or by password-reset token when the request carries one.
|
|
36
|
+
#
|
|
37
|
+
# @return [Object, nil]
|
|
38
|
+
def sqrl_policy_user
|
|
39
|
+
return @user if instance_variable_defined?(:@user) && @user
|
|
40
|
+
|
|
41
|
+
model = SQRL::Rails.config.user_model
|
|
42
|
+
token = params[:token].presence
|
|
43
|
+
if token
|
|
44
|
+
found =
|
|
45
|
+
if model.respond_to?(:find_by_password_reset_token)
|
|
46
|
+
model.find_by_password_reset_token(token)
|
|
47
|
+
elsif model.respond_to?(:find_by_token_for)
|
|
48
|
+
model.find_by_token_for(:password_reset, token)
|
|
49
|
+
end
|
|
50
|
+
return found if found
|
|
51
|
+
end
|
|
52
|
+
email = params[:email_address].presence || params[:email].presence
|
|
53
|
+
return nil if email.nil? || !model.respond_to?(:column_names)
|
|
54
|
+
|
|
55
|
+
column = %w[email_address email].find { |c| model.column_names.include?(c) }
|
|
56
|
+
column && model.find_by(column => email.to_s.strip.downcase)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Respond to a refused request. +reason+ is +:sqrl_only+ or +:hard_lock+.
|
|
62
|
+
def sqrl_policy_refused(reason)
|
|
63
|
+
message = I18n.t("sqrl.password_policy.#{reason}",
|
|
64
|
+
default: if reason == :sqrl_only
|
|
65
|
+
"This account signs in with SQRL only."
|
|
66
|
+
else
|
|
67
|
+
"Password recovery is disabled for this account; use SQRL."
|
|
68
|
+
end)
|
|
69
|
+
flash[:alert] = message if respond_to?(:flash)
|
|
70
|
+
target = sqrl_policy_redirect_path
|
|
71
|
+
redirect_to target, allow_other_host: false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sqrl_policy_redirect_path
|
|
75
|
+
if respond_to?(:new_session_path, true)
|
|
76
|
+
send(:new_session_path)
|
|
77
|
+
elsif respond_to?(:root_path, true)
|
|
78
|
+
send(:root_path)
|
|
79
|
+
else
|
|
80
|
+
"/"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Refuses password logins for +sqrlonly+ users.
|
|
86
|
+
module Sessions
|
|
87
|
+
extend ActiveSupport::Concern
|
|
88
|
+
include Base
|
|
89
|
+
|
|
90
|
+
included do
|
|
91
|
+
before_action :refuse_password_login_for_sqrl_only_users, only: :create
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def refuse_password_login_for_sqrl_only_users
|
|
97
|
+
user = sqrl_policy_user
|
|
98
|
+
sqrl_policy_refused(:sqrl_only) if user && SQRL::Rails.sqrl_only?(user)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Refuses password resets for +hardlock+ users.
|
|
103
|
+
module Passwords
|
|
104
|
+
extend ActiveSupport::Concern
|
|
105
|
+
include Base
|
|
106
|
+
|
|
107
|
+
included do
|
|
108
|
+
before_action :refuse_password_reset_for_hard_locked_users, only: %i[create edit update]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def refuse_password_reset_for_hard_locked_users
|
|
114
|
+
user = sqrl_policy_user
|
|
115
|
+
sqrl_policy_refused(:hard_lock) if user && SQRL::Rails.hard_locked?(user)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# A small, version-independent rate limiter for the engine's public actions,
|
|
6
|
+
# configured through +config.sqrl.rate_limit = { to: 60, within: 1.minute }+.
|
|
7
|
+
# Requests are counted per client IP (or per +by:+ callable) in +Rails.cache+ (or the
|
|
8
|
+
# +cache:+ option); over the limit the action answers 429. +nil+ (the default)
|
|
9
|
+
# disables it. Works on Rails 7.1 as well as on versions that have their own
|
|
10
|
+
# +rate_limit+ macro.
|
|
11
|
+
module RateLimited
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
before_action :sqrl_enforce_rate_limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The configured limit for an action name, or nil.
|
|
19
|
+
#
|
|
20
|
+
# @param action [Symbol] +:cli+, +:login+, +:status+, +:callback+, +:identities+
|
|
21
|
+
# @return [Hash, nil]
|
|
22
|
+
def self.limit_for(action)
|
|
23
|
+
limit = SQRL::Rails.config.rate_limit
|
|
24
|
+
return nil unless limit.is_a?(Hash) && limit[:to] && limit[:within]
|
|
25
|
+
|
|
26
|
+
only = limit[:only]
|
|
27
|
+
return nil if only && !Array(only).map(&:to_sym).include?(action.to_sym)
|
|
28
|
+
|
|
29
|
+
limit
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Name used to scope the limit; controllers override it.
|
|
35
|
+
def sqrl_rate_limit_scope
|
|
36
|
+
:cli
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sqrl_enforce_rate_limit
|
|
40
|
+
limit = RateLimited.limit_for(sqrl_rate_limit_scope)
|
|
41
|
+
return unless limit
|
|
42
|
+
|
|
43
|
+
by = limit[:by] ? limit[:by].call(request) : request.remote_ip
|
|
44
|
+
cache = limit[:cache] || ::Rails.cache
|
|
45
|
+
window = limit[:within].to_i
|
|
46
|
+
bucket = (Time.now.to_i / window).to_s
|
|
47
|
+
key = "sqrl:rate_limit:#{sqrl_rate_limit_scope}:#{by}:#{bucket}"
|
|
48
|
+
count = cache.increment(key, 1, expires_in: window)
|
|
49
|
+
if count.nil?
|
|
50
|
+
# Stores without increment support (or a missing key on some stores): fall back
|
|
51
|
+
# to a read/write cycle. Not atomic, but still bounded.
|
|
52
|
+
count = cache.read(key).to_i + 1
|
|
53
|
+
cache.write(key, count, expires_in: window)
|
|
54
|
+
end
|
|
55
|
+
return if count <= limit[:to].to_i
|
|
56
|
+
|
|
57
|
+
response.set_header("retry-after", (window - (Time.now.to_i % window)).to_s)
|
|
58
|
+
head :too_many_requests
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# The default sign-in adapter and redirect resolution.
|
|
6
|
+
module SignIn
|
|
7
|
+
# Session key used by the plain-session fallback.
|
|
8
|
+
SESSION_USER_KEY = :user_id
|
|
9
|
+
# Session key holding the user's class name in the plain-session fallback.
|
|
10
|
+
SESSION_USER_TYPE_KEY = :sqrl_user_type
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Sign +user+ in on +controller+.
|
|
15
|
+
#
|
|
16
|
+
# 1. Rails' generated +Authentication+ concern: +start_new_session_for(user)+.
|
|
17
|
+
# 2. Warden (if the app uses it directly): +warden.set_user(user)+.
|
|
18
|
+
# 3. Fallback: +session[:user_id]+ (and the user's class name).
|
|
19
|
+
def default(controller, _identity, user)
|
|
20
|
+
if controller.respond_to?(:start_new_session_for, true)
|
|
21
|
+
controller.send(:start_new_session_for, user)
|
|
22
|
+
elsif controller.request.env["warden"].respond_to?(:set_user)
|
|
23
|
+
controller.request.env["warden"].set_user(user)
|
|
24
|
+
else
|
|
25
|
+
controller.session[SESSION_USER_KEY] = user.id
|
|
26
|
+
controller.session[SESSION_USER_TYPE_KEY] = user.class.name
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The user signed in through {default}'s fallback, for applications without their
|
|
31
|
+
# own +current_user+.
|
|
32
|
+
def session_user(session)
|
|
33
|
+
id = session[SESSION_USER_KEY]
|
|
34
|
+
return nil if id.nil?
|
|
35
|
+
|
|
36
|
+
klass = session[SESSION_USER_TYPE_KEY].presence&.safe_constantize || SQRL::Rails.config.user_model
|
|
37
|
+
klass.find_by(klass.primary_key => id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Where to go after a successful login.
|
|
41
|
+
def after(controller, completion)
|
|
42
|
+
return_to = completion.return_to
|
|
43
|
+
return return_to if same_site_path?(return_to)
|
|
44
|
+
|
|
45
|
+
custom = SQRL::Rails.config.after_sign_in
|
|
46
|
+
return custom.call(controller, completion) if custom
|
|
47
|
+
|
|
48
|
+
if controller.respond_to?(:after_authentication_url, true)
|
|
49
|
+
controller.send(:after_authentication_url)
|
|
50
|
+
elsif controller.respond_to?(:main_app) && controller.main_app.respond_to?(:root_path)
|
|
51
|
+
controller.main_app.root_path
|
|
52
|
+
else
|
|
53
|
+
"/"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The signed-in user for linking purposes.
|
|
58
|
+
def current_user(controller)
|
|
59
|
+
custom = SQRL::Rails.config.current_user
|
|
60
|
+
return custom.call(controller) if custom
|
|
61
|
+
|
|
62
|
+
if Object.const_defined?(:Current) && ::Current.respond_to?(:user)
|
|
63
|
+
controller.send(:resume_session) if controller.respond_to?(:resume_session, true)
|
|
64
|
+
user = ::Current.user
|
|
65
|
+
return user if user
|
|
66
|
+
end
|
|
67
|
+
session_user(controller.session)
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A same-site, root-relative path. Rejects protocol-relative URLs (//host), the
|
|
73
|
+
# backslash form browsers normalise to a slash (/\host), and control characters or
|
|
74
|
+
# whitespace that URL parsers strip before interpreting the rest.
|
|
75
|
+
def same_site_path?(path)
|
|
76
|
+
path.is_a?(String) && path.match?(%r{\A/(?![/\\])}) && !path.match?(/[\x00-\x20\x7f\\]/)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
# ActiveRecord-backed implementations of the three {SQRL::Server} store interfaces,
|
|
7
|
+
# over the tables created by `bin/rails generate sqrl:install`:
|
|
8
|
+
#
|
|
9
|
+
# * +sqrl_identities+ – one row per SQRL association; +idk+, +suk+ and +vuk+ are
|
|
10
|
+
# stored base64url-encoded; a re-key updates the row in place and leaves a
|
|
11
|
+
# tombstone row for the old +idk+ with +superseded_by+ set.
|
|
12
|
+
# * +sqrl_nuts+ – issued nuts; +consume+ is a single conditional +UPDATE+, so a nut
|
|
13
|
+
# can only ever be used once even under concurrent requests.
|
|
14
|
+
# * +sqrl_pending_logins+ – one row per login page view; +claim_cps_token+ is a
|
|
15
|
+
# compare-and-set on the token.
|
|
16
|
+
#
|
|
17
|
+
# Key material crosses the boundary as raw bytes (what the protocol core uses) and
|
|
18
|
+
# is encoded/decoded here.
|
|
19
|
+
module ActiveRecord
|
|
20
|
+
# @return [SQRL::Server::Stores]
|
|
21
|
+
def self.stores
|
|
22
|
+
SQRL::Server::Stores.new(nuts: NutStore.new, identities: IdentityStore.new,
|
|
23
|
+
pending_logins: PendingLoginStore.new)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs a store operation with an explicitly checked-out connection, returned to
|
|
27
|
+
# the pool as soon as it finishes (Rails 7.1 would otherwise keep a thread's
|
|
28
|
+
# implicit connection until the thread dies, starving concurrent callers).
|
|
29
|
+
module WithConnection
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def with_connection(&)
|
|
33
|
+
::ActiveRecord::Base.connection_pool.with_connection(&)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Encoding helpers shared by the stores.
|
|
38
|
+
module Codec
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] binary bytes as base64url for a string column
|
|
42
|
+
def encode(bytes)
|
|
43
|
+
bytes.nil? ? nil : SQRL::Util.b64u_encode(bytes.b)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String, nil] binary bytes from a base64url column
|
|
47
|
+
def decode(text)
|
|
48
|
+
text.nil? || text.empty? ? nil : SQRL::Util.b64u_decode(text)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @see SQRL::Server::NutStore
|
|
53
|
+
class NutStore < SQRL::Server::NutStore
|
|
54
|
+
include WithConnection
|
|
55
|
+
|
|
56
|
+
# Issue on ActiveRecord; same contract as +SQRL::Server::NutStore#issue+.
|
|
57
|
+
def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
|
|
58
|
+
with_connection do
|
|
59
|
+
row = Nut.create!(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server, ip: ip,
|
|
60
|
+
bound_idk: Codec.encode(bound_idk), expires_at: expires_at)
|
|
61
|
+
to_record(row)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Consume on ActiveRecord; same contract as +SQRL::Server::NutStore#consume+.
|
|
66
|
+
def consume(nut, now: Time.now)
|
|
67
|
+
return nil if nut.nil? || nut.empty?
|
|
68
|
+
|
|
69
|
+
with_connection do
|
|
70
|
+
scope = Nut.where(nut: nut, used_at: nil).where("expires_at IS NULL OR expires_at > ?", now)
|
|
71
|
+
next nil unless scope.update_all(used_at: now) == 1
|
|
72
|
+
|
|
73
|
+
to_record(Nut.find_by!(nut: nut))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Delete expired rows.
|
|
78
|
+
# @return [Integer] number deleted
|
|
79
|
+
def prune!(now = Time.now)
|
|
80
|
+
with_connection { Nut.where("used_at IS NOT NULL OR expires_at <= ?", now).delete_all }
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def to_record(row)
|
|
87
|
+
SQRL::Server::NutRecord.new(nut: row.nut, pending_login_id: row.pending_login_id,
|
|
88
|
+
expected_server: row.expected_server, ip: row.ip,
|
|
89
|
+
bound_idk: Codec.decode(row.bound_idk), expires_at: row.expires_at,
|
|
90
|
+
used_at: row.used_at)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @see SQRL::Server::IdentityStore
|
|
95
|
+
class IdentityStore < SQRL::Server::IdentityStore
|
|
96
|
+
# Identity columns that +update+ may change.
|
|
97
|
+
ATTRIBUTE_COLUMNS = %i[suk vuk disabled sqrlonly hardlock ins logins].freeze
|
|
98
|
+
|
|
99
|
+
# Find on ActiveRecord; same contract as +SQRL::Server::IdentityStore#find+.
|
|
100
|
+
def find(idk)
|
|
101
|
+
row = Identity.active.find_by(idk: Codec.encode(idk))
|
|
102
|
+
row && to_record(row)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Create on ActiveRecord; same contract as +SQRL::Server::IdentityStore#create+.
|
|
106
|
+
def create(**attrs)
|
|
107
|
+
identity = SQRL::Server::Identity.new(**attrs)
|
|
108
|
+
row = Identity.new(idk: Codec.encode(identity.idk), suk: Codec.encode(identity.suk),
|
|
109
|
+
vuk: Codec.encode(identity.vuk), disabled: identity.disabled?,
|
|
110
|
+
sqrlonly: identity.sqrlonly?, hardlock: identity.hardlock?, ins: identity.ins,
|
|
111
|
+
logins: identity.logins.to_i, last_login_at: Time.current)
|
|
112
|
+
assign_user(row, identity.user_ref)
|
|
113
|
+
begin
|
|
114
|
+
row.save!
|
|
115
|
+
rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
|
|
116
|
+
raise ArgumentError, "identity already exists" if Identity.exists?(idk: row.idk)
|
|
117
|
+
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
to_record(row)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Update on ActiveRecord; same contract as +SQRL::Server::IdentityStore#update+.
|
|
124
|
+
def update(idk, **attrs)
|
|
125
|
+
row = Identity.active.find_by(idk: Codec.encode(idk))
|
|
126
|
+
return nil unless row
|
|
127
|
+
|
|
128
|
+
changes = {}
|
|
129
|
+
attrs.each do |key, value|
|
|
130
|
+
case key
|
|
131
|
+
when :suk, :vuk then changes[key] = Codec.encode(value)
|
|
132
|
+
when :idk then changes[key] = Codec.encode(value)
|
|
133
|
+
when :user_ref then assign_user(row, value)
|
|
134
|
+
when *ATTRIBUTE_COLUMNS then changes[key] = value
|
|
135
|
+
else raise ArgumentError, "unknown identity attribute #{key}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
changes[:last_login_at] = Time.current if attrs.key?(:logins)
|
|
139
|
+
row.assign_attributes(changes)
|
|
140
|
+
row.save!
|
|
141
|
+
to_record(row)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Rekey on ActiveRecord; same contract as +SQRL::Server::IdentityStore#rekey+.
|
|
145
|
+
def rekey(old_idk, new_idk, suk:, vuk:, ins:)
|
|
146
|
+
old_b64 = Codec.encode(old_idk)
|
|
147
|
+
new_b64 = Codec.encode(new_idk)
|
|
148
|
+
Identity.transaction do
|
|
149
|
+
row = Identity.active.lock.find_by(idk: old_b64)
|
|
150
|
+
return nil unless row
|
|
151
|
+
|
|
152
|
+
row.update!(idk: new_b64, suk: Codec.encode(suk), vuk: Codec.encode(vuk), ins: ins)
|
|
153
|
+
Identity.create!(idk: old_b64, superseded_by: new_b64, disabled: true)
|
|
154
|
+
to_record(row)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def superseded?(idk)
|
|
159
|
+
Identity.where(idk: Codec.encode(idk)).where.not(superseded_by: nil).exists?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Remove on ActiveRecord; same contract as +SQRL::Server::IdentityStore#remove+.
|
|
163
|
+
def remove(idk)
|
|
164
|
+
row = Identity.active.find_by(idk: Codec.encode(idk))
|
|
165
|
+
return nil unless row
|
|
166
|
+
|
|
167
|
+
record = to_record(row)
|
|
168
|
+
row.destroy!
|
|
169
|
+
record
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The ActiveRecord row for an identity record (for applications).
|
|
173
|
+
def row_for(identity)
|
|
174
|
+
Identity.active.find_by(idk: identity.respond_to?(:idk_b64) ? identity.idk_b64 : identity.to_s)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def assign_user(row, user_ref)
|
|
180
|
+
if user_ref.nil?
|
|
181
|
+
row.user_type = nil
|
|
182
|
+
row.user_id = nil
|
|
183
|
+
else
|
|
184
|
+
type, id = user_ref.to_s.split(":", 2)
|
|
185
|
+
row.user_type = type
|
|
186
|
+
row.user_id = id
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_record(row)
|
|
191
|
+
SQRL::Server::Identity.new(idk: Codec.decode(row.idk), suk: Codec.decode(row.suk),
|
|
192
|
+
vuk: Codec.decode(row.vuk), disabled: row.disabled, sqrlonly: row.sqrlonly,
|
|
193
|
+
hardlock: row.hardlock, ins: row.ins, logins: row.logins.to_i,
|
|
194
|
+
user_ref: row.user_type && row.user_id ? "#{row.user_type}:#{row.user_id}" : nil)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @see SQRL::Server::PendingLoginStore
|
|
199
|
+
class PendingLoginStore < SQRL::Server::PendingLoginStore
|
|
200
|
+
include WithConnection
|
|
201
|
+
|
|
202
|
+
# Pending-login columns that +update+ may change.
|
|
203
|
+
COLUMNS = %i[authenticated_at btn asked ip return_to link_user_ref expires_at cps_token
|
|
204
|
+
cps_token_expires_at ip_matched].freeze
|
|
205
|
+
|
|
206
|
+
# Create on ActiveRecord; same contract as +SQRL::Server::PendingLoginStore#create+.
|
|
207
|
+
def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
|
|
208
|
+
row = PendingLogin.create!(login_id: id, ip: ip, return_to: return_to, expires_at: expires_at,
|
|
209
|
+
link_user_ref: link_user_ref, created_at: created_at)
|
|
210
|
+
to_record(row)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Find on ActiveRecord; same contract as +SQRL::Server::IdentityStore#find+.
|
|
214
|
+
def find(id)
|
|
215
|
+
row = id && PendingLogin.find_by(login_id: id)
|
|
216
|
+
row && to_record(row)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Update on ActiveRecord; same contract as +SQRL::Server::IdentityStore#update+.
|
|
220
|
+
def update(id, **attrs)
|
|
221
|
+
row = PendingLogin.find_by(login_id: id)
|
|
222
|
+
return nil unless row
|
|
223
|
+
|
|
224
|
+
changes = {}
|
|
225
|
+
attrs.each do |key, value|
|
|
226
|
+
case key
|
|
227
|
+
when :authenticated_idk then changes[key] = Codec.encode(value)
|
|
228
|
+
when :last_command then changes[key] = value&.to_s
|
|
229
|
+
when *COLUMNS then changes[key] = value
|
|
230
|
+
else raise ArgumentError, "unknown pending login attribute #{key}"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
row.update!(changes)
|
|
234
|
+
to_record(row)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Claim cps token on ActiveRecord; same contract as +SQRL::Server::PendingLoginStore#claim_cps_token+.
|
|
238
|
+
def claim_cps_token(token, now: Time.now)
|
|
239
|
+
return nil if token.nil? || token.empty?
|
|
240
|
+
|
|
241
|
+
with_connection do
|
|
242
|
+
row = PendingLogin.find_by(cps_token: token)
|
|
243
|
+
next nil unless row
|
|
244
|
+
|
|
245
|
+
claimed = PendingLogin.where(id: row.id, cps_token: token)
|
|
246
|
+
.update_all(cps_token: nil, cps_token_expires_at: nil) == 1
|
|
247
|
+
next nil unless claimed
|
|
248
|
+
next nil if row.cps_token_expires_at && row.cps_token_expires_at <= now
|
|
249
|
+
|
|
250
|
+
row.cps_token = nil
|
|
251
|
+
row.cps_token_expires_at = nil
|
|
252
|
+
to_record(row)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Delete expired rows.
|
|
257
|
+
# @return [Integer] number deleted
|
|
258
|
+
def prune!(now = Time.now)
|
|
259
|
+
PendingLogin.where("expires_at <= ?", now).delete_all
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def to_record(row)
|
|
266
|
+
SQRL::Server::PendingLogin.new(
|
|
267
|
+
id: row.login_id, ip: row.ip, return_to: row.return_to, link_user_ref: row.link_user_ref,
|
|
268
|
+
created_at: row.created_at, expires_at: row.expires_at,
|
|
269
|
+
authenticated_idk: Codec.decode(row.authenticated_idk), authenticated_at: row.authenticated_at,
|
|
270
|
+
btn: row.btn, asked: row.asked, last_command: row.last_command&.to_sym,
|
|
271
|
+
cps_token: row.cps_token, cps_token_expires_at: row.cps_token_expires_at,
|
|
272
|
+
ip_matched: row.respond_to?(:ip_matched) ? row.ip_matched == true : false
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|