From e8d41bc2fe9418613cdc118c8674fc5fe7856685 Mon Sep 17 00:00:00 2001 From: santiagorodriguez96 <46354312+santiagorodriguez96@users.noreply.github.com> Date: Mon, 24 Aug 2020 11:46:27 -0300 Subject: Add WebAuthn as an alternative 2FA method (#14466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * feat: add possibility of deleting WebAuthn Credentials Co-authored-by: Facundo Padula * feat: disable WebAuthn when an Admin disables 2FA for a user Co-authored-by: Facundo Padula * 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 * 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 --- app/controllers/auth/sessions_controller.rb | 18 +++- .../concerns/two_factor_authentication_concern.rb | 45 ++++++++- .../confirmations_controller.rb | 14 ++- .../otp_authentication_controller.rb | 42 +++++++++ .../webauthn_credentials_controller.rb | 103 +++++++++++++++++++++ ...two_factor_authentication_methods_controller.rb | 30 ++++++ .../two_factor_authentications_controller.rb | 53 ----------- 7 files changed, 243 insertions(+), 62 deletions(-) create mode 100644 app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb create mode 100644 app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb create mode 100644 app/controllers/settings/two_factor_authentication_methods_controller.rb delete mode 100644 app/controllers/settings/two_factor_authentications_controller.rb (limited to 'app/controllers') 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 -- cgit From b241f20bd2387244c14fa5de70bd7c928b599a8b Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 24 Aug 2020 18:21:07 +0200 Subject: Add support for latest HTTP Signatures spec draft (#14556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for latest HTTP Signatures spec draft https://www.ietf.org/id/draft-ietf-httpbis-message-signatures-00.html - add support for the “hs2019” signature algorithm (assumed to be equivalent to RSA-SHA256, since we do not have a mechanism to specify the algorithm within the key metadata yet) - add support for (created) and (expires) pseudo-headers and related signature parameters, when using the hs2019 signature algorithm - adjust default “headers” parameter while being backwards-compatible with previous implementation - change the acceptable time window logic from 12 hours surrounding the “date” header to accepting signatures created up to 1 hour in the future and expiring up to 1 hour in the past (but only allowing expiration dates up to 12 hours after the creation date) This doesn't conform with the current draft, as it doesn't permit accounting for clock skew. This, however, should be addressed in a next version of the draft: https://github.com/httpwg/http-extensions/pull/1235 * Add additional signature requirements * Rewrite signature params parsing using Parslet * Make apparent which signature algorithm Mastodon on verification failure Mastodon uses RSASSA-PKCS1-v1_5, which is not recommended for new applications, and new implementers may thus unknowingly use RSASSA-PSS. * Add workaround for PeerTube's invalid signature header The previous parser allowed incorrect Signature headers, such as those produced by old versions of the `http-signature` node.js package, and seemingly used by PeerTube. This commit adds a workaround for that. * Fix `signature_key_id` raising an exception Previously, parsing failures would result in `signature_key_id` being nil, but the parser changes made that result in an exception. This commit changes the `signature_key_id` method to return `nil` in case of parsing failures. * Move extra HTTP signature helper methods to private methods * Relax (request-target) requirement to (request-target) || digest This lets requests from Plume work without lowering security significantly. --- app/controllers/concerns/signature_verification.rb | 163 ++++++++++++++------- 1 file changed, 108 insertions(+), 55 deletions(-) (limited to 'app/controllers') 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) -- cgit From 41eeb9ebaa65abe3fcbab60847b69d2469726d8a Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Tue, 25 Aug 2020 20:39:35 +0900 Subject: Use Status.group instead of Status.distinct in HashQueryService (#14662) DISTINCT clause removes duplicated records according to all the selected attributes. In reality, it can remove duplicated records only looking at statuses.id, but the clause confuses the query planner and yields insufficient performance. The behavior is also problematic if the scope produced by HashQueryService is used to query columns without id (using pluck method, for example). The scope is expected to contain unique statuses, but the uniquness will be evaluated with some arbitrary columns other than id. GROUP BY clause resolves those problem by explicitly specifying the column to take into account for the record distinction. A workaround for the problem of DISTINCT clause in Api::V1::Timelines::TagController is no longer necessary and removed. --- app/controllers/api/v1/timelines/tag_controller.rb | 4 +--- app/services/hashtag_query_service.rb | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 2d6ad5a80..62f34d3f7 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -33,9 +33,7 @@ class Api::V1::Timelines::TagController < Api::BaseController ) 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) else statuses end diff --git a/app/services/hashtag_query_service.rb b/app/services/hashtag_query_service.rb index 196de0639..0bdf60221 100644 --- a/app/services/hashtag_query_service.rb +++ b/app/services/hashtag_query_service.rb @@ -8,7 +8,7 @@ class HashtagQueryService < BaseService all = tags_for(params[:all]) none = tags_for(params[:none]) - Status.distinct + Status.group(:id) .as_tag_timeline(tags, account, local) .tagged_with_all(all) .tagged_with_none(none) -- cgit From 552e886b648faa2a2229d86c7fd9abc8bb5ff99c Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Fri, 28 Aug 2020 16:27:33 +0900 Subject: Eagerly load statuses with the main query in Api::V1::FavouritesController (#14673) The old implementation had two queries: 1. The query constructed in Api::V1::FavouritesController#results 2. The query constructed in #cached_favourites, which is merged with 1. Both of them are issued againt PostgreSQL. The combination of the two queries caused the following problems: - The small window between the two queries involves race conditions. - Minor performance inefficiency. Moreover, the construction of query 2, which involves merging with query 1 has a bug. Query 1 is finalized with paginate_by_id, but paginate_by_id returns an array when min_id parameter is specified. The behavior prevents from merging the query, and in the real world, ActiveRecord simply ignores the merge (!), which results in querying the entire scan of statuses and favourites table. This change fixes these issues by simply letting query 1 get all the works done. --- app/controllers/api/v1/favourites_controller.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) (limited to 'app/controllers') 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) ) -- cgit From e26e7a1cb5992375eecedbc10ab9bcef4e603a88 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Fri, 28 Aug 2020 19:29:59 +0900 Subject: Replace incorrect use of distinct with group (#14675) Some uses of ActiveRecord::QueryMethods#distinct pass field names but they are incorrect for the current version of Rails. ActiveRecord::QueryMethods#group provides the expected behavior and benefits performance. See commit 6da24aad4cafdef8d8a2c92bac2002a5fc2fe9c8. --- app/controllers/api/v1/accounts/statuses_controller.rb | 12 +----------- app/controllers/api/v1/timelines/public_controller.rb | 4 +--- 2 files changed, 2 insertions(+), 14 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 114ee0a82..1af768e54 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -41,17 +41,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/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index c6e7854d9..6ca903c16 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -30,9 +30,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController ) 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 -- cgit From b63ede5005d33b52266650ec716d345f166e2df0 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Fri, 28 Aug 2020 19:30:23 +0900 Subject: Eagerly load statuses with the main query in Api::V1::BookmarksController (#14674) This is same with commit 552e886b648faa2a2229d86c7fd9abc8bb5ff99c except that it was for Api::V1::FavouritesController while this is for Api::V1::BookmarksController. --- app/controllers/api/v1/bookmarks_controller.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) (limited to 'app/controllers') 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) ) -- cgit From 64ef37b89de806f49cc59e011aa0ee2039c82c46 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Fri, 28 Aug 2020 19:31:56 +0900 Subject: Introduce ApplicationController#cache_collection_paginated_by_id (#14677) * Replace incorrect use of distinct with group Some uses of ActiveRecord::QueryMethods#distinct pass field names but they are incorrect for the current version of Rails. ActiveRecord::QueryMethods#group provides the expected behavior and benefits performance. See commit 6da24aad4cafdef8d8a2c92bac2002a5fc2fe9c8. * Introduce ApplicationController#cache_collection_paginated_by_id ApplicationController#cache_collection_paginated_by_id fuses ApplicationController#cache_collection and Paginable.paginate_by_id. An advantage of this method is that it prevents from modifying scope which Paginable.paginate_by_id may provide. ApplicationController#cache_collection always return an array and there is no possibility of the scope modification. It is also clear for a programmer, considering the implication of "cache". This method can also emit more efficient queries by using Cacheable.cache_ids before calling Paginable.paginate_by_id. --- app/controllers/accounts_controller.rb | 12 ++++++++---- app/controllers/activitypub/outboxes_controller.rb | 8 ++++++-- app/controllers/api/v1/accounts/statuses_controller.rb | 11 ++++++----- app/controllers/api/v1/notifications_controller.rb | 8 +++----- app/controllers/api/v1/timelines/public_controller.rb | 16 +++++++++------- app/controllers/api/v1/timelines/tag_controller.rb | 17 ++++++----------- app/controllers/concerns/cache_concern.rb | 4 ++++ 7 files changed, 42 insertions(+), 34 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index db77b628c..d97d88fd9 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -28,8 +28,7 @@ class AccountsController < ApplicationController end @pinned_statuses = cache_collection(@account.pinned_statuses, 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? @@ -142,8 +141,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 1af768e54..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 diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 8ac227765..9d03cb879 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -31,11 +31,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 6ca903c16..26d877b00 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -16,18 +16,20 @@ 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 if truthy_param?(:only_media) statuses.joins(:media_attachments).group(:id) diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 62f34d3f7..76f7d3590 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -20,23 +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) - statuses.joins(:media_attachments) - else - statuses - end end end 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 -- cgit From a68ec50e4e38898e88a7dcc33bd0032adc946dda Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 30 Aug 2020 17:26:18 +0200 Subject: Adapt 2FA changes to glitch-soc's theming system --- .../webauthn_credentials_controller.rb | 4 + app/javascript/core/auth.js | 2 + app/javascript/core/theme.yml | 2 +- app/javascript/core/two_factor_authentication.js | 118 +++++++++++++++++++++ app/javascript/packs/two_factor_authentication.js | 118 --------------------- app/views/auth/sessions/two_factor.html.haml | 2 - .../webauthn_credentials/new.html.haml | 2 - 7 files changed, 125 insertions(+), 123 deletions(-) create mode 100644 app/javascript/core/auth.js create mode 100644 app/javascript/core/two_factor_authentication.js delete mode 100644 app/javascript/packs/two_factor_authentication.js (limited to 'app/controllers') diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index a19c604f3..ee5392785 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -85,6 +85,10 @@ module Settings private + def set_pack + use_pack 'auth' + end + def require_otp_enabled unless current_user.otp_enabled? flash[:error] = t('webauthn_credentials.otp_required') diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js new file mode 100644 index 000000000..ca04730a3 --- /dev/null +++ b/app/javascript/core/auth.js @@ -0,0 +1,2 @@ +import './settings'; +import './two_factor_authentication'; diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml index dc641772c..b9144e43a 100644 --- a/app/javascript/core/theme.yml +++ b/app/javascript/core/theme.yml @@ -3,7 +3,7 @@ pack: about: admin: admin.js - auth: settings.js + auth: auth.js common: filename: common.js stylesheet: true diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js new file mode 100644 index 000000000..dde06be8c --- /dev/null +++ b/app/javascript/core/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/packs/two_factor_authentication.js b/app/javascript/packs/two_factor_authentication.js deleted file mode 100644 index dde06be8c..000000000 --- a/app/javascript/packs/two_factor_authentication.js +++ /dev/null @@ -1,118 +0,0 @@ -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/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index f2f6fe19d..1867ec7f8 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -1,8 +1,6 @@ - content_for :page_title do = t('auth.login') -=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous' - - if @webauthn_enabled = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' } 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 index 0b23bb689..c5a323ee5 100644 --- a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml +++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml @@ -12,5 +12,3 @@ .actions = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit - -= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous' -- cgit