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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE +21 -0
  4. data/README.md +196 -0
  5. data/app/controllers/sqrl/rails/application_controller.rb +70 -0
  6. data/app/controllers/sqrl/rails/assets_controller.rb +22 -0
  7. data/app/controllers/sqrl/rails/callback_controller.rb +101 -0
  8. data/app/controllers/sqrl/rails/cli_controller.rb +18 -0
  9. data/app/controllers/sqrl/rails/identities_controller.rb +57 -0
  10. data/app/controllers/sqrl/rails/logins_controller.rb +24 -0
  11. data/app/controllers/sqrl/rails/refresh_controller.rb +22 -0
  12. data/app/controllers/sqrl/rails/status_controller.rb +21 -0
  13. data/app/helpers/sqrl/rails/login_helper.rb +85 -0
  14. data/app/models/sqrl/rails/identity.rb +48 -0
  15. data/app/models/sqrl/rails/nut.rb +13 -0
  16. data/app/models/sqrl/rails/pending_login.rb +15 -0
  17. data/app/views/layouts/sqrl/rails/application.html.erb +18 -0
  18. data/app/views/sqrl/rails/identities/index.html.erb +27 -0
  19. data/app/views/sqrl/rails/logins/new.html.erb +3 -0
  20. data/config/locales/en.yml +11 -0
  21. data/config/routes.rb +12 -0
  22. data/lib/generators/sqrl/install/install_generator.rb +131 -0
  23. data/lib/generators/sqrl/install/templates/create_sqrl_tables.rb.tt +56 -0
  24. data/lib/generators/sqrl/install/templates/initializer.rb.tt +91 -0
  25. data/lib/sqrl/rails/configuration.rb +145 -0
  26. data/lib/sqrl/rails/controller_helpers.rb +37 -0
  27. data/lib/sqrl/rails/engine.rb +42 -0
  28. data/lib/sqrl/rails/password_policy.rb +120 -0
  29. data/lib/sqrl/rails/rate_limited.rb +62 -0
  30. data/lib/sqrl/rails/sign_in.rb +80 -0
  31. data/lib/sqrl/rails/stores/active_record.rb +279 -0
  32. data/lib/sqrl/rails/stores/cache.rb +311 -0
  33. data/lib/sqrl/rails/user_factory.rb +41 -0
  34. data/lib/sqrl/rails.rb +220 -0
  35. data/lib/sqrl-rails.rb +3 -0
  36. data/lib/tasks/sqrl.rake +12 -0
  37. 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,3 @@
1
+ <h1>Sign in with SQRL</h1>
2
+ <% if flash[:alert] %><p class="sqrl-alert" role="alert"><%= flash[:alert] %></p><% end %>
3
+ <%= sqrl_login_block(return_to: @return_to) %>
@@ -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