Skip to content

Commit

Permalink
Allow using webauthn in places that currently require a password
Browse files Browse the repository at this point in the history
i.e. allow using passkeys as a single auth factor
  • Loading branch information
segiddins committed Dec 8, 2023
1 parent 05f6813 commit 3811143
Show file tree
Hide file tree
Showing 39 changed files with 479 additions and 100 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ GEM
optimist (>= 3.0.0)
rdoc (6.6.1)
psych (>= 4.0.0)
regexp_parser (2.8.1)
regexp_parser (2.8.3)
rexml (3.2.6)
roadie (5.2.0)
css_parser (~> 1.4)
Expand Down
69 changes: 41 additions & 28 deletions app/assets/javascripts/webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
authenticatorData: bufferToBase64url(credentials.response.authenticatorData),
attestationObject: bufferToBase64url(credentials.response.attestationObject),
clientDataJSON: bufferToBase64url(credentials.response.clientDataJSON),
signature: bufferToBase64url(credentials.response.signature)
signature: bufferToBase64url(credentials.response.signature),
userHandle: bufferToBase64url(credentials.response.userHandle),
},
};
};
Expand All @@ -46,6 +47,23 @@
});
};

var parseCreationOptionsFromJSON = function(json) {
return {
...json,
challenge: base64urlToBuffer(json.challenge),
user: { ...json.user, id: base64urlToBuffer(json.user.id) },
excludeCredentials: credentialsToBuffer(json.excludeCredentials),
};
};

var parseRequestOptionsFromJSON = function(json) {
return {
...json,
challenge: base64urlToBuffer(json.challenge),
allowCredentials: credentialsToBuffer(json.allowCredentials),
};
};

$(function() {
var credentialForm = $(".js-new-webauthn-credential--form");
var credentialError = $(".js-new-webauthn-credential--error");
Expand All @@ -63,11 +81,8 @@
}).then(function (response) {
return response.json();
}).then(function (json) {
json.user.id = base64urlToBuffer(json.user.id);
json.challenge = base64urlToBuffer(json.challenge);
json.excludeCredentials = credentialsToBuffer(json.excludeCredentials);
return navigator.credentials.create({
publicKey: json
publicKey: parseCreationOptionsFromJSON(json)
});
}).then(function (credentials) {
return fetch(form.action + "/callback.json", {
Expand Down Expand Up @@ -98,27 +113,24 @@
});
});

var getCredentials = function(event, csrfToken) {
var getCredentials = async function(event, csrfToken) {
var form = handleEvent(event);
var options = JSON.parse(form.dataset.options);
options.challenge = base64urlToBuffer(options.challenge);
options.allowCredentials = credentialsToBuffer(options.allowCredentials);
return navigator.credentials.get({
publicKey: options
}).then(function (credentials) {
return fetch(form.action, {
method: "POST",
credentials: "same-origin",
redirect: "follow",
headers: {
"X-CSRF-Token": csrfToken,
"Content-Type": "application/json"
},
body: JSON.stringify({
credentials: credentialsToBase64(credentials)
})
});
})
const credentials = await navigator.credentials.get({
publicKey: parseRequestOptionsFromJSON(options)
});
return await fetch(form.action, {
method: "POST",
credentials: "same-origin",
redirect: "follow",
headers: {
"X-CSRF-Token": csrfToken,
"Content-Type": "application/json"
},
body: JSON.stringify({
credentials: credentialsToBase64(credentials),
})
});
};

$(function() {
Expand Down Expand Up @@ -153,12 +165,13 @@
var sessionError = $(".js-webauthn-session--error");
var csrfToken = $("[name='csrf-token']").attr("content");

sessionForm.submit(function(event) {
getCredentials(event, csrfToken).then(function (response) {
sessionForm.submit(async function(event) {
try {
const response = await getCredentials(event, csrfToken);
handleHtmlResponse(sessionSubmit, sessionError, response);
}).catch(function (error) {
} catch (error) {
setError(sessionSubmit, sessionError, error);
});
}
});
});
})();
5 changes: 3 additions & 2 deletions app/avo/resources/user_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ class UserResource < Avo::BaseResource
field :mfa_level, as: :select, enum: ::User.mfa_levels
field :mfa_recovery_codes, as: :text, visible: ->(_) { false }
field :mfa_hashed_recovery_codes, as: :text, visible: ->(_) { false }
field :webauthn_id, as: :text, visible: ->(_) { false }
field :webauthn_credentials, as: :has_many, visible: ->(_) { false }
field :webauthn_id, as: :text
field :remember_token_expires_at, as: :date_time
field :api_key, as: :text, visible: ->(_) { false }
field :confirmation_token, as: :text, visible: ->(_) { false }
Expand All @@ -64,6 +63,8 @@ class UserResource < Avo::BaseResource
field :ownership_requests, as: :has_many
field :pushed_versions, as: :has_many
field :oidc_api_key_roles, as: :has_many
field :webauthn_credentials, as: :has_many
field :webauthn_verification, as: :has_one

field :audits, as: :has_many
end
Expand Down
13 changes: 13 additions & 0 deletions app/avo/resources/webauthn_credential_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class WebauthnCredentialResource < Avo::BaseResource
self.title = :id
self.includes = []

field :id, as: :id
# Fields generated from the model
field :external_id, as: :text
field :public_key, as: :text
field :nickname, as: :text
field :sign_count, as: :number
field :user, as: :belongs_to
# add fields here
end
13 changes: 13 additions & 0 deletions app/avo/resources/webauthn_verification_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class WebauthnVerificationResource < Avo::BaseResource
self.title = :id
self.includes = []

field :id, as: :id
# Fields generated from the model
field :path_token, as: :text
field :path_token_expires_at, as: :date_time
field :otp, as: :text
field :otp_expires_at, as: :date_time
field :user, as: :belongs_to
# add fields here
end
4 changes: 4 additions & 0 deletions app/controllers/avo/webauthn_credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::WebauthnCredentialsController < Avo::ResourcesController
end
4 changes: 4 additions & 0 deletions app/controllers/avo/webauthn_verifications_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::WebauthnVerificationsController < Avo::ResourcesController
end
23 changes: 21 additions & 2 deletions app/controllers/concerns/webauthn_verifiable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@ def setup_webauthn_authentication(form_url:, session_options: {})
def webauthn_credential_verified?
@credential = WebAuthn::Credential.from_get(credential_params)

unless user_webauthn_credential
@webauthn_error = t("credentials_required")
return false
end

@credential.verify(
challenge,
public_key: user_webauthn_credential.public_key,
sign_count: user_webauthn_credential.sign_count
)
user_webauthn_credential.update!(sign_count: @credential.sign_count)

if @credential.user_handle.present? && @credential.user_handle != user_webauthn_credential.user.webauthn_id
@webauthn_error = t("credentials_required")
return false
end

true
rescue WebAuthn::Error => e
@webauthn_error = e.message
Expand All @@ -35,8 +45,16 @@ def webauthn_credential_verified?

private

def webauthn_credential_scope
if @user.present?
@user.webauthn_credentials
else
User.find_by(webauthn_id: @credential.user_handle)&.webauthn_credentials || WebauthnCredential.none
end
end

def user_webauthn_credential
@user_webauthn_credential ||= @user.webauthn_credentials.find_by(
@user_webauthn_credential ||= webauthn_credential_scope.find_by(
external_id: @credential.id
)
end
Expand All @@ -50,7 +68,8 @@ def credential_params
:id,
:type,
:rawId,
response: %i[authenticatorData attestationObject clientDataJSON signature]
:authenticatorAttachment,
response: %i[authenticatorData attestationObject clientDataJSON signature userHandle]
)
end
end
63 changes: 55 additions & 8 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ class SessionsController < Clearance::SessionsController
include MfaExpiryMethods
include WebauthnVerifiable

before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify authenticate]
before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify authenticate]
before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify authenticate]
before_action :ensure_not_blocked, only: :create
after_action :delete_mfa_session, only: %i[webauthn_create otp_create]
before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify webauthn_authenticate authenticate]
before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify webauthn_authenticate authenticate]
before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify webauthn_authenticate authenticate]
before_action :ensure_not_blocked, only: %i[create webauthn_full_create]
before_action :webauthn_new_setup, only: :new
after_action :delete_mfa_session, only: %i[webauthn_create webauthn_full_create otp_create]
after_action :delete_session_verification, only: :destroy

def create
@user = find_user
Expand Down Expand Up @@ -39,6 +41,14 @@ def webauthn_create
do_login
end

def webauthn_full_create
return login_failure(@webauthn_error) unless webauthn_credential_verified?

@user = user_webauthn_credential.user

do_login
end

def otp_create
@user = User.find(session[:mfa_user])

Expand All @@ -54,21 +64,40 @@ def otp_create
end

def verify
@user = current_user
setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
end

def authenticate
@user = current_user
if verify_user
session[:verified_user] = current_user.id
session[:verification] = Time.current + Gemcutter::PASSWORD_VERIFICATION_EXPIRY
redirect_to session.delete(:redirect_uri) || root_path
mark_verified
else
flash.now[:alert] = t("profiles.request_denied")
setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
render :verify, status: :unauthorized
end
end

def webauthn_authenticate
@user = current_user
if webauthn_credential_verified?
mark_verified
else
flash.now[:alert] = @webauthn_error
setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
render :verify, status: :unauthorized
end
end

private

def mark_verified
session[:verified_user] = current_user.id
session[:verification] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now
redirect_to session.delete(:redirect_uri) || root_path
end

def verify_user
current_user.authenticated? verify_password_params[:password]
end
Expand All @@ -92,6 +121,7 @@ def do_login
def login_failure(message)
StatsD.increment "login.failure"
flash.now.notice = message
webauthn_new_setup
render "sessions/new", status: :unauthorized
end

Expand Down Expand Up @@ -134,6 +164,7 @@ def ensure_not_blocked
return unless user&.blocked_email

flash.now.alert = t(".account_blocked")
webauthn_new_setup
render template: "sessions/new", status: :unauthorized
end

Expand All @@ -154,4 +185,20 @@ def record_mfa_login_duration(mfa_type:)

StatsD.distribution("login.mfa.#{mfa_type}.duration", duration)
end

def webauthn_new_setup
@webauthn_options = WebAuthn::Credential.options_for_get(
user_verification: "discouraged"
)

@webauthn_verification_url = webauthn_full_create_session_path

session[:webauthn_authentication] = {
"challenge" => @webauthn_options.challenge
}
end

def delete_session_verification
session[:verified_user] = session[:verification] = nil
end
end
2 changes: 1 addition & 1 deletion app/models/concerns/user_webauthn_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def webauthn_options_for_create
name: display_id
},
exclude: webauthn_credentials.pluck(:external_id),
authenticator_selection: { user_verification: "discouraged" }
authenticator_selection: { user_verification: "discouraged", resident_key: "preferred" }
)
end

Expand Down
3 changes: 2 additions & 1 deletion app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ def act_on?
rubygems_org_admin?
end

has_association :webauthn_credentials
has_association :ownerships
has_association :rubygems
has_association :subscriptions
Expand All @@ -32,4 +31,6 @@ def act_on?
has_association :pushed_versions
has_association :audits
has_association :oidc_api_key_roles
has_association :webauthn_credentials
has_association :webauthn_verification
end
13 changes: 13 additions & 0 deletions app/policies/webauthn_credential_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class WebauthnCredentialPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.all
end
end

def avo_show?
Pundit.policy!(user, record.user).avo_show?
end

has_association :user
end
13 changes: 13 additions & 0 deletions app/policies/webauthn_verification_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class WebauthnVerificationPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.all
end
end

def avo_show?
Pundit.policy!(user, record.user).avo_show?
end

has_association :user
end

0 comments on commit 3811143

Please sign in to comment.