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 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
@@ -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)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise/sqrl"
@@ -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: []