about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/auth/sessions_controller.rb18
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb45
-rw-r--r--app/controllers/settings/two_factor_authentication/confirmations_controller.rb14
-rw-r--r--app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb42
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb103
-rw-r--r--app/controllers/settings/two_factor_authentication_methods_controller.rb30
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb53
-rw-r--r--app/javascript/packs/two_factor_authentication.js118
-rw-r--r--app/javascript/styles/mastodon/forms.scss18
-rw-r--r--app/mailers/user_mailer.rb46
-rw-r--r--app/models/user.rb18
-rw-r--r--app/models/webauthn_credential.rb22
-rw-r--r--app/views/auth/sessions/two_factor.html.haml13
-rw-r--r--app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml18
-rw-r--r--app/views/auth/sessions/two_factor/_webauthn_form.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml10
-rw-r--r--app/views/settings/two_factor_authentication/otp_authentication/show.html.haml9
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml17
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml16
-rw-r--r--app/views/settings/two_factor_authentication_methods/index.html.haml41
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml36
-rw-r--r--app/views/user_mailer/webauthn_credential_added.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_added.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.html.haml44
-rw-r--r--app/views/user_mailer/webauthn_credential_deleted.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_disabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_disabled.text.erb7
-rw-r--r--app/views/user_mailer/webauthn_enabled.html.haml43
-rw-r--r--app/views/user_mailer/webauthn_enabled.text.erb7
29 files changed, 793 insertions, 113 deletions
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 1fd755334..c1ea702ad 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -37,6 +37,22 @@ class Auth::SessionsController < Devise::SessionsController
     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
@@ -51,7 +67,7 @@ class Auth::SessionsController < Devise::SessionsController
   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)
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index daafe56f4..8a2a86a02 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -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)
@@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
   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)
@@ -43,6 +74,12 @@ module TwoFactorAuthenticationConcern
     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
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index ef4df3339..9f23011a7 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -18,18 +18,21 @@ module Settings
       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
@@ -43,12 +46,15 @@ module Settings
 
       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
diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
new file mode 100644
index 000000000..6836f7ef6
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
@@ -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
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
new file mode 100644
index 000000000..a19c604f3
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -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
diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb
new file mode 100644
index 000000000..224d3a45c
--- /dev/null
+++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Settings
+  class TwoFactorAuthenticationMethodsController < BaseController
+    include ChallengableConcern
+
+    layout 'admin'
+
+    before_action :authenticate_user!
+    before_action :require_challenge!, only: :disable
+    before_action :require_otp_enabled
+
+    skip_before_action :require_functional!
+
+    def index; end
+
+    def disable
+      current_user.disable_two_factor!
+      UserMailer.two_factor_disabled(current_user).deliver_later!
+
+      redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') }
+    end
+
+    private
+
+    def require_otp_enabled
+      redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
+    end
+  end
+end
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
deleted file mode 100644
index 9118a7933..000000000
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Settings
-  class TwoFactorAuthenticationsController < BaseController
-    include ChallengableConcern
-
-    layout 'admin'
-
-    before_action :authenticate_user!
-    before_action :verify_otp_required, only: [:create]
-    before_action :require_challenge!, only: [:create]
-
-    skip_before_action :require_functional!
-
-    def show
-      @confirmation = Form::TwoFactorConfirmation.new
-    end
-
-    def create
-      current_user.otp_secret = User.generate_otp_secret(32)
-      current_user.save!
-      redirect_to new_settings_two_factor_authentication_confirmation_path
-    end
-
-    def destroy
-      if acceptable_code?
-        current_user.otp_required_for_login = false
-        current_user.save!
-        UserMailer.two_factor_disabled(current_user).deliver_later!
-        redirect_to settings_two_factor_authentication_path
-      else
-        flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
-        @confirmation = Form::TwoFactorConfirmation.new
-        render :show
-      end
-    end
-
-    private
-
-    def confirmation_params
-      params.require(:form_two_factor_confirmation).permit(:otp_attempt)
-    end
-
-    def verify_otp_required
-      redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
-    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
diff --git a/app/javascript/packs/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js
new file mode 100644
index 000000000..dde06be8c
--- /dev/null
+++ b/app/javascript/packs/two_factor_authentication.js
@@ -0,0 +1,118 @@
+import axios from 'axios';
+import * as WebAuthnJSON from '@github/webauthn-json';
+import ready from '../mastodon/ready';
+import 'regenerator-runtime/runtime';
+
+function getCSRFToken() {
+  var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
+  if (CSRFSelector) {
+    return CSRFSelector.getAttribute('content');
+  } else {
+    return null;
+  }
+}
+
+function hideFlashMessages() {
+  Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
+    flashMessage.classList.add('hidden');
+  });
+}
+
+function callback(url, body) {
+  axios.post(url, JSON.stringify(body), {
+    headers: {
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+      'X-CSRF-Token': getCSRFToken(),
+    },
+    credentials: 'same-origin',
+  }).then(function(response) {
+    window.location.replace(response.data.redirect_path);
+  }).catch(function(error) {
+    if (error.response.status === 422) {
+      const errorMessage = document.getElementById('security-key-error-message');
+      errorMessage.classList.remove('hidden');
+      console.error(error.response.data.error);
+    } else {
+      console.error(error);
+    }
+  });
+}
+
+ready(() => {
+  if (!WebAuthnJSON.supported()) {
+    const unsupported_browser_message = document.getElementById('unsupported-browser-message');
+    if (unsupported_browser_message) {
+      unsupported_browser_message.classList.remove('hidden');
+      document.querySelector('.btn.js-webauthn').disabled = true;
+    }
+  }
+
+
+  const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
+  if (webAuthnCredentialRegistrationForm) {
+    webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
+      if (nickname.value) {
+        axios.get('/settings/security_keys/options')
+          .then((response) => {
+            const credentialOptions = response.data;
+
+            WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
+              var params = { 'credential': credential, 'nickname': nickname.value };
+              callback('/settings/security_keys', params);
+            }).catch((error) => {
+              const errorMessage = document.getElementById('security-key-error-message');
+              errorMessage.classList.remove('hidden');
+              console.error(error);
+            });
+          }).catch((error) => {
+            console.error(error.response.data.error);
+          });
+      } else {
+        nickname.focus();
+      }
+    });
+  }
+
+  const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
+  if (webAuthnCredentialAuthenticationForm) {
+    webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      axios.get('sessions/security_key_options')
+        .then((response) => {
+          const credentialOptions = response.data;
+
+          WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
+            var params = { 'user': { 'credential': credential } };
+            callback('sign_in', params);
+          }).catch((error) => {
+            const errorMessage = document.getElementById('security-key-error-message');
+            errorMessage.classList.remove('hidden');
+            console.error(error);
+          });
+        }).catch((error) => {
+          console.error(error.response.data.error);
+        });
+    });
+
+    const otpAuthenticationForm = document.getElementById('otp-authentication-form');
+
+    const linkToOtp = document.getElementById('link-to-otp');
+    linkToOtp.addEventListener('click', () => {
+      webAuthnCredentialAuthenticationForm.classList.add('hidden');
+      otpAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+
+    const linkToWebAuthn = document.getElementById('link-to-webauthn');
+    linkToWebAuthn.addEventListener('click', () => {
+      otpAuthenticationForm.classList.add('hidden');
+      webAuthnCredentialAuthenticationForm.classList.remove('hidden');
+      hideFlashMessages();
+    });
+  }
+});
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index a6df51f95..a54a5fded 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -12,6 +12,10 @@ code {
 }
 
 .simple_form {
+  &.hidden {
+    display: none;
+  }
+
   .input {
     margin-bottom: 15px;
     overflow: hidden;
@@ -100,6 +104,14 @@ code {
     }
   }
 
+  .title {
+    color: #d9e1e8;
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 400;
+    margin-bottom: 30px;
+  }
+
   .hint {
     color: $darker-text-color;
 
@@ -142,7 +154,7 @@ code {
     }
   }
 
-  .otp-hint {
+  .authentication-hint {
     margin-bottom: 25px;
   }
 
@@ -592,6 +604,10 @@ code {
     color: $error-value-color;
   }
 
+  &.hidden {
+    display: none;
+  }
+
   a {
     display: inline-block;
     color: $darker-text-color;
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 2cd58e60a..b55768551 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
     end
   end
 
+  def webauthn_enabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
+    end
+  end
+
+  def webauthn_disabled(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
+    end
+  end
+
+  def webauthn_credential_added(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
+    end
+  end
+
+  def webauthn_credential_deleted(user, webauthn_credential)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @webauthn_credential = webauthn_credential
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
+    end
+  end
+
   def welcome(user)
     @resource = user
     @instance = Rails.configuration.x.local_domain
diff --git a/app/models/user.rb b/app/models/user.rb
index 306e2d435..7e3b37475 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -40,6 +40,7 @@
 #  approved                  :boolean          default(TRUE), not null
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
+#  webauthn_id               :string
 #
 
 class User < ApplicationRecord
@@ -77,6 +78,7 @@ class User < ApplicationRecord
   has_many :backups, inverse_of: :user
   has_many :invites, inverse_of: :user
   has_many :markers, inverse_of: :user, dependent: :destroy
+  has_many :webauthn_credentials, dependent: :destroy
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@@ -197,9 +199,25 @@ class User < ApplicationRecord
     prepare_returning_user!
   end
 
+  def otp_enabled?
+    otp_required_for_login
+  end
+
+  def webauthn_enabled?
+    webauthn_credentials.any?
+  end
+
+  def two_factor_enabled?
+    otp_required_for_login? || webauthn_credentials.any?
+  end
+
   def disable_two_factor!
     self.otp_required_for_login = false
+    self.otp_secret = nil
     otp_backup_codes&.clear
+
+    webauthn_credentials.destroy_all if webauthn_enabled?
+
     save!
   end
 
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
new file mode 100644
index 000000000..4129ce539
--- /dev/null
+++ b/app/models/webauthn_credential.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: webauthn_credentials
+#
+#  id          :bigint(8)        not null, primary key
+#  external_id :string           not null
+#  public_key  :string           not null
+#  nickname    :string           not null
+#  sign_count  :bigint(8)        default(0), not null
+#  user_id     :bigint(8)
+#  created_at  :datetime         not null
+#  updated_at  :datetime         not null
+#
+
+class WebauthnCredential < ApplicationRecord
+  validates :external_id, :public_key, :nickname, :sign_count, presence: true
+  validates :external_id, uniqueness: true
+  validates :nickname, uniqueness: { scope: :user_id }
+  validates :sign_count,
+            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+end
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index b2e36f6bc..f2f6fe19d 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -1,14 +1,9 @@
 - content_for :page_title do
   = t('auth.login')
 
-= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
-  %p.hint.otp-hint= t('simple_form.hints.sessions.otp')
+=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
 
-  .fields-group
-    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+- if @webauthn_enabled
+  = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
 
-  .actions
-    = f.button :button, t('auth.login'), type: :submit
-
-  - if Setting.site_contact_email.present?
-    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+= render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }
diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
new file mode 100644
index 000000000..ab2d48c0a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
@@ -0,0 +1,18 @@
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
+
+  .fields-group
+    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
+
+  .actions
+    = f.button :button, t('auth.login'), type: :submit
+
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
+
+  - if @webauthn_enabled
+    .form-footer
+      = link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn')
diff --git a/app/views/auth/sessions/two_factor/_webauthn_form.html.haml b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
new file mode 100644
index 000000000..32ed1294a
--- /dev/null
+++ b/app/views/auth/sessions/two_factor/_webauthn_form.html.haml
@@ -0,0 +1,17 @@
+%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+
+= simple_form_for(resource,
+                  as: resource_name,
+                  url: session_path(resource_name),
+                  html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
+  %h3.title= t('simple_form.title.sessions.webauthn')
+  %p.hint= t('simple_form.hints.sessions.webauthn')
+
+  .actions
+    = f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit
+
+  .form-footer
+    %p= t('auth.dont_have_your_security_key')
+    = link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp')
diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
index 86cf1f695..671237db5 100644
--- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml
+++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
@@ -2,17 +2,17 @@
   = t('settings.two_factor_authentication')
 
 = simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f|
-  %p.hint= t('two_factor_authentication.instructions_html')
+  %p.hint= t('otp_authentication.instructions_html')
 
   .qr-wrapper
     .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
 
     .qr-alternative
-      %p.hint= t('two_factor_authentication.manual_instructions')
-      %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ')
+      %p.hint= t('otp_authentication.manual_instructions')
+      %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
 
   .fields-group
-    = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
+    = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
 
   .actions
-    = f.button :button, t('two_factor_authentication.enable'), type: :submit
+    = f.button :button, t('otp_authentication.enable'), type: :submit
diff --git a/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
new file mode 100644
index 000000000..d069ba12a
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/otp_authentication/show.html.haml
@@ -0,0 +1,9 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+.simple_form
+  %p.hint= t('otp_authentication.description_html')
+
+  %hr.spacer/
+
+  = link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
new file mode 100644
index 000000000..0dfd94ab9
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+.table-wrapper
+  %table.table
+    %tbody
+      - current_user.webauthn_credentials.each do |credential|
+        %tr
+          %td= credential.nickname
+          %td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name))
+          %td
+            = table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') }
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
new file mode 100644
index 000000000..0b23bb689
--- /dev/null
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
@@ -0,0 +1,16 @@
+- content_for :page_title do
+  = t('settings.webauthn_authentication')
+
+= simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f|
+  %p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
+  %p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
+
+  %p.hint= t('webauthn_credentials.description_html')
+
+  .fields_group
+    = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
+
+  .actions
+    = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
+
+= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
diff --git a/app/views/settings/two_factor_authentication_methods/index.html.haml b/app/views/settings/two_factor_authentication_methods/index.html.haml
new file mode 100644
index 000000000..315443e6d
--- /dev/null
+++ b/app/views/settings/two_factor_authentication_methods/index.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_title do
+  = t('settings.two_factor_authentication')
+
+- content_for :heading_actions do
+  = link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
+
+%p.hint
+  %span.positive-hint
+    = fa_icon 'check'
+    = ' '
+    = t 'two_factor_authentication.enabled'
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('two_factor_authentication.methods')
+        %th
+    %tbody
+      %tr
+        %td= t('two_factor_authentication.otp')
+        %td
+          = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post
+      %tr
+        %td= t('two_factor_authentication.webauthn')
+        - if current_user.webauthn_enabled?
+          %td
+            = table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get
+        - else
+          %td
+            = table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get
+
+%hr.spacer/
+
+%h3= t('two_factor_authentication.recovery_codes')
+%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
+
+%hr.spacer/
+
+.simple_form
+  = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml
deleted file mode 100644
index f1eecd000..000000000
--- a/app/views/settings/two_factor_authentications/show.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- content_for :page_title do
-  = t('settings.two_factor_authentication')
-
-- if current_user.otp_required_for_login
-  %p.hint
-    %span.positive-hint
-      = fa_icon 'check'
-      = ' '
-      = t 'two_factor_authentication.enabled'
-
-  %hr.spacer/
-
-  = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
-    .fields-group
-      = f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
-
-    .actions
-      = f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
-
-  %hr.spacer/
-
-  %h3= t('two_factor_authentication.recovery_codes')
-  %p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
-
-  %hr.spacer/
-
-  .simple_form
-    = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
-
-- else
-  .simple_form
-    %p.hint= t('two_factor_authentication.description_html')
-
-    %hr.spacer/
-
-    = link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'
diff --git a/app/views/user_mailer/webauthn_credential_added.html.haml b/app/views/user_mailer/webauthn_credential_added.html.haml
new file mode 100644
index 000000000..81de84b56
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.added.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_added.text.erb b/app/views/user_mailer/webauthn_credential_added.text.erb
new file mode 100644
index 000000000..4319dddbf
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_added.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.two_factor_enabled.title' %>
+
+===
+
+<%= t 'devise.mailer.two_factor_enabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_credential_deleted.html.haml b/app/views/user_mailer/webauthn_credential_deleted.html.haml
new file mode 100644
index 000000000..7b47f0c88
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.html.haml
@@ -0,0 +1,44 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_credential.deleted.title'
+                              %p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:"
+                              %p.lead= @webauthn_credential.nickname
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_credential_deleted.text.erb b/app/views/user_mailer/webauthn_credential_deleted.text.erb
new file mode 100644
index 000000000..53e5bc78c
--- /dev/null
+++ b/app/views/user_mailer/webauthn_credential_deleted.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_disabled.html.haml b/app/views/user_mailer/webauthn_disabled.html.haml
new file mode 100644
index 000000000..81a2a7954
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_disabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_disabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_disabled.text.erb b/app/views/user_mailer/webauthn_disabled.text.erb
new file mode 100644
index 000000000..962df77ca
--- /dev/null
+++ b/app/views/user_mailer/webauthn_disabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_disabled.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_disabled.explanation' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/views/user_mailer/webauthn_enabled.html.haml b/app/views/user_mailer/webauthn_enabled.html.haml
new file mode 100644
index 000000000..f08e764e8
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.html.haml
@@ -0,0 +1,43 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
+
+                              %h1= t 'devise.mailer.webauthn_enabled.title'
+                              %p.lead= t 'devise.mailer.webauthn_enabled.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t('settings.account_settings')
diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb
new file mode 100644
index 000000000..4c233fefb
--- /dev/null
+++ b/app/views/user_mailer/webauthn_enabled.text.erb
@@ -0,0 +1,7 @@
+<%= t 'devise.mailer.webauthn_credentia.added.title' %>
+
+===
+
+<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
+
+=> <%= edit_user_registration_url %>