From 1b493c9fee954b5bd4c4b00f9f945a5d97e2d699 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 24 Jan 2022 19:06:19 +0100 Subject: [PATCH] Add optional hCaptcha support Fixes #1649 This requires setting `HCAPTCHA_SECRET_KEY` and `HCAPTCHA_SITE_KEY`, then enabling the admin setting at `/admin/settings/edit#form_admin_settings_captcha_enabled` Subsequently, a hCaptcha widget will be displayed on `/about` and `/auth/sign_up` unless: - the user is already signed-up already - the user has used an invite link - the user has already solved the captcha (and registration failed for another reason) The Content-Security-Policy headers are altered automatically to allow the third-party hCaptcha scripts on `/about` and `/auth/sign_up` following the same rules as above. --- .env.production.sample | 4 ++ Gemfile | 2 + Gemfile.lock | 3 + app/controllers/about_controller.rb | 2 + app/controllers/api/v1/accounts_controller.rb | 4 +- .../auth/registrations_controller.rb | 17 +++++ app/controllers/concerns/captcha_concern.rb | 66 +++++++++++++++++++ app/helpers/admin/settings_helper.rb | 4 ++ .../flavours/glitch/styles/forms.scss | 4 ++ app/models/form/admin_settings.rb | 2 + app/views/about/_registration.html.haml | 3 + app/views/admin/settings/edit.html.haml | 5 +- app/views/auth/registrations/new.html.haml | 3 + config/locales-glitch/en.yml | 3 + config/settings.yml | 1 + 15 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 app/controllers/concerns/captcha_concern.rb diff --git a/.env.production.sample b/.env.production.sample index 13e89b40d..7de5e00f4 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -285,3 +285,7 @@ MAX_POLL_OPTION_CHARS=100 # Units are in bytes MAX_EMOJI_SIZE=51200 MAX_REMOTE_EMOJI_SIZE=204800 + +# Optional hCaptcha support +# HCAPTCHA_SECRET_KEY= +# HCAPTCHA_SITE_KEY= diff --git a/Gemfile b/Gemfile index eae5f11b7..282ab65e4 100644 --- a/Gemfile +++ b/Gemfile @@ -156,3 +156,5 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' + +gem "hcaptcha", "~> 7.1" diff --git a/Gemfile.lock b/Gemfile.lock index 8d72732eb..cc9a53e41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -271,6 +271,8 @@ GEM railties (>= 4.0.1) hashdiff (1.0.1) hashie (4.1.0) + hcaptcha (7.1.0) + json highline (2.0.3) hiredis (0.6.3) hkdf (0.3.0) @@ -719,6 +721,7 @@ DEPENDENCIES fog-openstack (~> 0.3) fuubar (~> 2.5) hamlit-rails (~> 0.2) + hcaptcha (~> 7.1) hiredis (~> 0.6) htmlentities (~> 4.3) http (~> 5.0) diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 620c0ff78..5a35dbbcb 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -2,6 +2,7 @@ class AboutController < ApplicationController include RegistrationSpamConcern + include CaptchaConcern before_action :set_pack @@ -12,6 +13,7 @@ class AboutController < ApplicationController before_action :set_instance_presenter before_action :set_expires_in, only: [:more, :terms] before_action :set_registration_form_time, only: :show + before_action :extend_csp_for_captcha!, only: :show skip_before_action :require_functional!, only: [:more, :terms] diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5c47158e0..8916c3f96 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::AccountsController < Api::BaseController + include CaptchaConcern + before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute] before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers] before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute] @@ -83,7 +85,7 @@ class Api::V1::AccountsController < Api::BaseController end def check_enabled_registrations - forbidden if single_user_mode? || omniauth_only? || !allowed_registrations? + forbidden if single_user_mode? || omniauth_only? || !allowed_registrations? || captcha_enabled? end def allowed_registrations? diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 6b1f3fa82..3c9b38a4b 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -2,6 +2,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController include RegistrationSpamConcern + include CaptchaConcern layout :determine_layout @@ -15,6 +16,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :require_not_suspended!, only: [:update] before_action :set_cache_headers, only: [:edit, :update] before_action :set_registration_form_time, only: :new + before_action :extend_csp_for_captcha!, only: [:new, :create] + before_action :check_captcha!, only: :create skip_before_action :require_functional!, only: [:edit, :update] @@ -135,4 +138,18 @@ class Auth::RegistrationsController < Devise::RegistrationsController def set_cache_headers response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' end + + def sign_up(resource_name, resource) + clear_captcha! + super + end + + def check_captcha! + super do |error| + build_resource(sign_up_params) + resource.validate + resource.errors.add(:base, error) + respond_with resource + end + end end diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb new file mode 100644 index 000000000..5a23e59e3 --- /dev/null +++ b/app/controllers/concerns/captcha_concern.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module CaptchaConcern + extend ActiveSupport::Concern + include Hcaptcha::Adapters::ViewMethods + + CAPTCHA_TIMEOUT = 2.hours.freeze + + included do + helper_method :render_captcha_if_needed + end + + def captcha_available? + ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + end + + def captcha_enabled? + captcha_available? && Setting.captcha_enabled + end + + def captcha_recently_passed? + session[:captcha_passed_at].present? && session[:captcha_passed_at] >= CAPTCHA_TIMEOUT.ago + end + + def captcha_required? + captcha_enabled? && !current_user && !(@invite.present? && @invite.valid_for_use? && !@invite.max_uses.nil?) && !captcha_recently_passed? + end + + def clear_captcha! + session.delete(:captcha_passed_at) + end + + def check_captcha! + return true unless captcha_required? + + if verify_hcaptcha + session[:captcha_passed_at] = Time.now.utc + return true + else + if block_given? + message = flash[:hcaptcha_error] + flash.delete(:hcaptcha_error) + yield message + end + return false + end + end + + def extend_csp_for_captcha! + policy = request.content_security_policy + return unless captcha_required? && policy.present? + + %w(script_src frame_src style_src connect_src).each do |directive| + values = policy.send(directive) + values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:') + values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:') + policy.send(directive, *values) + end + end + + def render_captcha_if_needed + return unless captcha_required? + + hcaptcha_tags + end +end diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb index baf14ab25..f99a2b8c8 100644 --- a/app/helpers/admin/settings_helper.rb +++ b/app/helpers/admin/settings_helper.rb @@ -8,4 +8,8 @@ module Admin::SettingsHelper link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete } safe_join([hint, link], '
'.html_safe) end + + def captcha_available? + ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + end end diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index 3433abcdd..64d441fb2 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -1058,3 +1058,7 @@ code { display: none; } } + +.simple_form .h-captcha { + text-align: center; +} diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 3202d1fc2..34f14e312 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -40,6 +40,7 @@ class Form::AdminSettings noindex outgoing_spoilers require_invite_text + captcha_enabled ).freeze BOOLEAN_KEYS = %i( @@ -58,6 +59,7 @@ class Form::AdminSettings trendable_by_default noindex require_invite_text + captcha_enabled ).freeze UPLOAD_KEYS = %i( diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index e4d614d71..5bb5d08a2 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -21,6 +21,9 @@ .fields-group = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true, disabled: closed_registrations? + .fields-group + = render_captcha_if_needed + .actions = f.button :button, sign_up_message, type: :submit, class: 'button button-primary', disabled: closed_registrations? diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index b9daae8f0..49b03a9e3 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -42,7 +42,10 @@ .fields-group = f.input :require_invite_text, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.require_invite_text.title'), hint: t('admin.settings.registrations.require_invite_text.desc_html'), disabled: !approved_registrations? - .fields-group + + - if captcha_available? + .fields-group + = f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html') %hr.spacer/ diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 6981195ed..5cb558297 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -38,6 +38,9 @@ .fields-group = f.input :agreement, as: :boolean, wrapper: :with_label, label: whitelist_mode? ? t('auth.checkbox_agreement_without_rules_html', terms_path: terms_path) : t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true + .field-group + = render_captcha_if_needed + .actions = f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml index 5cc2625fc..c96f21c92 100644 --- a/config/locales-glitch/en.yml +++ b/config/locales-glitch/en.yml @@ -2,6 +2,9 @@ en: admin: settings: + captcha_enabled: + desc_html: Enable hCaptcha integration, requiring new users to solve a challenge when signing up. Note that this disables app-based registration, and requires third-party scripts from hCaptcha to be embedded in the registration pages. This may have security and privacy concerns. + title: Require new users to go through a CAPTCHA to sign up enable_keybase: desc_html: Allow your users to prove their identity via keybase title: Enable keybase integration diff --git a/config/settings.yml b/config/settings.yml index 094209822..7d192f369 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -77,6 +77,7 @@ defaults: &defaults show_domain_blocks_rationale: 'disabled' outgoing_spoilers: '' require_invite_text: false + captcha_enabled: false development: <<: *defaults