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.
- checksums.yaml +4 -4
- data/README.md +83 -14
- data/lib/generators/authentication/authentication_generator.rb +31 -0
- data/lib/generators/authentication/features/confirmable.rb +13 -12
- data/lib/generators/authentication/features/invitable.rb +14 -13
- data/lib/generators/authentication/features/lockable.rb +13 -12
- data/lib/generators/authentication/features/magic_link.rb +13 -12
- data/lib/generators/authentication/features/ott.rb +31 -0
- data/lib/generators/authentication/features/passkey.rb +36 -0
- data/lib/generators/authentication/features/recoverable.rb +13 -12
- data/lib/generators/authentication/features/registerable.rb +7 -6
- data/lib/generators/authentication/features/rememberable.rb +6 -5
- data/lib/generators/authentication/features/timeoutable.rb +7 -6
- data/lib/generators/authentication/features/trackable.rb +7 -6
- data/lib/generators/authentication/features/validatable.rb +5 -4
- data/lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt +95 -0
- data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt +68 -0
- data/lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt +4 -0
- data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt +43 -0
- data/lib/generators/authentication/templates/app/mailers/otts_mailer.rb.tt +6 -0
- data/lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt +18 -17
- data/lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt +50 -0
- data/lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt +19 -0
- data/lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt +6 -0
- data/lib/generators/authentication/templates/app/views/otts/edit.html.erb.tt +79 -0
- data/lib/generators/authentication/templates/app/views/otts_mailer/ott.html.erb.tt +8 -0
- data/lib/generators/authentication/templates/app/views/otts_mailer/ott.text.erb.tt +6 -0
- data/lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt +4 -0
- data/lib/generators/authentication/templates/app/views/sessions/new.html.erb.tt +81 -0
- data/lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt +19 -0
- data/lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt +71 -0
- data/lib/generators/authentication/templates/config/initializers/webauthn.rb.tt +11 -0
- data/lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt +7 -0
- data/lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt +7 -0
- data/lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt +15 -0
- data/lib/rails_authentication/version.rb +1 -1
- 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
|
data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt
ADDED
|
@@ -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
|
data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt
ADDED
|
@@ -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
|
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<% end -%>
|
|
60
|
+
def postponing_email_address_change?
|
|
61
|
+
!@confirming && email_address_changed? && confirmed?
|
|
62
|
+
end
|
|
68
63
|
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
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,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>
|
|
@@ -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 %> — 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
|