rails_authentication 0.3.0 → 0.5.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 +4 -4
  2. data/README.md +83 -14
  3. data/lib/generators/authentication/authentication_generator.rb +31 -0
  4. data/lib/generators/authentication/features/confirmable.rb +13 -12
  5. data/lib/generators/authentication/features/invitable.rb +14 -13
  6. data/lib/generators/authentication/features/lockable.rb +13 -12
  7. data/lib/generators/authentication/features/magic_link.rb +13 -12
  8. data/lib/generators/authentication/features/ott.rb +31 -0
  9. data/lib/generators/authentication/features/passkey.rb +36 -0
  10. data/lib/generators/authentication/features/recoverable.rb +13 -12
  11. data/lib/generators/authentication/features/registerable.rb +7 -6
  12. data/lib/generators/authentication/features/rememberable.rb +6 -5
  13. data/lib/generators/authentication/features/timeoutable.rb +7 -6
  14. data/lib/generators/authentication/features/trackable.rb +7 -6
  15. data/lib/generators/authentication/features/validatable.rb +5 -4
  16. data/lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt +95 -0
  17. data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt +68 -0
  18. data/lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt +4 -0
  19. data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt +43 -0
  20. data/lib/generators/authentication/templates/app/mailers/otts_mailer.rb.tt +6 -0
  21. data/lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt +18 -17
  22. data/lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt +50 -0
  23. data/lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt +19 -0
  24. data/lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt +6 -0
  25. data/lib/generators/authentication/templates/app/views/otts/edit.html.erb.tt +79 -0
  26. data/lib/generators/authentication/templates/app/views/otts_mailer/ott.html.erb.tt +8 -0
  27. data/lib/generators/authentication/templates/app/views/otts_mailer/ott.text.erb.tt +6 -0
  28. data/lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt +4 -0
  29. data/lib/generators/authentication/templates/app/views/sessions/new.html.erb.tt +81 -0
  30. data/lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt +19 -0
  31. data/lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt +71 -0
  32. data/lib/generators/authentication/templates/config/initializers/webauthn.rb.tt +11 -0
  33. data/lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt +7 -0
  34. data/lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt +7 -0
  35. data/lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt +15 -0
  36. data/lib/rails_authentication/version.rb +1 -1
  37. metadata +19 -1
@@ -0,0 +1,95 @@
1
+ class OttsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: %i[ create update ], with: -> { redirect_to new_session_path, alert: "Try again later." }
4
+
5
+ def create
6
+ email_address = params[:email_address].presence || session[:ott_email_address]
7
+ user = User.find_by(email_address: email_address)
8
+ user&.send_ott
9
+ session[:ott_email_address] = email_address
10
+
11
+ redirect_to edit_ott_path, notice: "We've emailed you a sign-in code (if a user with that email address exists)."
12
+ end
13
+
14
+ def edit
15
+ redirect_to new_session_path if session[:ott_email_address].blank?
16
+ end
17
+
18
+ def update
19
+ user = User.find_by(email_address: session[:ott_email_address])
20
+
21
+ case user && user.verify_ott(params[:code])
22
+ when nil, :expired
23
+ redirect_to new_session_path, alert: "Your sign-in code is invalid or has expired. Request a new one."
24
+ when :exhausted
25
+ <% if trackable? -%>
26
+ record_authentication_attempt(user, success: false, failure_reason: "ott_attempts_exhausted")
27
+ <% end -%>
28
+ redirect_to new_session_path, alert: "Too many incorrect attempts. Request a new code."
29
+ when :invalid
30
+ <% if trackable? -%>
31
+ record_authentication_attempt(user, success: false, failure_reason: "invalid_ott")
32
+ <% end -%>
33
+ redirect_to edit_ott_path, alert: "Incorrect code. Try again."
34
+ else
35
+ complete_ott_sign_in(user)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def complete_ott_sign_in(user)
42
+ <% if lockable? -%>
43
+ if user.locked?
44
+ <% if trackable? -%>
45
+ record_authentication_attempt(user, success: false, failure_reason: "locked")
46
+ <% end -%>
47
+ return redirect_to new_session_path, alert: "Your account is locked. Check your email for unlock instructions."
48
+ end
49
+ <% end -%>
50
+ <% if confirmable? -%>
51
+ unless user.confirmed?
52
+ <% if trackable? -%>
53
+ record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
54
+ <% end -%>
55
+ return redirect_to new_session_path, alert: "You must confirm your email address before signing in."
56
+ end
57
+ <% end -%>
58
+ <% if invitable? -%>
59
+ if user.invitation_pending?
60
+ <% if trackable? -%>
61
+ record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
62
+ <% end -%>
63
+ return redirect_to new_session_path, alert: "You must accept your invitation before signing in."
64
+ end
65
+ <% end -%>
66
+ # Add your own authentication restrictions specific to your application
67
+ # if user.deleted_at.present?
68
+ <% if trackable? -%>
69
+ # record_authentication_attempt(user, success: false, failure_reason: "soft_deleted")
70
+ <% end -%>
71
+ # return redirect_to new_session_path, alert: "Your account is not available."
72
+ # end
73
+
74
+ user.consume_ott!
75
+ session.delete(:ott_email_address)
76
+ <% if lockable? -%>
77
+ user.reset_failed_attempts!
78
+ <% end -%>
79
+ <% if trackable? -%>
80
+ record_authentication_attempt(user, success: true)
81
+ <% end -%>
82
+ <% if rememberable? -%>
83
+ start_new_session_for user, remember: params[:remember_me] == "1"
84
+ <% else -%>
85
+ start_new_session_for user
86
+ <% end -%>
87
+ redirect_to after_authentication_url
88
+ end
89
+ <% if trackable? -%>
90
+
91
+ def record_authentication_attempt(user, success:, failure_reason: nil)
92
+ UserAuth.record(user, request, success: success, failure_reason: failure_reason)
93
+ end
94
+ <% end -%>
95
+ end
@@ -0,0 +1,68 @@
1
+ class PasskeySessionsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: { error: "Try again later." }, status: :too_many_requests }
4
+
5
+ def create
6
+ webauthn_credential = WebAuthn::Credential.from_get(params)
7
+ stored_credential = WebauthnCredential.find_by(external_id: webauthn_credential.id)
8
+
9
+ if stored_credential.nil?
10
+ return render json: { error: "Passkey not recognized." }, status: :unprocessable_content
11
+ end
12
+
13
+ begin
14
+ webauthn_credential.verify(
15
+ session[:webauthn_authentication_challenge],
16
+ public_key: stored_credential.public_key,
17
+ sign_count: stored_credential.sign_count
18
+ )
19
+ rescue WebAuthn::Error
20
+ return render json: { error: "Passkey verification failed." }, status: :unprocessable_content
21
+ end
22
+
23
+ stored_credential.update!(sign_count: webauthn_credential.sign_count)
24
+ user = stored_credential.user
25
+ session.delete(:webauthn_authentication_challenge)
26
+
27
+ <% if lockable? -%>
28
+ if user.locked?
29
+ <% if trackable? -%>
30
+ record_authentication_attempt(user, success: false, failure_reason: "locked")
31
+ <% end -%>
32
+ return render json: { error: "Your account is locked. Check your email for unlock instructions." }, status: :unprocessable_content
33
+ end
34
+ <% end -%>
35
+ <% if confirmable? -%>
36
+ unless user.confirmed?
37
+ <% if trackable? -%>
38
+ record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
39
+ <% end -%>
40
+ return render json: { error: "You must confirm your email address before signing in." }, status: :unprocessable_content
41
+ end
42
+ <% end -%>
43
+ <% if invitable? -%>
44
+ if user.invitation_pending?
45
+ <% if trackable? -%>
46
+ record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
47
+ <% end -%>
48
+ return render json: { error: "You must accept your invitation before signing in." }, status: :unprocessable_content
49
+ end
50
+ <% end -%>
51
+ <% if lockable? -%>
52
+ user.reset_failed_attempts!
53
+ <% end -%>
54
+ <% if trackable? -%>
55
+ record_authentication_attempt(user, success: true)
56
+ <% end -%>
57
+ start_new_session_for user
58
+ render json: { redirect_to: after_authentication_url }
59
+ end
60
+ <% if trackable? -%>
61
+
62
+ private
63
+
64
+ def record_authentication_attempt(user, success:, failure_reason: nil)
65
+ UserAuth.record(user, request, success: success, failure_reason: failure_reason)
66
+ end
67
+ <% end -%>
68
+ end
@@ -3,6 +3,10 @@ class SessionsController < ApplicationController
3
3
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
4
4
 
5
5
  def new
6
+ <% if passkey? -%>
7
+ @webauthn_credential_options = WebAuthn::Credential.options_for_get(user_verification: "required")
8
+ session[:webauthn_authentication_challenge] = @webauthn_credential_options.challenge
9
+ <% end -%>
6
10
  end
7
11
 
8
12
  def create
@@ -0,0 +1,43 @@
1
+ class WebauthnCredentialsController < ApplicationController
2
+ before_action :set_webauthn_credential, only: :destroy
3
+
4
+ def index
5
+ @webauthn_credentials = current_user.webauthn_credentials.order(:created_at)
6
+ end
7
+
8
+ def new
9
+ @webauthn_credential_options = WebAuthn::Credential.options_for_create(
10
+ user: { id: current_user.webauthn_id, name: current_user.email_address },
11
+ exclude: current_user.webauthn_credentials.pluck(:external_id)
12
+ )
13
+ session[:webauthn_registration_challenge] = @webauthn_credential_options.challenge
14
+ end
15
+
16
+ def create
17
+ webauthn_credential = WebAuthn::Credential.from_create(params)
18
+ webauthn_credential.verify(session[:webauthn_registration_challenge])
19
+
20
+ current_user.webauthn_credentials.create!(
21
+ external_id: webauthn_credential.id,
22
+ public_key: webauthn_credential.public_key,
23
+ sign_count: webauthn_credential.sign_count,
24
+ nickname: params[:nickname].presence || "Passkey"
25
+ )
26
+ session.delete(:webauthn_registration_challenge)
27
+
28
+ render json: { redirect_to: webauthn_credentials_path }
29
+ rescue WebAuthn::Error => e
30
+ render json: { error: e.message }, status: :unprocessable_content
31
+ end
32
+
33
+ def destroy
34
+ @webauthn_credential.destroy
35
+ redirect_to webauthn_credentials_path, notice: "Passkey removed."
36
+ end
37
+
38
+ private
39
+
40
+ def set_webauthn_credential
41
+ @webauthn_credential = current_user.webauthn_credentials.find(params[:id])
42
+ end
43
+ end
@@ -0,0 +1,6 @@
1
+ class OttsMailer < ApplicationMailer
2
+ def ott(user)
3
+ @user = user
4
+ mail subject: "Your sign-in code", to: user.email_address
5
+ end
6
+ end
@@ -47,28 +47,29 @@ module ConfirmableConcern
47
47
  def generate_confirmation_token!
48
48
  update!(confirmation_token: generate_confirmable_token, confirmation_sent_at: Time.current)
49
49
  end
50
+
51
+ protected
52
+
53
+ def generate_confirmable_token
54
+ SecureRandom.urlsafe_base64(32)
55
+ end
50
56
  <% if reconfirmable? -%>
51
57
 
52
58
  private
53
- def postponing_email_address_change?
54
- !@confirming && email_address_changed? && confirmed?
55
- end
56
-
57
- def postpone_email_address_change
58
- self.unconfirmed_email = email_address
59
- self.email_address = email_address_in_database
60
- self.confirmation_token = generate_confirmable_token
61
- self.confirmation_sent_at = Time.current
62
- end
63
59
 
64
- def deliver_reconfirmation_instructions
65
- ConfirmationsMailer.confirmation_instructions(self).deliver_later
66
- end
67
- <% end -%>
60
+ def postponing_email_address_change?
61
+ !@confirming && email_address_changed? && confirmed?
62
+ end
68
63
 
69
- protected
64
+ def postpone_email_address_change
65
+ self.unconfirmed_email = email_address
66
+ self.email_address = email_address_in_database
67
+ self.confirmation_token = generate_confirmable_token
68
+ self.confirmation_sent_at = Time.current
69
+ end
70
70
 
71
- def generate_confirmable_token
72
- SecureRandom.urlsafe_base64(32)
71
+ def deliver_reconfirmation_instructions
72
+ ConfirmationsMailer.confirmation_instructions(self).deliver_later
73
73
  end
74
+ <% end -%>
74
75
  end
@@ -0,0 +1,50 @@
1
+ module OttConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ OTT_EXPIRES_IN = 10.minutes
5
+ OTT_MAX_ATTEMPTS = 5
6
+ OTT_CODE_LENGTH = 6
7
+
8
+ def send_ott
9
+ generate_ott!
10
+ OttsMailer.ott(self).deliver_later
11
+ end
12
+
13
+ def generate_ott!
14
+ update!(ott_code: build_ott_code, ott_sent_at: Time.current, ott_attempts: 0)
15
+ end
16
+
17
+ # Returns :valid, :invalid, :exhausted, or :expired. Codes are single-use:
18
+ # consumed on successful sign-in, and voided once OTT_MAX_ATTEMPTS wrong
19
+ # codes have been entered.
20
+ def verify_ott(code)
21
+ return :expired unless ott_active?
22
+
23
+ if ActiveSupport::SecurityUtils.secure_compare(ott_code, code.to_s)
24
+ :valid
25
+ else
26
+ increment!(:ott_attempts)
27
+
28
+ if ott_attempts >= OTT_MAX_ATTEMPTS
29
+ consume_ott!
30
+ :exhausted
31
+ else
32
+ :invalid
33
+ end
34
+ end
35
+ end
36
+
37
+ def consume_ott!
38
+ update!(ott_code: nil, ott_sent_at: nil, ott_attempts: 0)
39
+ end
40
+
41
+ protected
42
+
43
+ def ott_active?
44
+ ott_code.present? && ott_sent_at&.after?(OTT_EXPIRES_IN.ago)
45
+ end
46
+
47
+ def build_ott_code
48
+ SecureRandom.random_number(10**OTT_CODE_LENGTH).to_s.rjust(OTT_CODE_LENGTH, "0")
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ module PasskeyConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ has_many :webauthn_credentials, dependent: :destroy
6
+
7
+ before_validation :ensure_webauthn_id, on: :create
8
+ end
9
+
10
+ def passkeys?
11
+ webauthn_credentials.exists?
12
+ end
13
+
14
+ private
15
+
16
+ def ensure_webauthn_id
17
+ self.webauthn_id ||= WebAuthn.generate_user_id
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ class WebauthnCredential < ApplicationRecord
2
+ belongs_to :user
3
+
4
+ validates :external_id, presence: true, uniqueness: true
5
+ validates :public_key, presence: true
6
+ end
@@ -0,0 +1,79 @@
1
+ <h1>Enter your sign-in code</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
5
+
6
+ <p>
7
+ We emailed a <%%= OttConcern::OTT_CODE_LENGTH %>-digit code to <%%= session[:ott_email_address] %>.
8
+ It expires in <%%= distance_of_time_in_words(0, OttConcern::OTT_EXPIRES_IN) %>.
9
+ </p>
10
+
11
+ <%%= form_with url: ott_path, method: :patch do |form| %>
12
+ <%%= form.hidden_field :code, id: "ott-code" %>
13
+ <div id="ott-digits">
14
+ <%% OttConcern::OTT_CODE_LENGTH.times do |i| %>
15
+ <input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="<%%= OttConcern::OTT_CODE_LENGTH %>" required
16
+ autocomplete="<%%= i.zero? ? "one-time-code" : "off" %>"
17
+ aria-label="Digit <%%= i + 1 %>" <%%= "autofocus" if i.zero? %>
18
+ style="width:2em;text-align:center;font-size:1.5em">
19
+ <%% end %>
20
+ </div>
21
+ <br>
22
+ <% if rememberable? -%>
23
+ <%%= form.check_box :remember_me %> <%%= form.label :remember_me, "Remember me" %><br>
24
+ <% end -%>
25
+ <%%= form.submit "Sign in" %>
26
+ <%% end %>
27
+ <br>
28
+
29
+ <%%= button_to "Resend code", ott_path, method: :post %>
30
+
31
+ <script>
32
+ (function() {
33
+ var boxes = Array.prototype.slice.call(document.querySelectorAll("#ott-digits input"));
34
+ var hidden = document.getElementById("ott-code");
35
+
36
+ function sync() {
37
+ hidden.value = boxes.map(function(box) { return box.value; }).join("");
38
+ }
39
+
40
+ // Distributes a string of digits across the boxes, starting at `start`.
41
+ function fill(text, start) {
42
+ var digits = text.replace(/\D/g, "").slice(0, boxes.length - start).split("");
43
+ digits.forEach(function(digit, i) { boxes[start + i].value = digit; });
44
+ boxes[Math.min(start + digits.length, boxes.length - 1)].focus();
45
+ sync();
46
+ }
47
+
48
+ boxes.forEach(function(box, i) {
49
+ box.addEventListener("input", function() {
50
+ // A multi-character value means a paste or OS one-time-code autofill.
51
+ if (box.value.length > 1) return fill(box.value, i);
52
+
53
+ box.value = box.value.replace(/\D/g, "");
54
+ if (box.value && i < boxes.length - 1) boxes[i + 1].focus();
55
+ sync();
56
+ });
57
+
58
+ box.addEventListener("keydown", function(event) {
59
+ if (event.key === "Backspace" && !box.value && i > 0) {
60
+ event.preventDefault();
61
+ boxes[i - 1].value = "";
62
+ boxes[i - 1].focus();
63
+ sync();
64
+ } else if (event.key === "ArrowLeft" && i > 0) {
65
+ event.preventDefault();
66
+ boxes[i - 1].focus();
67
+ } else if (event.key === "ArrowRight" && i < boxes.length - 1) {
68
+ event.preventDefault();
69
+ boxes[i + 1].focus();
70
+ }
71
+ });
72
+
73
+ box.addEventListener("paste", function(event) {
74
+ event.preventDefault();
75
+ fill((event.clipboardData || window.clipboardData).getData("text"), 0);
76
+ });
77
+ });
78
+ })();
79
+ </script>
@@ -0,0 +1,8 @@
1
+ <p>Your sign-in code is:</p>
2
+
3
+ <p><strong><%%= @user.ott_code %></strong></p>
4
+
5
+ <p>
6
+ This code will expire in <%%= distance_of_time_in_words(0, OttConcern::OTT_EXPIRES_IN) %> and can only be used once.
7
+ If you didn't request it, you can ignore this email.
8
+ </p>
@@ -0,0 +1,6 @@
1
+ Your sign-in code is:
2
+
3
+ <%%= @user.ott_code %>
4
+
5
+ This code will expire in <%%= distance_of_time_in_words(0, OttConcern::OTT_EXPIRES_IN) %> and can only be used once.
6
+ If you didn't request it, you can ignore this email.
@@ -19,4 +19,8 @@
19
19
  <%% end %>
20
20
  <br>
21
21
 
22
+ <% if passkey? -%>
23
+ <%%= link_to "Manage your passkeys", webauthn_credentials_path %>
24
+ <br><br>
25
+ <% end -%>
22
26
  <%%= button_to "Delete my account", registration_path, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
@@ -1,6 +1,17 @@
1
1
  <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
2
2
  <%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
3
3
 
4
+ <% if ott? -%>
5
+ <%% if params[:with_password].blank? %>
6
+ <%%= form_with url: ott_path do |form| %>
7
+ <%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
8
+ <%%= form.submit "Email me a sign-in code" %>
9
+ <%% end %>
10
+ <br>
11
+
12
+ <br><%%= link_to "Sign in with password instead", new_session_path(with_password: 1) %>
13
+ <%% else %>
14
+ <% end -%>
4
15
  <%%= form_with url: session_path do |form| %>
5
16
  <%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
6
17
  <%%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
@@ -10,10 +21,80 @@
10
21
  <%%= form.submit "Sign in" %>
11
22
  <%% end %>
12
23
  <br>
24
+ <% if ott? -%>
25
+
26
+ <br><%%= link_to "Sign in with a one-time code instead", new_session_path %>
27
+ <%% end %>
28
+ <% end -%>
13
29
 
14
30
  <% if magic_link? -%>
15
31
  <br><%%= link_to "Sign in with magic link", new_magic_link_path %>
16
32
  <% end -%>
33
+ <% if passkey? -%>
34
+ <br><%%= button_tag "Sign in with a passkey", type: "button", id: "passkey-sign-in-button" %>
35
+ <%%= tag.span "", id: "passkey-sign-in-error", style: "color:red" %>
36
+
37
+ <%%= javascript_tag do %>
38
+ (function () {
39
+ function bufferEncode(value) {
40
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
41
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
42
+ }
43
+
44
+ function bufferDecode(value) {
45
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
46
+ const raw = window.atob(padded);
47
+ const buffer = new Uint8Array(raw.length);
48
+ for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
49
+ return buffer.buffer;
50
+ }
51
+
52
+ document.getElementById("passkey-sign-in-button").addEventListener("click", async function () {
53
+ const options = <%%= raw @webauthn_credential_options.to_json %>;
54
+ options.challenge = bufferDecode(options.challenge);
55
+ if (options.allowCredentials) {
56
+ options.allowCredentials = options.allowCredentials.map(function (cred) {
57
+ return Object.assign({}, cred, { id: bufferDecode(cred.id) });
58
+ });
59
+ }
60
+
61
+ let credential;
62
+ try {
63
+ credential = await navigator.credentials.get({ publicKey: options });
64
+ } catch (error) {
65
+ document.getElementById("passkey-sign-in-error").textContent = error.message;
66
+ return;
67
+ }
68
+
69
+ const response = await fetch("<%%= passkey_session_path %>", {
70
+ method: "POST",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
74
+ },
75
+ body: JSON.stringify({
76
+ id: credential.id,
77
+ rawId: bufferEncode(credential.rawId),
78
+ type: credential.type,
79
+ response: {
80
+ clientDataJSON: bufferEncode(credential.response.clientDataJSON),
81
+ authenticatorData: bufferEncode(credential.response.authenticatorData),
82
+ signature: bufferEncode(credential.response.signature),
83
+ userHandle: credential.response.userHandle ? bufferEncode(credential.response.userHandle) : null
84
+ }
85
+ })
86
+ });
87
+
88
+ const result = await response.json();
89
+ if (response.ok) {
90
+ window.location.href = result.redirect_to;
91
+ } else {
92
+ document.getElementById("passkey-sign-in-error").textContent = result.error || "Could not sign in with passkey.";
93
+ }
94
+ });
95
+ })();
96
+ <%% end %>
97
+ <% end -%>
17
98
  <br><%%= link_to "Forgot password?", new_password_path %>
18
99
  <% if registerable? -%>
19
100
  <br><%%= link_to "Sign up", new_registration_path %>
@@ -0,0 +1,19 @@
1
+ <h1>Your passkeys</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
5
+
6
+ <%% if @webauthn_credentials.empty? %>
7
+ <p>You haven't added a passkey yet.</p>
8
+ <%% else %>
9
+ <ul>
10
+ <%% @webauthn_credentials.each do |credential| %>
11
+ <li>
12
+ <%%= credential.nickname %> &mdash; added <%%= credential.created_at.to_date %>
13
+ <%%= button_to "Remove", webauthn_credential_path(credential), method: :delete, data: { turbo_confirm: "Remove this passkey?" } %>
14
+ </li>
15
+ <%% end %>
16
+ </ul>
17
+ <%% end %>
18
+
19
+ <br><%%= link_to "Add a passkey", new_webauthn_credential_path %>
@@ -0,0 +1,71 @@
1
+ <h1>Add a passkey</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.span "", id: "passkey-error", style: "color:red" %>
5
+
6
+ <%%= label_tag :passkey_nickname, "Name this passkey (e.g. \"MacBook\" or \"iPhone\")" %>
7
+ <%%= text_field_tag :passkey_nickname, nil, placeholder: "Passkey" %><br><br>
8
+
9
+ <%%= button_tag "Create passkey", type: "button", id: "create-passkey-button" %>
10
+
11
+ <%%= javascript_tag do %>
12
+ (function () {
13
+ function bufferEncode(value) {
14
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
15
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
16
+ }
17
+
18
+ function bufferDecode(value) {
19
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
20
+ const raw = window.atob(padded);
21
+ const buffer = new Uint8Array(raw.length);
22
+ for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
23
+ return buffer.buffer;
24
+ }
25
+
26
+ document.getElementById("create-passkey-button").addEventListener("click", async function () {
27
+ const options = <%%= raw @webauthn_credential_options.to_json %>;
28
+
29
+ options.challenge = bufferDecode(options.challenge);
30
+ options.user.id = bufferDecode(options.user.id);
31
+ if (options.excludeCredentials) {
32
+ options.excludeCredentials = options.excludeCredentials.map(function (cred) {
33
+ return Object.assign({}, cred, { id: bufferDecode(cred.id) });
34
+ });
35
+ }
36
+
37
+ let credential;
38
+ try {
39
+ credential = await navigator.credentials.create({ publicKey: options });
40
+ } catch (error) {
41
+ document.getElementById("passkey-error").textContent = error.message;
42
+ return;
43
+ }
44
+
45
+ const response = await fetch("<%%= webauthn_credentials_path %>", {
46
+ method: "POST",
47
+ headers: {
48
+ "Content-Type": "application/json",
49
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
50
+ },
51
+ body: JSON.stringify({
52
+ id: credential.id,
53
+ rawId: bufferEncode(credential.rawId),
54
+ type: credential.type,
55
+ nickname: document.getElementById("passkey_nickname").value,
56
+ response: {
57
+ clientDataJSON: bufferEncode(credential.response.clientDataJSON),
58
+ attestationObject: bufferEncode(credential.response.attestationObject)
59
+ }
60
+ })
61
+ });
62
+
63
+ const result = await response.json();
64
+ if (response.ok) {
65
+ window.location.href = result.redirect_to;
66
+ } else {
67
+ document.getElementById("passkey-error").textContent = result.error || "Could not save passkey.";
68
+ }
69
+ });
70
+ })();
71
+ <%% end %>
@@ -0,0 +1,11 @@
1
+ WebAuthn.configure do |config|
2
+ # This value needs to match `window.location.hostname` where the API is called from in
3
+ # your client-side (front-end) code.
4
+ # config.rp_id = "example.com"
5
+
6
+ # This value needs to match `window.location.origin` where the API is called from in
7
+ # your client-side (front-end) code.
8
+ # config.allowed_origins = ["https://example.com"]
9
+
10
+ config.rp_name = Rails.application.class.module_parent_name
11
+ end
@@ -0,0 +1,7 @@
1
+ class AddOttToUsers < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :users, :ott_code, :string
4
+ add_column :users, :ott_sent_at, :datetime
5
+ add_column :users, :ott_attempts, :integer, default: 0, null: false
6
+ end
7
+ end