sqrl-core 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 +21 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/lib/sqrl/core.rb +6 -0
- data/lib/sqrl/crypto.rb +166 -0
- data/lib/sqrl/errors.rb +15 -0
- data/lib/sqrl/identity.rb +143 -0
- data/lib/sqrl/protocol/session.rb +396 -0
- data/lib/sqrl/protocol.rb +433 -0
- data/lib/sqrl/server/config.rb +255 -0
- data/lib/sqrl/server/memory.rb +187 -0
- data/lib/sqrl/server/redis.rb +420 -0
- data/lib/sqrl/server/stores.rb +182 -0
- data/lib/sqrl/server.rb +487 -0
- data/lib/sqrl/storage.rb +624 -0
- data/lib/sqrl/test_vectors.rb +152 -0
- data/lib/sqrl/textual.rb +124 -0
- data/lib/sqrl/url.rb +261 -0
- data/lib/sqrl/util.rb +114 -0
- data/lib/sqrl/version.rb +6 -0
- data/lib/sqrl.rb +24 -0
- metadata +70 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Server
|
|
5
|
+
# In-memory store implementations: thread-safe, process-local. Suitable for tests,
|
|
6
|
+
# development and single-process demos; production deployments use a database-backed
|
|
7
|
+
# implementation (see the framework gems).
|
|
8
|
+
module Memory
|
|
9
|
+
# @return [Stores] a fresh set of in-memory stores
|
|
10
|
+
def self.stores
|
|
11
|
+
Stores.new(nuts: NutStore.new, identities: IdentityStore.new, pending_logins: PendingLoginStore.new)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @see Server::NutStore
|
|
15
|
+
class NutStore < Server::NutStore
|
|
16
|
+
def initialize
|
|
17
|
+
super
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@nuts = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# (see SQRL::Server::NutStore#issue)
|
|
23
|
+
def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
|
|
24
|
+
record = NutRecord.new(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server,
|
|
25
|
+
ip: ip, bound_idk: bound_idk, expires_at: expires_at, used_at: nil)
|
|
26
|
+
@mutex.synchronize { @nuts[nut] = record }
|
|
27
|
+
record.dup
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# (see SQRL::Server::NutStore#consume)
|
|
31
|
+
def consume(nut, now: Time.now)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
record = @nuts[nut]
|
|
34
|
+
return nil if record.nil? || record.used? || record.expired?(now)
|
|
35
|
+
|
|
36
|
+
record.used_at = now
|
|
37
|
+
record.dup
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Remove expired records.
|
|
42
|
+
# @return [Integer] number removed
|
|
43
|
+
def prune!(now = Time.now)
|
|
44
|
+
@mutex.synchronize { @nuts.delete_if { |_, r| r.used? || r.expired?(now) } }
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Integer] number of records held
|
|
49
|
+
def size
|
|
50
|
+
@mutex.synchronize { @nuts.size }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @see Server::IdentityStore
|
|
55
|
+
class IdentityStore < Server::IdentityStore
|
|
56
|
+
def initialize
|
|
57
|
+
super
|
|
58
|
+
@mutex = Mutex.new
|
|
59
|
+
@identities = {}
|
|
60
|
+
@superseded = {}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# (see SQRL::Server::IdentityStore#find)
|
|
64
|
+
def find(idk)
|
|
65
|
+
@mutex.synchronize { @identities[idk.b]&.dup }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# (see SQRL::Server::IdentityStore#create)
|
|
69
|
+
def create(**attrs)
|
|
70
|
+
identity = Identity.new(**attrs)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
raise ArgumentError, "identity already exists" if @identities.key?(identity.idk.b)
|
|
73
|
+
|
|
74
|
+
@identities[identity.idk.b] = identity
|
|
75
|
+
end
|
|
76
|
+
identity.dup
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# (see SQRL::Server::IdentityStore#update)
|
|
80
|
+
def update(idk, **attrs)
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
identity = @identities[idk.b]
|
|
83
|
+
return nil unless identity
|
|
84
|
+
|
|
85
|
+
attrs.each { |k, v| identity[k] = v }
|
|
86
|
+
identity.dup
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# (see SQRL::Server::IdentityStore#rekey)
|
|
91
|
+
def rekey(old_idk, new_idk, suk:, vuk:, ins:)
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
identity = @identities.delete(old_idk.b)
|
|
94
|
+
return nil unless identity
|
|
95
|
+
|
|
96
|
+
identity.idk = new_idk.b
|
|
97
|
+
identity.suk = suk
|
|
98
|
+
identity.vuk = vuk
|
|
99
|
+
identity.ins = ins
|
|
100
|
+
@identities[new_idk.b] = identity
|
|
101
|
+
@superseded[old_idk.b] = new_idk.b
|
|
102
|
+
identity.dup
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def superseded?(idk)
|
|
107
|
+
@mutex.synchronize { @superseded.key?(idk.b) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# (see SQRL::Server::IdentityStore#remove)
|
|
111
|
+
def remove(idk)
|
|
112
|
+
@mutex.synchronize { @identities.delete(idk.b) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Integer] number of active identities
|
|
116
|
+
def count
|
|
117
|
+
@mutex.synchronize { @identities.size }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Array<Identity>] every active identity
|
|
121
|
+
def all
|
|
122
|
+
@mutex.synchronize { @identities.values.map(&:dup) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @see Server::PendingLoginStore
|
|
127
|
+
class PendingLoginStore < Server::PendingLoginStore
|
|
128
|
+
def initialize
|
|
129
|
+
super
|
|
130
|
+
@mutex = Mutex.new
|
|
131
|
+
@logins = {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# (see SQRL::Server::IdentityStore#create)
|
|
135
|
+
def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
|
|
136
|
+
login = PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
|
|
137
|
+
link_user_ref: link_user_ref, created_at: created_at)
|
|
138
|
+
@mutex.synchronize { @logins[id] = login }
|
|
139
|
+
login.dup
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# (see SQRL::Server::IdentityStore#find)
|
|
143
|
+
def find(id)
|
|
144
|
+
@mutex.synchronize { @logins[id]&.dup }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# (see SQRL::Server::IdentityStore#update)
|
|
148
|
+
def update(id, **attrs)
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
login = @logins[id]
|
|
151
|
+
return nil unless login
|
|
152
|
+
|
|
153
|
+
attrs.each { |k, v| login[k] = v }
|
|
154
|
+
login.dup
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# (see SQRL::Server::PendingLoginStore#claim_cps_token)
|
|
159
|
+
def claim_cps_token(token, now: Time.now)
|
|
160
|
+
return nil if token.nil? || token.empty?
|
|
161
|
+
|
|
162
|
+
@mutex.synchronize do
|
|
163
|
+
login = @logins.values.find { |l| l.cps_token && Util.secure_compare(l.cps_token, token) }
|
|
164
|
+
return nil unless login
|
|
165
|
+
|
|
166
|
+
expired = login.cps_token_expires_at && login.cps_token_expires_at <= now
|
|
167
|
+
login.cps_token = nil
|
|
168
|
+
login.cps_token_expires_at = nil
|
|
169
|
+
expired ? nil : login.dup
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Remove expired records.
|
|
174
|
+
# @return [Integer] number removed
|
|
175
|
+
def prune!(now = Time.now)
|
|
176
|
+
@mutex.synchronize { @logins.delete_if { |_, l| l.expired?(now) } }
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Integer] number of records held
|
|
181
|
+
def size
|
|
182
|
+
@mutex.synchronize { @logins.size }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Server
|
|
7
|
+
# Redis-backed store implementations for sites that run several processes without a
|
|
8
|
+
# relational database (Sinatra, Roda, bare Rack, ...).
|
|
9
|
+
#
|
|
10
|
+
# require "sqrl/server/redis"
|
|
11
|
+
# stores = SQRL::Server::Redis.stores(redis: ::Redis.new(url: ENV["REDIS_URL"]))
|
|
12
|
+
#
|
|
13
|
+
# Needs the +redis+ gem (not a dependency of sqrl-core; add it to your Gemfile) and a
|
|
14
|
+
# Redis server of version 2.6 or later (Lua scripting). Atomicity:
|
|
15
|
+
#
|
|
16
|
+
# * a nut is consumed with +GETDEL+ (Redis 6.2+) or an equivalent Lua script, so
|
|
17
|
+
# exactly one of any number of concurrent consumers wins;
|
|
18
|
+
# * a CPS token is claimed by a Lua script that checks the token index against the
|
|
19
|
+
# pending login and clears both in one step;
|
|
20
|
+
# * a re-key renames the identity hash and records the superseded key in one script.
|
|
21
|
+
#
|
|
22
|
+
# Nuts and pending logins carry Redis TTLs derived from their +expires_at+, so they
|
|
23
|
+
# vanish on their own; +prune!+ still exists for records that were given no expiry.
|
|
24
|
+
# Binary values (identity keys, lock keys) are stored base64url-encoded. All keys live
|
|
25
|
+
# under +namespace+ (default "sqrl"), separated by colons; the keys a single script
|
|
26
|
+
# touches share that prefix, which Redis Cluster users should turn into a hash tag
|
|
27
|
+
# (for example "{sqrl}").
|
|
28
|
+
module Redis
|
|
29
|
+
# @param redis [::Redis] a connected client from the +redis+ gem (or any object
|
|
30
|
+
# speaking the same method API)
|
|
31
|
+
# @param namespace [String] key prefix
|
|
32
|
+
# @param default_ttl [Integer] seconds to keep records that were given no expiry
|
|
33
|
+
# @return [Stores]
|
|
34
|
+
def self.stores(redis:, namespace: "sqrl", default_ttl: 24 * 3600)
|
|
35
|
+
Stores.new(nuts: NutStore.new(redis, namespace: namespace, default_ttl: default_ttl),
|
|
36
|
+
identities: IdentityStore.new(redis, namespace: namespace),
|
|
37
|
+
pending_logins: PendingLoginStore.new(redis, namespace: namespace, default_ttl: default_ttl))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Connect with the +redis+ gem and build the stores.
|
|
41
|
+
#
|
|
42
|
+
# @param url [String] a redis:// URL
|
|
43
|
+
def self.connect(url = ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"), **options)
|
|
44
|
+
begin
|
|
45
|
+
require "redis"
|
|
46
|
+
rescue LoadError
|
|
47
|
+
raise SQRL::Error, "SQRL::Server::Redis needs the redis gem: add `gem \"redis\"` to your Gemfile"
|
|
48
|
+
end
|
|
49
|
+
stores(redis: ::Redis.new(url: url), **options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Lua scripts. The first line of each is a tag the in-process test double uses to
|
|
53
|
+
# dispatch; Redis ignores it as a comment.
|
|
54
|
+
module Scripts
|
|
55
|
+
# Fallback for +GETDEL+ on Redis servers older than 6.2.
|
|
56
|
+
GETDEL = <<~LUA
|
|
57
|
+
-- sqrl:getdel
|
|
58
|
+
local v = redis.call('GET', KEYS[1])
|
|
59
|
+
if v then redis.call('DEL', KEYS[1]) end
|
|
60
|
+
return v
|
|
61
|
+
LUA
|
|
62
|
+
|
|
63
|
+
# Create an identity hash only when the key does not exist yet.
|
|
64
|
+
CREATE_IDENTITY = <<~LUA
|
|
65
|
+
-- sqrl:create
|
|
66
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
|
67
|
+
redis.call('HSET', KEYS[1], unpack(ARGV))
|
|
68
|
+
return 1
|
|
69
|
+
LUA
|
|
70
|
+
|
|
71
|
+
# Delete an identity hash and its superseded marker.
|
|
72
|
+
REMOVE_IDENTITY = <<~LUA
|
|
73
|
+
-- sqrl:remove
|
|
74
|
+
local v = redis.call('HGETALL', KEYS[1])
|
|
75
|
+
if #v == 0 then return false end
|
|
76
|
+
redis.call('DEL', KEYS[1])
|
|
77
|
+
return v
|
|
78
|
+
LUA
|
|
79
|
+
|
|
80
|
+
# KEYS: old hash, new hash, superseded set. ARGV: new idk, suk, vuk, ins ("" = none), old idk.
|
|
81
|
+
REKEY = <<~LUA
|
|
82
|
+
-- sqrl:rekey
|
|
83
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return false end
|
|
84
|
+
redis.call('RENAME', KEYS[1], KEYS[2])
|
|
85
|
+
redis.call('HSET', KEYS[2], 'idk', ARGV[1], 'suk', ARGV[2], 'vuk', ARGV[3])
|
|
86
|
+
if ARGV[4] == '' then
|
|
87
|
+
redis.call('HDEL', KEYS[2], 'ins')
|
|
88
|
+
else
|
|
89
|
+
redis.call('HSET', KEYS[2], 'ins', ARGV[4])
|
|
90
|
+
end
|
|
91
|
+
redis.call('SADD', KEYS[3], ARGV[5])
|
|
92
|
+
return redis.call('HGETALL', KEYS[2])
|
|
93
|
+
LUA
|
|
94
|
+
|
|
95
|
+
# KEYS: token index key. ARGV: pending-login key prefix, token.
|
|
96
|
+
# Returns [cps_token_expires_at or "", login id, field, value, field, value, ...].
|
|
97
|
+
CLAIM = <<~LUA
|
|
98
|
+
-- sqrl:claim
|
|
99
|
+
local id = redis.call('GET', KEYS[1])
|
|
100
|
+
if not id then return false end
|
|
101
|
+
redis.call('DEL', KEYS[1])
|
|
102
|
+
local pl = ARGV[1] .. id
|
|
103
|
+
local tok = redis.call('HGET', pl, 'cps_token')
|
|
104
|
+
if tok ~= ARGV[2] then return false end
|
|
105
|
+
local exp = redis.call('HGET', pl, 'cps_token_expires_at') or ''
|
|
106
|
+
redis.call('HDEL', pl, 'cps_token', 'cps_token_expires_at')
|
|
107
|
+
local data = redis.call('HGETALL', pl)
|
|
108
|
+
table.insert(data, 1, id)
|
|
109
|
+
table.insert(data, 1, exp)
|
|
110
|
+
return data
|
|
111
|
+
LUA
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Encoding helpers shared by the three stores.
|
|
115
|
+
module Codec
|
|
116
|
+
module_function
|
|
117
|
+
|
|
118
|
+
# @return [String, nil] a Time encoded as epoch seconds
|
|
119
|
+
def time_out(time) = time.nil? ? nil : format("%.6f", time.to_f)
|
|
120
|
+
# @return [Time, nil] a Time decoded from epoch seconds
|
|
121
|
+
def time_in(str) = str.nil? || str.empty? ? nil : Time.at(Float(str))
|
|
122
|
+
# @return [String] a boolean encoded for Redis
|
|
123
|
+
def bool_out(value) = value ? "1" : "0"
|
|
124
|
+
# @return [Boolean] a boolean decoded from Redis
|
|
125
|
+
def bool_in(str) = str == "1"
|
|
126
|
+
# @return [String, nil] binary bytes encoded for Redis (base64url)
|
|
127
|
+
def bin_out(bytes) = bytes.nil? ? nil : Util.b64u_encode(bytes)
|
|
128
|
+
# @return [String, nil] binary bytes decoded from Redis
|
|
129
|
+
def bin_in(str) = str.nil? || str.empty? ? nil : Util.b64u_decode(str)
|
|
130
|
+
|
|
131
|
+
# Milliseconds until +time+, at least 1 (Redis rejects non-positive TTLs).
|
|
132
|
+
def ttl_ms(time, default_seconds)
|
|
133
|
+
return default_seconds * 1000 if time.nil?
|
|
134
|
+
|
|
135
|
+
[((time.to_f - Time.now.to_f) * 1000).ceil, 1].max
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Hash] a flat Redis field/value list as a Hash
|
|
139
|
+
def pairs_to_hash(flat)
|
|
140
|
+
return nil if flat.nil? || flat == false
|
|
141
|
+
return flat if flat.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
Hash[*flat]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Common plumbing.
|
|
148
|
+
class Base
|
|
149
|
+
def initialize(redis, namespace:)
|
|
150
|
+
@redis = redis
|
|
151
|
+
@ns = namespace
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def key(kind, id) = "#{@ns}:#{kind}:#{id}"
|
|
157
|
+
|
|
158
|
+
def each_key(kind, &)
|
|
159
|
+
@redis.scan_each(match: "#{@ns}:#{kind}:*", &)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @see Server::NutStore
|
|
164
|
+
class NutStore < Base
|
|
165
|
+
def initialize(redis, namespace:, default_ttl:)
|
|
166
|
+
super(redis, namespace: namespace)
|
|
167
|
+
@default_ttl = default_ttl
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# (see SQRL::Server::NutStore#issue)
|
|
171
|
+
def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
|
|
172
|
+
payload = JSON.generate("pl" => pending_login_id, "srv" => expected_server, "ip" => ip,
|
|
173
|
+
"idk" => Codec.bin_out(bound_idk), "exp" => Codec.time_out(expires_at))
|
|
174
|
+
@redis.set(key(:nut, nut), payload, px: Codec.ttl_ms(expires_at, @default_ttl))
|
|
175
|
+
NutRecord.new(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server, ip: ip,
|
|
176
|
+
bound_idk: bound_idk, expires_at: expires_at, used_at: nil)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# (see SQRL::Server::NutStore#consume)
|
|
180
|
+
def consume(nut, now: Time.now)
|
|
181
|
+
return nil if nut.nil? || nut.empty?
|
|
182
|
+
|
|
183
|
+
payload = getdel(key(:nut, nut))
|
|
184
|
+
return nil unless payload
|
|
185
|
+
|
|
186
|
+
data = JSON.parse(payload)
|
|
187
|
+
record = NutRecord.new(nut: nut, pending_login_id: data["pl"], expected_server: data["srv"], ip: data["ip"],
|
|
188
|
+
bound_idk: Codec.bin_in(data["idk"]), expires_at: Codec.time_in(data["exp"]),
|
|
189
|
+
used_at: now)
|
|
190
|
+
record.expired?(now) ? nil : record
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Used nuts are deleted when consumed and expiry is enforced by Redis TTLs; this
|
|
194
|
+
# only removes records whose stored +expires_at+ has passed.
|
|
195
|
+
def prune!(now = Time.now)
|
|
196
|
+
each_key(:nut) do |k|
|
|
197
|
+
payload = @redis.get(k)
|
|
198
|
+
next unless payload
|
|
199
|
+
|
|
200
|
+
exp = Codec.time_in(JSON.parse(payload)["exp"])
|
|
201
|
+
@redis.del(k) if exp && exp <= now
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def getdel(k)
|
|
209
|
+
@redis.getdel(k)
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
raise unless e.class.name.to_s.include?("CommandError")
|
|
212
|
+
|
|
213
|
+
# Redis < 6.2: emulate GETDEL atomically with a script.
|
|
214
|
+
@redis.eval(Scripts::GETDEL, keys: [k])
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @see Server::IdentityStore
|
|
219
|
+
class IdentityStore < Base
|
|
220
|
+
# Hash fields of an identity record.
|
|
221
|
+
FIELDS = %i[idk suk vuk disabled sqrlonly hardlock ins logins user_ref].freeze
|
|
222
|
+
|
|
223
|
+
# (see SQRL::Server::IdentityStore#find)
|
|
224
|
+
def find(idk)
|
|
225
|
+
to_identity(@redis.hgetall(key(:id, Util.b64u_encode(idk))))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def create(**attrs)
|
|
229
|
+
identity = Identity.new(**attrs)
|
|
230
|
+
fields = encode(identity.to_h).compact.flatten
|
|
231
|
+
created = @redis.eval(Scripts::CREATE_IDENTITY, keys: [key(:id, identity.idk_b64)], argv: fields)
|
|
232
|
+
raise ArgumentError, "identity already exists" unless created.to_i == 1
|
|
233
|
+
|
|
234
|
+
identity.dup
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# (see SQRL::Server::IdentityStore#update)
|
|
238
|
+
def update(idk, **attrs)
|
|
239
|
+
k = key(:id, Util.b64u_encode(idk))
|
|
240
|
+
return nil unless @redis.exists?(k)
|
|
241
|
+
|
|
242
|
+
encoded = encode(attrs)
|
|
243
|
+
removals = encoded.select { |_, v| v.nil? }.keys
|
|
244
|
+
writes = encoded.compact
|
|
245
|
+
@redis.hdel(k, *removals) unless removals.empty?
|
|
246
|
+
@redis.hset(k, writes) unless writes.empty?
|
|
247
|
+
find(idk)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# (see SQRL::Server::IdentityStore#rekey)
|
|
251
|
+
def rekey(old_idk, new_idk, suk:, vuk:, ins:)
|
|
252
|
+
old_b64 = Util.b64u_encode(old_idk)
|
|
253
|
+
new_b64 = Util.b64u_encode(new_idk)
|
|
254
|
+
flat = @redis.eval(Scripts::REKEY,
|
|
255
|
+
keys: [key(:id, old_b64), key(:id, new_b64), "#{@ns}:superseded"],
|
|
256
|
+
argv: [new_b64, Util.b64u_encode(suk), Util.b64u_encode(vuk), ins.to_s, old_b64])
|
|
257
|
+
to_identity(Codec.pairs_to_hash(flat))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def superseded?(idk)
|
|
261
|
+
@redis.sismember("#{@ns}:superseded", Util.b64u_encode(idk)) ? true : false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# (see SQRL::Server::IdentityStore#remove)
|
|
265
|
+
def remove(idk)
|
|
266
|
+
flat = @redis.eval(Scripts::REMOVE_IDENTITY, keys: [key(:id, Util.b64u_encode(idk))])
|
|
267
|
+
to_identity(Codec.pairs_to_hash(flat))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def encode(attrs)
|
|
273
|
+
attrs.to_h do |k, v|
|
|
274
|
+
k = k.to_sym
|
|
275
|
+
raise ArgumentError, "unknown identity attribute #{k}" unless FIELDS.include?(k)
|
|
276
|
+
|
|
277
|
+
value =
|
|
278
|
+
case k
|
|
279
|
+
when :idk, :suk, :vuk then Codec.bin_out(v)
|
|
280
|
+
when :disabled, :sqrlonly, :hardlock then Codec.bool_out(v)
|
|
281
|
+
when :logins then v.nil? ? nil : v.to_i.to_s
|
|
282
|
+
else v&.to_s
|
|
283
|
+
end
|
|
284
|
+
[k.to_s, value]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def to_identity(hash)
|
|
289
|
+
return nil if hash.nil? || hash.empty?
|
|
290
|
+
|
|
291
|
+
Identity.new(idk: Codec.bin_in(hash["idk"]), suk: Codec.bin_in(hash["suk"]), vuk: Codec.bin_in(hash["vuk"]),
|
|
292
|
+
disabled: Codec.bool_in(hash["disabled"]), sqrlonly: Codec.bool_in(hash["sqrlonly"]),
|
|
293
|
+
hardlock: Codec.bool_in(hash["hardlock"]), ins: hash["ins"], logins: hash["logins"].to_i,
|
|
294
|
+
user_ref: hash["user_ref"])
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @see Server::PendingLoginStore
|
|
299
|
+
class PendingLoginStore < Base
|
|
300
|
+
# Hash fields of a pending-login record.
|
|
301
|
+
FIELDS = %i[authenticated_idk authenticated_at btn asked last_command cps_token cps_token_expires_at
|
|
302
|
+
ip return_to link_user_ref created_at expires_at ip_matched].freeze
|
|
303
|
+
|
|
304
|
+
def initialize(redis, namespace:, default_ttl:)
|
|
305
|
+
super(redis, namespace: namespace)
|
|
306
|
+
@default_ttl = default_ttl
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# (see SQRL::Server::PendingLoginStore#create)
|
|
310
|
+
def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
|
|
311
|
+
login = PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
|
|
312
|
+
link_user_ref: link_user_ref, created_at: created_at)
|
|
313
|
+
k = key(:pl, id)
|
|
314
|
+
@redis.del(k)
|
|
315
|
+
@redis.hset(k, encode(login.to_h.except(:id)).compact)
|
|
316
|
+
@redis.pexpire(k, Codec.ttl_ms(expires_at, @default_ttl))
|
|
317
|
+
login.dup
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# (see SQRL::Server::IdentityStore#find)
|
|
321
|
+
def find(id)
|
|
322
|
+
return nil if id.nil?
|
|
323
|
+
|
|
324
|
+
to_login(id, @redis.hgetall(key(:pl, id)))
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# (see SQRL::Server::IdentityStore#update)
|
|
328
|
+
def update(id, **attrs)
|
|
329
|
+
k = key(:pl, id)
|
|
330
|
+
return nil unless @redis.exists?(k)
|
|
331
|
+
|
|
332
|
+
reindex_token(id, k, attrs) if attrs.key?(:cps_token)
|
|
333
|
+
encoded = encode(attrs)
|
|
334
|
+
removals = encoded.select { |_, v| v.nil? }.keys
|
|
335
|
+
writes = encoded.compact
|
|
336
|
+
@redis.hdel(k, *removals) unless removals.empty?
|
|
337
|
+
@redis.hset(k, writes) unless writes.empty?
|
|
338
|
+
@redis.pexpire(k, Codec.ttl_ms(attrs[:expires_at], @default_ttl)) if attrs.key?(:expires_at)
|
|
339
|
+
find(id)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# (see SQRL::Server::PendingLoginStore#claim_cps_token)
|
|
343
|
+
def claim_cps_token(token, now: Time.now)
|
|
344
|
+
return nil if token.nil? || token.empty?
|
|
345
|
+
|
|
346
|
+
flat = @redis.eval(Scripts::CLAIM, keys: [key(:cps, token)], argv: ["#{@ns}:pl:", token])
|
|
347
|
+
return nil unless flat.is_a?(Array) && !flat.empty?
|
|
348
|
+
|
|
349
|
+
expires_at = Codec.time_in(flat[0])
|
|
350
|
+
id = flat[1]
|
|
351
|
+
hash = Codec.pairs_to_hash(flat.drop(2))
|
|
352
|
+
return nil if expires_at && expires_at <= now
|
|
353
|
+
|
|
354
|
+
to_login(id, hash)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Remove expired records (Redis TTLs do most of this already).
|
|
358
|
+
# @return [Integer] number removed
|
|
359
|
+
def prune!(now = Time.now)
|
|
360
|
+
each_key(:pl) do |k|
|
|
361
|
+
exp = Codec.time_in(@redis.hget(k, "expires_at"))
|
|
362
|
+
@redis.del(k) if exp && exp <= now
|
|
363
|
+
end
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
private
|
|
368
|
+
|
|
369
|
+
# Keep the token → login index in step with the +cps_token+ field.
|
|
370
|
+
def reindex_token(id, k, attrs)
|
|
371
|
+
old = @redis.hget(k, "cps_token")
|
|
372
|
+
@redis.del(key(:cps, old)) if old && !old.empty?
|
|
373
|
+
new = attrs[:cps_token]
|
|
374
|
+
return if new.nil? || new.empty?
|
|
375
|
+
|
|
376
|
+
expires_at = if attrs.key?(:cps_token_expires_at)
|
|
377
|
+
attrs[:cps_token_expires_at]
|
|
378
|
+
else
|
|
379
|
+
Codec.time_in(@redis.hget(k,
|
|
380
|
+
"cps_token_expires_at"))
|
|
381
|
+
end
|
|
382
|
+
# The index outlives the token's own expiry by a minute so that a late claim still
|
|
383
|
+
# finds the login and clears the stale token (the expiry itself is enforced by
|
|
384
|
+
# value in claim_cps_token).
|
|
385
|
+
@redis.set(key(:cps, new), id, px: Codec.ttl_ms(expires_at, @default_ttl) + 60_000)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def encode(attrs)
|
|
389
|
+
attrs.to_h do |f, v|
|
|
390
|
+
f = f.to_sym
|
|
391
|
+
raise ArgumentError, "unknown pending login attribute #{f}" unless FIELDS.include?(f) || f == :id
|
|
392
|
+
|
|
393
|
+
value =
|
|
394
|
+
case f
|
|
395
|
+
when :authenticated_idk then Codec.bin_out(v)
|
|
396
|
+
when :authenticated_at, :cps_token_expires_at, :created_at, :expires_at then Codec.time_out(v)
|
|
397
|
+
when :asked, :ip_matched then Codec.bool_out(v)
|
|
398
|
+
when :btn then v&.to_i&.to_s
|
|
399
|
+
else v&.to_s
|
|
400
|
+
end
|
|
401
|
+
[f.to_s, value]
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def to_login(id, hash)
|
|
406
|
+
return nil if hash.nil? || hash.empty?
|
|
407
|
+
|
|
408
|
+
PendingLogin.new(id: id, ip: hash["ip"], return_to: hash["return_to"], link_user_ref: hash["link_user_ref"],
|
|
409
|
+
created_at: Codec.time_in(hash["created_at"]), expires_at: Codec.time_in(hash["expires_at"]),
|
|
410
|
+
authenticated_idk: Codec.bin_in(hash["authenticated_idk"]),
|
|
411
|
+
authenticated_at: Codec.time_in(hash["authenticated_at"]),
|
|
412
|
+
btn: hash["btn"]&.to_i, asked: Codec.bool_in(hash["asked"]),
|
|
413
|
+
last_command: hash["last_command"]&.to_sym, cps_token: hash["cps_token"],
|
|
414
|
+
cps_token_expires_at: Codec.time_in(hash["cps_token_expires_at"]),
|
|
415
|
+
ip_matched: Codec.bool_in(hash["ip_matched"]))
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|