-
-
Notifications
You must be signed in to change notification settings - Fork 934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add WebAuthn 2FA in UI #2108
Add WebAuthn 2FA in UI #2108
Changes from 49 commits
4ae5350
a46d073
66c3c64
506f643
5a55035
dbdbe2a
f5d9a8c
f299e83
d1a046c
83b7129
aaa3ec1
b090654
9d4db17
9065ef2
8b9d9fc
7a43041
30047e2
a7ae3ca
d82f0c2
e36e0e9
a8cfa5c
f66f05c
08da7fe
fd99d4a
b152de8
e33578c
1a55716
5eb73cc
92e4f5b
0c869da
ca71cda
a34aadf
6c6a457
89f9d7a
90c4e2e
adc2aa4
e8f4a42
7a1b547
9ffc99a
c0ed0c2
501085e
77dd527
9d63848
c00319f
ca33802
c059002
14c0fb5
058fc3e
d99cbf8
b879f36
1b192e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
$(function(){ | ||
if(!webauthnJSON.supported()) { | ||
if($("#unsupported-browser-message").length) { | ||
$("#unsupported-browser-message").show(); | ||
|
||
$(".js-webauthn-button").each(function() { | ||
$(this).prop("disabled", true); | ||
}); | ||
} | ||
} | ||
}); | ||
|
||
$(".js-webauthn-registration-form").submit(function(event) { | ||
event.preventDefault(); | ||
$("#security-key-error-message").hide(); | ||
var $form = $(this); | ||
|
||
$.get({ | ||
url: "/internal/webauthn_registration/options", | ||
dataType: "json", | ||
}).done(function(options) { | ||
webauthnJSON.create({ "publicKey": options }).then( | ||
function(credential) { | ||
callback( | ||
"/internal/webauthn_registration", | ||
{ | ||
"credential": credential, | ||
"nickname": $("#nickname").val() | ||
} | ||
); | ||
}, | ||
function(reason) { | ||
$("#security-key-error-message").show(); | ||
var registerButton = $form.find("input.form__submit"); | ||
registerButton.attr('value', registerButton.attr('data-enable-with')); | ||
registerButton.prop('disabled', false); | ||
}); | ||
}).fail(function(response) { console.log(response) }) | ||
}); | ||
|
||
$(".js-webauthn-authentication-form").submit(function(event) { | ||
event.preventDefault(); | ||
$("#security-key-error-message").hide(); | ||
var $form = $(this); | ||
|
||
$.get({ | ||
url: "/internal/webauthn_session/options", | ||
dataType: "json" | ||
}).done(function(options) { | ||
webauthnJSON.get({ "publicKey": options }).then( | ||
function(credential) { | ||
callback("/internal/webauthn_session", { "credential": credential }); | ||
}, | ||
function(reason) { | ||
$("#security-key-error-message").show(); | ||
signInButton = $form.find(".js-webauthn-button"); | ||
signInButton.attr('value', signInButton.attr('data-enable-with')); | ||
signInButton.prop('disabled', false); | ||
}); | ||
}).fail(function(response) { window.location.replace(response.responseJSON["redirect_path"]) }) | ||
}); | ||
|
||
function callback(url, body) { | ||
$.post({ | ||
url: url, | ||
data: JSON.stringify(body), | ||
dataType: "json", | ||
headers: { | ||
"Content-Type": "application/json" | ||
} | ||
}).done(function(response) { | ||
window.location.replace(response["redirect_path"]); | ||
}).error(function(response) { | ||
window.location.replace(response.responseJSON["redirect_path"]); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
class Internal::WebauthnRegistrationsController < ApplicationController | ||
def options | ||
current_user.update(webauthn_handle: WebAuthn.generate_user_id) unless current_user.webauthn_handle | ||
|
||
options_for_create = WebAuthn::Credential.options_for_create( | ||
user: { | ||
name: current_user.handle, | ||
display_name: current_user.handle, | ||
id: current_user.webauthn_handle | ||
}, | ||
exclude: current_user.webauthn_credentials.pluck(:external_id) | ||
) | ||
|
||
session[:webauthn_challenge] = options_for_create.challenge | ||
|
||
render json: options_for_create, status: :ok | ||
end | ||
|
||
def create | ||
webauthn_credential = WebAuthn::Credential.from_create(params[:credential]) | ||
|
||
if webauthn_credential.verify(session[:webauthn_challenge]) | ||
user_credential = current_user.webauthn_credentials.build( | ||
external_id: webauthn_credential.id, | ||
public_key: webauthn_credential.public_key, | ||
nickname: params[:nickname], | ||
sign_count: webauthn_credential.sign_count | ||
) | ||
|
||
if user_credential.save | ||
flash[:success] = t(".success") | ||
status = :ok | ||
else | ||
flash[:error] = t(".fail") | ||
status = :internal_server_error | ||
end | ||
else | ||
flash[:error] = t("internal.webauthn_sessions.incorrect_security_key") | ||
status = :unauthorized | ||
end | ||
|
||
render json: { redirect_path: webauthn_credentials_path }, status: status | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
class Internal::WebauthnSessionsController < Clearance::SessionsController | ||
def options | ||
user = User.find_by(handle: session[:mfa_user]) | ||
|
||
if user&.webauthn_enabled? | ||
options_for_get = WebAuthn::Credential.options_for_get( | ||
allow: user.webauthn_credentials.pluck(:external_id) | ||
) | ||
|
||
session[:webauthn_challenge] = options_for_get.challenge | ||
|
||
render json: options_for_get, status: :ok | ||
else | ||
flash[:error] = t("webauthn_credentials.not_enabled") | ||
render json: { redirect_path: sign_in_path }, status: :unauthorized | ||
end | ||
end | ||
|
||
def create | ||
user = User.find_by(handle: session.delete(:mfa_user)) | ||
webauthn_credential = WebAuthn::Credential.from_get(params[:credential]) | ||
|
||
if user&.webauthn_enabled? && user&.webauthn_verified?(session[:webauthn_challenge], webauthn_credential) | ||
sign_in(user) do |status| | ||
if status.success? | ||
render json: { redirect_path: root_path }, status: :ok | ||
else | ||
flash[:notice] = status.failure_message | ||
render json: { redirect_path: sign_in_path } | ||
end | ||
end | ||
else | ||
flash[:error] = t("internal.webauthn_sessions.incorrect_security_key") | ||
render json: { redirect_path: sign_in_path }, status: :unauthorized | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
class WebauthnCredentialsController < ApplicationController | ||
before_action :redirect_to_signin, unless: :signed_in? | ||
|
||
def index | ||
@user = current_user | ||
end | ||
|
||
def destroy | ||
credential = current_user.webauthn_credentials.find_by(id: params[:id]) | ||
if credential | ||
credential.destroy | ||
if credential.destroyed? | ||
flash[:success] = t(".success") | ||
else | ||
flash[:error] = t(".fail") | ||
end | ||
else | ||
flash[:error] = t(".not_found") | ||
end | ||
redirect_to webauthn_credentials_url | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,8 @@ class User < ApplicationRecord | |
has_many :deletions, dependent: :nullify | ||
has_many :web_hooks, dependent: :destroy | ||
|
||
has_many :webauthn_credentials, dependent: :destroy | ||
|
||
after_validation :set_unconfirmed_email, if: :email_changed?, on: :update | ||
before_create :generate_api_key, :generate_confirmation_token | ||
|
||
|
@@ -172,6 +174,10 @@ def mfa_enabled? | |
!mfa_disabled? | ||
end | ||
|
||
def webauthn_enabled? | ||
webauthn_credentials.any? | ||
end | ||
|
||
def disable_mfa! | ||
mfa_disabled! | ||
self.mfa_seed = "" | ||
|
@@ -210,6 +216,26 @@ def otp_verified?(otp) | |
save!(validate: false) | ||
end | ||
|
||
def webauthn_verified?(current_challenge, webauthn_credential) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure I understand why this method is in |
||
user_credential = webauthn_credentials.find_by!(external_id: webauthn_credential.id) | ||
|
||
if webauthn_credential.user_handle.present? | ||
return false unless webauthn_handle == webauthn_credential.user_handle | ||
end | ||
|
||
begin | ||
webauthn_credential.verify( | ||
current_challenge, | ||
public_key: user_credential.public_key, | ||
sign_count: user_credential.sign_count | ||
) | ||
|
||
user_credential.update!(sign_count: webauthn_credential.sign_count, last_used_on: Time.current) | ||
rescue WebAuthn::Error | ||
false | ||
end | ||
end | ||
|
||
def block! | ||
transaction do | ||
update_attribute(:email, "security+locked-#{SecureRandom.hex(4)}-#{id}-#{handle}@rubygems.org") | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
class WebauthnCredential < ApplicationRecord | ||
validates :external_id, :public_key, :nickname, presence: true | ||
belongs_to :user | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -102,6 +102,8 @@ | |
</div> | ||
<%= submit_tag t('.mfa.update'), :class => 'form__submit' %> | ||
<% end %> | ||
|
||
<h2><%= link_to t('webauthn_credentials.index.title'), webauthn_credentials_path %></h2> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As this stands, the user can only add webauth security keys after they have registered a TOTP authenticator device. I am not sure I understand this requirement. Shouldn't the user be able to enable mfa and only register webauth keys? |
||
<% else %> | ||
<p> | ||
<%= t '.mfa.disabled' %> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it acceptable to allow users to delete the key without verifying that they have access to the key?