about summary refs log tree commit diff
path: root/app/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'app/controllers')
-rw-r--r--app/controllers/accounts_controller.rb12
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb23
-rw-r--r--app/controllers/api/v1/bookmarks_controller.rb7
-rw-r--r--app/controllers/api/v1/favourites_controller.rb7
-rw-r--r--app/controllers/api/v1/notifications_controller.rb8
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb20
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb19
-rw-r--r--app/controllers/auth/sessions_controller.rb18
-rw-r--r--app/controllers/concerns/cache_concern.rb4
-rw-r--r--app/controllers/concerns/signature_verification.rb163
-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
17 files changed, 399 insertions, 177 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 5c8cdd174..54106933c 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -29,8 +29,7 @@ class AccountsController < ApplicationController
         end
 
         @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
-        @statuses        = filtered_status_page
-        @statuses        = cache_collection(@statuses, Status)
+        @statuses        = cached_filtered_status_page
         @rss_url         = rss_url
 
         unless @statuses.empty?
@@ -143,8 +142,13 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
-  def filtered_status_page
-    filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id))
+  def cached_filtered_status_page
+    cache_collection_paginated_by_id(
+      filtered_statuses,
+      Status,
+      PAGE_SIZE,
+      params_slice(:max_id, :min_id, :since_id)
+    )
   end
 
   def params_slice(*keys)
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index e25a4bc07..c33c15255 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -50,8 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
     return unless page_requested?
 
     @statuses = @account.statuses.permitted_for(@account, signed_request_account)
-    @statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id))
-    @statuses = cache_collection(@statuses, Status)
+    @statuses = cache_collection_paginated_by_id(
+      @statuses,
+      Status,
+      LIMIT,
+      params_slice(:max_id, :min_id, :since_id)
+    )
   end
 
   def page_requested?
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 114ee0a82..85a9133e3 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -22,10 +22,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def cached_account_statuses
-    cache_collection account_statuses, Status
-  end
-
-  def account_statuses
     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
 
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
@@ -33,7 +29,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
     statuses.merge!(hashtag_scope)    if params[:tagged].present?
 
-    statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
+    cache_collection_paginated_by_id(
+      statuses,
+      Status,
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params_slice(:max_id, :since_id, :min_id)
+    )
   end
 
   def permitted_account_statuses
@@ -41,17 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def only_media_scope
-    Status.where(id: account_media_status_ids)
-  end
-
-  def account_media_status_ids
-    # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-    # Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
-    # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
-    # and the table will be joined by `Merge Semi Join`, so the query will be slow.
-    @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
-            .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
-            .reorder(id: :desc).distinct(:id).pluck(:id)
+    Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
   end
 
   def pinned_scope
diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb
index c15212f0a..5c72f4a1a 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -17,14 +17,11 @@ class Api::V1::BookmarksController < Api::BaseController
   end
 
   def cached_bookmarks
-    cache_collection(
-      Status.reorder(nil).joins(:bookmarks).merge(results),
-      Status
-    )
+    cache_collection(results.map(&:status), Status)
   end
 
   def results
-    @_results ||= account_bookmarks.paginate_by_id(
+    @_results ||= account_bookmarks.eager_load(:status).paginate_by_id(
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index 3e242905d..71a707d2a 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -17,14 +17,11 @@ class Api::V1::FavouritesController < Api::BaseController
   end
 
   def cached_favourites
-    cache_collection(
-      Status.reorder(nil).joins(:favourites).merge(results),
-      Status
-    )
+    cache_collection(results.map(&:status), Status)
   end
 
   def results
-    @_results ||= account_favourites.paginate_by_id(
+    @_results ||= account_favourites.eager_load(:status).paginate_by_id(
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 9dce9b807..9ff168367 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -40,11 +40,9 @@ class Api::V1::NotificationsController < Api::BaseController
   private
 
   def load_notifications
-    cache_collection paginated_notifications, Notification
-  end
-
-  def paginated_notifications
-    browserable_account_notifications.paginate_by_id(
+    cache_collection_paginated_by_id(
+      browserable_account_notifications,
+      Notification,
       limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index b449bcadf..52b5cb323 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -16,25 +16,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def load_statuses
-    cached_public_statuses
+    cached_public_statuses_page
   end
 
-  def cached_public_statuses
-    cache_collection public_statuses, Status
-  end
-
-  def public_statuses
-    statuses = public_timeline_statuses.paginate_by_id(
+  def cached_public_statuses_page
+    cache_collection_paginated_by_id(
+      public_statuses,
+      Status,
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
+  end
+
+  def public_statuses
+    statuses = public_timeline_statuses
 
     statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
 
     if truthy_param?(:only_media)
-      # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-      status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
-      statuses.where(id: status_ids)
+      statuses.joins(:media_attachments).group(:id)
     else
       statuses
     end
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 2d6ad5a80..76f7d3590 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -20,25 +20,18 @@ class Api::V1::Timelines::TagController < Api::BaseController
   end
 
   def cached_tagged_statuses
-    cache_collection tagged_statuses, Status
-  end
-
-  def tagged_statuses
     if @tag.nil?
       []
     else
-      statuses = tag_timeline_statuses.paginate_by_id(
+      statuses = tag_timeline_statuses
+      statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
+
+      cache_collection_paginated_by_id(
+        statuses,
+        Status,
         limit_param(DEFAULT_STATUSES_LIMIT),
         params_slice(:max_id, :since_id, :min_id)
       )
-
-      if truthy_param?(:only_media)
-        # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-        status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
-        statuses.where(id: status_ids)
-      else
-        statuses
-      end
     end
   end
 
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 441833e85..1cf6a0a59 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -39,6 +39,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
@@ -53,7 +69,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/cache_concern.rb b/app/controllers/concerns/cache_concern.rb
index c7d25ae00..189b92012 100644
--- a/app/controllers/concerns/cache_concern.rb
+++ b/app/controllers/concerns/cache_concern.rb
@@ -47,4 +47,8 @@ module CacheConcern
 
     raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
   end
+
+  def cache_collection_paginated_by_id(raw, klass, limit, options)
+    cache_collection raw.cache_ids.paginate_by_id(limit, options), klass
+  end
 end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 10efbf2e0..18f549de9 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -7,6 +7,44 @@ module SignatureVerification
 
   include DomainControlHelper
 
+  EXPIRATION_WINDOW_LIMIT = 12.hours
+  CLOCK_SKEW_MARGIN       = 1.hour
+
+  class SignatureVerificationError < StandardError; end
+
+  class SignatureParamsParser < Parslet::Parser
+    rule(:token)         { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
+    rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
+    # qdtext and quoted_pair are not exactly according to spec but meh
+    rule(:qdtext)        { match('[^\\\\"]') }
+    rule(:quoted_pair)   { str('\\') >> any }
+    rule(:bws)           { match('\s').repeat }
+    rule(:param)         { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
+    rule(:comma)         { bws >> str(',') >> bws }
+    # Old versions of node-http-signature add an incorrect "Signature " prefix to the header
+    rule(:buggy_prefix)  { str('Signature ') }
+    rule(:params)        { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
+    root(:params)
+  end
+
+  class SignatureParamsTransformer < Parslet::Transform
+    rule(params: subtree(:p)) do
+      (p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
+    end
+
+    rule(param: { key: simple(:key), value: simple(:val) }) do
+      [key, val]
+    end
+
+    rule(quoted_string: simple(:string)) do
+      string.to_s
+    end
+
+    rule(token: simple(:string)) do
+      string.to_s
+    end
+  end
+
   def require_signature!
     render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
   end
@@ -24,72 +62,40 @@ module SignatureVerification
   end
 
   def signature_key_id
-    raw_signature    = request.headers['Signature']
-    signature_params = {}
-
-    raw_signature.split(',').each do |part|
-      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
-      next if parsed_parts.nil? || parsed_parts.size != 3
-      signature_params[parsed_parts[1]] = parsed_parts[2]
-    end
-
     signature_params['keyId']
+  rescue SignatureVerificationError
+    nil
   end
 
   def signed_request_account
     return @signed_request_account if defined?(@signed_request_account)
 
-    unless signed_request?
-      @signature_verification_failure_reason = 'Request not signed'
-      @signed_request_account = nil
-      return
-    end
-
-    if request.headers['Date'].present? && !matches_time_window?
-      @signature_verification_failure_reason = 'Signed request date outside acceptable time window'
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, 'Request not signed' unless signed_request?
+    raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
+    raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
+    raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
 
-    raw_signature    = request.headers['Signature']
-    signature_params = {}
-
-    raw_signature.split(',').each do |part|
-      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
-      next if parsed_parts.nil? || parsed_parts.size != 3
-      signature_params[parsed_parts[1]] = parsed_parts[2]
-    end
-
-    if incompatible_signature?(signature_params)
-      @signature_verification_failure_reason = 'Incompatible request signature'
-      @signed_request_account = nil
-      return
-    end
+    verify_signature_strength!
 
     account = account_from_key_id(signature_params['keyId'])
 
-    if account.nil?
-      @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
 
     signature             = Base64.decode64(signature_params['signature'])
-    compare_signed_string = build_signed_string(signature_params['headers'])
+    compare_signed_string = build_signed_string
 
     return account unless verify_signature(account, signature, compare_signed_string).nil?
 
     account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
 
-    if account.nil?
-      @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
-      @signed_request_account = nil
-      return
-    end
+    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
 
     return account unless verify_signature(account, signature, compare_signed_string).nil?
 
-    @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
+    @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
+    @signed_request_account = nil
+  rescue SignatureVerificationError => e
+    @signature_verification_failure_reason = e.message
     @signed_request_account = nil
   end
 
@@ -99,6 +105,31 @@ module SignatureVerification
 
   private
 
+  def signature_params
+    @signature_params ||= begin
+      raw_signature = request.headers['Signature']
+      tree          = SignatureParamsParser.new.parse(raw_signature)
+      SignatureParamsTransformer.new.apply(tree)
+    end
+  rescue Parslet::ParseFailed
+    raise SignatureVerificationError, 'Error parsing signature parameters'
+  end
+
+  def signature_algorithm
+    signature_params.fetch('algorithm', 'hs2019')
+  end
+
+  def signed_headers
+    signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
+  end
+
+  def verify_signature_strength!
+    raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
+    raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
+    raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
+    raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
+  end
+
   def verify_signature(account, signature, compare_signed_string)
     if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
       @signed_request_account = account
@@ -108,12 +139,20 @@ module SignatureVerification
     nil
   end
 
-  def build_signed_string(signed_headers)
-    signed_headers = 'date' if signed_headers.blank?
-
-    signed_headers.downcase.split(' ').map do |signed_header|
+  def build_signed_string
+    signed_headers.map do |signed_header|
       if signed_header == Request::REQUEST_TARGET
         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+      elsif signed_header == '(created)'
+        raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
+        raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
+
+        "(created): #{signature_params['created']}"
+      elsif signed_header == '(expires)'
+        raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
+        raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
+
+        "(expires): #{signature_params['expires']}"
       elsif signed_header == 'digest'
         "digest: #{body_digest}"
       else
@@ -123,13 +162,28 @@ module SignatureVerification
   end
 
   def matches_time_window?
+    created_time = nil
+    expires_time = nil
+
     begin
-      time_sent = Time.httpdate(request.headers['Date'])
+      if signature_algorithm == 'hs2019' && signature_params['created'].present?
+        created_time = Time.at(signature_params['created'].to_i).utc
+      elsif request.headers['Date'].present?
+        created_time = Time.httpdate(request.headers['Date']).utc
+      end
+
+      expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
     rescue ArgumentError
       return false
     end
 
-    (Time.now.utc - time_sent).abs <= 12.hours
+    expires_time ||= created_time + 5.minutes unless created_time.nil?
+    expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
+
+    return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
+    return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
+
+    true
   end
 
   def body_digest
@@ -140,9 +194,8 @@ module SignatureVerification
     name.split(/-/).map(&:capitalize).join('-')
   end
 
-  def incompatible_signature?(signature_params)
-    signature_params['keyId'].blank? ||
-      signature_params['signature'].blank?
+  def missing_required_signature_parameters?
+    signature_params['keyId'].blank? || signature_params['signature'].blank?
   end
 
   def account_from_key_id(key_id)
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 35c0c27cf..6b043a804 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)
@@ -44,6 +75,12 @@ module TwoFactorAuthenticationConcern
       session[:attempt_user_id] = user.id
       use_pack 'auth'
       @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