sqrl-rack 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 +16 -0
- data/LICENSE +21 -0
- data/README.md +194 -0
- data/assets/sqrl.js +165 -0
- data/lib/roda/plugins/sqrl.rb +109 -0
- data/lib/sinatra/sqrl.rb +86 -0
- data/lib/sqrl/rack/assets.rb +30 -0
- data/lib/sqrl/rack/endpoint.rb +263 -0
- data/lib/sqrl/rack/facade.rb +79 -0
- data/lib/sqrl/rack/host_resolver.rb +115 -0
- data/lib/sqrl/rack/login_block.rb +96 -0
- data/lib/sqrl/rack/middleware.rb +175 -0
- data/lib/sqrl/rack/qr.rb +54 -0
- data/lib/sqrl/rack.rb +25 -0
- metadata +109 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Rack
|
|
7
|
+
# Framework-neutral request handlers around a {Server::Handler}: issue login URLs,
|
|
8
|
+
# answer the SQRL client's POST, the browser's status poll and the login callback.
|
|
9
|
+
# {Middleware} drives these for plain Rack; the Rails engine calls them from
|
|
10
|
+
# controllers.
|
|
11
|
+
#
|
|
12
|
+
# The pending login id lives in the browser session under +session_key+; the
|
|
13
|
+
# identity of a completed login is written under +identity_key+ by {#callback}.
|
|
14
|
+
class Endpoint
|
|
15
|
+
# Default session key holding the pending-login id.
|
|
16
|
+
SESSION_KEY = "sqrl.pending_login"
|
|
17
|
+
# Default session key holding the logged-in identity key (base64url).
|
|
18
|
+
IDENTITY_KEY = "sqrl.identity"
|
|
19
|
+
|
|
20
|
+
# What {#callback} returns when a login completes.
|
|
21
|
+
Completion = Struct.new(:identity, :pending_login, :via, keyword_init: true) do
|
|
22
|
+
# @return [String] the identity key as base64url
|
|
23
|
+
def idk_b64 = identity.idk_b64
|
|
24
|
+
# @return [String, nil] the same-site return path captured when the login page was issued
|
|
25
|
+
def return_to = pending_login.return_to
|
|
26
|
+
def cps? = via == :cps
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :handler, :config, :session_key, :identity_key, :status_path, :callback_path, :script_path,
|
|
30
|
+
:refresh_path
|
|
31
|
+
|
|
32
|
+
# Headers of a 429 answer.
|
|
33
|
+
RATE_LIMITED_HEADERS = { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }.freeze
|
|
34
|
+
|
|
35
|
+
# @param handler [Server::Handler]
|
|
36
|
+
# @param status_path [String] app-relative path of the JSON poll endpoint
|
|
37
|
+
# @param callback_path [String] app-relative path of the login callback
|
|
38
|
+
# (+config.callback_path+ must be the public form of the same path)
|
|
39
|
+
# @param script_path [String] app-relative path serving +sqrl.js+
|
|
40
|
+
# @param ip [#call, nil] +(request) -> String+ resolving the client IP; defaults to
|
|
41
|
+
# +Rack::Request#ip+
|
|
42
|
+
# +poll_requires_ip_match+ (default false): when true, a login completed through the
|
|
43
|
+
# browser's own cookie (the poll path, no CPS token) counts only when the SQRL
|
|
44
|
+
# client's IP matched the browser's. This is an optional hardening against a client
|
|
45
|
+
# that sent +noiptest+ being tricked into authenticating a login page opened by
|
|
46
|
+
# someone else; it also refuses legitimate logins from a phone on another network,
|
|
47
|
+
# which is why it is off by default. CPS completions are unaffected.
|
|
48
|
+
# +refresh_path+ serves {#refresh}: a JSON endpoint the page calls (through
|
|
49
|
+
# +SQRL.refresh+) to swap an expired login URL for a fresh one without a reload.
|
|
50
|
+
def initialize(handler:, session_key: SESSION_KEY, identity_key: IDENTITY_KEY, status_path: "/sqrl/status",
|
|
51
|
+
callback_path: nil, script_path: "/sqrl/sqrl.js", refresh_path: "/sqrl/refresh", ip: nil,
|
|
52
|
+
poll_requires_ip_match: false)
|
|
53
|
+
@handler = handler
|
|
54
|
+
@refresh_path = refresh_path
|
|
55
|
+
@poll_requires_ip_match = poll_requires_ip_match
|
|
56
|
+
@config = handler.config
|
|
57
|
+
@session_key = session_key
|
|
58
|
+
@identity_key = identity_key
|
|
59
|
+
@status_path = status_path
|
|
60
|
+
@callback_path = callback_path || handler.config.callback_path
|
|
61
|
+
@script_path = script_path
|
|
62
|
+
@ip = ip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Issue a fresh login URL and remember its pending login in the browser session.
|
|
66
|
+
#
|
|
67
|
+
# @param request [Rack::Request]
|
|
68
|
+
# @param return_to [String, nil] where the browser goes after login (same-site path)
|
|
69
|
+
# @param cancel_url [String, nil] encoded into +can=+
|
|
70
|
+
# @param link_user_ref [Object, nil] signed-in user to link a new identity to
|
|
71
|
+
# @return [Server::LoginURL]
|
|
72
|
+
def issue(request, return_to: nil, cancel_url: nil, link_user_ref: nil)
|
|
73
|
+
session = session_of(request)
|
|
74
|
+
login = @handler.new_login_url(ip: client_ip(request), cancel_url: cancel_url, return_to: return_to,
|
|
75
|
+
link_user_ref: link_user_ref)
|
|
76
|
+
session[@session_key] = login.pending_login_id
|
|
77
|
+
login
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The pending login id stored in the browser session, if any.
|
|
81
|
+
def pending_login_id(request)
|
|
82
|
+
session = request.env["rack.session"]
|
|
83
|
+
session && session[@session_key]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Handle the SQRL client's POST. Always 200 with the protocol body; TIF carries
|
|
87
|
+
# failures.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array] a Rack response triple
|
|
90
|
+
def cli(request)
|
|
91
|
+
result = @handler.handle(nut: request.GET["nut"].to_s, body: form_body(request), ip: client_ip(request))
|
|
92
|
+
[200, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store",
|
|
93
|
+
"x-sqrl-tif" => result.tif.to_hex }, [result.body]]
|
|
94
|
+
rescue Server::RateLimited => e
|
|
95
|
+
rate_limited(e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The 429 response for a {Server::RateLimited} raised by a hook.
|
|
99
|
+
#
|
|
100
|
+
# @return [Array] a Rack response triple
|
|
101
|
+
def rate_limited(error, json: false)
|
|
102
|
+
headers = RATE_LIMITED_HEADERS.dup
|
|
103
|
+
headers["retry-after"] = error.retry_after.to_i.to_s if error.retry_after
|
|
104
|
+
body = json ? JSON.generate(error: "rate_limited", message: error.message) : "#{error.message}\n"
|
|
105
|
+
headers["content-type"] = "application/json; charset=utf-8" if json
|
|
106
|
+
[429, headers, [body]]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Hand the page a fresh login URL for its browser session (a new pending login and
|
|
110
|
+
# nut, carrying over +return_to+ and the link target of the previous one), as JSON:
|
|
111
|
+
# +{"sqrl_url", "status_url", "qr_svg"}+. +qr=0+ leaves the QR code out.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array] a Rack response triple
|
|
114
|
+
def refresh(request)
|
|
115
|
+
previous_id = pending_login_id(request)
|
|
116
|
+
previous = previous_id && @handler.stores.pending_logins.find(previous_id)
|
|
117
|
+
login = issue(request, return_to: previous&.return_to, link_user_ref: previous&.link_user_ref)
|
|
118
|
+
payload = { sqrl_url: login.url, status_url: status_url(request), refresh_url: refresh_url(request) }
|
|
119
|
+
payload[:qr_svg] = QR.svg(login.url) unless request.GET["qr"] == "0"
|
|
120
|
+
[200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" },
|
|
121
|
+
[JSON.generate(payload)]]
|
|
122
|
+
rescue Server::RateLimited => e
|
|
123
|
+
rate_limited(e, json: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Answer the browser's poll with JSON.
|
|
127
|
+
#
|
|
128
|
+
# @return [Array] a Rack response triple
|
|
129
|
+
def status(request)
|
|
130
|
+
id = pending_login_id(request)
|
|
131
|
+
payload =
|
|
132
|
+
if id.nil?
|
|
133
|
+
{ authenticated: false }
|
|
134
|
+
else
|
|
135
|
+
login = @handler.stores.pending_logins.find(id)
|
|
136
|
+
if login.nil? || login.expired?(@config.now)
|
|
137
|
+
{ authenticated: false, expired: true }
|
|
138
|
+
elsif login.authenticated? && poll_completable?(login)
|
|
139
|
+
{ authenticated: true, next: callback_url(request) }
|
|
140
|
+
else
|
|
141
|
+
{ authenticated: false }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
[200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" },
|
|
145
|
+
[JSON.generate(payload)]]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Complete a login. With a +t=+ token (CPS) the token is claimed once and must
|
|
149
|
+
# belong to this browser's pending login; without one, the browser's own pending
|
|
150
|
+
# login is completed if the SQRL client authenticated it. Either way the pending
|
|
151
|
+
# login is detached from the session so it completes at most once. On success the
|
|
152
|
+
# identity's +idk+ is stored under +identity_key+.
|
|
153
|
+
#
|
|
154
|
+
# @return [Completion, nil]
|
|
155
|
+
def callback(request)
|
|
156
|
+
session = session_of(request)
|
|
157
|
+
token = request.GET["t"]
|
|
158
|
+
expected_id = session[@session_key]
|
|
159
|
+
login, via =
|
|
160
|
+
if token && !token.empty?
|
|
161
|
+
[@handler.claim_cps(token), :cps]
|
|
162
|
+
else
|
|
163
|
+
[expected_id && @handler.stores.pending_logins.find(expected_id), :poll]
|
|
164
|
+
end
|
|
165
|
+
return nil if login.nil? || expected_id.nil? || !Util.secure_compare(login.id.to_s, expected_id.to_s)
|
|
166
|
+
return nil unless login.authenticated? && !login.expired?(@config.now)
|
|
167
|
+
return nil if via == :poll && !poll_completable?(login)
|
|
168
|
+
|
|
169
|
+
identity = @handler.stores.identities.find(login.authenticated_idk)
|
|
170
|
+
return nil if identity.nil? || identity.disabled?
|
|
171
|
+
|
|
172
|
+
session.delete(@session_key)
|
|
173
|
+
session[@identity_key] = identity.idk_b64
|
|
174
|
+
Completion.new(identity: identity, pending_login: login, via: via)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Whether a pending login may be completed without a CPS token.
|
|
178
|
+
def poll_completable?(login)
|
|
179
|
+
!@poll_requires_ip_match || login.ip_matched == true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The identity currently logged in through SQRL in this browser session, or nil.
|
|
183
|
+
def current_identity(request)
|
|
184
|
+
session = request.env["rack.session"]
|
|
185
|
+
idk_b64 = session && session[@identity_key]
|
|
186
|
+
return nil unless idk_b64
|
|
187
|
+
|
|
188
|
+
@handler.stores.identities.find(Util.b64u_decode(idk_b64.to_s))
|
|
189
|
+
rescue FormatError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Forget the SQRL login and any pending login in this browser session.
|
|
194
|
+
def logout(request)
|
|
195
|
+
session = request.env["rack.session"]
|
|
196
|
+
return unless session
|
|
197
|
+
|
|
198
|
+
session.delete(@identity_key)
|
|
199
|
+
session.delete(@session_key)
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Serve +sqrl.js+.
|
|
204
|
+
#
|
|
205
|
+
# @return [Array] a Rack response triple
|
|
206
|
+
def script(request)
|
|
207
|
+
etag = %("#{Assets.javascript_digest}")
|
|
208
|
+
if request.env["HTTP_IF_NONE_MATCH"] == etag
|
|
209
|
+
return [304, { "etag" => etag, "cache-control" => "public, max-age=86400" }, []]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[200, { "content-type" => "application/javascript; charset=utf-8", "etag" => etag,
|
|
213
|
+
"cache-control" => "public, max-age=86400" }, [Assets.javascript]]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Browser-facing URLs, honouring the mount prefix (+SCRIPT_NAME+).
|
|
217
|
+
def status_url(request) = prefixed(request, @status_path)
|
|
218
|
+
# @return [String] path of the callback for this request (mount prefix included)
|
|
219
|
+
def callback_url(request) = prefixed(request, @callback_path)
|
|
220
|
+
# @return [String] path of +sqrl.js+ for this request (mount prefix included)
|
|
221
|
+
def script_url(request) = prefixed(request, @script_path)
|
|
222
|
+
# @return [String] path of the refresh endpoint for this request (mount prefix included)
|
|
223
|
+
def refresh_url(request) = prefixed(request, @refresh_path)
|
|
224
|
+
|
|
225
|
+
# The client IP a request comes from.
|
|
226
|
+
def client_ip(request)
|
|
227
|
+
(@ip ? @ip.call(request) : request.ip).to_s
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
# The POST body. Middleware upstream (Rack::Protection, parameter parsers) may
|
|
233
|
+
# already have consumed +rack.input+ without rewinding it, so a form body is rebuilt
|
|
234
|
+
# from Rack's cached form hash; SQRL values are base64url, which the form decoding
|
|
235
|
+
# leaves untouched.
|
|
236
|
+
def form_body(request)
|
|
237
|
+
if request.form_data? && request.POST.any?
|
|
238
|
+
request.POST.map { |k, v| "#{k}=#{v}" }.join("&")
|
|
239
|
+
else
|
|
240
|
+
body = request.body
|
|
241
|
+
body.rewind if body.respond_to?(:rewind)
|
|
242
|
+
data = body.read.to_s
|
|
243
|
+
body.rewind if body.respond_to?(:rewind)
|
|
244
|
+
data
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError
|
|
247
|
+
""
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def prefixed(request, path)
|
|
251
|
+
"#{request.script_name}#{path}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def session_of(request)
|
|
255
|
+
session = request.env["rack.session"]
|
|
256
|
+
return session if session
|
|
257
|
+
|
|
258
|
+
raise Error, "SQRL::Rack needs a session: add a session middleware (for example " \
|
|
259
|
+
"Rack::Session::Cookie) before SQRL::Rack::Middleware, or `enable :sessions` in Sinatra"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rack
|
|
5
|
+
# Request-scoped view of SQRL for the application, available as
|
|
6
|
+
# +env["sqrl.server"]+ on every request the {Middleware} passes through.
|
|
7
|
+
class Facade
|
|
8
|
+
attr_reader :endpoint, :request
|
|
9
|
+
|
|
10
|
+
def initialize(endpoint, request)
|
|
11
|
+
@endpoint = endpoint
|
|
12
|
+
@request = request
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The login URL for this page view (issued once per request).
|
|
16
|
+
#
|
|
17
|
+
# @return [Server::LoginURL]
|
|
18
|
+
def login_url(return_to: nil, cancel_url: nil, link_user_ref: nil)
|
|
19
|
+
@login_url ||= @endpoint.issue(@request, return_to: return_to, cancel_url: cancel_url,
|
|
20
|
+
link_user_ref: link_user_ref)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Full login markup (link, QR, status text, script tag). Options are those of
|
|
24
|
+
# {LoginBlock.render} plus +return_to:+, +link_user_ref:+ and +script:+ (false to
|
|
25
|
+
# leave the script tag out).
|
|
26
|
+
#
|
|
27
|
+
# When a +before_issue+ hook refuses the request ({Server::RateLimited}) the block
|
|
28
|
+
# degrades to a short "try again later" message instead of raising.
|
|
29
|
+
def login_block(return_to: nil, cancel_url: nil, link_user_ref: nil, script: true, **options)
|
|
30
|
+
login = login_url(return_to: return_to, cancel_url: cancel_url, link_user_ref: link_user_ref)
|
|
31
|
+
LoginBlock.render(login_url: login.url, status_url: @endpoint.status_url(@request),
|
|
32
|
+
cancel_url: cancel_url, script_url: script ? @endpoint.script_url(@request) : nil,
|
|
33
|
+
refresh_url: @endpoint.refresh_url(@request), **options)
|
|
34
|
+
rescue Server::RateLimited => e
|
|
35
|
+
LoginBlock.unavailable(message: e.message, css_class: options.fetch(:css_class, "sqrl-login"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String] a +<script>+ tag loading +sqrl.js+ from this site
|
|
39
|
+
def script_tag
|
|
40
|
+
LoginBlock.script_tag(src: @endpoint.script_url(@request))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [String] the login URL as an SVG QR code (see {QR.svg})
|
|
44
|
+
def qr_svg(**options)
|
|
45
|
+
QR.svg(login_url.url, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] see {Endpoint#status_url}
|
|
49
|
+
def status_url = @endpoint.status_url(@request)
|
|
50
|
+
# @return [String] see {Endpoint#callback_url}
|
|
51
|
+
def callback_url = @endpoint.callback_url(@request)
|
|
52
|
+
# @return [String] see {Endpoint#script_url}
|
|
53
|
+
def script_url = @endpoint.script_url(@request)
|
|
54
|
+
# @return [String] see {Endpoint#refresh_url}
|
|
55
|
+
def refresh_url = @endpoint.refresh_url(@request)
|
|
56
|
+
|
|
57
|
+
# The SQRL identity record logged in to this browser session, or nil.
|
|
58
|
+
def identity
|
|
59
|
+
return @identity if defined?(@identity)
|
|
60
|
+
|
|
61
|
+
@identity = @endpoint.current_identity(@request)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] the logged-in identity key as base64url
|
|
65
|
+
def idk_b64 = identity&.idk_b64
|
|
66
|
+
|
|
67
|
+
def authenticated?
|
|
68
|
+
!identity.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Forget the SQRL login in this session.
|
|
72
|
+
def logout!
|
|
73
|
+
@endpoint.logout(@request)
|
|
74
|
+
remove_instance_variable(:@identity) if defined?(@identity)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rack
|
|
5
|
+
# Raised when a request's host may not be used to issue SQRL URLs. Integrations answer
|
|
6
|
+
# 400.
|
|
7
|
+
class HostNotAllowed < Error; end
|
|
8
|
+
|
|
9
|
+
# Decides which host (with port) and scheme a request's SQRL URLs are issued for, and
|
|
10
|
+
# bounds the set of hosts an integration keeps per-host state for.
|
|
11
|
+
#
|
|
12
|
+
# The host is the string every identity key is derived from, so a deployment should
|
|
13
|
+
# configure it explicitly. When it is not configured the request's +Host+ header is
|
|
14
|
+
# used: a warning is logged once, the host must pass +allowed_hosts+ when that list is
|
|
15
|
+
# given, and at most +limit+ distinct hosts are accepted per process; further ones
|
|
16
|
+
# raise {HostNotAllowed} rather than growing the per-host caches without bound.
|
|
17
|
+
#
|
|
18
|
+
# {#fetch} is the bounded, thread-safe cache integrations keep their per-host handler
|
|
19
|
+
# or endpoint in; a configured host never counts against the limit.
|
|
20
|
+
class HostResolver
|
|
21
|
+
# Default number of distinct derived hosts the handler cache holds.
|
|
22
|
+
DEFAULT_LIMIT = 16
|
|
23
|
+
|
|
24
|
+
attr_reader :host, :scheme, :limit
|
|
25
|
+
|
|
26
|
+
# @param host [String, nil] the configured public host (with port), or nil to derive
|
|
27
|
+
# @param scheme [String, nil] +sqrl+ (the default and the only standard scheme) or
|
|
28
|
+
# +qrl+ for the loopback-only +insecure_http+ testing mode; never derived from the request
|
|
29
|
+
# @param allowed_hosts [Array<String, Regexp>, nil] accepted derived hosts
|
|
30
|
+
# @param limit [Integer] maximum number of distinct derived hosts kept
|
|
31
|
+
# @param logger [#warn, nil] receives the one-time warning; +Kernel#warn+ when nil
|
|
32
|
+
def initialize(host: nil, scheme: nil, allowed_hosts: nil, limit: DEFAULT_LIMIT, logger: nil)
|
|
33
|
+
@host = host&.to_s
|
|
34
|
+
@scheme = scheme&.to_s
|
|
35
|
+
@allowed_hosts = allowed_hosts
|
|
36
|
+
@limit = limit || DEFAULT_LIMIT
|
|
37
|
+
@logger = logger
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@cache = {}
|
|
40
|
+
@warned = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] whether the host comes from configuration rather than requests
|
|
44
|
+
def configured?
|
|
45
|
+
!@host.nil? && !@host.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The host (with port) and scheme for a request.
|
|
49
|
+
#
|
|
50
|
+
# @param request [Rack::Request]
|
|
51
|
+
# @return [Array(String, String)]
|
|
52
|
+
# @raise [HostNotAllowed] when a derived host is not allowed
|
|
53
|
+
def resolve(request)
|
|
54
|
+
host = configured? ? @host : derived_host(request)
|
|
55
|
+
[host, @scheme || "sqrl"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fetch (or build with the block) the cached object for +key+, whose first element
|
|
59
|
+
# must be the host. Once +limit+ distinct derived hosts are cached, a key for a new
|
|
60
|
+
# host raises {HostNotAllowed} (keys for already-known hosts, for example other
|
|
61
|
+
# mount prefixes, are still accepted).
|
|
62
|
+
def fetch(key)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return @cache[key] if @cache.key?(key)
|
|
65
|
+
|
|
66
|
+
host = host_of(key)
|
|
67
|
+
if !configured? && known_hosts.size >= @limit && !known_hosts.include?(host)
|
|
68
|
+
raise HostNotAllowed, "too many distinct request hosts (limit #{@limit}); configure host: explicitly"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@cache[key] = yield
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Distinct hosts currently cached.
|
|
76
|
+
def known_hosts
|
|
77
|
+
@cache.keys.map { |k| host_of(k) }.uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Number of cached entries.
|
|
81
|
+
def size
|
|
82
|
+
@mutex.synchronize { @cache.size }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether +host+ (with or without port) passes +allowed_hosts+ (always when none).
|
|
86
|
+
def allowed?(host)
|
|
87
|
+
Server::Config.new(host: "placeholder.invalid", allowed_hosts: @allowed_hosts).host_allowed?(host)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def host_of(key)
|
|
93
|
+
key.is_a?(Array) ? key.first : key
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def derived_host(request)
|
|
97
|
+
host = request.host_with_port.to_s
|
|
98
|
+
raise HostNotAllowed, "request has no Host" if host.empty?
|
|
99
|
+
raise HostNotAllowed, "host #{host.inspect} is not in allowed_hosts" unless allowed?(host)
|
|
100
|
+
|
|
101
|
+
warn_once(host)
|
|
102
|
+
host
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def warn_once(host)
|
|
106
|
+
return if @warned
|
|
107
|
+
|
|
108
|
+
@warned = true
|
|
109
|
+
message = "[sqrl] host not configured; deriving it from the request (#{host}). Identities are keyed " \
|
|
110
|
+
"by host, so set host: explicitly in production."
|
|
111
|
+
@logger.respond_to?(:warn) ? @logger.warn(message) : Kernel.warn(message)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Rack
|
|
7
|
+
# Framework-neutral HTML for a SQRL login: the link, the QR code and the status text
|
|
8
|
+
# that +sqrl.js+ drives. Every framework integration renders this.
|
|
9
|
+
module LoginBlock
|
|
10
|
+
# Default status texts, overridable through +texts:+.
|
|
11
|
+
DEFAULT_TEXTS = {
|
|
12
|
+
message: "Scan the code with your SQRL client, or click the link if it is on this device.",
|
|
13
|
+
cps: "Local SQRL client detected.",
|
|
14
|
+
done: "Logged in, redirecting…",
|
|
15
|
+
expired: "This login code expired. Reload the page to get a new one.",
|
|
16
|
+
refresh: "Get a new code",
|
|
17
|
+
unavailable: "SQRL login is temporarily unavailable. Please try again in a moment."
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param login_url [String, #to_s] the +sqrl://+ URL (a {Server::LoginURL} works)
|
|
23
|
+
# @param status_url [String] cookie-bound poll endpoint
|
|
24
|
+
# @param cancel_url [String, nil] renders a cancel link when given
|
|
25
|
+
# @param qr [Boolean] include the QR code
|
|
26
|
+
# @param qr_options [Hash] passed to {QR.svg}
|
|
27
|
+
# @param link_text [String]
|
|
28
|
+
# @param cancel_text [String]
|
|
29
|
+
# @param texts [Hash] overrides for {DEFAULT_TEXTS} (+:message+, +:cps+, +:done+, +:expired+)
|
|
30
|
+
# @param css_class [String] class of the root element (BEM children derive from it)
|
|
31
|
+
# @param poll_ms [Integer] poll interval
|
|
32
|
+
# @param script_url [String, nil] when given, a deferred +<script src>+ is appended
|
|
33
|
+
# @param refresh_url [String, nil] JSON endpoint +sqrl.js+ calls to replace an
|
|
34
|
+
# expired login URL in place (renders a hidden "Get a new code" button)
|
|
35
|
+
# @param extra_attrs [Hash] extra attributes on the root element
|
|
36
|
+
# @return [String] HTML
|
|
37
|
+
def render(login_url:, status_url:, cancel_url: nil, qr: true, qr_options: {}, link_text: "Sign in with SQRL",
|
|
38
|
+
cancel_text: "Cancel", texts: {}, css_class: "sqrl-login", poll_ms: 2000, script_url: nil,
|
|
39
|
+
refresh_url: nil, extra_attrs: {})
|
|
40
|
+
url = login_url.to_s
|
|
41
|
+
t = DEFAULT_TEXTS.merge(texts)
|
|
42
|
+
attrs = {
|
|
43
|
+
"class" => css_class, "data-sqrl-login" => "", "data-sqrl-url" => url, "data-sqrl-status" => status_url,
|
|
44
|
+
"data-sqrl-poll" => poll_ms.to_i, "data-sqrl-text-message" => t[:message], "data-sqrl-text-cps" => t[:cps],
|
|
45
|
+
"data-sqrl-text-done" => t[:done], "data-sqrl-text-expired" => t[:expired]
|
|
46
|
+
}
|
|
47
|
+
attrs["data-sqrl-refresh"] = refresh_url if refresh_url
|
|
48
|
+
attrs.merge!(extra_attrs.transform_keys(&:to_s))
|
|
49
|
+
html = "<div#{attributes(attrs)}>\n"
|
|
50
|
+
html << %( <a class="#{h(css_class)}__link" data-sqrl-link href="#{h(url)}" rel="nofollow">#{h(link_text)}</a>\n)
|
|
51
|
+
html << %( <div class="#{h(css_class)}__qr" data-sqrl-qr>#{QR.svg(url, **qr_options)}</div>\n) if qr
|
|
52
|
+
html << %( <p class="#{h(css_class)}__message" data-sqrl-message aria-live="polite">#{h(t[:message])}</p>\n)
|
|
53
|
+
if refresh_url
|
|
54
|
+
html << %( <button type="button" class="#{h(css_class)}__refresh" data-sqrl-refresh-button hidden>#{h(t[:refresh])}</button>\n)
|
|
55
|
+
end
|
|
56
|
+
html << %( <a class="#{h(css_class)}__cancel" href="#{h(cancel_url)}">#{h(cancel_text)}</a>\n) if cancel_url
|
|
57
|
+
html << "</div>"
|
|
58
|
+
html << "\n" << script_tag(src: script_url) if script_url
|
|
59
|
+
html
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The markup shown instead of the login block when issuing a login URL was refused
|
|
63
|
+
# (for example by a rate-limit hook).
|
|
64
|
+
def unavailable(message: DEFAULT_TEXTS[:unavailable], css_class: "sqrl-login")
|
|
65
|
+
%(<div class="#{h(css_class)} #{h(css_class)}--unavailable" data-sqrl-unavailable role="status">) \
|
|
66
|
+
"#{h(message)}</div>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A script tag for +sqrl.js+: external when +src+ is given, inline otherwise.
|
|
70
|
+
def script_tag(src: nil)
|
|
71
|
+
if src
|
|
72
|
+
%(<script src="#{h(src)}" defer></script>)
|
|
73
|
+
else
|
|
74
|
+
"<script>\n#{Assets.javascript}\n</script>"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Attribute names are escaped too; a name with characters outside the HTML attribute
|
|
79
|
+
# name grammar is dropped rather than rendered.
|
|
80
|
+
def attributes(hash)
|
|
81
|
+
hash.map do |k, v|
|
|
82
|
+
name = k.to_s
|
|
83
|
+
next "" unless name.match?(/\A[A-Za-z_:][-A-Za-z0-9_:.]*\z/)
|
|
84
|
+
|
|
85
|
+
v.to_s.empty? && name.start_with?("data-") ? " #{name}" : %( #{name}="#{h(v)}")
|
|
86
|
+
end.join
|
|
87
|
+
end
|
|
88
|
+
private_class_method :attributes
|
|
89
|
+
|
|
90
|
+
def h(str)
|
|
91
|
+
CGI.escapeHTML(str.to_s)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :h
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|