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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5e531c844edf1a741273c373f1f310549df1cd2434a371a240ffa1c14eddf885
|
|
4
|
+
data.tar.gz: f01f6e38861b0a286f0ac1138d46750e7fe9336e8c25d067736d2a55420f3901
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 38266ffecad16700685b01f50bb189b1c425036392787c8de14867232d24971da0491ef10754cbce43f7699aaa8ed0fabf0980ea5d4c98fe1e074abb721fcf0d
|
|
7
|
+
data.tar.gz: 118424b7a269f1b30c87567475062c63c9cd6c38b3842b42203f311a4f4eddc7a3b77bc34afb8d4458695ddf3ff0a71653092ba6801d60981e2d73777ccecd43
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Isolated engine serving the SQRL endpoint, status poll, CPS/poll callback, login page,
|
|
8
|
+
script, refresh endpoint and identity-management pages.
|
|
9
|
+
- ActiveRecord stores (`sqrl_identities`, `sqrl_nuts`, `sqrl_pending_logins`) with atomic
|
|
10
|
+
single use, plus an `ActiveSupport::Cache` store set.
|
|
11
|
+
- `bin/rails generate sqrl:install`: migration, mount, initializer, user association,
|
|
12
|
+
login block in `sessions/new`, password-policy includes.
|
|
13
|
+
- Sign-in adapter for the Rails 8 `generate authentication` concern, with Warden and plain
|
|
14
|
+
session fallbacks; `:create` / `:link` / `:refuse` policies for unknown identities.
|
|
15
|
+
- `sqrlonly` / `hardlock` enforcement for password login and reset; identity management;
|
|
16
|
+
per-IP rate limiting; `ask` with deny button; `allowed_hosts`, hooks, `prune` task.
|
|
17
|
+
- `insecure_http` testing mode; `poll_requires_ip_match` off by default.
|
|
18
|
+
- Requires Ruby 3.2+ and Rails 7.1, 7.2, 8.0 or 8.1.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel P. Clark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# sqrl-rails
|
|
2
|
+
|
|
3
|
+
SQRL login for Rails applications: an isolated engine that serves the SQRL protocol
|
|
4
|
+
endpoint, the status poll, the login callback and the browser script; ActiveRecord
|
|
5
|
+
stores; a view helper that renders the login block; and a sign-in adapter that plugs
|
|
6
|
+
into the authentication stack generated by `bin/rails generate authentication` (Rails 8)
|
|
7
|
+
or into any custom session scheme.
|
|
8
|
+
|
|
9
|
+
Devise applications should use [`devise-sqrl`](../devise-sqrl), which builds on this gem.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "sqrl-rails"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bin/rails generate sqrl:install # migration, mount, initializer, User association, login block
|
|
20
|
+
bin/rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The generator:
|
|
24
|
+
|
|
25
|
+
- copies the `create_sqrl_tables` migration (`sqrl_identities`, `sqrl_nuts`,
|
|
26
|
+
`sqrl_pending_logins`);
|
|
27
|
+
- mounts the engine: `mount SQRL::Rails::Engine, at: "/sqrl", as: :sqrl`;
|
|
28
|
+
- writes `config/initializers/sqrl.rb` (every option documented inline);
|
|
29
|
+
- adds `has_many :sqrl_identities` to `app/models/user.rb`;
|
|
30
|
+
- appends `<%= sqrl_login_block %>` to `app/views/sessions/new.html.erb` when that view
|
|
31
|
+
exists (the Rails 8 generated login form).
|
|
32
|
+
|
|
33
|
+
Every step is idempotent. Options: `--host`, `--mount`, `--user-model`,
|
|
34
|
+
`--skip-migration`, `--skip-routes`, `--skip-views`.
|
|
35
|
+
|
|
36
|
+
## How a login works
|
|
37
|
+
|
|
38
|
+
1. `sqrl_login_block` issues a `sqrl://host/sqrl/cli?nut=…` URL, remembers the pending
|
|
39
|
+
login in the browser session and renders the link, the QR code and the status text
|
|
40
|
+
that `/sqrl/sqrl.js` drives.
|
|
41
|
+
2. The SQRL client POSTs its signed `query`/`ident` to `/sqrl/cli`. On success the
|
|
42
|
+
identity is marked authenticated on the pending login and, when the client asked for
|
|
43
|
+
Client Provided Session, a one-time token is handed back in `url=`.
|
|
44
|
+
3. The browser lands on `/sqrl/callback?t=TOKEN` (CPS) or, after the poller saw
|
|
45
|
+
`{"authenticated": true}` on `/sqrl/status`, on `/sqrl/callback` with its own cookie.
|
|
46
|
+
The callback resolves the user, signs them in and redirects.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
`config/initializers/sqrl.rb`:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
Rails.application.config.sqrl.tap do |sqrl|
|
|
54
|
+
sqrl.host = ENV["SQRL_HOST"].presence # public host for sqrl:// URLs (default: request host)
|
|
55
|
+
sqrl.user_class = "User"
|
|
56
|
+
sqrl.on_unknown_identity = :create # :create | :link | :refuse
|
|
57
|
+
# sqrl.create_user = ->(identity) { User.create!(name: "SQRL user", password: SecureRandom.hex(32)) }
|
|
58
|
+
# sqrl.sign_in = ->(controller, identity, user) { controller.send(:start_new_session_for, user) }
|
|
59
|
+
# sqrl.after_sign_in = ->(controller, completion) { controller.send(:after_authentication_url) }
|
|
60
|
+
# sqrl.failure_path = "/session/new"
|
|
61
|
+
# sqrl.stores = :active_record # or :memory, or an SQRL::Server::Stores
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Option | Default | Meaning |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `host` | request host | host placed in the `sqrl://` URLs issued to clients |
|
|
68
|
+
| `insecure_http` | `false` | loopback-only testing mode issuing `qrl://` (plain http) URLs, for trying the browser extension against `bin/rails server`; not part of SQRL, refused for other hosts |
|
|
69
|
+
| `poll_requires_ip_match` | `false` | optional hardening: refuse poll (cookie) completions when the client's IP did not match the browser's |
|
|
70
|
+
| `on_unknown_identity` | `:create` | `:create` a user ({SQRL::Rails::UserFactory}), `:link` to the signed-in user, or `:refuse` |
|
|
71
|
+
| `create_user` | `UserFactory` | `(identity) -> user`; the default fills placeholder columns (random password, `sqrl-…@sqrl.invalid`) |
|
|
72
|
+
| `find_user` | by `user_ref` | `(identity) -> user or nil` |
|
|
73
|
+
| `current_user` | `Current.user`, else `session[:user_id]` | `(controller) -> user` used for `:link` |
|
|
74
|
+
| `sign_in` | `SignIn.default` | `(controller, identity, user)`; default calls `start_new_session_for` when the Rails 8 concern is present, else Warden, else `session[:user_id]` |
|
|
75
|
+
| `after_sign_in` | `after_authentication_url` / root | `(controller, completion) -> url`; a same-site `return_to` wins |
|
|
76
|
+
| `failure_path` | `/sqrl/login` | where a failed callback redirects |
|
|
77
|
+
| `stores` | `:active_record` | storage back end |
|
|
78
|
+
| `nut_ttl`, `cps_token_ttl`, `require_ip_match`, `ask`, `sin`, `path_extension`, `on_authenticated` | see `SQRL::Server::Config` | protocol options |
|
|
79
|
+
|
|
80
|
+
### Rails 8 `generate authentication`
|
|
81
|
+
|
|
82
|
+
Nothing to configure: when `Authentication` (the generated concern) exists, the
|
|
83
|
+
callback controller includes it and calls `start_new_session_for(user)`, so SQRL logins
|
|
84
|
+
produce the same `Session` rows and cookies as the password form, and
|
|
85
|
+
`after_authentication_url` sends the browser back to the page that required login.
|
|
86
|
+
|
|
87
|
+
### Custom session schemes
|
|
88
|
+
|
|
89
|
+
Set `sign_in` (and usually `after_sign_in`) to whatever your application does; `identity`
|
|
90
|
+
is an `SQRL::Server::Identity` (`idk_b64`, `user_ref`, `disabled?`, …).
|
|
91
|
+
|
|
92
|
+
## Helpers
|
|
93
|
+
|
|
94
|
+
In views: `sqrl_login_block(return_to:, cancel_url:, link_user:, script:, **login_block_options)`,
|
|
95
|
+
`sqrl_login_url`, `sqrl_qr_svg`, `sqrl_script_tag`, `sqrl_status_path`, `sqrl_script_path`.
|
|
96
|
+
In controllers: `current_sqrl_identity`, `sqrl_authenticated?`, `sqrl_logout!`
|
|
97
|
+
(call it from your sign-out action), `sqrl_endpoint`.
|
|
98
|
+
|
|
99
|
+
## Storage
|
|
100
|
+
|
|
101
|
+
`SQRL::Rails::Identity` (`sqrl_identities`): `idk`, `suk`, `vuk` (base64url), `disabled`,
|
|
102
|
+
`sqrlonly`, `hardlock`, `ins`, `logins`, polymorphic `user`, `superseded_by` (tombstone
|
|
103
|
+
rows left by a re-key), `last_login_at`. `SQRL::Rails::Nut` and
|
|
104
|
+
`SQRL::Rails::PendingLogin` are single-use records; `bin/rails sqrl:prune` deletes the
|
|
105
|
+
expired ones (run it from cron or a job).
|
|
106
|
+
|
|
107
|
+
Nut consumption and CPS-token claims are single conditional `UPDATE`s, so they stay
|
|
108
|
+
single-use under concurrent requests.
|
|
109
|
+
|
|
110
|
+
## Account policies: `sqrlonly` and `hardlock`
|
|
111
|
+
|
|
112
|
+
A SQRL client can ask the site to refuse every non-SQRL way of signing in (`sqrlonly`)
|
|
113
|
+
and of recovering the account (`hardlock`). The flags are stored on the identity;
|
|
114
|
+
`SQRL::Rails.sqrl_only?(user)` and `SQRL::Rails.hard_locked?(user)` answer for a user,
|
|
115
|
+
and `SQRL::Rails::Identity.sqrl_only` / `.hard_locked` are scopes.
|
|
116
|
+
|
|
117
|
+
`bin/rails generate sqrl:install` enforces them in the controllers that
|
|
118
|
+
`generate authentication` produced:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class SessionsController < ApplicationController
|
|
122
|
+
include SQRL::Rails::PasswordPolicy::Sessions # refuses password login for sqrlonly users (create)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class PasswordsController < ApplicationController
|
|
126
|
+
include SQRL::Rails::PasswordPolicy::Passwords # refuses password reset for hardlock users (create, edit, update)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Both look the user up by `email_address`/`email` parameter or by reset token
|
|
131
|
+
(`sqrl_policy_user`) and redirect to the sign-in page with a flash alert
|
|
132
|
+
(`sqrl_policy_refused`); override either method for other flows. Messages are the
|
|
133
|
+
`sqrl.password_policy.*` translations.
|
|
134
|
+
|
|
135
|
+
## Identity management
|
|
136
|
+
|
|
137
|
+
`GET /sqrl/identities` lists the signed-in user's identities (short key, linked date,
|
|
138
|
+
last login, login count, flags) with an Unlink button (`DELETE /sqrl/identities/:id`).
|
|
139
|
+
Unlinking detaches the identity from the user; the site keeps the protocol association,
|
|
140
|
+
so the next login with it is treated as an unknown identity again. The view helper
|
|
141
|
+
`sqrl_identities_path` links to the page. `last_login_at` and `logins` are updated on
|
|
142
|
+
every successful `ident` and `enable`.
|
|
143
|
+
|
|
144
|
+
## `ask`, `sin` and the deny button
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
sqrl.ask = SQRL::Ask.new(message: "Allow this login?", buttons: [{ label: "Allow" }, { label: "Deny" }],
|
|
148
|
+
accept: [1]) # only "Allow" (btn=1) lets the ident proceed
|
|
149
|
+
sqrl.ask_deny_button = 2 # a "Deny" answer is also refused at the callback
|
|
150
|
+
sqrl.sin = "0" # secret-index challenge; the client answers with ins=
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Both may be callables receiving the request context. The generated initializer turns
|
|
154
|
+
them on with `SQRL_ASK=1` / `SQRL_SIN=1` for testing.
|
|
155
|
+
|
|
156
|
+
## Rate limiting, hooks and hosts
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
sqrl.rate_limit = { to: 60, within: 1.minute } # per client IP, counted in Rails.cache; 429 over the limit
|
|
160
|
+
sqrl.before_handle = ->(ctx) { raise SQRL::Server::RateLimited.new(retry_after: 10) if ... }
|
|
161
|
+
sqrl.before_issue = ->(ctx) { ... } # before a login URL is issued
|
|
162
|
+
sqrl.allowed_hosts = ["example.com", /\.example\.com\z/] # when sqrl.host is not set
|
|
163
|
+
sqrl.host_cache_limit = 16 # distinct request hosts kept per process
|
|
164
|
+
sqrl.prune_every = 1000 # opportunistic pruning of expired rows
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`rate_limit` applies to the protocol endpoint, the login and refresh pages, the status
|
|
168
|
+
poll, the callback and the identities page (`only:` narrows it; `by:` and `cache:`
|
|
169
|
+
override the key and store). A request whose host is not allowed answers 400.
|
|
170
|
+
`GET /sqrl/refresh` hands the page a fresh login URL when a nut expires (`sqrl.js`
|
|
171
|
+
calls it through `SQRL.refresh`).
|
|
172
|
+
|
|
173
|
+
## Storage back ends
|
|
174
|
+
|
|
175
|
+
`sqrl.stores` is `:active_record` (default), `:cache` (`SQRL::Rails::Stores::Cache` on
|
|
176
|
+
`Rails.cache` or `sqrl.cache_store`; needs a store that deletes atomically such as Redis
|
|
177
|
+
or Memcached, and a persistent one if identities live there too) or `:memory`. Every
|
|
178
|
+
store set passes the shared store contract spec from `sqrl-core`.
|
|
179
|
+
|
|
180
|
+
## Linking identities to existing accounts
|
|
181
|
+
|
|
182
|
+
With `on_unknown_identity = :link`, unknown identities are only accepted while a user is
|
|
183
|
+
signed in; render `sqrl_login_block` on an authenticated settings page and the new
|
|
184
|
+
identity is attached to `Current.user` (or whatever `current_user` returns).
|
|
185
|
+
|
|
186
|
+
## Example and testing
|
|
187
|
+
|
|
188
|
+
[`examples/rails-auth-app`](../../examples/rails-auth-app) is a Rails 8 application built
|
|
189
|
+
with `rails generate authentication` followed by `rails generate sqrl:install`.
|
|
190
|
+
`bundle exec rake spec:sqrl-rails` runs the engine, store, policy and generator specs
|
|
191
|
+
against an in-gem dummy application; the `gemfiles/` directory runs them on Rails 7.1, 7.2
|
|
192
|
+
and 8.0 as well.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Base controller of the engine. Deliberately not derived from the host's
|
|
6
|
+
# +ApplicationController+, so host filters (authentication, browser checks) do not
|
|
7
|
+
# apply to the protocol endpoints.
|
|
8
|
+
class ApplicationController < ::ActionController::Base
|
|
9
|
+
protect_from_forgery with: :exception
|
|
10
|
+
|
|
11
|
+
# A before_issue / before_handle hook refused the request.
|
|
12
|
+
rescue_from SQRL::Server::RateLimited do |error|
|
|
13
|
+
render_rack(sqrl_endpoint.rate_limited(error, json: request.format.json?))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The request host may not be used to issue SQRL URLs (allowed_hosts, host cache
|
|
17
|
+
# limit).
|
|
18
|
+
rescue_from SQRL::Rack::HostNotAllowed do |error|
|
|
19
|
+
render plain: error.message, status: :bad_request
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def sqrl_endpoint
|
|
25
|
+
@sqrl_endpoint ||= SQRL::Rails.endpoint_for(request)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sqrl_config
|
|
29
|
+
SQRL::Rails.config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The host application's URL prefix (its relative URL root), without the engine
|
|
33
|
+
# mount the current request came in through.
|
|
34
|
+
def host_script_name
|
|
35
|
+
name = request.script_name.to_s
|
|
36
|
+
mount = SQRL::Rails.mount_path
|
|
37
|
+
name.end_with?(mount) ? name[0, name.length - mount.length] : name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Public path of an engine route (+:login+, +:status+, ...).
|
|
41
|
+
def engine_path(name)
|
|
42
|
+
SQRL::Rails.path_for(name, script_name: host_script_name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_rack(triple)
|
|
46
|
+
status, headers, body = triple
|
|
47
|
+
headers.each { |k, v| response.set_header(k, v) }
|
|
48
|
+
render body: body.join, status: status, content_type: headers["content-type"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The host's Authentication concern uses the host's route helpers (root_url,
|
|
52
|
+
# new_session_path); resolve them through the main_app proxy.
|
|
53
|
+
def method_missing(name, ...)
|
|
54
|
+
if host_route_helper?(name)
|
|
55
|
+
main_app.public_send(name, ...)
|
|
56
|
+
else
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def respond_to_missing?(name, include_private = false)
|
|
62
|
+
host_route_helper?(name) || super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def host_route_helper?(name)
|
|
66
|
+
name.to_s.match?(/_(url|path)\z/) && respond_to?(:main_app) && main_app.respond_to?(name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Serves +sqrl.js+ with an ETag.
|
|
6
|
+
class AssetsController < ApplicationController
|
|
7
|
+
# A public static script; Rails' cross-origin JavaScript check does not apply.
|
|
8
|
+
skip_forgery_protection
|
|
9
|
+
|
|
10
|
+
# Serve +sqrl.js+ with an ETag.
|
|
11
|
+
def script
|
|
12
|
+
status, headers, body = sqrl_endpoint.script(request)
|
|
13
|
+
headers.each { |k, v| response.set_header(k, v) }
|
|
14
|
+
if status == 304
|
|
15
|
+
head :not_modified
|
|
16
|
+
else
|
|
17
|
+
render body: body.join, status: status, content_type: "application/javascript"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Completes a login: with +t=+ (the one-time CPS token the SQRL client sent the
|
|
6
|
+
# browser to) or without it (the browser's own pending login, after the poller saw
|
|
7
|
+
# it authenticated). Resolves or creates the user, signs them in through the
|
|
8
|
+
# configured adapter and redirects.
|
|
9
|
+
class CallbackController < ApplicationController
|
|
10
|
+
include RateLimited
|
|
11
|
+
|
|
12
|
+
concern = SQRL::Rails.host_authentication_concern
|
|
13
|
+
if concern
|
|
14
|
+
include concern
|
|
15
|
+
|
|
16
|
+
allow_unauthenticated_access if respond_to?(:allow_unauthenticated_access)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Complete a login (CPS token or the browser's own pending login), resolve the user and sign in.
|
|
20
|
+
def show
|
|
21
|
+
completion = sqrl_endpoint.callback(request)
|
|
22
|
+
return failure("SQRL login failed or expired. Please try again.") unless completion
|
|
23
|
+
return failure(I18n.t("sqrl.callback.declined", default: "You declined the SQRL login."), completion) if declined?(completion)
|
|
24
|
+
|
|
25
|
+
user = resolve_user(completion)
|
|
26
|
+
return failure("This SQRL identity is not linked to an account.", completion) unless user
|
|
27
|
+
|
|
28
|
+
sign_in = SQRL::Rails.config.sign_in || SignIn.method(:default)
|
|
29
|
+
sign_in.call(self, completion.identity, user)
|
|
30
|
+
redirect_to SignIn.after(self, completion), allow_other_host: false
|
|
31
|
+
rescue SQRL::Rails::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved,
|
|
32
|
+
ActionController::Redirecting::UnsafeRedirectError => e
|
|
33
|
+
failure(e.message, completion)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def sqrl_rate_limit_scope
|
|
39
|
+
:callback
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The client answered the site's +ask+ with the button configured as "deny".
|
|
43
|
+
def declined?(completion)
|
|
44
|
+
deny = sqrl_config.ask_deny_button
|
|
45
|
+
!deny.nil? && completion.pending_login.btn == deny.to_i
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The user behind an identity; for a brand-new identity apply the configured
|
|
49
|
+
# policy.
|
|
50
|
+
def resolve_user(completion)
|
|
51
|
+
identity = completion.identity
|
|
52
|
+
user = find_user(identity)
|
|
53
|
+
return user if user
|
|
54
|
+
|
|
55
|
+
case sqrl_config.on_unknown_identity
|
|
56
|
+
when :refuse
|
|
57
|
+
nil
|
|
58
|
+
when :link
|
|
59
|
+
link_to_current_user(identity)
|
|
60
|
+
else
|
|
61
|
+
create_user(identity)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def find_user(identity)
|
|
66
|
+
return sqrl_config.find_user.call(identity) if sqrl_config.find_user
|
|
67
|
+
|
|
68
|
+
SQRL::Rails.user_from_ref(identity.user_ref)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create_user(identity)
|
|
72
|
+
user = sqrl_config.create_user ? sqrl_config.create_user.call(identity) : UserFactory.call(identity)
|
|
73
|
+
attach(identity, user)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def link_to_current_user(identity)
|
|
77
|
+
user = SignIn.current_user(self)
|
|
78
|
+
return nil unless user
|
|
79
|
+
|
|
80
|
+
attach(identity, user)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def attach(identity, user)
|
|
84
|
+
return nil unless user
|
|
85
|
+
|
|
86
|
+
SQRL::Rails.stores.identities.update(identity.idk, user_ref: SQRL::Rails.user_ref(user))
|
|
87
|
+
user
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# +completion+ is passed when the pending login was already consumed: the endpoint
|
|
91
|
+
# recorded the identity in the session at that point, so a failed sign-in must clear
|
|
92
|
+
# it again, otherwise the controller helpers would report a refused identity as
|
|
93
|
+
# authenticated.
|
|
94
|
+
def failure(message, completion = nil)
|
|
95
|
+
sqrl_endpoint.logout(request) if completion
|
|
96
|
+
flash[:alert] = message if respond_to?(:flash)
|
|
97
|
+
redirect_to(sqrl_config.failure_path || engine_path(:login), allow_other_host: false)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# The SQRL endpoint: the client's signed POSTs. Authenticated by the protocol's own
|
|
6
|
+
# signatures, so forgery protection does not apply.
|
|
7
|
+
class CliController < ApplicationController
|
|
8
|
+
include RateLimited
|
|
9
|
+
|
|
10
|
+
skip_forgery_protection
|
|
11
|
+
|
|
12
|
+
# The SQRL protocol endpoint: hands the signed POST to the handler and renders its reply.
|
|
13
|
+
def create
|
|
14
|
+
render_rack(sqrl_endpoint.cli(request))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Lets a signed-in user see the SQRL identities attached to their account and unlink
|
|
6
|
+
# one. Unlinking detaches the identity from the user (the site keeps the protocol
|
|
7
|
+
# association, so the SQRL client still "knows" the site); the next login with that
|
|
8
|
+
# identity is treated as an unknown identity again.
|
|
9
|
+
#
|
|
10
|
+
# The signed-in user comes from the host's +Authentication+ concern (Rails 8), from
|
|
11
|
+
# +config.sqrl.current_user+, or from the session fallback of {SignIn}.
|
|
12
|
+
class IdentitiesController < ApplicationController
|
|
13
|
+
include RateLimited
|
|
14
|
+
|
|
15
|
+
concern = SQRL::Rails.host_authentication_concern
|
|
16
|
+
include concern if concern
|
|
17
|
+
|
|
18
|
+
layout "sqrl/rails/application"
|
|
19
|
+
|
|
20
|
+
before_action :require_sqrl_user
|
|
21
|
+
|
|
22
|
+
# List the current user's SQRL identities.
|
|
23
|
+
def index
|
|
24
|
+
@identities = Identity.active.for_user(@sqrl_user).order(:created_at)
|
|
25
|
+
@current_idk = sqrl_endpoint.current_identity(request)&.idk_b64
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Unlink one of the current user's SQRL identities.
|
|
29
|
+
def destroy
|
|
30
|
+
identity = Identity.active.for_user(@sqrl_user).find_by(id: params[:id])
|
|
31
|
+
if identity
|
|
32
|
+
current = sqrl_endpoint.current_identity(request)&.idk_b64
|
|
33
|
+
identity.update!(user: nil)
|
|
34
|
+
sqrl_endpoint.logout(request) if current && current == identity.idk
|
|
35
|
+
flash[:notice] = I18n.t("sqrl.identities.unlinked", default: "SQRL identity unlinked.")
|
|
36
|
+
else
|
|
37
|
+
flash[:alert] = I18n.t("sqrl.identities.not_found", default: "That SQRL identity is not linked to your account.")
|
|
38
|
+
end
|
|
39
|
+
redirect_to engine_path(:identities), status: :see_other
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def sqrl_rate_limit_scope
|
|
45
|
+
:identities
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def require_sqrl_user
|
|
49
|
+
@sqrl_user = SignIn.current_user(self)
|
|
50
|
+
return if @sqrl_user
|
|
51
|
+
|
|
52
|
+
flash[:alert] = I18n.t("sqrl.identities.sign_in_required", default: "Sign in to manage your SQRL identities.")
|
|
53
|
+
redirect_to(sqrl_config.failure_path || engine_path(:login), allow_other_host: false)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# A stand-alone login page, for applications that do not embed
|
|
6
|
+
# +sqrl_login_block+ in their own view.
|
|
7
|
+
class LoginsController < ApplicationController
|
|
8
|
+
include RateLimited
|
|
9
|
+
|
|
10
|
+
layout "sqrl/rails/application"
|
|
11
|
+
|
|
12
|
+
# Render the stand-alone login page.
|
|
13
|
+
def new
|
|
14
|
+
@return_to = params[:return_to].presence
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def sqrl_rate_limit_scope
|
|
20
|
+
:login
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Hands the login page a fresh login URL (and QR code) for its browser session when
|
|
6
|
+
# the previous nut expired, as JSON; +sqrl.js+ calls it through +SQRL.refresh+.
|
|
7
|
+
class RefreshController < ApplicationController
|
|
8
|
+
include RateLimited
|
|
9
|
+
|
|
10
|
+
# Issue a fresh login URL for the browser's session as JSON (used by +SQRL.refresh+).
|
|
11
|
+
def show
|
|
12
|
+
render_rack(sqrl_endpoint.refresh(request))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def sqrl_rate_limit_scope
|
|
18
|
+
:refresh
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Cookie-bound poll answered with +{"authenticated": bool, "next": url}+.
|
|
6
|
+
class StatusController < ApplicationController
|
|
7
|
+
include RateLimited
|
|
8
|
+
|
|
9
|
+
# The cookie-bound poll: is this browser's pending login authenticated yet?
|
|
10
|
+
def show
|
|
11
|
+
render_rack(sqrl_endpoint.status(request))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def sqrl_rate_limit_scope
|
|
17
|
+
:status
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# View helpers available in the host application and the engine.
|
|
6
|
+
module LoginHelper
|
|
7
|
+
# The complete SQRL login markup (link, QR code, status text and script tag),
|
|
8
|
+
# wired to the engine's endpoints. Each render issues a fresh login URL.
|
|
9
|
+
#
|
|
10
|
+
# @param return_to [String, nil] same-site path to land on after login (defaults to
|
|
11
|
+
# +params[:return_to]+ or Rails 8's +session[:return_to_after_authenticating]+)
|
|
12
|
+
# @param cancel_url [String, nil]
|
|
13
|
+
# @param link_user [Object, nil, false] user to link a new identity to; defaults to
|
|
14
|
+
# the signed-in user when known, +false+ disables linking
|
|
15
|
+
# @param script [Boolean] append the +sqrl.js+ script tag
|
|
16
|
+
# @param options [Hash] passed to {SQRL::Rack::LoginBlock.render}
|
|
17
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
18
|
+
def sqrl_login_block(return_to: nil, cancel_url: nil, link_user: nil, script: true, **options)
|
|
19
|
+
login = sqrl_login_url(return_to: return_to, cancel_url: cancel_url, link_user: link_user)
|
|
20
|
+
SQRL::Rack::LoginBlock.render(login_url: login.url, status_url: sqrl_status_path,
|
|
21
|
+
cancel_url: cancel_url, script_url: (sqrl_script_path if script),
|
|
22
|
+
refresh_url: sqrl_refresh_path,
|
|
23
|
+
**options).html_safe
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Issue (once per request) and return the +sqrl://+ login URL.
|
|
27
|
+
#
|
|
28
|
+
# @return [SQRL::Server::LoginURL]
|
|
29
|
+
def sqrl_login_url(return_to: nil, cancel_url: nil, link_user: nil)
|
|
30
|
+
@sqrl_login_url ||= begin
|
|
31
|
+
return_to ||= sqrl_default_return_to
|
|
32
|
+
link_ref = link_user == false ? nil : SQRL::Rails.user_ref(link_user || sqrl_linkable_user)
|
|
33
|
+
SQRL::Rails.endpoint_for(request).issue(request, return_to: return_to, cancel_url: cancel_url,
|
|
34
|
+
link_user_ref: link_ref)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The QR code of the login URL as inline SVG.
|
|
39
|
+
def sqrl_qr_svg(**options)
|
|
40
|
+
SQRL::Rack::QR.svg(sqrl_login_url.url, **options).html_safe
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# +<script src="/sqrl/sqrl.js" defer>+
|
|
44
|
+
def sqrl_script_tag
|
|
45
|
+
SQRL::Rack::LoginBlock.script_tag(src: sqrl_script_path).html_safe
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] public path of the status poll
|
|
49
|
+
def sqrl_status_path = SQRL::Rails.path_for(:status, script_name: sqrl_host_script_name)
|
|
50
|
+
# The identity-management page of the engine (list and unlink SQRL identities).
|
|
51
|
+
def sqrl_identities_path = SQRL::Rails.path_for(:identities, script_name: sqrl_host_script_name)
|
|
52
|
+
# @return [String] public path of the login callback
|
|
53
|
+
def sqrl_callback_path = SQRL::Rails.path_for(:callback, script_name: sqrl_host_script_name)
|
|
54
|
+
# @return [String] public path of +sqrl.js+
|
|
55
|
+
def sqrl_script_path = SQRL::Rails.path_for(:script, script_name: sqrl_host_script_name)
|
|
56
|
+
# @return [String] public path of the refresh endpoint
|
|
57
|
+
def sqrl_refresh_path = SQRL::Rails.path_for(:refresh, script_name: sqrl_host_script_name)
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The host application's own prefix (its relative URL root), excluding the engine
|
|
62
|
+
# mount when rendering inside the engine.
|
|
63
|
+
def sqrl_host_script_name
|
|
64
|
+
name = request.script_name.to_s
|
|
65
|
+
mount = SQRL::Rails.mount_path
|
|
66
|
+
name.end_with?(mount) ? name[0, name.length - mount.length] : name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sqrl_default_return_to
|
|
70
|
+
candidate = params[:return_to].presence || (session[:return_to_after_authenticating] if respond_to?(:session))
|
|
71
|
+
SignIn.same_site_path?(candidate) ? candidate : nil
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sqrl_linkable_user
|
|
77
|
+
return nil unless SQRL::Rails.config.on_unknown_identity == :link
|
|
78
|
+
|
|
79
|
+
SignIn.current_user(controller)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|