Skip to content
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

Closed
wants to merge 51 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
4ae5350
Add WebAuthn 2FA support
Aug 16, 2019
a46d073
refactor: collapse js code into one file
Aug 22, 2019
66c3c64
refactor: use button_to instead of form_tag in webauthn sign in prompt
Aug 22, 2019
506f643
refactor: modifiy name and columns of credentials table
Aug 23, 2019
5a55035
feat: add support for multiple webauthn credentials
Aug 23, 2019
dbdbe2a
Host webauthn-json in vendor/assets/javascript
grzuy Aug 26, 2019
f5d9a8c
Prefer non-minified version of webauthn-json
grzuy Aug 27, 2019
f299e83
Check if the user agent supports WebAuthn
Aug 26, 2019
d1a046c
Better handle sign in/credential registration failures
Aug 27, 2019
83b7129
Randomize user handle
Aug 27, 2019
aaa3ec1
Refactor routes and controllers
Aug 28, 2019
b090654
Pollish translations
Aug 29, 2019
9d4db17
Don't allow repeated credentials
Aug 29, 2019
9065ef2
Pollish UX
Sep 2, 2019
8b9d9fc
Add 'last_used_on' field to webauthn credentials
Sep 3, 2019
7a43041
Integrate webauthn in the 'Edit profile' page
Sep 3, 2019
30047e2
Allow max sign count value
Sep 3, 2019
a7ae3ca
Consider 'destroy' might fail silently
Sep 3, 2019
d82f0c2
Throttle webauthn routes
Sep 3, 2019
e36e0e9
Merge branch 'master' into add_webauthn
grzuy Sep 4, 2019
a8cfa5c
Update to webauthn gem edge master
grzuy Sep 4, 2019
f66f05c
Update to latest webauthn gem
Sep 6, 2019
08da7fe
Attempt to fix build_docker CI step
grzuy Sep 6, 2019
fd99d4a
Fix uglifier compilation
grzuy Sep 6, 2019
b152de8
Fix uglifier compilation of webauthn-json
grzuy Sep 6, 2019
e33578c
Remove unnecessary encoding
Sep 6, 2019
1a55716
Display error message if security key registration/authentication fails
Sep 9, 2019
5eb73cc
Push encoding/decoding to webauthn gem
Sep 9, 2019
92e4f5b
Update to latest webauthn
grzuy Sep 13, 2019
0c869da
Update to latest webauthn
Sep 13, 2019
ca71cda
Update webauthn gem to released v2.0.0.beta1
grzuy Sep 17, 2019
a34aadf
Remove no longer needed git in docker builds
grzuy Sep 17, 2019
6c6a457
Remove unnecessary local variable
grzuy Sep 17, 2019
89f9d7a
Attempt to improve local variable naming semantics
grzuy Sep 17, 2019
90c4e2e
WebAuthn possible only if OTP already enabled
grzuy Sep 18, 2019
adc2aa4
Remove unnecessary conditional on sign_count update
grzuy Sep 18, 2019
e8f4a42
Prefer Time.current shortcut
grzuy Sep 18, 2019
7a1b547
Avoid second lookup of the user credential
grzuy Sep 18, 2019
9ffc99a
Avoid unnecessary session access before delete
grzuy Sep 18, 2019
c0ed0c2
Fix indentation
grzuy Sep 18, 2019
501085e
Generalize disabling of webauthn buttons
grzuy Sep 18, 2019
77dd527
Remove unnecessary presence check
grzuy Sep 18, 2019
9d63848
Simplify webauthn authentication event handler plugging
grzuy Sep 18, 2019
c00319f
Attempt to make form reference more consistent
grzuy Sep 18, 2019
ca33802
Remove unneeded method option
grzuy Sep 18, 2019
c059002
Remove unnecessary function naming
grzuy Sep 18, 2019
14c0fb5
Namespace WebAuthn API provided credential in params
grzuy Sep 18, 2019
058fc3e
Make local variable name consistent with the rest of the file
grzuy Sep 18, 2019
d99cbf8
Update @github/webauthn-json to v0.3.6
grzuy Sep 20, 2019
b879f36
Update webauthn gem to v2.2
grzuy May 10, 2020
1b192e0
Merge branch 'master' into add_webauthn
grzuy May 10, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ gem "sprockets-rails"
gem "rack-attack"
gem "rqrcode"
gem "rotp"
gem "webauthn", git: "https://github.com/cedarcode/webauthn-ruby"

# Logging
gem "lograge"
Expand Down
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
GIT
remote: https://github.com/cedarcode/webauthn-ruby
revision: 4b86238aafb51d492b08cfdc0e8c1b296e02849f
specs:
webauthn (1.18.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 0.8.0)
jwt (>= 1.5, < 3.0)
openssl (~> 2.0)
securecompare (~> 1.0)

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -59,6 +71,7 @@ GEM
aws-sigv4 (1.1.0)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.12)
bindata (2.4.4)
bootsnap (1.4.3)
msgpack (~> 1.0)
builder (3.2.3)
Expand All @@ -70,6 +83,7 @@ GEM
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (>= 2.0, < 4.0)
cbor (0.5.9.6)
celluloid (0.17.3)
celluloid-essentials
celluloid-extras
Expand Down Expand Up @@ -103,6 +117,8 @@ GEM
colorize (0.8.1)
compact_index (0.11.0)
concurrent-ruby (1.1.5)
cose (0.8.0)
cbor (~> 0.5.9)
crass (1.0.4)
css_parser (1.7.0)
addressable
Expand Down Expand Up @@ -171,6 +187,7 @@ GEM
http_parser.rb (0.6.0)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
ipaddr (1.2.2)
jaro_winkler (1.5.2)
jmespath (1.4.0)
jquery-rails (4.3.3)
Expand Down Expand Up @@ -251,6 +268,8 @@ GEM
nokogiri (1.10.4)
mini_portile2 (~> 2.4.0)
oj (3.7.12)
openssl (2.1.2)
ipaddr
optimist (3.0.0)
os (1.0.1)
parallel (1.17.0)
Expand Down Expand Up @@ -348,6 +367,7 @@ GEM
ruby-progressbar (1.10.0)
ruby_dep (1.5.0)
sass (3.4.24)
securecompare (1.0.0)
shoryuken (2.1.3)
aws-sdk-core (~> 2)
celluloid (~> 0.17)
Expand Down Expand Up @@ -459,6 +479,7 @@ DEPENDENCIES
uglifier (>= 1.0.3)
unicorn (~> 5.5.0.1.g6836)
validates_formatting_of
webauthn!
xml-simple

BUNDLED WITH
Expand Down
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//= require jquery_ujs
//= require clipboard
//= require github_buttons
//= require webauthn-json
//= require_tree .

function handleClick(event, nav, removeNavExpandedClass, addNavExpandedClass) {
Expand Down
49 changes: 49 additions & 0 deletions app/assets/javascripts/webauthn_credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
var registerCredentialForm = $("#webauthn-credential-create");
if(registerCredentialForm.length) {
registerCredentialForm.submit(registrationHandler);
}

var signInButton = $(".js-webauthn-credential-authenticate");
if(signInButton.length) {
signInButton.parent().submit(event => { event.preventDefault() });
signInButton.click(signInHandler);
}

function registrationHandler(event) {
event.preventDefault();

$.get({
url: "/webauthn_credentials/create_options",
dataType: "json",
}).done(options => {
webauthnJSON.create({ "publicKey": options }).then(credential => {
callback("/webauthn_credentials", $.extend(credential, { "nickname": $("#nickname").val() }));
});
})
}

function signInHandler(event) {
$.get({
url: "/session/webauthn_authentication_options",
dataType: "json"
}).done(options => {
webauthnJSON.get({ "publicKey": options }).then(credential => {
callback("session/webauthn_authentication", credential);
});
})
}

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) {
console.log("WebAuthn callback error");
});
}
8 changes: 8 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,12 @@ def valid_page_param?(max_page)
def reject_null_char_param
render plain: "bad request", status: :bad_request if params.to_s.include?("\\u0000")
end

def bin_to_str(bin)
Base64.urlsafe_encode64(bin, padding: false)
end

def str_to_bin(str)
Base64.urlsafe_decode64(str)
end
end
51 changes: 50 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ class SessionsController < Clearance::SessionsController
def create
@user = find_user(params.require(:session))

if @user&.mfa_enabled?
if @user&.webauthn_enabled?
session[:mfa_user] = @user.handle
render "sessions/webauthn_prompt"
elsif @user&.mfa_enabled?
session[:mfa_user] = @user.handle
render "sessions/otp_prompt"
else
Expand All @@ -21,6 +24,52 @@ def mfa_create
end
end

def webauthn_authentication_options
user = User.find_by_name(session[:mfa_user])
grzuy marked this conversation as resolved.
Show resolved Hide resolved

if user&.webauthn_enabled?
credentials_request_options = WebAuthn.credential_request_options
credentials_request_options[:allowCredentials] = user.webauthn_credentials.map do |cred|
{ id: cred.external_id, type: "public-key" }
end

credentials_request_options[:challenge] = bin_to_str(credentials_request_options[:challenge])
session[:webauthn_challenge] = credentials_request_options[:challenge]

respond_to do |format|
format.json { render json: credentials_request_options }
end
else
respond_to do |format|
format.json { render json: { errors: ["Unprocessable request"] }, status: :unprocessable_entity }
end
end
end

def webauthn_authentication
@user = User.find_by_name(session[:mfa_user])
grzuy marked this conversation as resolved.
Show resolved Hide resolved
session.delete(:mfa_user)

public_key_credential = WebAuthn::PublicKeyCredential.from_get(params, encoding: :base64url)
current_challenge = session[:webauthn_challenge]

if @user&.webauthn_enabled? && @user&.webauthn_verified?(current_challenge, public_key_credential)
credential = @user.webauthn_credentials.find_by(external_id: public_key_credential.id)
new_sign_count = public_key_credential.sign_count
credential.update!(sign_count: new_sign_count) if new_sign_count

sign_in(@user) do |status|
if status.success?
render json: { status: "ok", redirect_path: "/" }, status: :ok
else
login_failure(status.failure_message)
end
end
else
login_failure(t("webauthn_credentials.incorrect_credentials"))
end
end

private

def do_login
Expand Down
58 changes: 58 additions & 0 deletions app/controllers/webauthn_credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
class WebauthnCredentialsController < ApplicationController
before_action :redirect_to_signin, unless: :signed_in?

def index
@user = current_user
end

def create_options
credential_options = WebAuthn.credential_creation_options(
user_name: current_user.handle,
display_name: current_user.handle,
user_id: bin_to_str(current_user.handle)
)

credential_options[:challenge] = bin_to_str(credential_options[:challenge])
session[:webauthn_challenge] = credential_options[:challenge]

respond_to do |format|
format.json { render json: credential_options }
end
end

def create
current_challenge = session[:webauthn_challenge]
public_key_credential = WebAuthn::PublicKeyCredential.from_create(params, encoding: :base64url)

if public_key_credential.verify(str_to_bin(current_challenge))
credential = current_user.webauthn_credentials.build(
external_id: bin_to_str(public_key_credential.raw_id),
public_key: bin_to_str(public_key_credential.public_key),
nickname: params[:nickname],
sign_count: public_key_credential.sign_count
)
if credential.save
flash[:success] = t(".success")
status = :ok
else
flash[:error] = t(".problem")
status = :internal_server_error
end
else
flash[:error] = t(".problem")
status = :unprocessable_entity
end

render json: { status: status, redirect_path: webauthn_credentials_path }, status: status
end

def destroy
begin
current_user.webauthn_credentials.find(params[:id]).destroy
grzuy marked this conversation as resolved.
Show resolved Hide resolved
flash[:success] = t(".success")
rescue ActiveRecord::RecordNotFound
flash[:error] = t(".not_found")
end
redirect_to webauthn_credentials_url
end
end
21 changes: 21 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -210,6 +216,17 @@ def otp_verified?(otp)
save!(validate: false)
end

def webauthn_verified?(current_challenge, public_key_credential)
credential = webauthn_credentials.find_by!(external_id: public_key_credential.id)
begin
public_key_credential.verify(str_to_bin(current_challenge),
public_key: str_to_bin(credential.public_key),
sign_count: credential.sign_count)
rescue WebAuthn::Error
false
end
end

def block!
transaction do
update_attribute(:email, "security+locked-#{SecureRandom.hex(4)}-#{id}-#{handle}@rubygems.org")
Expand Down Expand Up @@ -251,4 +268,8 @@ def yank_gems
deletions.create(version: v)
end
end

def str_to_bin(str)
Base64.urlsafe_decode64(str)
end
end
4 changes: 4 additions & 0 deletions app/models/webauthn_credential.rb
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
4 changes: 4 additions & 0 deletions app/views/sessions/webauthn_prompt.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<% @title = t('webauthn_authentication') %>

<%= label_tag :webauthn_prompt, t('webauthn_credentials.sign_in_prompt'), class: 'form__label' %>
<%= button_to t('sign_in'), webauthn_authentication_options_session_path, method: 'get', class: 'form__submit js-webauthn-credential-authenticate', data: { disable_with: 'Please wait...' } %>
28 changes: 28 additions & 0 deletions app/views/webauthn_credentials/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<% @title = 'WebAuthn credentials' %>

<div class="t-body l-overflow">
<% if @user.webauthn_enabled? %>
<ul>
<% @user.webauthn_credentials.each do |credential| %>
<li>
<strong><%= credential.nickname %></strong> - <%= "#{t('.registered_on')} #{credential.created_at.to_date.to_s(:long)}" %>
<%= link_to t('.delete'), credential, method: :delete, class: 'l-col--r--pad', data: { confirm: t('.confirm') } %>
</li>
<% end %>
</ul>
<% else %>
<div class="push--bottom-s text_field">
<%= t('.disabled') %>
</div>
<% end %>

<%= form_tag create_options_webauthn_credentials_path, method: 'get', id: 'webauthn-credential-create' do %>
<div class="text_field">
<%= label_tag :nickname, t('.nickname'), class: 'form__label' %>
<%= text_field_tag :nickname, '', class: 'form__input', autofocus: true, autocomplete: :off, required: true %>
</div>
<div class="form_bottom">
<%= submit_tag t('.go_settings'), class: 'form__submit', data: { disable_with: t('form_disable_with') } %>
</div>
<% end %>
</div>
24 changes: 24 additions & 0 deletions config/initializers/webauthn.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
WebAuthn.configure do |config|
# This value needs to match `window.location.origin` evaluated by
# the User Agent during registration and authentication ceremonies.
config.origin = ENV["WEBAUTHN_ORIGIN"] || "http://localhost:3000"

# Relying Party name for display purposes
config.rp_name = "RubyGems.org"

# Optionally configure a client timeout hint, in milliseconds.
# This hint specifies how long the browser should wait for an
# attestation or an assertion response.
# This hint may be overridden by the browser.
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
config.credential_options_timeout = 120_000

# You can optionally specify a different Relying Party ID
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
# if it differs from the default one.
#
# In this case the default would be "auth.example.com", but you can set it to
# the suffix "example.com"
#
# config.rp_id = "example.com"
end
Loading