diff --git a/Gemfile b/Gemfile
index 63b7713395c..8c718b35699 100644
--- a/Gemfile
+++ b/Gemfile
@@ -160,3 +160,5 @@ gem 'cocoon', '~> 1.2'
gem 'net-http', '~> 0.3.2'
gem 'rubyzip', '~> 2.3'
+
+gem 'hcaptcha', '~> 7.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 20cb29355dd..b5d277097a9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -312,6 +312,8 @@ GEM
sysexits (~> 1.1)
hashdiff (1.0.1)
hashie (5.0.0)
+ hcaptcha (7.1.0)
+ json
highline (2.1.0)
hiredis (0.6.3)
hkdf (0.3.0)
@@ -806,6 +808,7 @@ DEPENDENCIES
fuubar (~> 2.5)
haml-rails (~> 2.0)
haml_lint
+ hcaptcha (~> 7.1)
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 5.1)
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 010fd375566..c57eb946e18 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -1,21 +1,63 @@
# frozen_string_literal: true
class Auth::ConfirmationsController < Devise::ConfirmationsController
+ include CaptchaConcern
+
layout 'auth'
before_action :set_body_classes
+ before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :require_unconfirmed!
+ before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
+ before_action :require_captcha_if_needed!, only: [:show]
+
skip_before_action :require_functional!
+ def show
+ old_session_values = session.to_hash
+ reset_session
+ session.update old_session_values.except('session_id')
+
+ super
+ end
+
def new
super
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
end
+ def confirm_captcha
+ check_captcha! do |message|
+ flash.now[:alert] = message
+ render :captcha
+ return
+ end
+
+ show
+ end
+
private
+ def require_captcha_if_needed!
+ render :captcha if captcha_required?
+ end
+
+ def set_confirmation_user!
+ # We need to reimplement looking up the user because
+ # Devise::ConfirmationsController#show looks up and confirms in one
+ # step.
+ confirmation_token = params[:confirmation_token]
+ return if confirmation_token.nil?
+
+ @confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
+ end
+
+ def captcha_user_bypass?
+ return true if @confirmation_user.nil? || @confirmation_user.confirmed?
+ end
+
def require_unconfirmed!
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb
new file mode 100644
index 00000000000..538c1ffb147
--- /dev/null
+++ b/app/controllers/concerns/captcha_concern.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module CaptchaConcern
+ extend ActiveSupport::Concern
+ include Hcaptcha::Adapters::ViewMethods
+
+ included do
+ helper_method :render_captcha
+ 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_user_bypass?
+ false
+ end
+
+ def captcha_required?
+ captcha_enabled? && !captcha_user_bypass?
+ end
+
+ def check_captcha!
+ return true unless captcha_required?
+
+ if verify_hcaptcha
+ true
+ else
+ if block_given?
+ message = flash[:hcaptcha_error]
+ flash.delete(:hcaptcha_error)
+ yield message
+ end
+ 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
+ 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 a133b4e7d9b..552a3ee5a86 100644
--- a/app/helpers/admin/settings_helper.rb
+++ b/app/helpers/admin/settings_helper.rb
@@ -1,4 +1,7 @@
# frozen_string_literal: true
module Admin::SettingsHelper
+ def captcha_available?
+ ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
+ end
end
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 03e06c1000f..57f077c4e80 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -136,6 +136,10 @@ code {
line-height: 22px;
color: $secondary-text-color;
margin-bottom: 30px;
+
+ a {
+ color: $highlight-text-color;
+ }
}
.rules-list {
@@ -1039,6 +1043,10 @@ code {
}
}
+.simple_form .h-captcha {
+ text-align: center;
+}
+
.permissions-list {
&__item {
padding: 15px;
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index de965cb0ba3..a6be55fd7b2 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -33,6 +33,7 @@ class Form::AdminSettings
content_cache_retention_period
backups_retention_period
status_page_url
+ captcha_enabled
).freeze
INTEGER_KEYS = %i(
@@ -52,6 +53,7 @@ class Form::AdminSettings
trendable_by_default
noindex
require_invite_text
+ captcha_enabled
).freeze
UPLOAD_KEYS = %i(
diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml
index 0db9f3536fb..84492a08a16 100644
--- a/app/views/admin/settings/registrations/show.html.haml
+++ b/app/views/admin/settings/registrations/show.html.haml
@@ -20,6 +20,10 @@
.fields-row__column.fields-row__column-6.fields-group
= f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations?
+ - 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')
+
.fields-group
= f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, input_html: { rows: 2 }
diff --git a/app/views/auth/confirmations/captcha.html.haml b/app/views/auth/confirmations/captcha.html.haml
new file mode 100644
index 00000000000..1f577383eb6
--- /dev/null
+++ b/app/views/auth/confirmations/captcha.html.haml
@@ -0,0 +1,15 @@
+- content_for :page_title do
+ = t('auth.captcha_confirmation.title')
+
+= form_tag auth_captcha_confirmation_url, method: 'POST', class: 'simple_form' do
+ = render 'auth/shared/progress', stage: 'confirm'
+
+ = hidden_field_tag :confirmation_token, params[:confirmation_token]
+
+ %p.lead= t('auth.captcha_confirmation.hint_html')
+
+ .field-group
+ = render_captcha
+
+ .actions
+ %button.button= t('challenge.confirm')
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 29abec94371..aea9656602f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -731,6 +731,9 @@ en:
branding:
preamble: Your server's branding differentiates it from other servers in the network. This information may be displayed across a variety of environments, such as Mastodon's web interface, native applications, in link previews on other websites and within messaging apps, and so on. For this reason, it is best to keep this information clear, short and concise.
title: Branding
+ captcha_enabled:
+ desc_html: This relies on external scripts from hCaptcha, which may be a security and privacy concern. In addition, this can make the registration process significantly less accessible to some (especially disabled) people. For these reasons, please consider alternative measures such as approval-based or invite-based registration.
+ title: Require new users to solve a CAPTCHA to confirm their account
content_retention:
preamble: Control how user-generated content is stored in Mastodon.
title: Content retention
@@ -979,6 +982,9 @@ en:
your_token: Your access token
auth:
apply_for_account: Request an account
+ captcha_confirmation:
+ hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can contact the server administrator if you have questions or need assistance with confirming your account.
+ title: User verification
change_password: Password
confirmations:
wrong_email_hint: If that e-mail address is not correct, you can change it in account settings.
diff --git a/config/routes.rb b/config/routes.rb
index 04d5ec2a65f..45b3d90e0b3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -71,6 +71,7 @@ Rails.application.routes.draw do
resource :setup, only: [:show, :update], controller: :setup
resource :challenge, only: [:create], controller: :challenges
get 'sessions/security_key_options', to: 'sessions#webauthn_options'
+ post 'captcha_confirmation', to: 'confirmations#confirm_captcha', as: :captcha_confirmation
end
end
diff --git a/config/settings.yml b/config/settings.yml
index 4ac521a4b0b..67297c26cea 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -37,6 +37,7 @@ defaults: &defaults
show_domain_blocks_rationale: 'disabled'
require_invite_text: false
backups_retention_period: 7
+ captcha_enabled: false
development:
<<: *defaults