sqrl-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/app/controllers/sqrl/rails/application_controller.rb +70 -0
- data/app/controllers/sqrl/rails/assets_controller.rb +22 -0
- data/app/controllers/sqrl/rails/callback_controller.rb +101 -0
- data/app/controllers/sqrl/rails/cli_controller.rb +18 -0
- data/app/controllers/sqrl/rails/identities_controller.rb +57 -0
- data/app/controllers/sqrl/rails/logins_controller.rb +24 -0
- data/app/controllers/sqrl/rails/refresh_controller.rb +22 -0
- data/app/controllers/sqrl/rails/status_controller.rb +21 -0
- data/app/helpers/sqrl/rails/login_helper.rb +85 -0
- data/app/models/sqrl/rails/identity.rb +48 -0
- data/app/models/sqrl/rails/nut.rb +13 -0
- data/app/models/sqrl/rails/pending_login.rb +15 -0
- data/app/views/layouts/sqrl/rails/application.html.erb +18 -0
- data/app/views/sqrl/rails/identities/index.html.erb +27 -0
- data/app/views/sqrl/rails/logins/new.html.erb +3 -0
- data/config/locales/en.yml +11 -0
- data/config/routes.rb +12 -0
- data/lib/generators/sqrl/install/install_generator.rb +131 -0
- data/lib/generators/sqrl/install/templates/create_sqrl_tables.rb.tt +56 -0
- data/lib/generators/sqrl/install/templates/initializer.rb.tt +91 -0
- data/lib/sqrl/rails/configuration.rb +145 -0
- data/lib/sqrl/rails/controller_helpers.rb +37 -0
- data/lib/sqrl/rails/engine.rb +42 -0
- data/lib/sqrl/rails/password_policy.rb +120 -0
- data/lib/sqrl/rails/rate_limited.rb +62 -0
- data/lib/sqrl/rails/sign_in.rb +80 -0
- data/lib/sqrl/rails/stores/active_record.rb +279 -0
- data/lib/sqrl/rails/stores/cache.rb +311 -0
- data/lib/sqrl/rails/user_factory.rb +41 -0
- data/lib/sqrl/rails.rb +220 -0
- data/lib/sqrl-rails.rb +3 -0
- data/lib/tasks/sqrl.rake +12 -0
- metadata +129 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# A SQRL association (+sqrl_identities+). +idk+, +suk+ and +vuk+ are base64url.
|
|
6
|
+
# Rows with +superseded_by+ set are tombstones left behind by a re-key.
|
|
7
|
+
class Identity < ::ActiveRecord::Base
|
|
8
|
+
self.table_name = "sqrl_identities"
|
|
9
|
+
|
|
10
|
+
belongs_to :user, polymorphic: true, optional: true
|
|
11
|
+
|
|
12
|
+
scope :active, -> { where(superseded_by: nil) }
|
|
13
|
+
scope :superseded, -> { where.not(superseded_by: nil) }
|
|
14
|
+
# Identities whose client asked the site to refuse non-SQRL authentication.
|
|
15
|
+
scope :sqrl_only, -> { where(sqrlonly: true) }
|
|
16
|
+
# Identities whose client asked the site to refuse non-SQRL account recovery.
|
|
17
|
+
scope :hard_locked, -> { where(hardlock: true) }
|
|
18
|
+
# Identities attached to a user record (or a +"Type:id"+ reference).
|
|
19
|
+
scope :for_user, lambda { |user|
|
|
20
|
+
ref = SQRL::Rails.user_ref(user)
|
|
21
|
+
type, id = ref.to_s.split(":", 2)
|
|
22
|
+
where(user_type: type, user_id: id)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validates :idk, presence: true, uniqueness: true
|
|
26
|
+
|
|
27
|
+
def superseded? = !superseded_by.nil?
|
|
28
|
+
|
|
29
|
+
# Raw +idk+ bytes.
|
|
30
|
+
def idk_bytes = SQRL::Util.b64u_decode(idk)
|
|
31
|
+
|
|
32
|
+
# A short, display-safe handle for the identity (the key is a public value, but a
|
|
33
|
+
# full base64url key is unwieldy in a table).
|
|
34
|
+
def short_idk = "#{idk[0, 12]}…"
|
|
35
|
+
|
|
36
|
+
# The policy flags the client set, as words.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
def flags
|
|
40
|
+
list = []
|
|
41
|
+
list << "disabled" if disabled
|
|
42
|
+
list << "SQRL only" if sqrlonly
|
|
43
|
+
list << "hard lock" if hardlock
|
|
44
|
+
list
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# An issued nut (+sqrl_nuts+). Consumed at most once; pruned by +sqrl:prune+.
|
|
6
|
+
class Nut < ::ActiveRecord::Base
|
|
7
|
+
self.table_name = "sqrl_nuts"
|
|
8
|
+
|
|
9
|
+
scope :unused, -> { where(used_at: nil) }
|
|
10
|
+
scope :expired, ->(now = Time.current) { where("expires_at <= ?", now) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# One login page view (+sqrl_pending_logins+), keyed by the opaque +login_id+ kept in
|
|
6
|
+
# the browser session. Pruned by +sqrl:prune+.
|
|
7
|
+
class PendingLogin < ::ActiveRecord::Base
|
|
8
|
+
self.table_name = "sqrl_pending_logins"
|
|
9
|
+
|
|
10
|
+
scope :expired, ->(now = Time.current) { where("expires_at <= ?", now) }
|
|
11
|
+
|
|
12
|
+
def authenticated? = !authenticated_idk.nil?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Sign in with SQRL</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: system-ui, sans-serif; max-width: 36em; margin: 3em auto; line-height: 1.5; padding: 0 1em; }
|
|
10
|
+
.sqrl-login__link { display: inline-block; padding: .6em 1.2em; background: #007cc3; color: #fff; border-radius: .4em; text-decoration: none; }
|
|
11
|
+
.sqrl-login__qr svg { max-width: 16em; height: auto; display: block; margin: 1em 0; }
|
|
12
|
+
.sqrl-alert { color: #b00020; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<%= yield %>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<h1>Your SQRL identities</h1>
|
|
2
|
+
<% if flash[:alert] %><p class="sqrl-alert" role="alert"><%= flash[:alert] %></p><% end %>
|
|
3
|
+
<% if flash[:notice] %><p class="sqrl-notice" role="status"><%= flash[:notice] %></p><% end %>
|
|
4
|
+
|
|
5
|
+
<% if @identities.empty? %>
|
|
6
|
+
<p id="sqrl-no-identities">No SQRL identity is linked to your account yet.</p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<table class="sqrl-identities">
|
|
9
|
+
<thead>
|
|
10
|
+
<tr><th>Identity</th><th>Linked</th><th>Last login</th><th>Logins</th><th>Flags</th><th></th></tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
<% @identities.each do |identity| %>
|
|
14
|
+
<tr id="sqrl-identity-<%= identity.id %>" data-sqrl-idk="<%= identity.idk %>">
|
|
15
|
+
<td><code><%= identity.short_idk %></code><% if identity.idk == @current_idk %> <em>(this session)</em><% end %></td>
|
|
16
|
+
<td><%= identity.created_at&.to_fs(:short) %></td>
|
|
17
|
+
<td><%= identity.last_login_at&.to_fs(:short) || "never" %></td>
|
|
18
|
+
<td><%= identity.logins %></td>
|
|
19
|
+
<td><%= identity.flags.join(", ") %></td>
|
|
20
|
+
<td><%= button_to "Unlink", identity_unlink_path(identity), method: :delete,
|
|
21
|
+
form: { data: { turbo_confirm: "Unlink this SQRL identity?" } } %></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% end %>
|
|
24
|
+
</tbody>
|
|
25
|
+
</table>
|
|
26
|
+
<% end %>
|
|
27
|
+
<p><%= link_to "Back", main_app.respond_to?(:root_path) ? main_app.root_path : "/" %></p>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
en:
|
|
2
|
+
sqrl:
|
|
3
|
+
password_policy:
|
|
4
|
+
sqrl_only: "This account signs in with SQRL only."
|
|
5
|
+
hard_lock: "Password recovery is disabled for this account; sign in with SQRL instead."
|
|
6
|
+
identities:
|
|
7
|
+
unlinked: "SQRL identity unlinked."
|
|
8
|
+
not_found: "That SQRL identity is not linked to your account."
|
|
9
|
+
sign_in_required: "Sign in to manage your SQRL identities."
|
|
10
|
+
callback:
|
|
11
|
+
declined: "You declined the SQRL login."
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SQRL::Rails::Engine.routes.draw do
|
|
4
|
+
post "cli", to: "cli#create", as: :cli
|
|
5
|
+
get "status", to: "status#show", as: :status
|
|
6
|
+
get "callback", to: "callback#show", as: :callback
|
|
7
|
+
get "login", to: "logins#new", as: :login
|
|
8
|
+
get "sqrl.js", to: "assets#script", as: :script
|
|
9
|
+
get "refresh", to: "refresh#show", as: :refresh
|
|
10
|
+
get "identities", to: "identities#index", as: :identities
|
|
11
|
+
delete "identities/:id", to: "identities#destroy", as: :identity_unlink
|
|
12
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
# Generator namespace (+bin/rails generate sqrl:install+).
|
|
7
|
+
module Sqrl
|
|
8
|
+
# Generator namespace.
|
|
9
|
+
module Generators
|
|
10
|
+
# `bin/rails generate sqrl:install`
|
|
11
|
+
#
|
|
12
|
+
# Adds the SQRL tables, mounts the engine, writes the initializer, links the user
|
|
13
|
+
# model and, when the application has Rails 8's generated login form, drops the SQRL
|
|
14
|
+
# login block into it. Every step is idempotent.
|
|
15
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
16
|
+
include ::ActiveRecord::Generators::Migration
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
class_option :skip_migration, type: :boolean, default: false, desc: "Do not copy the migration"
|
|
21
|
+
class_option :skip_views, type: :boolean, default: false, desc: "Do not touch app/views/sessions/new.html.erb"
|
|
22
|
+
class_option :skip_routes, type: :boolean, default: false, desc: "Do not mount the engine"
|
|
23
|
+
class_option :host, type: :string, default: nil, desc: "Public host for sqrl:// URLs (default: request host)"
|
|
24
|
+
class_option :mount, type: :string, default: "/sqrl", desc: "Where to mount the engine"
|
|
25
|
+
class_option :user_model, type: :string, default: "User", desc: "Name of the user model"
|
|
26
|
+
|
|
27
|
+
# Copy the +create_sqrl_tables+ migration unless one exists.
|
|
28
|
+
def copy_migration
|
|
29
|
+
return if options[:skip_migration]
|
|
30
|
+
|
|
31
|
+
migration_template "create_sqrl_tables.rb.tt", "db/migrate/create_sqrl_tables.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Mount the engine in +config/routes.rb+ (idempotent).
|
|
35
|
+
def mount_engine
|
|
36
|
+
return if options[:skip_routes]
|
|
37
|
+
|
|
38
|
+
line = %(mount SQRL::Rails::Engine, at: "#{options[:mount]}", as: :sqrl)
|
|
39
|
+
return say_status(:identical, "config/routes.rb already mounts the engine", :blue) if routes_include?(line)
|
|
40
|
+
|
|
41
|
+
route line
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Write +config/initializers/sqrl.rb+ with every option documented.
|
|
45
|
+
def create_initializer
|
|
46
|
+
template "initializer.rb.tt", "config/initializers/sqrl.rb"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Add +has_many :sqrl_identities+ to the user model (idempotent).
|
|
50
|
+
def link_user_model
|
|
51
|
+
path = user_model_path
|
|
52
|
+
full = File.expand_path(path, destination_root)
|
|
53
|
+
return say_status(:skip, "#{path} not found; add `has_many :sqrl_identities` yourself", :yellow) unless File.exist?(full)
|
|
54
|
+
|
|
55
|
+
association = %( has_many :sqrl_identities, class_name: "SQRL::Rails::Identity", as: :user, dependent: :destroy\n)
|
|
56
|
+
if File.read(full).include?("has_many :sqrl_identities")
|
|
57
|
+
say_status(:identical, path, :blue)
|
|
58
|
+
else
|
|
59
|
+
inject_into_class path, options[:user_model].classify, association
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Enforce the sqrlonly / hardlock account policies in the controllers that
|
|
64
|
+
# `bin/rails generate authentication` produced.
|
|
65
|
+
def include_password_policy
|
|
66
|
+
inject_policy("app/controllers/sessions_controller.rb", "SessionsController",
|
|
67
|
+
"SQRL::Rails::PasswordPolicy::Sessions")
|
|
68
|
+
inject_policy("app/controllers/passwords_controller.rb", "PasswordsController",
|
|
69
|
+
"SQRL::Rails::PasswordPolicy::Passwords")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Append the login block to +sessions/new+ when that view exists (idempotent).
|
|
73
|
+
def add_login_block_to_sessions_view
|
|
74
|
+
return if options[:skip_views]
|
|
75
|
+
|
|
76
|
+
path = "app/views/sessions/new.html.erb"
|
|
77
|
+
full = File.expand_path(path, destination_root)
|
|
78
|
+
return unless File.exist?(full)
|
|
79
|
+
|
|
80
|
+
if File.read(full).include?("sqrl_login_block")
|
|
81
|
+
say_status(:identical, path, :blue)
|
|
82
|
+
else
|
|
83
|
+
append_to_file path, "\n<hr>\n<h2>Or sign in with SQRL</h2>\n<%= sqrl_login_block %>\n"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Print what to do next.
|
|
88
|
+
def show_next_steps
|
|
89
|
+
say <<~TEXT
|
|
90
|
+
|
|
91
|
+
SQRL login is set up. Next:
|
|
92
|
+
|
|
93
|
+
bin/rails db:migrate
|
|
94
|
+
|
|
95
|
+
Then start the server and open /sessions/new (or #{options[:mount]}/login).
|
|
96
|
+
Configuration lives in config/initializers/sqrl.rb.
|
|
97
|
+
TEXT
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def inject_policy(path, klass, concern)
|
|
103
|
+
full = File.expand_path(path, destination_root)
|
|
104
|
+
return unless File.exist?(full)
|
|
105
|
+
|
|
106
|
+
if File.read(full).include?(concern)
|
|
107
|
+
say_status(:identical, path, :blue)
|
|
108
|
+
else
|
|
109
|
+
inject_into_class path, klass, " include #{concern}\n"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def user_model_path
|
|
114
|
+
"app/models/#{options[:user_model].underscore}.rb"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def routes_include?(line)
|
|
118
|
+
path = File.expand_path("config/routes.rb", destination_root)
|
|
119
|
+
File.exist?(path) && File.read(path).include?(line)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def host_option
|
|
123
|
+
options[:host]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def migration_version
|
|
127
|
+
"[#{::ActiveRecord::Migration.current_version}]"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSQRLTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :sqrl_identities do |t|
|
|
6
|
+
t.string :idk, null: false
|
|
7
|
+
t.string :suk
|
|
8
|
+
t.string :vuk
|
|
9
|
+
t.boolean :disabled, null: false, default: false
|
|
10
|
+
t.boolean :sqrlonly, null: false, default: false
|
|
11
|
+
t.boolean :hardlock, null: false, default: false
|
|
12
|
+
t.string :ins
|
|
13
|
+
t.integer :logins, null: false, default: 0
|
|
14
|
+
t.string :user_type
|
|
15
|
+
t.bigint :user_id
|
|
16
|
+
t.string :superseded_by
|
|
17
|
+
t.datetime :last_login_at
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
add_index :sqrl_identities, :idk, unique: true
|
|
21
|
+
add_index :sqrl_identities, %i[user_type user_id]
|
|
22
|
+
|
|
23
|
+
create_table :sqrl_nuts do |t|
|
|
24
|
+
t.string :nut, null: false
|
|
25
|
+
t.string :pending_login_id, null: false
|
|
26
|
+
t.text :expected_server, null: false
|
|
27
|
+
t.string :ip
|
|
28
|
+
t.string :bound_idk
|
|
29
|
+
t.datetime :expires_at
|
|
30
|
+
t.datetime :used_at
|
|
31
|
+
t.datetime :created_at, null: false
|
|
32
|
+
end
|
|
33
|
+
add_index :sqrl_nuts, :nut, unique: true
|
|
34
|
+
add_index :sqrl_nuts, :expires_at
|
|
35
|
+
|
|
36
|
+
create_table :sqrl_pending_logins do |t|
|
|
37
|
+
t.string :login_id, null: false
|
|
38
|
+
t.string :authenticated_idk
|
|
39
|
+
t.datetime :authenticated_at
|
|
40
|
+
t.integer :btn
|
|
41
|
+
t.boolean :asked, null: false, default: false
|
|
42
|
+
t.string :last_command
|
|
43
|
+
t.string :cps_token
|
|
44
|
+
t.datetime :cps_token_expires_at
|
|
45
|
+
t.string :ip
|
|
46
|
+
t.string :return_to
|
|
47
|
+
t.string :link_user_ref
|
|
48
|
+
t.boolean :ip_matched, null: false, default: false
|
|
49
|
+
t.datetime :expires_at
|
|
50
|
+
t.timestamps
|
|
51
|
+
end
|
|
52
|
+
add_index :sqrl_pending_logins, :login_id, unique: true
|
|
53
|
+
add_index :sqrl_pending_logins, :cps_token, unique: true
|
|
54
|
+
add_index :sqrl_pending_logins, :expires_at
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SQRL login (sqrl-rails). Every option is documented in SQRL::Rails::Configuration.
|
|
4
|
+
Rails.application.config.sqrl.tap do |sqrl|
|
|
5
|
+
# The public host (and port, when not the default) placed in the sqrl:// login URLs.
|
|
6
|
+
# SQRL identities are derived from this host, so it must be the name users see.
|
|
7
|
+
# When nil, the host of each request is used.
|
|
8
|
+
<% if host_option -%>
|
|
9
|
+
sqrl.host = <%= host_option.inspect %>
|
|
10
|
+
<% else -%>
|
|
11
|
+
sqrl.host = ENV["SQRL_HOST"].presence
|
|
12
|
+
<% end -%>
|
|
13
|
+
|
|
14
|
+
# The user model.
|
|
15
|
+
sqrl.user_class = <%= options[:user_model].classify.inspect %>
|
|
16
|
+
|
|
17
|
+
# What to do when an unknown SQRL identity logs in:
|
|
18
|
+
# :create create an account for it (the SQRL default; see sqrl.create_user)
|
|
19
|
+
# :link only accept it while a user is signed in, and attach it to that user
|
|
20
|
+
# :refuse reject it
|
|
21
|
+
sqrl.on_unknown_identity = :create
|
|
22
|
+
|
|
23
|
+
# How a user is created for a new identity under :create. The default
|
|
24
|
+
# (SQRL::Rails::UserFactory) fills in placeholders for the columns your model has:
|
|
25
|
+
# a random password for has_secure_password and a non-routable email address.
|
|
26
|
+
# Replace it to collect real details, for example by redirecting to a profile form.
|
|
27
|
+
# sqrl.create_user = ->(identity) { User.create!(name: "SQRL user", password: SecureRandom.hex(32)) }
|
|
28
|
+
|
|
29
|
+
# How the user is signed in. By default the Authentication concern generated by
|
|
30
|
+
# `bin/rails generate authentication` is used (start_new_session_for); without it,
|
|
31
|
+
# session[:user_id] is set. Devise users should use the devise-sqrl gem instead.
|
|
32
|
+
# sqrl.sign_in = ->(controller, identity, user) { controller.send(:start_new_session_for, user) }
|
|
33
|
+
|
|
34
|
+
# Where to go after signing in (a same-site return_to recorded at login time wins).
|
|
35
|
+
# sqrl.after_sign_in = ->(controller, completion) { controller.send(:after_authentication_url) }
|
|
36
|
+
|
|
37
|
+
# Where to go when a login cannot be completed (default: the engine's login page).
|
|
38
|
+
# sqrl.failure_path = "/session/new"
|
|
39
|
+
|
|
40
|
+
# Protocol options (see SQRL::Server::Config): sqrl.nut_ttl, sqrl.cps_token_ttl,
|
|
41
|
+
# sqrl.require_ip_match, sqrl.path_extension, sqrl.on_authenticated.
|
|
42
|
+
|
|
43
|
+
# Optional hardening: refuse to complete a login through the page's poller (no CPS
|
|
44
|
+
# token) when the SQRL client's IP did not match the browser's. Off by default because
|
|
45
|
+
# a phone on another network is a normal SQRL login.
|
|
46
|
+
# sqrl.poll_requires_ip_match = true
|
|
47
|
+
|
|
48
|
+
# Loopback-only testing mode: issue qrl:// (plain http) URLs so the browser extension's
|
|
49
|
+
# test client can log in to `bin/rails server` without TLS. qrl:// is not part of the
|
|
50
|
+
# final SQRL specification and real clients refuse it; it is rejected for any host that
|
|
51
|
+
# is not loopback.
|
|
52
|
+
# sqrl.insecure_http = Rails.env.development? && ENV["SQRL_INSECURE_HTTP"].present?
|
|
53
|
+
|
|
54
|
+
# Ask the user a question on every login (the client shows it with the buttons; the
|
|
55
|
+
# answer arrives as btn=1 or btn=2). ask_deny_button names the button that refuses
|
|
56
|
+
# the login. SQRL_ASK=1 turns the example question on for testing.
|
|
57
|
+
if ENV["SQRL_ASK"].present?
|
|
58
|
+
sqrl.ask = SQRL::Ask.new(message: "Allow this login?", buttons: [{ label: "Allow" }, { label: "Deny" }],
|
|
59
|
+
accept: [1]) # only "Allow" lets the ident proceed
|
|
60
|
+
sqrl.ask_deny_button = 2 # and a "Deny" that slips through is refused at the callback
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Secret index: the server sends sin= and the client answers with ins=, a per-site
|
|
64
|
+
# secret derived from its identity, which the site can use as an extra factor.
|
|
65
|
+
sqrl.sin = "0" if ENV["SQRL_SIN"].present?
|
|
66
|
+
|
|
67
|
+
# Rate limit for the protocol endpoint and the login page, per client IP, counted in
|
|
68
|
+
# Rails.cache. nil disables it.
|
|
69
|
+
# sqrl.rate_limit = { to: 60, within: 1.minute }
|
|
70
|
+
|
|
71
|
+
# Hosts a request may use when sqrl.host is not set (Strings or Regexps), and how many
|
|
72
|
+
# distinct request hosts are kept per process; nuts and pending logins are pruned
|
|
73
|
+
# opportunistically every prune_every handled requests.
|
|
74
|
+
# sqrl.allowed_hosts = ["example.com", /\.example\.com\z/]
|
|
75
|
+
# sqrl.host_cache_limit = 16
|
|
76
|
+
# sqrl.prune_every = 1000
|
|
77
|
+
|
|
78
|
+
# Hooks run before a login URL is issued and before a protocol request is handled;
|
|
79
|
+
# raise SQRL::Server::RateLimited to refuse (the request gets a 429).
|
|
80
|
+
# sqrl.before_handle = ->(context) { raise SQRL::Server::RateLimited.new(retry_after: 10) if Throttle.hot?(context[:ip]) }
|
|
81
|
+
|
|
82
|
+
# Storage: :active_record (default, tables from the sqrl:install migration), :cache
|
|
83
|
+
# (Rails.cache or sqrl.cache_store; needs Redis or Memcached) or :memory.
|
|
84
|
+
sqrl.stores = :active_record
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Account policies: a SQRL client can ask the site to refuse password logins (sqrlonly)
|
|
88
|
+
# and password recovery (hardlock) for its account. The generator includes
|
|
89
|
+
# SQRL::Rails::PasswordPolicy::Sessions / ::Passwords into the generated controllers to
|
|
90
|
+
# enforce them; SQRL::Rails.sqrl_only?(user) and SQRL::Rails.hard_locked?(user) are
|
|
91
|
+
# available for your own code. Users can review their identities at /sqrl/identities.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# +Rails.application.config.sqrl+. Every {SQRL::Server::Config} option is available,
|
|
6
|
+
# plus the Rails-specific settings that map a SQRL identity to a user and a user to a
|
|
7
|
+
# signed-in session.
|
|
8
|
+
#
|
|
9
|
+
# Rails.application.config.sqrl.tap do |sqrl|
|
|
10
|
+
# sqrl.host = "example.com" # public host placed in sqrl:// URLs (default: request host)
|
|
11
|
+
# sqrl.on_unknown_identity = :create # :create, :link or :refuse
|
|
12
|
+
# sqrl.sign_in = ->(controller, identity, user) { controller.send(:start_new_session_for, user) }
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
# Options forwarded unchanged to {SQRL::Server::Config}.
|
|
16
|
+
SERVER_OPTIONS = %i[insecure_http path_extension nut_ttl cps_token_ttl require_ip_match ask sin
|
|
17
|
+
on_unknown_identity on_authenticated cps_url clock logger allowed_hosts prune_every
|
|
18
|
+
before_issue before_handle].freeze
|
|
19
|
+
|
|
20
|
+
# --- protocol (see SQRL::Server::Config) ---------------------------------------
|
|
21
|
+
# Public host (with port when not default) for issued +sqrl://+ URLs. Defaults to
|
|
22
|
+
# the request's host.
|
|
23
|
+
attr_accessor :host
|
|
24
|
+
# Always +"sqrl"+ (HTTPS). Leave nil; set +insecure_http+ for the testing mode.
|
|
25
|
+
attr_accessor :scheme
|
|
26
|
+
# Loopback-only testing mode issuing +qrl://+ (plain http) URLs, for example to try
|
|
27
|
+
# the browser extension against +bin/rails server+ without TLS. +qrl://+ is not part
|
|
28
|
+
# of the final SQRL specification; real clients require +sqrl://+ over HTTPS.
|
|
29
|
+
attr_accessor :insecure_http
|
|
30
|
+
# Optional hardening (default false): a login completed through the browser's own
|
|
31
|
+
# cookie (the poll path) counts only when the SQRL client's IP matched the
|
|
32
|
+
# browser's. It also refuses legitimate logins from a phone on another network.
|
|
33
|
+
attr_accessor :poll_requires_ip_match
|
|
34
|
+
attr_accessor :path_extension, :nut_ttl, :cps_token_ttl, :require_ip_match, :ask, :sin, :on_unknown_identity, :on_authenticated, :cps_url,
|
|
35
|
+
:clock, :logger, :session_key, :identity_key
|
|
36
|
+
# Hosts (Strings or Regexps) a request may use when +host+ is not configured.
|
|
37
|
+
attr_accessor :allowed_hosts
|
|
38
|
+
# Maximum number of distinct derived request hosts kept per process (default 16).
|
|
39
|
+
attr_accessor :host_cache_limit
|
|
40
|
+
# Delete expired nuts and pending logins after every N handled requests (default
|
|
41
|
+
# 1000; nil disables opportunistic pruning).
|
|
42
|
+
attr_accessor :prune_every
|
|
43
|
+
# Callables +(context)+ run before a login URL is issued / before a protocol request
|
|
44
|
+
# is handled; raise +SQRL::Server::RateLimited+ to refuse (answered with 429).
|
|
45
|
+
attr_accessor :before_issue, :before_handle
|
|
46
|
+
|
|
47
|
+
# When the site poses an +ask+ (see +ask+), the number of the button that means
|
|
48
|
+
# "do not log me in" (buttons are numbered from 1). A login completed with that
|
|
49
|
+
# answer is refused at the callback. +nil+ accepts every answer.
|
|
50
|
+
attr_accessor :ask_deny_button
|
|
51
|
+
|
|
52
|
+
# --- storage ---------------------------------------------------------------------
|
|
53
|
+
# +:active_record+ (default), +:cache+ (see +cache_store+), +:memory+, or an
|
|
54
|
+
# +SQRL::Server::Stores+.
|
|
55
|
+
attr_accessor :stores
|
|
56
|
+
# The +ActiveSupport::Cache::Store+ used by +stores = :cache+ (default +Rails.cache+).
|
|
57
|
+
# It must delete atomically (Redis, Memcached); see {Stores::Cache}.
|
|
58
|
+
attr_accessor :cache_store
|
|
59
|
+
|
|
60
|
+
# --- users -----------------------------------------------------------------------
|
|
61
|
+
# Name of the user model (+"User"+).
|
|
62
|
+
attr_accessor :user_class
|
|
63
|
+
# Callable +(identity) -> user or nil+ resolving the record an identity belongs to.
|
|
64
|
+
attr_accessor :find_user
|
|
65
|
+
# Callable +(identity) -> user+ creating a user for a brand-new identity
|
|
66
|
+
# (+on_unknown_identity = :create+). Defaults to {UserFactory}.
|
|
67
|
+
attr_accessor :create_user
|
|
68
|
+
# Callable +(controller) -> user or nil+ giving the signed-in user, used to link new
|
|
69
|
+
# identities (+:link+). Defaults to +Current.user+ when the host defines it.
|
|
70
|
+
attr_accessor :current_user
|
|
71
|
+
# Callable +(controller, identity, user)+ that signs the user in. Defaults to
|
|
72
|
+
# {SignIn.default}: +start_new_session_for+ from Rails' generated
|
|
73
|
+
# +Authentication+ concern when present, otherwise +session[:user_id]+.
|
|
74
|
+
attr_accessor :sign_in
|
|
75
|
+
# Callable +(controller, completion) -> String+ giving the redirect target after
|
|
76
|
+
# sign-in; a same-site +return_to+ recorded on the pending login wins.
|
|
77
|
+
attr_accessor :after_sign_in
|
|
78
|
+
# Where to send the browser when the callback cannot complete a login.
|
|
79
|
+
attr_accessor :failure_path
|
|
80
|
+
|
|
81
|
+
# --- plumbing --------------------------------------------------------------------
|
|
82
|
+
# Mount path of the engine; auto-detected from the routes when nil.
|
|
83
|
+
attr_accessor :mount_path
|
|
84
|
+
# Callable +(request) -> String+ resolving the client IP (default +remote_ip+).
|
|
85
|
+
attr_accessor :ip
|
|
86
|
+
# Rate limit for the protocol endpoint and the login page, as
|
|
87
|
+
# +{ to: 60, within: 1.minute }+ (per client IP, counted in +Rails.cache+); +nil+
|
|
88
|
+
# disables it. Optional keys: +by:+ a callable +(request) -> String+, +cache:+ a
|
|
89
|
+
# cache store, +only:+ an array of +:cli+, +:login+, +:status+, +:callback+.
|
|
90
|
+
attr_accessor :rate_limit
|
|
91
|
+
|
|
92
|
+
def initialize
|
|
93
|
+
@host = nil
|
|
94
|
+
@scheme = nil
|
|
95
|
+
@insecure_http = false
|
|
96
|
+
@poll_requires_ip_match = false
|
|
97
|
+
@path_extension = nil
|
|
98
|
+
@nut_ttl = 300
|
|
99
|
+
@cps_token_ttl = 60
|
|
100
|
+
@require_ip_match = true
|
|
101
|
+
@ask = nil
|
|
102
|
+
@sin = nil
|
|
103
|
+
@ask_deny_button = nil
|
|
104
|
+
@allowed_hosts = nil
|
|
105
|
+
@host_cache_limit = nil
|
|
106
|
+
@prune_every = nil
|
|
107
|
+
@before_issue = nil
|
|
108
|
+
@before_handle = nil
|
|
109
|
+
@on_unknown_identity = :create
|
|
110
|
+
@on_authenticated = nil
|
|
111
|
+
@cps_url = nil
|
|
112
|
+
@clock = nil
|
|
113
|
+
@logger = nil
|
|
114
|
+
@stores = :active_record
|
|
115
|
+
@cache_store = nil
|
|
116
|
+
@user_class = "User"
|
|
117
|
+
@find_user = nil
|
|
118
|
+
@create_user = nil
|
|
119
|
+
@current_user = nil
|
|
120
|
+
@sign_in = nil
|
|
121
|
+
@after_sign_in = nil
|
|
122
|
+
@failure_path = nil
|
|
123
|
+
@mount_path = nil
|
|
124
|
+
@session_key = SQRL::Rack::Endpoint::SESSION_KEY
|
|
125
|
+
@identity_key = SQRL::Rack::Endpoint::IDENTITY_KEY
|
|
126
|
+
@ip = nil
|
|
127
|
+
@rate_limit = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Keyword options for {SQRL::Server::Config} (host, scheme and paths are added by
|
|
131
|
+
# the engine per request).
|
|
132
|
+
def server_options
|
|
133
|
+
SERVER_OPTIONS.each_with_object({}) do |key, opts|
|
|
134
|
+
value = public_send(key)
|
|
135
|
+
opts[key] = value unless value.nil?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The user model class.
|
|
140
|
+
def user_model
|
|
141
|
+
user_class.is_a?(Class) ? user_class : user_class.to_s.constantize
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rails
|
|
5
|
+
# Included into +ActionController::Base+ of the host application.
|
|
6
|
+
module ControllerHelpers
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
helper_method :current_sqrl_identity, :sqrl_authenticated? if respond_to?(:helper_method)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The {SQRL::Rack::Endpoint} for this request.
|
|
14
|
+
def sqrl_endpoint
|
|
15
|
+
SQRL::Rails.endpoint_for(request)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The SQRL identity record this browser session logged in with, or nil.
|
|
19
|
+
def current_sqrl_identity
|
|
20
|
+
return @current_sqrl_identity if defined?(@current_sqrl_identity)
|
|
21
|
+
|
|
22
|
+
@current_sqrl_identity = sqrl_endpoint.current_identity(request)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sqrl_authenticated?
|
|
26
|
+
!current_sqrl_identity.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Forget the SQRL login in this browser session (call it from your logout action).
|
|
30
|
+
def sqrl_logout!
|
|
31
|
+
sqrl_endpoint.logout(request)
|
|
32
|
+
remove_instance_variable(:@current_sqrl_identity) if defined?(@current_sqrl_identity)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The engine's app/ directories are namespaced SQRL::Rails; teach the inflector the acronym
|
|
4
|
+
# so Zeitwerk maps app/models/sqrl/rails/identity.rb to SQRL::Rails::Identity.
|
|
5
|
+
ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym "SQRL" }
|
|
6
|
+
|
|
7
|
+
module SQRL
|
|
8
|
+
module Rails
|
|
9
|
+
# The Rails engine. Mount it in the host application:
|
|
10
|
+
#
|
|
11
|
+
# mount SQRL::Rails::Engine, at: "/sqrl", as: :sqrl
|
|
12
|
+
#
|
|
13
|
+
# It serves +POST /sqrl/cli+ (the SQRL endpoint), +GET /sqrl/status+ (cookie-bound
|
|
14
|
+
# poll), +GET /sqrl/callback+ (login completion), +GET /sqrl/login+ (a stand-alone
|
|
15
|
+
# login page) and +GET /sqrl/sqrl.js+.
|
|
16
|
+
class Engine < ::Rails::Engine
|
|
17
|
+
isolate_namespace SQRL::Rails
|
|
18
|
+
engine_name "sqrl"
|
|
19
|
+
|
|
20
|
+
config.sqrl = Configuration.new
|
|
21
|
+
|
|
22
|
+
initializer "sqrl.helpers" do
|
|
23
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
24
|
+
include SQRL::Rails::ControllerHelpers
|
|
25
|
+
|
|
26
|
+
helper SQRL::Rails::LoginHelper
|
|
27
|
+
end
|
|
28
|
+
ActiveSupport.on_load(:action_view) do
|
|
29
|
+
include SQRL::Rails::LoginHelper
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
initializer "sqrl.reset_on_reload" do |app|
|
|
34
|
+
app.reloader.to_prepare { SQRL::Rails.reset! } if app.respond_to?(:reloader)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rake_tasks do
|
|
38
|
+
load File.expand_path("../../tasks/sqrl.rake", __dir__)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|