Skip to content
This repository has been archived by the owner on Dec 21, 2023. It is now read-only.

Commit

Permalink
Add WebAuthn as an alternative 2FA method (mastodon#14466)
Browse files Browse the repository at this point in the history
* feat: add possibility of adding WebAuthn security keys to use as 2FA

This adds a basic UI for enabling WebAuthn 2FA. We did a little refactor
to the Settings page for editing the 2FA methods – now it will list the
methods that are available to the user (TOTP and WebAuthn) and from
there they'll be able to add or remove any of them.
Also, it's worth mentioning that for enabling WebAuthn it's required to
have TOTP enabled, so the first time that you go to the 2FA Settings
page, you'll be asked to set it up.
This work was inspired by the one donde by Github in their platform, and
despite it could be approached in different ways, we decided to go with
this one given that we feel that this gives a great UX.

Co-authored-by: Facundo Padula <[email protected]>

* feat: add request for WebAuthn as second factor at login if enabled

This commits adds the feature for using WebAuthn as a second factor for
login when enabled.
If users have WebAuthn enabled, now a page requesting for the use of a
WebAuthn credential for log in will appear, although a link redirecting
to the old page for logging in using a two-factor code will also be
present.

Co-authored-by: Facundo Padula <[email protected]>

* feat: add possibility of deleting WebAuthn Credentials

Co-authored-by: Facundo Padula <[email protected]>

* feat: disable WebAuthn when an Admin disables 2FA for a user

Co-authored-by: Facundo Padula <[email protected]>

* feat: remove ability to disable TOTP leaving only WebAuthn as 2FA

Following examples form other platforms like Github, we decided to make
Webauthn 2FA secondary to 2FA with TOTP, so that we removed the
possibility of removing TOTP authentication only, leaving users with
just WEbAuthn as 2FA. Instead, users will have to click on 'Disable 2FA'
in order to remove second factor auth.
The reason for WebAuthn being secondary to TOPT is that in that way,
users will still be able to log in using their code from their phone's
application if they don't have their security keys with them – or maybe
even lost them.

* We had to change a little the flow for setting up TOTP, given that now
  it's possible to setting up again if you already had TOTP, in order to
  let users modify their authenticator app – given that now it's not
  possible for them to disable TOTP and set it up again with another
  authenticator app.
  So, basically, now instead of storing the new `otp_secret` in the
  user, we store it in the session until the process of set up is
  finished.
  This was because, as it was before, when users clicked on 'Edit' in
  the new two-factor methods lists page, but then went back without
  finishing the flow, their `otp_secret` had been changed therefore
  invalidating their previous authenticator app, making them unable to
  log in again using TOTP.

Co-authored-by: Facundo Padula <[email protected]>

* refactor: fix eslint errors

The PR build was failing given that linting returning some errors.
This commit attempts to fix them.

* refactor: normalize i18n translations

The build was failing given that i18n translations files were not
normalized.
This commits fixes that.

* refactor: avoid having the webauthn gem locked to a specific version

* refactor: use symbols for routes without '/'

* refactor: avoid sending webauthn disabled email when 2FA is disabled

When an admins disable 2FA for users, we were sending two mails
to them, one notifying that 2FA was disabled and the other to notify
that WebAuthn was disabled.
As the second one is redundant since the first email includes it, we can
remove it and send just one email to users.

* refactor: avoid creating new env variable for webauthn_origin config

* refactor: improve flash error messages for webauthn pages

Co-authored-by: Facundo Padula <[email protected]>
  • Loading branch information
santiagorodriguez96 and Facundo Padula authored Aug 24, 2020
1 parent a3ec9af commit e8d41bc
Show file tree
Hide file tree
Showing 53 changed files with 1,831 additions and 375 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020'
gem 'webpacker', '~> 5.2'
gem 'webpush'
gem 'webauthn', '~> 3.0.0.alpha1'

gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.1'
Expand Down
26 changes: 26 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0)
sshkit (>= 1.6.1, != 1.7.0)
android_key_attestation (0.3.0)
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
Expand All @@ -76,6 +77,7 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
awrence (1.1.1)
aws-eventstream (1.1.0)
aws-partitions (1.356.0)
aws-sdk-core (3.104.3)
Expand All @@ -97,6 +99,7 @@ GEM
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
bindata (2.4.8)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
blurhash (0.1.4)
Expand Down Expand Up @@ -138,6 +141,7 @@ GEM
xpath (~> 3.2)
case_transform (0.2)
activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7)
chewy (5.1.0)
activesupport (>= 4.0)
Expand All @@ -153,6 +157,9 @@ GEM
color_diff (0.1)
concurrent-ruby (1.1.7)
connection_pool (2.2.3)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
Expand Down Expand Up @@ -377,6 +384,8 @@ GEM
omniauth-saml (1.10.2)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.9)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0)
ox (2.13.2)
paperclip (6.0.0)
Expand Down Expand Up @@ -547,10 +556,13 @@ GEM
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
securecompare (1.0.0)
semantic_range (2.3.0)
sidekiq (6.1.1)
connection_pool (>= 2.2.2)
Expand Down Expand Up @@ -605,6 +617,9 @@ GEM
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0)
tty-color (0.5.2)
tty-cursor (0.7.1)
tty-prompt (0.22.0)
Expand All @@ -628,6 +643,16 @@ GEM
uniform_notifier (1.13.0)
warden (1.2.8)
rack (>= 2.0.6)
webauthn (3.0.0.alpha1)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webmock (3.8.3)
addressable (>= 2.3.6)
crack (>= 0.3.2)
Expand Down Expand Up @@ -775,6 +800,7 @@ DEPENDENCIES
tty-prompt (~> 0.22)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2020)
webauthn (~> 3.0.0.alpha1)
webmock (~> 3.8)
webpacker (~> 5.2)
webpush
18 changes: 17 additions & 1 deletion app/controllers/auth/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ def destroy
store_location_for(:user, tmp_stored_location) if continue_after?
end

def webauthn_options
user = find_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
render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized
end
end

protected

def find_user
Expand All @@ -51,7 +67,7 @@ def find_user
end

def user_params
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
end

def after_sign_in_path_for(resource)
Expand Down
45 changes: 41 additions & 4 deletions app/controllers/concerns/two_factor_authentication_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
end

def two_factor_enabled?
find_user&.otp_required_for_login?
find_user&.two_factor_enabled?
end

def valid_webauthn_credential?(user, webauthn_credential)
user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id)

begin
webauthn_credential.verify(
session[:webauthn_challenge],
public_key: user_credential.public_key,
sign_count: user_credential.sign_count
)

user_credential.update!(sign_count: webauthn_credential.sign_count)
rescue WebAuthn::Error
false
end
end

def valid_otp_attempt?(user)
Expand All @@ -21,14 +37,29 @@ def valid_otp_attempt?(user)
def authenticate_with_two_factor
user = self.resource = find_user

if user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user)
if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
authenticate_with_two_factor_via_webauthn(user)
elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end

def authenticate_with_two_factor_attempt(user)
def authenticate_with_two_factor_via_webauthn(user)
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])

if valid_webauthn_credential?(user, webauthn_credential)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
render json: { redirect_path: root_path }, status: :ok
else
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
end
end

def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
Expand All @@ -43,6 +74,12 @@ def prompt_for_two_factor(user)
set_locale do
session[:attempt_user_id] = user.id
@body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn'
else
'totp'
end
render :two_factor
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ def new
end

def create
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt])
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret])
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')

current_user.otp_required_for_login = true
current_user.otp_secret = session[:new_otp_secret]
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!

UserMailer.two_factor_enabled(current_user).deliver_later!

session.delete(:new_otp_secret)

render 'settings/two_factor_authentication/recovery_codes/index'
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
prepare_two_factor_form
render :new
end
Expand All @@ -43,12 +46,15 @@ def confirmation_params

def prepare_two_factor_form
@confirmation = Form::TwoFactorConfirmation.new
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
@new_otp_secret = session[:new_otp_secret]
@provision_url = current_user.otp_provisioning_uri(current_user.email,
otp_secret: @new_otp_secret,
issuer: Rails.configuration.x.local_domain)
@qrcode = RQRCode::QRCode.new(@provision_url)
end

def ensure_otp_secret
redirect_to settings_two_factor_authentication_path unless current_user.otp_secret
redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank?
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

module Settings
module TwoFactorAuthentication
class OtpAuthenticationController < BaseController
include ChallengableConcern

layout 'admin'

before_action :authenticate_user!
before_action :verify_otp_not_enabled, only: [:show]
before_action :require_challenge!, only: [:create]

skip_before_action :require_functional!

def show
@confirmation = Form::TwoFactorConfirmation.new
end

def create
session[:new_otp_secret] = User.generate_otp_secret(32)

redirect_to new_settings_two_factor_authentication_confirmation_path
end

private

def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end

def verify_otp_not_enabled
redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled?
end

def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

module Settings
module TwoFactorAuthentication
class WebauthnCredentialsController < BaseController
layout 'admin'

before_action :authenticate_user!
before_action :require_otp_enabled
before_action :require_webauthn_enabled, only: [:index, :destroy]

def new; end

def index; end

def options
current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id

options_for_create = WebAuthn::Credential.options_for_create(
user: {
name: current_user.account.username,
display_name: current_user.account.username,
id: current_user.webauthn_id,
},
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] = I18n.t('webauthn_credentials.create.success')
status = :ok

if current_user.webauthn_credentials.size == 1
UserMailer.webauthn_enabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :internal_server_error
end
else
flash[:error] = t('webauthn_credentials.create.error')
status = :unauthorized
end

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

def destroy
credential = current_user.webauthn_credentials.find_by(id: params[:id])
if credential
credential.destroy
if credential.destroyed?
flash[:success] = I18n.t('webauthn_credentials.destroy.success')

if current_user.webauthn_credentials.empty?
UserMailer.webauthn_disabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
redirect_to settings_two_factor_authentication_methods_path
end

private

def require_otp_enabled
unless current_user.otp_enabled?
flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end
end

def require_webauthn_enabled
unless current_user.webauthn_enabled?
flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end
end
end
end
end
Loading

0 comments on commit e8d41bc

Please sign in to comment.