devise-sqrl 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 +158 -0
- data/app/controllers/devise/sqrl_controller.rb +158 -0
- data/app/helpers/devise_sqrl_helper.rb +67 -0
- data/app/views/devise/shared/_sqrl.html.erb +6 -0
- data/app/views/devise/sqrl/identities.html.erb +29 -0
- data/app/views/devise/sqrl/new.html.erb +9 -0
- data/config/locales/en.yml +16 -0
- data/lib/devise/models/sqrl_authenticatable.rb +134 -0
- data/lib/devise/sqrl/engine.rb +23 -0
- data/lib/devise/sqrl/hooks.rb +15 -0
- data/lib/devise/sqrl/routes.rb +29 -0
- data/lib/devise/sqrl.rb +170 -0
- data/lib/devise/strategies/sqrl_authenticatable.rb +72 -0
- data/lib/devise-sqrl.rb +3 -0
- data/lib/generators/devise/sqrl/install/install_generator.rb +113 -0
- data/lib/generators/devise/sqrl/install/templates/devise_sqrl.rb.tt +63 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f0f71964bae04a2a020056cfe296a40e14a3e8c01ab3709119f274f1b8d7c253
|
|
4
|
+
data.tar.gz: 519784d0cc906b24fb2b70ac0159b583ea1ef0d89602cd58ef1a934eb13354df
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3c17c54774d7953a2e0c4d4681c9b596e05f0726582742be9ae42d7e25ff91a01a82b39777823229592e4be3fb2cb277a7a47edec3605a8dd8983a577431796e
|
|
7
|
+
data.tar.gz: 48090a83d016885227aa0ebe5c3cdda318e20e02b9c99332e072617234c815a65482acd7ab1e60be377e6949734a1f58abe65a5b007b626b1555321618f349b6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `devise :sqrl_authenticatable`: routes under the mapping (`/users/sqrl`, `cli`, `status`,
|
|
8
|
+
`callback`, `sqrl.js`, `refresh`, `identities`), a Warden strategy that signs in through
|
|
9
|
+
Devise's own hooks (trackable, rememberable, activatable), a controller, views and a
|
|
10
|
+
shared partial for `sessions/new`.
|
|
11
|
+
- `bin/rails generate devise:sqrl:install`.
|
|
12
|
+
- `sqrlonly` users cannot log in with a password (configurable blocked strategies);
|
|
13
|
+
`hardlock` users cannot reset passwords.
|
|
14
|
+
- Identity management under the mapping; `ask` with deny button; 429/400 handling for
|
|
15
|
+
rate-limit hooks and disallowed hosts.
|
|
16
|
+
- Requires Ruby 3.2+, Devise 4.9 or 5.0, Rails 7.1 to 8.1, and `sqrl-rails`.
|
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,158 @@
|
|
|
1
|
+
# devise-sqrl
|
|
2
|
+
|
|
3
|
+
SQRL (Secure Quick Reliable Login) for [Devise](https://github.com/heartcombo/devise):
|
|
4
|
+
one module, and your Devise models can sign in with SQRL next to (or instead of) a
|
|
5
|
+
password.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "devise-sqrl"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bin/rails generate devise:sqrl:install # or: devise:sqrl:install Admin
|
|
14
|
+
bin/rails db:migrate
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The generator
|
|
18
|
+
|
|
19
|
+
- copies the `sqrl_identities` / `sqrl_nuts` / `sqrl_pending_logins` migration (shared
|
|
20
|
+
with [sqrl-rails](../sqrl-rails)),
|
|
21
|
+
- adds `:sqrl_authenticatable` to the model's `devise` call,
|
|
22
|
+
- writes `config/initializers/devise_sqrl.rb`,
|
|
23
|
+
- appends `<%= render "devise/shared/sqrl", resource_name: resource_name %>` to
|
|
24
|
+
`app/views/devise/sessions/new.html.erb` when you have generated Devise's views.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class User < ApplicationRecord
|
|
28
|
+
devise :database_authenticatable, :registerable, :rememberable, :sqrl_authenticatable
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`devise_for :users` then routes:
|
|
33
|
+
|
|
34
|
+
| Route | Purpose |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `GET /users/sqrl` | stand-alone SQRL login page (`new_user_sqrl_path`); `.json` gives the URL |
|
|
37
|
+
| `POST /users/sqrl/cli` | the SQRL protocol endpoint (no CSRF token: authenticated by signatures) |
|
|
38
|
+
| `GET /users/sqrl/status` | cookie-bound poll for logins made from another device |
|
|
39
|
+
| `GET /users/sqrl/callback` | login completion (CPS `url=` or poll), signs the user in |
|
|
40
|
+
| `GET /users/sqrl/sqrl.js` | the browser-side script |
|
|
41
|
+
| `GET /users/sqrl/refresh` | a fresh login URL for the page (JSON) |
|
|
42
|
+
| `GET /users/sqrl/identities` | the signed-in user's identities, with Unlink |
|
|
43
|
+
|
|
44
|
+
## How a login completes
|
|
45
|
+
|
|
46
|
+
The callback runs the `:sqrl_authenticatable` Warden strategy, which claims the one-time
|
|
47
|
+
CPS token (or the browser's own pending login after the poller saw it authenticated),
|
|
48
|
+
finds the resource the identity belongs to, applies the unknown-identity policy, checks
|
|
49
|
+
`active_for_authentication?` and calls `success!`. The controller then does
|
|
50
|
+
`sign_in_and_redirect(resource_name, resource, event: :authentication)`, so
|
|
51
|
+
`:trackable`, `:rememberable`, `:confirmable`, `:lockable` and your
|
|
52
|
+
`after_sign_in_path_for` all behave exactly as with a password login.
|
|
53
|
+
|
|
54
|
+
## Views
|
|
55
|
+
|
|
56
|
+
Inside your own sign-in page:
|
|
57
|
+
|
|
58
|
+
```erb
|
|
59
|
+
<%= render "devise/shared/sqrl", resource_name: resource_name %>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
or directly:
|
|
63
|
+
|
|
64
|
+
```erb
|
|
65
|
+
<%= sqrl_login_block_for(resource_name, return_to: "/dashboard", link_text: "Use SQRL") %>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`sqrl_login_url_for(:user)` and `sqrl_script_tag_for(:user)` are available for custom
|
|
69
|
+
markup. The markup follows the `sqrl.js` contract of `sqrl-rack`.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Protocol and storage settings live in `Rails.application.config.sqrl` (see
|
|
74
|
+
[sqrl-rails](../sqrl-rails): `host`, `insecure_http`, `require_ip_match`, `poll_requires_ip_match`, `nut_ttl`, `cps_token_ttl`,
|
|
75
|
+
`ask`, `sin`, `stores`). The Devise-specific options go in `Devise.setup`:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Devise.setup do |config|
|
|
79
|
+
config.sqrl_on_unknown_identity = :create # :create (default), :link or :refuse
|
|
80
|
+
config.sqrl_create_resource = ->(identity, klass) { klass.new(email: "...", password: SecureRandom.hex(32)) }
|
|
81
|
+
config.sqrl_remember_me = false # treat SQRL logins as "remember me"
|
|
82
|
+
config.sqrl_login_block = { link_text: "Sign in with SQRL" }
|
|
83
|
+
config.sqrl_failure_path = nil # default: the scope's SQRL login page
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- `:create` builds a record with `SQRL::Rails::UserFactory` (placeholder email, random
|
|
88
|
+
password, so `:validatable` passes) and links the identity to it.
|
|
89
|
+
- `:link` only accepts unknown identities while a user of that scope is signed in, and
|
|
90
|
+
attaches the identity to them (`user.link_sqrl_identity!`). Use it for "add SQRL to my
|
|
91
|
+
account" pages.
|
|
92
|
+
- `:refuse` rejects unknown identities at the protocol level.
|
|
93
|
+
|
|
94
|
+
Model API: `user.sqrl_identities`, `user.sqrl_linked?`, `user.sqrl_identity_for(idk)`,
|
|
95
|
+
`user.link_sqrl_identity!(identity)`, `user.unlink_sqrl_identity!(idk)`,
|
|
96
|
+
`User.find_for_sqrl(identity)`, `User.create_for_sqrl!(identity)`.
|
|
97
|
+
|
|
98
|
+
Failure messages are Devise translations under `devise.failure`: `sqrl_invalid`,
|
|
99
|
+
`sqrl_disabled`, `sqrl_unknown`, `sqrl_link_required`.
|
|
100
|
+
|
|
101
|
+
## Account policies: `sqrlonly` and `hardlock`
|
|
102
|
+
|
|
103
|
+
An identity that asked for `sqrlonly` makes `user.sqrl_only?` true: Devise's password
|
|
104
|
+
strategy (and any strategy in `Devise.sqrl_only_blocked_strategies`) is then refused
|
|
105
|
+
with the `devise.failure.sqrl_only` message, while SQRL logins, remember-me cookies and
|
|
106
|
+
restored sessions keep working. An identity that asked for `hardlock` makes
|
|
107
|
+
`user.hard_locked?` true: `send_reset_password_instructions` and `reset_password`
|
|
108
|
+
(`:recoverable`) add the `errors.messages.sqrl_hard_locked` error instead of acting.
|
|
109
|
+
|
|
110
|
+
## Identity management
|
|
111
|
+
|
|
112
|
+
`GET /users/sqrl/identities` (`identities_user_sqrl_path`) lists the signed-in user's
|
|
113
|
+
identities with an Unlink button (`DELETE /users/sqrl/identities/:id`,
|
|
114
|
+
`unlink_identity_user_sqrl_path`). Unlinking calls `unlink_sqrl_identity!`.
|
|
115
|
+
|
|
116
|
+
## `ask`, `sin`, hooks and the refresh route
|
|
117
|
+
|
|
118
|
+
Protocol settings come from `Rails.application.config.sqrl` exactly as in `sqrl-rails`:
|
|
119
|
+
`ask` (with `accept:` and `ask_deny_button`), `sin`, `rate_limit`, `before_issue`,
|
|
120
|
+
`before_handle`, `allowed_hosts`, `host_cache_limit`, `prune_every`. A hook that raises
|
|
121
|
+
`SQRL::Server::RateLimited` gets a 429; a disallowed host a 400. `GET /users/sqrl/refresh`
|
|
122
|
+
hands the page a fresh login URL when a nut expires.
|
|
123
|
+
|
|
124
|
+
## Custom controller
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
devise_for :users, controllers: { sqrl: "users/sqrl" }
|
|
128
|
+
|
|
129
|
+
class Users::SQRLController < Devise::SQRLController
|
|
130
|
+
def callback
|
|
131
|
+
super
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## OmniAuth instead?
|
|
137
|
+
|
|
138
|
+
If you already route social logins through OmniAuth, `devise :omniauthable,
|
|
139
|
+
omniauth_providers: [:sqrl]` with the [omniauth-sqrl](../omniauth-sqrl) gem works too.
|
|
140
|
+
`devise-sqrl` is the lighter path: no OmniAuth middleware, and the SQRL endpoint lives
|
|
141
|
+
under the Devise scope.
|
|
142
|
+
|
|
143
|
+
## Testing your app
|
|
144
|
+
|
|
145
|
+
The [sqrl-client](../sqrl-client) gem logs in without a browser:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
agent = SQRL::Client::Agent.new(identity: SQRL::Client::Identity.ephemeral)
|
|
149
|
+
result = agent.login("http://127.0.0.1:3000/users/sign_in")
|
|
150
|
+
result.authenticated? # => true; result.cookies carries the Devise session
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
See [`examples/devise-app`](../../examples/devise-app) for a complete application;
|
|
154
|
+
`bundle exec rake spec:devise-sqrl` runs this gem's specs against an in-gem dummy app.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The SQRL routes of a Devise mapping: the login page, the protocol endpoint, the
|
|
4
|
+
# cookie-bound status poll, the login callback and the browser script.
|
|
5
|
+
#
|
|
6
|
+
# Override it like any Devise controller:
|
|
7
|
+
#
|
|
8
|
+
# devise_for :users, controllers: { sqrl: "users/sqrl" }
|
|
9
|
+
class Devise::SQRLController < DeviseController
|
|
10
|
+
# The protocol endpoint is authenticated by SQRL's own signatures; the client is not a
|
|
11
|
+
# browser and carries no CSRF token.
|
|
12
|
+
skip_forgery_protection only: %i[cli script]
|
|
13
|
+
# Polls and protocol requests must not count as user activity for :timeoutable.
|
|
14
|
+
prepend_before_action(only: %i[cli status script refresh]) { request.env["devise.skip_timeout"] = true }
|
|
15
|
+
|
|
16
|
+
# A before_issue / before_handle hook refused the request.
|
|
17
|
+
rescue_from ::SQRL::Server::RateLimited do |error|
|
|
18
|
+
render_sqrl_rack(sqrl_endpoint.rate_limited(error, json: request.format.json?))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The request host may not be used to issue SQRL URLs.
|
|
22
|
+
rescue_from ::SQRL::Rack::HostNotAllowed do |error|
|
|
23
|
+
render plain: error.message, status: :bad_request
|
|
24
|
+
end
|
|
25
|
+
# Identity management is for the signed-in resource of this scope.
|
|
26
|
+
prepend_before_action :authenticate_scope!, only: %i[identities unlink]
|
|
27
|
+
|
|
28
|
+
# GET /users/sqrl : a stand-alone SQRL login page (the view issues the login URL
|
|
29
|
+
# through +sqrl_login_block_for+; the JSON form issues it here).
|
|
30
|
+
def new
|
|
31
|
+
respond_to do |format|
|
|
32
|
+
format.html
|
|
33
|
+
format.json do
|
|
34
|
+
login = sqrl_login_url_for(resource_name, return_to: sqrl_return_to)
|
|
35
|
+
render json: { url: login.url, nut: login.nut, status: sqrl_endpoint.status_url(request),
|
|
36
|
+
callback: sqrl_endpoint.callback_url(request) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# POST /users/sqrl/cli : the SQRL client's signed requests.
|
|
42
|
+
def cli
|
|
43
|
+
render_sqrl_rack(sqrl_endpoint.cli(request))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GET /users/sqrl/status : {"authenticated": bool, "next": url}
|
|
47
|
+
def status
|
|
48
|
+
render_sqrl_rack(sqrl_endpoint.status(request))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /users/sqrl/refresh : a fresh login URL for this browser session, as JSON.
|
|
52
|
+
def refresh
|
|
53
|
+
render_sqrl_rack(sqrl_endpoint.refresh(request))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GET /users/sqrl/sqrl.js
|
|
57
|
+
def script
|
|
58
|
+
status, headers, body = sqrl_endpoint.script(request)
|
|
59
|
+
headers.each { |k, v| response.set_header(k, v) }
|
|
60
|
+
if status == 304
|
|
61
|
+
head :not_modified
|
|
62
|
+
else
|
|
63
|
+
render body: body.join, status: status, content_type: "application/javascript"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# GET /users/sqrl/callback[?t=TOKEN] : completes the login through the
|
|
68
|
+
# :sqrl_authenticatable Warden strategy and signs the resource in like Devise's
|
|
69
|
+
# sessions controller does. The strategy is run explicitly (not through
|
|
70
|
+
# +warden.authenticate!+) so that the one-time token is verified even when this
|
|
71
|
+
# browser already has a signed-in user, for example when linking a second identity.
|
|
72
|
+
def callback
|
|
73
|
+
strategy = Warden::Strategies[:sqrl_authenticatable].new(request.env, resource_name)
|
|
74
|
+
strategy._run!
|
|
75
|
+
return failure(strategy.message || :sqrl_invalid) unless strategy.successful?
|
|
76
|
+
|
|
77
|
+
self.resource = strategy.user
|
|
78
|
+
completion = request.env["devise.sqrl.completion"]
|
|
79
|
+
return_to = completion&.return_to
|
|
80
|
+
store_location_for(resource_name, return_to) if same_site_path?(return_to)
|
|
81
|
+
set_flash_message!(:notice, :signed_in)
|
|
82
|
+
sign_in_and_redirect(resource_name, resource, event: :authentication)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# GET /users/sqrl/identities : the SQRL identities linked to the signed-in resource.
|
|
86
|
+
def identities
|
|
87
|
+
@identities = resource.sqrl_identities.order(:created_at)
|
|
88
|
+
@current_idk = sqrl_endpoint.current_identity(request)&.idk_b64
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# DELETE /users/sqrl/identities/:id : detach one identity from the signed-in resource.
|
|
92
|
+
# The site keeps the protocol association; the next login with that identity is
|
|
93
|
+
# treated as an unknown identity again.
|
|
94
|
+
def unlink
|
|
95
|
+
identity = resource.sqrl_identities.find_by(id: params[:id])
|
|
96
|
+
if identity
|
|
97
|
+
current = sqrl_endpoint.current_identity(request)&.idk_b64
|
|
98
|
+
resource.unlink_sqrl_identity!(identity)
|
|
99
|
+
sqrl_endpoint.logout(request) if current && current == identity.idk
|
|
100
|
+
set_flash_message!(:notice, :identity_unlinked)
|
|
101
|
+
else
|
|
102
|
+
set_flash_message!(:alert, :identity_not_found)
|
|
103
|
+
end
|
|
104
|
+
redirect_to identities_sqrl_path(resource_name), status: :see_other
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
protected
|
|
108
|
+
|
|
109
|
+
# Like Devise's registrations controller: the signed-in resource of this scope.
|
|
110
|
+
def authenticate_scope!
|
|
111
|
+
send(:"authenticate_#{resource_name}!", force: true)
|
|
112
|
+
self.resource = send(:"current_#{resource_name}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Redirect to the SQRL login page (or +Devise.sqrl_failure_path+) with the reason.
|
|
116
|
+
def failure(reason)
|
|
117
|
+
message = reason.is_a?(Symbol) ? I18n.t("devise.failure.#{reason}", default: reason.to_s.humanize) : reason.to_s
|
|
118
|
+
flash[:alert] = message if is_flashing_format?
|
|
119
|
+
redirect_to sqrl_failure_path, allow_other_host: false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def sqrl_endpoint
|
|
123
|
+
@sqrl_endpoint ||= Devise::SQRL.endpoint_for(devise_mapping, request)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sqrl_failure_path
|
|
127
|
+
Devise.sqrl_failure_path || new_sqrl_path(resource_name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sqrl_return_to
|
|
131
|
+
candidate = params[:return_to].presence
|
|
132
|
+
same_site_path?(candidate) ? candidate : nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The signed-in resource a new identity may be linked to (policy :link).
|
|
136
|
+
def sqrl_link_user_ref
|
|
137
|
+
return nil unless Devise::SQRL.on_unknown_identity == :link
|
|
138
|
+
|
|
139
|
+
current = warden.user(resource_name)
|
|
140
|
+
current && ::SQRL::Rails.user_ref(current)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def same_site_path?(path)
|
|
144
|
+
::SQRL::Rails::SignIn.same_site_path?(path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_sqrl_rack(triple)
|
|
148
|
+
status, headers, body = triple
|
|
149
|
+
headers.each { |k, v| response.set_header(k, v) }
|
|
150
|
+
render body: body.join, status: status, content_type: headers["content-type"]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def translation_scope
|
|
154
|
+
"devise.sqrl"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
include DeviseSQRLHelper
|
|
158
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# View helpers for the SQRL login block of a Devise mapping.
|
|
4
|
+
module DeviseSQRLHelper
|
|
5
|
+
# The complete SQRL login markup (link, QR code, status text and script tag) for a
|
|
6
|
+
# Devise scope, wired to that scope's +/…/sqrl/*+ routes. Each render issues a fresh
|
|
7
|
+
# login URL.
|
|
8
|
+
#
|
|
9
|
+
# <%= sqrl_login_block_for(resource_name) %>
|
|
10
|
+
#
|
|
11
|
+
# @param resource_or_scope [Symbol, Object] +:user+, +resource_name+ or a record
|
|
12
|
+
# @param return_to [String, nil] same-site path to land on after login
|
|
13
|
+
# @param script [Boolean] append the +sqrl.js+ script tag
|
|
14
|
+
# @param options [Hash] passed to +SQRL::Rack::LoginBlock.render+ (merged over
|
|
15
|
+
# +Devise.sqrl_login_block+)
|
|
16
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
17
|
+
def sqrl_login_block_for(resource_or_scope, return_to: nil, script: true, **options)
|
|
18
|
+
mapping = sqrl_devise_mapping(resource_or_scope)
|
|
19
|
+
endpoint = Devise::SQRL.endpoint_for(mapping, request)
|
|
20
|
+
login = sqrl_login_url_for(mapping.name, return_to: return_to)
|
|
21
|
+
opts = Devise.sqrl_login_block.to_h.merge(options)
|
|
22
|
+
::SQRL::Rack::LoginBlock.render(login_url: login.url, status_url: endpoint.status_url(request),
|
|
23
|
+
script_url: (endpoint.script_url(request) if script),
|
|
24
|
+
refresh_url: endpoint.refresh_url(request), **opts).html_safe
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The +sqrl://+ login URL issued for a scope (one per request per scope).
|
|
28
|
+
#
|
|
29
|
+
# @return [SQRL::Server::LoginURL]
|
|
30
|
+
def sqrl_login_url_for(resource_or_scope, return_to: nil)
|
|
31
|
+
mapping = sqrl_devise_mapping(resource_or_scope)
|
|
32
|
+
@_devise_sqrl_logins ||= {}
|
|
33
|
+
@_devise_sqrl_logins[mapping.name] ||= Devise::SQRL.endpoint_for(mapping, request)
|
|
34
|
+
.issue(request, return_to: sqrl_devise_return_to(return_to),
|
|
35
|
+
link_user_ref: sqrl_devise_link_user_ref(mapping))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# +<script src="/users/sqrl/sqrl.js" defer>+ for a scope.
|
|
39
|
+
def sqrl_script_tag_for(resource_or_scope)
|
|
40
|
+
mapping = sqrl_devise_mapping(resource_or_scope)
|
|
41
|
+
src = Devise::SQRL.endpoint_for(mapping, request).script_url(request)
|
|
42
|
+
::SQRL::Rack::LoginBlock.script_tag(src: src).html_safe
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def sqrl_devise_mapping(resource_or_scope)
|
|
48
|
+
Devise.mappings.fetch(Devise::Mapping.find_scope!(resource_or_scope))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sqrl_devise_return_to(explicit)
|
|
52
|
+
candidate = explicit.presence || (params[:return_to].presence if respond_to?(:params))
|
|
53
|
+
::SQRL::Rails::SignIn.same_site_path?(candidate) ? candidate : nil
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sqrl_devise_link_user_ref(mapping)
|
|
59
|
+
return nil unless Devise::SQRL.on_unknown_identity == :link
|
|
60
|
+
|
|
61
|
+
warden = request.env["warden"]
|
|
62
|
+
current = warden&.user(mapping.name)
|
|
63
|
+
current && ::SQRL::Rails.user_ref(current)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Drop this partial into devise/sessions/new.html.erb:
|
|
2
|
+
<%= render "devise/shared/sqrl", resource_name: resource_name %> %>
|
|
3
|
+
<div class="devise-sqrl">
|
|
4
|
+
<h3>Or sign in with SQRL</h3>
|
|
5
|
+
<%= sqrl_login_block_for(local_assigns.fetch(:resource_name) { resource_name }) %>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<h2>Your SQRL identities</h2>
|
|
2
|
+
|
|
3
|
+
<% if flash[:alert].present? %><p class="alert"><%= flash[:alert] %></p><% end %>
|
|
4
|
+
<% if flash[:notice].present? %><p class="notice"><%= flash[:notice] %></p><% end %>
|
|
5
|
+
|
|
6
|
+
<% if @identities.empty? %>
|
|
7
|
+
<p id="sqrl-no-identities">No SQRL identity is linked to your account yet.</p>
|
|
8
|
+
<% else %>
|
|
9
|
+
<table class="sqrl-identities">
|
|
10
|
+
<thead>
|
|
11
|
+
<tr><th>Identity</th><th>Linked</th><th>Last login</th><th>Logins</th><th>Flags</th><th></th></tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody>
|
|
14
|
+
<% @identities.each do |identity| %>
|
|
15
|
+
<tr id="sqrl-identity-<%= identity.id %>" data-sqrl-idk="<%= identity.idk %>">
|
|
16
|
+
<td><code><%= identity.short_idk %></code><% if identity.idk == @current_idk %> <em>(this session)</em><% end %></td>
|
|
17
|
+
<td><%= identity.created_at&.to_fs(:short) %></td>
|
|
18
|
+
<td><%= identity.last_login_at&.to_fs(:short) || "never" %></td>
|
|
19
|
+
<td><%= identity.logins %></td>
|
|
20
|
+
<td><%= identity.flags.join(", ") %></td>
|
|
21
|
+
<td><%= button_to "Unlink", unlink_identity_sqrl_path(resource_name, identity), method: :delete,
|
|
22
|
+
form: { data: { turbo_confirm: "Unlink this SQRL identity?" } } %></td>
|
|
23
|
+
</tr>
|
|
24
|
+
<% end %>
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<p><%= link_to "Add another SQRL identity", new_sqrl_path(resource_name) if Devise::SQRL.on_unknown_identity == :link %></p>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<h2>Sign in with SQRL</h2>
|
|
2
|
+
|
|
3
|
+
<% if flash[:alert].present? %>
|
|
4
|
+
<p class="alert"><%= flash[:alert] %></p>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<%= sqrl_login_block_for(resource_name, return_to: params[:return_to]) %>
|
|
8
|
+
|
|
9
|
+
<%= render "devise/shared/links" if lookup_context.exists?("devise/shared/_links") %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
en:
|
|
2
|
+
devise:
|
|
3
|
+
failure:
|
|
4
|
+
sqrl_invalid: "SQRL login failed or expired. Please try again."
|
|
5
|
+
sqrl_disabled: "SQRL login is disabled for this account."
|
|
6
|
+
sqrl_unknown: "This SQRL identity is not linked to an account."
|
|
7
|
+
sqrl_link_required: "Sign in first, then link your SQRL identity to your account."
|
|
8
|
+
sqrl_declined: "You declined the SQRL login."
|
|
9
|
+
sqrl_only: "This account signs in with SQRL only."
|
|
10
|
+
sqrl:
|
|
11
|
+
signed_in: "Signed in successfully."
|
|
12
|
+
identity_unlinked: "SQRL identity unlinked."
|
|
13
|
+
identity_not_found: "That SQRL identity is not linked to your account."
|
|
14
|
+
errors:
|
|
15
|
+
messages:
|
|
16
|
+
sqrl_hard_locked: "Password recovery is disabled for this account; sign in with SQRL instead."
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
# Devise's model-module namespace.
|
|
5
|
+
module Models
|
|
6
|
+
# Adds SQRL login to a Devise model.
|
|
7
|
+
#
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# devise :database_authenticatable, :sqrl_authenticatable
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# The model gets +sqrl_identities+ (rows of the +sqrl_identities+ table from the
|
|
13
|
+
# sqrl-rails migration) and the lookup and linking methods the
|
|
14
|
+
# +:sqrl_authenticatable+ strategy relies on. No columns are needed on the model.
|
|
15
|
+
module SQRLAuthenticatable
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
if respond_to?(:has_many)
|
|
20
|
+
has_many :sqrl_identities, -> { where(superseded_by: nil) },
|
|
21
|
+
class_name: "SQRL::Rails::Identity", as: :user, dependent: :destroy
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<Symbol>] columns the module needs on the model (none)
|
|
26
|
+
def self.required_fields(_klass)
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The SQRL identity record (+SQRL::Rails::Identity+) for an identity key, or nil.
|
|
31
|
+
#
|
|
32
|
+
# @param idk [String] base64url identity key
|
|
33
|
+
def sqrl_identity_for(idk)
|
|
34
|
+
sqrl_identities.find_by(idk: idk)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether at least one SQRL identity is attached.
|
|
38
|
+
def sqrl_linked?
|
|
39
|
+
sqrl_identities.exists?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether a SQRL identity of this record asked the site to refuse every non-SQRL
|
|
43
|
+
# way of signing in (+opt=sqrlonly+). Password logins are then refused by the
|
|
44
|
+
# Warden hook devise-sqrl installs (see +Devise.sqrl_only_blocked_strategies+).
|
|
45
|
+
def sqrl_only?
|
|
46
|
+
sqrl_identities.where(sqrlonly: true).exists?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether a SQRL identity of this record asked the site to refuse every non-SQRL
|
|
50
|
+
# way of recovering the account (+opt=hardlock+). Password resets are then
|
|
51
|
+
# refused (+:recoverable+).
|
|
52
|
+
def hard_locked?
|
|
53
|
+
sqrl_identities.where(hardlock: true).exists?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# :recoverable – refuse to mail reset instructions to a hard-locked account. The
|
|
57
|
+
# record comes back with an error, which Devise's passwords controller renders.
|
|
58
|
+
def send_reset_password_instructions
|
|
59
|
+
return super unless hard_locked?
|
|
60
|
+
|
|
61
|
+
errors.add(:base, :sqrl_hard_locked)
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# :recoverable – refuse to set a new password through a reset token for a
|
|
66
|
+
# hard-locked account.
|
|
67
|
+
def reset_password(new_password, new_password_confirmation)
|
|
68
|
+
return super unless hard_locked?
|
|
69
|
+
|
|
70
|
+
errors.add(:base, :sqrl_hard_locked)
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Attach an identity (an +SQRL::Server::Identity+ from the protocol handler, an
|
|
75
|
+
# +SQRL::Rails::Identity+ row, or a base64url idk) to this record.
|
|
76
|
+
def link_sqrl_identity!(identity)
|
|
77
|
+
::SQRL::Rails.stores.identities.update(sqrl_identity_idk_bytes(identity), user_ref: ::SQRL::Rails.user_ref(self))
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Detach an identity from this record (the site association stays; the identity can
|
|
82
|
+
# no longer sign in as this user).
|
|
83
|
+
def unlink_sqrl_identity!(identity)
|
|
84
|
+
row = sqrl_identity_for(sqrl_identity_idk_b64(identity))
|
|
85
|
+
row&.update!(user: nil)
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def sqrl_identity_idk_b64(identity)
|
|
92
|
+
case identity
|
|
93
|
+
when String then identity
|
|
94
|
+
when ::SQRL::Rails::Identity then identity.idk
|
|
95
|
+
else identity.idk_b64
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sqrl_identity_idk_bytes(identity)
|
|
100
|
+
::SQRL::Util.b64u_decode(sqrl_identity_idk_b64(identity))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Class-level API added to the model.
|
|
104
|
+
module ClassMethods
|
|
105
|
+
# The record a SQRL identity belongs to, when it belongs to this model.
|
|
106
|
+
#
|
|
107
|
+
# @param identity [SQRL::Server::Identity]
|
|
108
|
+
# @return [Object, nil]
|
|
109
|
+
def find_for_sqrl(identity)
|
|
110
|
+
user = ::SQRL::Rails.user_from_ref(identity.user_ref)
|
|
111
|
+
return user if user.is_a?(self)
|
|
112
|
+
|
|
113
|
+
row = ::SQRL::Rails::Identity.active.find_by(idk: identity.idk_b64)
|
|
114
|
+
owner = row&.user
|
|
115
|
+
owner.is_a?(self) ? owner : nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Create a record for a brand-new identity and attach the identity to it.
|
|
119
|
+
#
|
|
120
|
+
# @param identity [SQRL::Server::Identity]
|
|
121
|
+
# @return [Object] the saved record
|
|
122
|
+
def create_for_sqrl!(identity)
|
|
123
|
+
factory = Devise.sqrl_create_resource
|
|
124
|
+
resource = factory ? factory.call(identity, self) : ::SQRL::Rails::UserFactory.call(identity, self)
|
|
125
|
+
raise Devise::SQRL::Error, "sqrl_create_resource returned no record" unless resource
|
|
126
|
+
|
|
127
|
+
resource.save! if resource.respond_to?(:new_record?) && resource.new_record?
|
|
128
|
+
resource.link_sqrl_identity!(identity)
|
|
129
|
+
resource
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module SQRL
|
|
5
|
+
# Loads the controller, views, helper and translations into the host application.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
engine_name "devise_sqrl"
|
|
8
|
+
|
|
9
|
+
initializer "devise_sqrl.helpers" do
|
|
10
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
11
|
+
helper DeviseSQRLHelper
|
|
12
|
+
end
|
|
13
|
+
ActiveSupport.on_load(:action_view) do
|
|
14
|
+
include DeviseSQRLHelper
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "devise_sqrl.reset_on_reload" do |app|
|
|
19
|
+
app.reloader.to_prepare { Devise::SQRL.reset! } if app.respond_to?(:reloader)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enforces +sqrlonly+: a record whose SQRL identity asked the site to refuse non-SQRL
|
|
4
|
+
# authentication is not allowed to sign in through a password strategy. The hook runs
|
|
5
|
+
# like Devise's own activatable hook, after Warden set the user, and only when the
|
|
6
|
+
# login came through one of +Devise.sqrl_only_blocked_strategies+ (the password
|
|
7
|
+
# strategy by default). SQRL logins, remember-me cookies and sessions restored from the
|
|
8
|
+
# cookie are untouched.
|
|
9
|
+
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
|
|
10
|
+
if record.respond_to?(:sqrl_only?) && Devise::SQRL.blocked_strategy?(warden.winning_strategy) && record.sqrl_only?
|
|
11
|
+
scope = options[:scope]
|
|
12
|
+
warden.logout(scope)
|
|
13
|
+
throw :warden, scope: scope, message: :sqrl_only
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails routing namespace, reopened to add +devise_sqrl+.
|
|
4
|
+
module ActionDispatch
|
|
5
|
+
# Rails routing namespace.
|
|
6
|
+
module Routing
|
|
7
|
+
# Devise calls +devise_sqrl+ for every mapping whose model declares
|
|
8
|
+
# +:sqrl_authenticatable+. The routes live under the mapping's path (+/users/sqrl+)
|
|
9
|
+
# and get the helpers +new_user_sqrl_path+, +cli_user_sqrl_path+,
|
|
10
|
+
# +status_user_sqrl_path+, +callback_user_sqrl_path+, +script_user_sqrl_path+,
|
|
11
|
+
# +identities_user_sqrl_path+ and +unlink_identity_user_sqrl_path(id)+.
|
|
12
|
+
class Mapper
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def devise_sqrl(mapping, controllers) # :nodoc:
|
|
16
|
+
resource :sqrl, only: [], controller: controllers[:sqrl], path: mapping.path_names[:sqrl] do
|
|
17
|
+
get :new, path: "", as: "new"
|
|
18
|
+
post :cli
|
|
19
|
+
get :status
|
|
20
|
+
get :callback
|
|
21
|
+
get :script, path: "sqrl.js"
|
|
22
|
+
get :refresh
|
|
23
|
+
get :identities
|
|
24
|
+
delete "identities/:id", action: :unlink, as: :unlink_identity
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/devise/sqrl.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "devise"
|
|
4
|
+
require "sqrl-rails"
|
|
5
|
+
|
|
6
|
+
# Devise namespace, reopened to add the SQRL module.
|
|
7
|
+
module Devise
|
|
8
|
+
# SQRL login for Devise.
|
|
9
|
+
#
|
|
10
|
+
# # Gemfile
|
|
11
|
+
# gem "devise-sqrl"
|
|
12
|
+
#
|
|
13
|
+
# class User < ApplicationRecord
|
|
14
|
+
# devise :database_authenticatable, :sqrl_authenticatable
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# `devise_for :users` then routes +/users/sqrl+ (login page), +POST /users/sqrl/cli+ (the
|
|
18
|
+
# SQRL endpoint), +/users/sqrl/status+ (cookie-bound poll), +/users/sqrl/callback+
|
|
19
|
+
# (login completion, through the +:sqrl_authenticatable+ Warden strategy) and
|
|
20
|
+
# +/users/sqrl/sqrl.js+. Render the login block inside your sign-in page with
|
|
21
|
+
# +<%= render "devise/shared/sqrl", resource_name: resource_name %>+.
|
|
22
|
+
#
|
|
23
|
+
# Protocol and storage settings come from +Rails.application.config.sqrl+ (the sqrl-rails
|
|
24
|
+
# gem); the Devise-specific choices are the +sqrl_*+ options below, set in
|
|
25
|
+
# +Devise.setup+.
|
|
26
|
+
module SQRL
|
|
27
|
+
# Same as SQRL::VERSION; the gems release in lockstep.
|
|
28
|
+
VERSION = ::SQRL::VERSION
|
|
29
|
+
|
|
30
|
+
# Raised for configuration problems.
|
|
31
|
+
class Error < ::SQRL::Error; end
|
|
32
|
+
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@resolver = nil
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# The {::SQRL::Rack::Endpoint} serving a Devise mapping for a request's host and
|
|
38
|
+
# scheme. Memoized per [host, scheme, mapping] in a bounded
|
|
39
|
+
# {::SQRL::Rack::HostResolver} cache; the stores are shared with sqrl-rails
|
|
40
|
+
# (+config.sqrl.stores+).
|
|
41
|
+
#
|
|
42
|
+
# @param mapping [Devise::Mapping]
|
|
43
|
+
# @param request [ActionDispatch::Request, Rack::Request]
|
|
44
|
+
# @return [::SQRL::Rack::Endpoint]
|
|
45
|
+
# @raise [::SQRL::Rack::HostNotAllowed] when the request host may not be used
|
|
46
|
+
def endpoint_for(mapping, request)
|
|
47
|
+
resolver = host_resolver
|
|
48
|
+
host, scheme = resolver.resolve(request)
|
|
49
|
+
resolver.fetch([host, scheme, mapping.name, mapping.fullpath]) { build_endpoint(mapping, host, scheme) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The host resolver built from +config.sqrl+ (host, scheme, allowed_hosts,
|
|
53
|
+
# host_cache_limit).
|
|
54
|
+
def host_resolver
|
|
55
|
+
config = ::SQRL::Rails.config
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
scheme = config.scheme.presence || (config.insecure_http ? "qrl" : nil)
|
|
58
|
+
@resolver ||= ::SQRL::Rack::HostResolver.new(host: config.host.presence, scheme: scheme,
|
|
59
|
+
allowed_hosts: config.allowed_hosts,
|
|
60
|
+
limit: config.host_cache_limit,
|
|
61
|
+
logger: config.logger || ::Rails.logger)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Forget memoized endpoints (tests, configuration changes).
|
|
66
|
+
def reset!
|
|
67
|
+
@mutex.synchronize { @resolver = nil }
|
|
68
|
+
::SQRL::Rails.reset!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Devise-level paths of a mapping's SQRL routes.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash{Symbol => String}]
|
|
74
|
+
def paths_for(mapping)
|
|
75
|
+
base = "#{mapping.fullpath}/sqrl".squeeze("/")
|
|
76
|
+
{ new: base, cli: "#{base}/cli", status: "#{base}/status", callback: "#{base}/callback",
|
|
77
|
+
script: "#{base}/sqrl.js", identities: "#{base}/identities", refresh: "#{base}/refresh" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether +strategy+ (a Warden strategy instance or name) is one that a
|
|
81
|
+
# +sqrlonly+ account must not sign in through.
|
|
82
|
+
def blocked_strategy?(strategy)
|
|
83
|
+
name = strategy.is_a?(Symbol) || strategy.is_a?(String) ? strategy.to_sym : strategy_name(strategy)
|
|
84
|
+
Array(Devise.sqrl_only_blocked_strategies).map(&:to_sym).include?(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Symbol] the Warden name of a strategy class
|
|
88
|
+
def strategy_name(strategy)
|
|
89
|
+
return nil if strategy.nil?
|
|
90
|
+
|
|
91
|
+
Warden::Strategies.send(:_strategies).key(strategy.class)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The effective unknown-identity policy: Devise's +sqrl_on_unknown_identity+ when
|
|
95
|
+
# set, otherwise +config.sqrl.on_unknown_identity+.
|
|
96
|
+
#
|
|
97
|
+
# @return [Symbol] +:create+, +:link+ or +:refuse+
|
|
98
|
+
def on_unknown_identity
|
|
99
|
+
(Devise.sqrl_on_unknown_identity || ::SQRL::Rails.config.on_unknown_identity || :create).to_sym
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_endpoint(mapping, host, scheme)
|
|
105
|
+
rails_config = ::SQRL::Rails.config
|
|
106
|
+
paths = paths_for(mapping)
|
|
107
|
+
options = rails_config.server_options.merge(on_unknown_identity: on_unknown_identity)
|
|
108
|
+
server_config = ::SQRL::Server::Config.new(**options, host: host, scheme: scheme,
|
|
109
|
+
path: paths[:cli], callback_path: paths[:callback])
|
|
110
|
+
handler = ::SQRL::Server::Handler.new(config: server_config, stores: ::SQRL::Rails.stores)
|
|
111
|
+
::SQRL::Rack::Endpoint.new(handler: handler,
|
|
112
|
+
session_key: "devise.sqrl.#{mapping.name}.pending_login",
|
|
113
|
+
identity_key: "devise.sqrl.#{mapping.name}.identity",
|
|
114
|
+
status_path: paths[:status], callback_path: paths[:callback],
|
|
115
|
+
script_path: paths[:script], refresh_path: paths[:refresh],
|
|
116
|
+
poll_requires_ip_match: rails_config.poll_requires_ip_match ? true : false,
|
|
117
|
+
ip: rails_config.ip || method(:client_ip))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def client_ip(request)
|
|
121
|
+
request.respond_to?(:remote_ip) ? request.remote_ip : request.ip
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- Devise.setup options -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# What to do when an unknown SQRL identity completes a login: +:create+ an account,
|
|
129
|
+
# +:link+ it to the signed-in user only, or +:refuse+. +nil+ (the default) defers to
|
|
130
|
+
# +Rails.application.config.sqrl.on_unknown_identity+ (+:create+).
|
|
131
|
+
mattr_accessor :sqrl_on_unknown_identity
|
|
132
|
+
@@sqrl_on_unknown_identity = nil
|
|
133
|
+
|
|
134
|
+
# Callable +(identity, resource_class) -> resource+ used to build the account for a new
|
|
135
|
+
# identity under +:create+. Defaults to +SQRL::Rails::UserFactory+, which fills in a
|
|
136
|
+
# non-routable email and a random password so +:validatable+ passes.
|
|
137
|
+
mattr_accessor :sqrl_create_resource
|
|
138
|
+
@@sqrl_create_resource = nil
|
|
139
|
+
|
|
140
|
+
# Mark SQRL logins as "remember me" (needs +:rememberable+ on the model).
|
|
141
|
+
mattr_accessor :sqrl_remember_me
|
|
142
|
+
@@sqrl_remember_me = false
|
|
143
|
+
|
|
144
|
+
# Options passed to +SQRL::Rack::LoginBlock.render+ (link text, QR size, texts...).
|
|
145
|
+
mattr_accessor :sqrl_login_block
|
|
146
|
+
@@sqrl_login_block = {}
|
|
147
|
+
|
|
148
|
+
# Where to send the browser after a failed SQRL login; +nil+ means the mapping's SQRL
|
|
149
|
+
# login page.
|
|
150
|
+
mattr_accessor :sqrl_failure_path
|
|
151
|
+
@@sqrl_failure_path = nil
|
|
152
|
+
|
|
153
|
+
# Warden strategies a +sqrlonly+ account may not sign in through. The default blocks
|
|
154
|
+
# Devise's password strategy; add your own password-like strategies here. Remember-me
|
|
155
|
+
# cookies and the SQRL strategy itself are never blocked.
|
|
156
|
+
mattr_accessor :sqrl_only_blocked_strategies
|
|
157
|
+
@@sqrl_only_blocked_strategies = [:database_authenticatable]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Devise.add_module :sqrl_authenticatable,
|
|
161
|
+
strategy: true,
|
|
162
|
+
controller: :sqrl,
|
|
163
|
+
route: { sqrl: %i[new cli status callback script refresh identities unlink_identity] },
|
|
164
|
+
model: "devise/models/sqrl_authenticatable",
|
|
165
|
+
no_input: true
|
|
166
|
+
|
|
167
|
+
require_relative "sqrl/routes"
|
|
168
|
+
require_relative "strategies/sqrl_authenticatable"
|
|
169
|
+
require_relative "sqrl/hooks"
|
|
170
|
+
require_relative "sqrl/engine"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "devise/strategies/base"
|
|
4
|
+
|
|
5
|
+
module Devise
|
|
6
|
+
# Devise's Warden strategy namespace.
|
|
7
|
+
module Strategies
|
|
8
|
+
# Completes a SQRL login on the mapping's callback route: claims the one-time CPS
|
|
9
|
+
# token (or the browser's own, cookie-bound pending login after the poller saw it
|
|
10
|
+
# authenticated), resolves or creates the resource and signs it in.
|
|
11
|
+
class SQRLAuthenticatable < Base
|
|
12
|
+
# Only the callback route of this mapping is handled.
|
|
13
|
+
def valid?
|
|
14
|
+
request.get? && request.path_info == Devise::SQRL.paths_for(mapping)[:callback]
|
|
15
|
+
rescue StandardError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Claim the CPS token or the browser's pending login, apply the unknown-identity policy and
|
|
20
|
+
# sign the resource in, or fail with a Devise failure key.
|
|
21
|
+
def authenticate!
|
|
22
|
+
endpoint = Devise::SQRL.endpoint_for(mapping, request)
|
|
23
|
+
completion = endpoint.callback(request)
|
|
24
|
+
return fail!(:sqrl_invalid) unless completion
|
|
25
|
+
|
|
26
|
+
identity = completion.identity
|
|
27
|
+
return fail!(:sqrl_disabled) if identity.disabled?
|
|
28
|
+
return fail!(:sqrl_declined) if declined?(completion)
|
|
29
|
+
|
|
30
|
+
resource = mapping.to.find_for_sqrl(identity) || resource_for_unknown(identity)
|
|
31
|
+
return if halted?
|
|
32
|
+
return fail!(:sqrl_unknown) unless resource
|
|
33
|
+
return fail!(resource.inactive_message) unless resource.active_for_authentication?
|
|
34
|
+
|
|
35
|
+
resource.remember_me = true if Devise.sqrl_remember_me && resource.respond_to?(:remember_me=)
|
|
36
|
+
env["devise.sqrl.completion"] = completion
|
|
37
|
+
success!(resource)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clearing CSRF data would log out any other scope; a SQRL callback is a fresh,
|
|
41
|
+
# signed login so the default behaviour is kept.
|
|
42
|
+
def clean_up_csrf?
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The client answered the site's +ask+ with the configured "deny" button.
|
|
49
|
+
def declined?(completion)
|
|
50
|
+
deny = ::SQRL::Rails.config.ask_deny_button
|
|
51
|
+
!deny.nil? && completion.pending_login.btn == deny.to_i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resource_for_unknown(identity)
|
|
55
|
+
case Devise::SQRL.on_unknown_identity
|
|
56
|
+
when :refuse
|
|
57
|
+
nil
|
|
58
|
+
when :link
|
|
59
|
+
current = env["warden"].user(scope)
|
|
60
|
+
return fail!(:sqrl_link_required) unless current.is_a?(mapping.to)
|
|
61
|
+
|
|
62
|
+
current.link_sqrl_identity!(identity)
|
|
63
|
+
current
|
|
64
|
+
else
|
|
65
|
+
mapping.to.create_for_sqrl!(identity)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Warden::Strategies.add(:sqrl_authenticatable, Devise::Strategies::SQRLAuthenticatable)
|
data/lib/devise-sqrl.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require "sqrl/rails"
|
|
6
|
+
|
|
7
|
+
module Devise
|
|
8
|
+
module SQRL
|
|
9
|
+
# Generator namespace (+bin/rails generate devise:sqrl:install+).
|
|
10
|
+
module Generators
|
|
11
|
+
# `bin/rails generate devise:sqrl:install [MODEL]`
|
|
12
|
+
#
|
|
13
|
+
# Adds the SQRL tables (the sqrl-rails migration), declares +:sqrl_authenticatable+
|
|
14
|
+
# on the Devise model, writes +config/initializers/devise_sqrl.rb+ and drops the
|
|
15
|
+
# SQRL block into +devise/sessions/new.html.erb+ when the app has it. Idempotent.
|
|
16
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
17
|
+
include ::ActiveRecord::Generators::Migration
|
|
18
|
+
|
|
19
|
+
# The migration is the sqrl-rails one, so both gems create identical tables.
|
|
20
|
+
SQRL_RAILS_TEMPLATES = File.join(::SQRL::Rails::Engine.root, "lib/generators/sqrl/install/templates")
|
|
21
|
+
|
|
22
|
+
source_root File.expand_path("templates", __dir__)
|
|
23
|
+
source_paths << SQRL_RAILS_TEMPLATES
|
|
24
|
+
|
|
25
|
+
argument :model_name, type: :string, default: "User", desc: "The Devise model"
|
|
26
|
+
|
|
27
|
+
class_option :skip_migration, type: :boolean, default: false, desc: "Do not copy the migration"
|
|
28
|
+
class_option :skip_views, type: :boolean, default: false,
|
|
29
|
+
desc: "Do not touch app/views/devise/sessions/new.html.erb"
|
|
30
|
+
class_option :host, type: :string, default: nil, desc: "Public host for sqrl:// URLs (default: request host)"
|
|
31
|
+
|
|
32
|
+
# @return [String] the generator's name (+devise:sqrl:install+)
|
|
33
|
+
def self.namespace(name = nil)
|
|
34
|
+
name ? super : "devise:sqrl:install"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Copy the +create_sqrl_tables+ migration unless one exists.
|
|
38
|
+
def copy_migration
|
|
39
|
+
return if options[:skip_migration]
|
|
40
|
+
if Dir[File.join(destination_root, "db/migrate/*_create_sqrl_tables.rb")].any?
|
|
41
|
+
return say_status(:identical, "db/migrate/*_create_sqrl_tables.rb", :blue)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
migration_template "create_sqrl_tables.rb.tt", "db/migrate/create_sqrl_tables.rb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Add +:sqrl_authenticatable+ to the model's +devise+ call (idempotent).
|
|
48
|
+
def add_module_to_model
|
|
49
|
+
path = "app/models/#{model_name.underscore}.rb"
|
|
50
|
+
full = File.expand_path(path, destination_root)
|
|
51
|
+
unless File.exist?(full)
|
|
52
|
+
return say_status(:skip, "#{path} not found; add :sqrl_authenticatable to its devise call", :yellow)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
content = File.read(full)
|
|
56
|
+
return say_status(:identical, path, :blue) if content.include?("sqrl_authenticatable")
|
|
57
|
+
|
|
58
|
+
if content.match?(/^\s*devise\s+:/)
|
|
59
|
+
inject_into_file path, ":sqrl_authenticatable, ", after: /^\s*devise\s+(?=:)/
|
|
60
|
+
else
|
|
61
|
+
inject_into_class path, model_name.classify, " devise :sqrl_authenticatable\n"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write +config/initializers/devise_sqrl.rb+.
|
|
66
|
+
def create_initializer
|
|
67
|
+
template "devise_sqrl.rb.tt", "config/initializers/devise_sqrl.rb"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Render the shared SQRL partial in +devise/sessions/new+ when it exists (idempotent).
|
|
71
|
+
def add_partial_to_sessions_view
|
|
72
|
+
return if options[:skip_views]
|
|
73
|
+
|
|
74
|
+
path = "app/views/devise/sessions/new.html.erb"
|
|
75
|
+
full = File.expand_path(path, destination_root)
|
|
76
|
+
unless File.exist?(full)
|
|
77
|
+
return say_status(:skip, "#{path} not found (run `bin/rails g devise:views -v sessions`); " \
|
|
78
|
+
"render \"devise/shared/sqrl\" in your sign-in page", :yellow)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if File.read(full).include?("devise/shared/sqrl")
|
|
82
|
+
say_status(:identical, path, :blue)
|
|
83
|
+
else
|
|
84
|
+
append_to_file path, %(\n<%= render "devise/shared/sqrl", resource_name: resource_name %>\n)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Print what to do next.
|
|
89
|
+
def show_next_steps
|
|
90
|
+
say <<~TEXT
|
|
91
|
+
|
|
92
|
+
SQRL login is set up for #{model_name.classify}. Next:
|
|
93
|
+
|
|
94
|
+
bin/rails db:migrate
|
|
95
|
+
|
|
96
|
+
Then open the sign-in page: the SQRL block (link, QR code) is rendered by the
|
|
97
|
+
devise/shared/sqrl partial. Settings: config/initializers/devise_sqrl.rb.
|
|
98
|
+
TEXT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def host_option
|
|
104
|
+
options[:host]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def migration_version
|
|
108
|
+
"[#{::ActiveRecord::Migration.current_version}]"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SQRL login for Devise (devise-sqrl). Protocol and storage settings are those of the
|
|
4
|
+
# sqrl-rails gem (SQRL::Rails::Configuration); the Devise-specific choices follow.
|
|
5
|
+
|
|
6
|
+
Rails.application.config.sqrl.tap do |sqrl|
|
|
7
|
+
# The public host (and port, when not the default) placed in the sqrl:// login URLs.
|
|
8
|
+
# SQRL identities are derived from this host, so it must be the name users see.
|
|
9
|
+
# When nil, the host of each request is used.
|
|
10
|
+
<% if host_option -%>
|
|
11
|
+
sqrl.host = <%= host_option.inspect %>
|
|
12
|
+
<% else -%>
|
|
13
|
+
sqrl.host = ENV["SQRL_HOST"].presence
|
|
14
|
+
<% end -%>
|
|
15
|
+
|
|
16
|
+
# Storage: :active_record (default; tables from the create_sqrl_tables migration).
|
|
17
|
+
sqrl.stores = :active_record
|
|
18
|
+
|
|
19
|
+
# Protocol options: sqrl.require_ip_match (true), sqrl.nut_ttl (300 s),
|
|
20
|
+
# sqrl.cps_token_ttl (60 s), sqrl.path_extension, sqrl.poll_requires_ip_match (false;
|
|
21
|
+
# optional hardening for poll completions) and sqrl.insecure_http (false; loopback-only
|
|
22
|
+
# qrl:// testing mode, not part of SQRL).
|
|
23
|
+
|
|
24
|
+
# Ask the user a question on every login; ask_deny_button is the button that refuses
|
|
25
|
+
# it. SQRL_ASK=1 turns the example question on for testing.
|
|
26
|
+
if ENV["SQRL_ASK"].present?
|
|
27
|
+
sqrl.ask = SQRL::Ask.new(message: "Allow this login?", buttons: [{ label: "Allow" }, { label: "Deny" }],
|
|
28
|
+
accept: [1]) # only "Allow" lets the ident proceed
|
|
29
|
+
sqrl.ask_deny_button = 2 # and a "Deny" that slips through is refused at the callback
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Secret index challenge (sin=/ins=). SQRL_SIN=1 turns it on for testing.
|
|
33
|
+
sqrl.sin = "0" if ENV["SQRL_SIN"].present?
|
|
34
|
+
|
|
35
|
+
# Rate limit for the protocol endpoint and login page (per IP, in Rails.cache).
|
|
36
|
+
# sqrl.rate_limit = { to: 60, within: 1.minute }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Devise.setup do |config|
|
|
40
|
+
# What to do when an unknown SQRL identity completes a login:
|
|
41
|
+
# :create create a <%= model_name.classify %> for it (default; see sqrl_create_resource)
|
|
42
|
+
# :link only accept it while a user is signed in, and attach it to that user
|
|
43
|
+
# :refuse reject it
|
|
44
|
+
config.sqrl_on_unknown_identity = :create
|
|
45
|
+
|
|
46
|
+
# How the record is built under :create. The default fills in a non-routable email
|
|
47
|
+
# and a random password so :validatable passes; replace it to collect real details.
|
|
48
|
+
# config.sqrl_create_resource = ->(identity, klass) { klass.new(email: "...", password: SecureRandom.hex(32)) }
|
|
49
|
+
|
|
50
|
+
# Treat SQRL logins as "remember me" (needs :rememberable).
|
|
51
|
+
config.sqrl_remember_me = false
|
|
52
|
+
|
|
53
|
+
# Options for the login block (SQRL::Rack::LoginBlock.render): link text, QR size...
|
|
54
|
+
# config.sqrl_login_block = { link_text: "Sign in with SQRL", qr_options: { size: 200 } }
|
|
55
|
+
|
|
56
|
+
# Where to go after a failed SQRL login (default: the scope's SQRL login page).
|
|
57
|
+
# config.sqrl_failure_path = "/users/sign_in"
|
|
58
|
+
|
|
59
|
+
# Account policies: an identity that asked for "sqrlonly" cannot sign in through these
|
|
60
|
+
# strategies (password logins), one that asked for "hardlock" cannot reset its
|
|
61
|
+
# password. Users manage their identities at /<scope>/sqrl/identities.
|
|
62
|
+
# config.sqrl_only_blocked_strategies = [:database_authenticatable]
|
|
63
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: devise-sqrl
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Daniel P. Clark
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: devise
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.9'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '6'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '4.9'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '6'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: sqrl-rails
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 0.1.0
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 0.1.0
|
|
46
|
+
email:
|
|
47
|
+
- 6ftdan@gmail.com
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- app/controllers/devise/sqrl_controller.rb
|
|
56
|
+
- app/helpers/devise_sqrl_helper.rb
|
|
57
|
+
- app/views/devise/shared/_sqrl.html.erb
|
|
58
|
+
- app/views/devise/sqrl/identities.html.erb
|
|
59
|
+
- app/views/devise/sqrl/new.html.erb
|
|
60
|
+
- config/locales/en.yml
|
|
61
|
+
- lib/devise-sqrl.rb
|
|
62
|
+
- lib/devise/models/sqrl_authenticatable.rb
|
|
63
|
+
- lib/devise/sqrl.rb
|
|
64
|
+
- lib/devise/sqrl/engine.rb
|
|
65
|
+
- lib/devise/sqrl/hooks.rb
|
|
66
|
+
- lib/devise/sqrl/routes.rb
|
|
67
|
+
- lib/devise/strategies/sqrl_authenticatable.rb
|
|
68
|
+
- lib/generators/devise/sqrl/install/install_generator.rb
|
|
69
|
+
- lib/generators/devise/sqrl/install/templates/devise_sqrl.rb.tt
|
|
70
|
+
homepage: https://github.com/danielpclark/sqrl-ruby
|
|
71
|
+
licenses:
|
|
72
|
+
- MIT
|
|
73
|
+
metadata:
|
|
74
|
+
homepage_uri: https://github.com/danielpclark/sqrl-ruby
|
|
75
|
+
source_code_uri: https://github.com/danielpclark/sqrl-ruby/tree/main/gems/devise-sqrl
|
|
76
|
+
changelog_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/devise-sqrl/CHANGELOG.md
|
|
77
|
+
bug_tracker_uri: https://github.com/danielpclark/sqrl-ruby/issues
|
|
78
|
+
documentation_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/devise-sqrl/README.md
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '3.2'
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.6.9
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: Devise module adding SQRL login (devise :sqrl_authenticatable)
|
|
97
|
+
test_files: []
|