about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-08-26 13:56:20 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:45:20 -0500
commit1d3e5bdd24f25e1870f3d3bec1c81040a423f138 (patch)
tree4c586be72d8ebe144420bdd33e16a7e08e72a643
parent0622450209274a9051c03458156a694f2274f61e (diff)
[Spam Prevention] Add username confirmation and simple, frictionless anti-spam mechanism
-rw-r--r--app/controllers/admin/pending_accounts_controller.rb6
-rw-r--r--app/controllers/auth/registrations_controller.rb8
-rw-r--r--app/models/user.rb30
-rw-r--r--app/views/about/_registration.html.haml5
-rw-r--r--app/views/about/show.html.haml1
-rw-r--r--app/views/admin/accounts/index.html.haml2
-rw-r--r--app/views/admin/pending_accounts/index.html.haml2
-rw-r--r--app/views/auth/registrations/new.html.haml5
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb5
-rw-r--r--config/locales/simple_form.en-MP.yml3
-rw-r--r--db/migrate/20200826125821_add_username_and_nospam_to_users.rb6
-rw-r--r--db/schema.rb4
-rw-r--r--public/registration.js54
13 files changed, 119 insertions, 12 deletions
diff --git a/app/controllers/admin/pending_accounts_controller.rb b/app/controllers/admin/pending_accounts_controller.rb
index b62a9bc84..8a9a51d84 100644
--- a/app/controllers/admin/pending_accounts_controller.rb
+++ b/app/controllers/admin/pending_accounts_controller.rb
@@ -18,19 +18,19 @@ module Admin
     end
 
     def approve_all
-      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save
+      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'approve').save
       redirect_to admin_pending_accounts_path(current_params)
     end
 
     def reject_all
-      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save
+      Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'reject').save
       redirect_to admin_pending_accounts_path(current_params)
     end
 
     private
 
     def set_accounts
-      @accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page])
+      @accounts = Account.joins(:user).merge(User.pending.confirmed.recent).includes(user: :invite_request).page(params[:page])
     end
 
     def form_account_batch_params
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 96d973394..55975b274 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -35,6 +35,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     end
   end
 
+  def create
+    super do |resource|
+      return redirect_to root_path if resource.destroyed?
+    end
+  end
+
   protected
 
   def update_resource(resource, params)
@@ -55,7 +61,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   def configure_sign_up_params
     devise_parameter_sanitizer.permit(:sign_up) do |u|
-      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement)
+      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :username, :email, :password, :password_confirmation, :kobold, :invite_code, :agreement)
     end
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 9d1af7db6..b996c9dbe 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -40,6 +40,8 @@
 #  approved                  :boolean          default(TRUE), not null
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
+#  username                  :string
+#  kobold                    :string
 #
 
 class User < ApplicationRecord
@@ -87,7 +89,7 @@ class User < ApplicationRecord
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
   scope :recent, -> { order(id: :desc) }
-  scope :pending, -> { where(approved: false) }
+  scope :pending, -> { where(approved: false).where.not(kobold: '') }
   scope :approved, -> { where(approved: true) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :enabled, -> { where(disabled: false) }
@@ -153,7 +155,7 @@ class User < ApplicationRecord
 
     if new_user && approved?
       prepare_new_user!
-    elsif new_user
+    elsif new_user && user_might_not_be_a_spam_bot
       notify_staff_about_pending_account!
     end
   end
@@ -295,6 +297,17 @@ class User < ApplicationRecord
     super
   end
 
+  def send_confirmation_instructions
+    unless user_might_not_be_a_spam_bot
+      invite_request&.destroy
+      account&.destroy
+      destroy
+      return false
+    end
+
+    super
+  end
+
   def reset_password!(new_password, new_password_confirmation)
     return false if encrypted_password.blank?
 
@@ -421,4 +434,17 @@ class User < ApplicationRecord
   def validate_email_dns?
     email_changed? && !(Rails.env.test? || Rails.env.development?)
   end
+
+  def user_might_not_be_a_spam_bot
+    username == account.username && invite_request&.text.present? && kobold_hash_matches?
+  end
+
+  def kobold_hash_matches?
+    kobold.present? && kobold == kobold_hash
+  end
+
+  def kobold_hash
+    value = [account.username, username.downcase, email, invite_request.text].compact.map(&:downcase).join("\u{F0666}")
+    Digest::SHA512.hexdigest(value).upcase
+  end
 end
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 5d159e9e6..c3bd3ed60 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -6,14 +6,17 @@
       = f.simple_fields_for :account do |account_fields|
         = account_fields.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
 
+      = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
       = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
       = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations?
       = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
+      = f.hidden_field :kobold, input_html: { :autocomplete => 'off' }
+
 
     - if approved_registrations?
       .fields-group
         = f.simple_fields_for :invite_request do |invite_request_fields|
-          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true
 
     .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?
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 565c4ed59..b80a9207f 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -3,6 +3,7 @@
 
 - content_for :header_tags do
   %link{ rel: 'canonical', href: about_url }/
+  %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }/
   = render partial: 'shared/og'
 
 .landing
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 8eac226e0..bff1f2b20 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -10,7 +10,7 @@
   .filter-subset
     %strong= t('admin.accounts.moderation.title')
     %ul
-      %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), admin_pending_accounts_path
+      %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.confirmed.count)})"], ' '), admin_pending_accounts_path
       %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil
       %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil
       %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil
diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml
index 8101d7f99..2f73d12b4 100644
--- a/app/views/admin/pending_accounts/index.html.haml
+++ b/app/views/admin/pending_accounts/index.html.haml
@@ -1,5 +1,5 @@
 - content_for :page_title do
-  = t('admin.pending_accounts.title', count: User.pending.count)
+  = t('admin.pending_accounts.title', count: User.pending.confirmed.count)
 
 = form_for(@form, url: batch_admin_pending_accounts_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index cc72b87ce..b9033f553 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -2,6 +2,7 @@
   = t('auth.register')
 
 - content_for :header_tags do
+  %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }/
   = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
@@ -15,6 +16,7 @@
   = f.simple_fields_for :account do |ff|
     .fields-group
       = ff.input :username, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: t('simple_form.hints.defaults.username', domain: site_hostname)
+      = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
 
   .fields-group
     = f.input :email, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
@@ -28,9 +30,10 @@
   - if approved_registrations? && !@invite.present?
     .fields-group
       = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields|
-        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true
 
   = f.input :invite_code, as: :hidden
+  = f.hidden_field :kobold, input_html: { :autocomplete => 'off' }
 
   .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
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index 6113edde1..dade63028 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -10,5 +10,10 @@ class Scheduler::UserCleanupScheduler
       Account.where(id: batch.map(&:account_id)).delete_all
       User.where(id: batch.map(&:id)).delete_all
     end
+
+    User.where(kobold: '', approved: false).find_in_batches do |batch|
+      Account.where(id: batch.map(&:account_id)).delete_all
+      User.where(id: batch.map(&:id)).delete_all
+    end
   end
 end
diff --git a/config/locales/simple_form.en-MP.yml b/config/locales/simple_form.en-MP.yml
index 7f1c9912e..6ce6ae877 100644
--- a/config/locales/simple_form.en-MP.yml
+++ b/config/locales/simple_form.en-MP.yml
@@ -33,7 +33,7 @@ en-MP:
         setting_skin: Reskins the selected UI flavour
         show_replies: Disable if you'd prefer your replies not be a part of your public profile
         show_unlisted: Disable if you'd prefer to only show unlisted roars on your profile page to visitors who are logged-in or are your followers.
-        text: This helps us determine if registrations are made in sincerity and prevent spam. It is only visible to admins.
+        text: This helps us determine if registrations are made in sincerity and prevents spam. It is only visible to admins.
       user:
         chosen_languages: When checked, only roars in selected languages will be displayed in public timelines
     labels:
@@ -71,6 +71,7 @@ en-MP:
         setting_use_pending_items: Relax mode
         show_replies: Show replies on profile
         show_unlisted: Show unlisted roars to anonymous visitors
+        username_confirmation: Confirm your username
       invite_request:
         text: "Introduce yourself and let the admins know what brings you to Monsterpit."
       notification_emails:
diff --git a/db/migrate/20200826125821_add_username_and_nospam_to_users.rb b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb
new file mode 100644
index 000000000..9a964b980
--- /dev/null
+++ b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb
@@ -0,0 +1,6 @@
+class AddUsernameAndNospamToUsers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :users, :username, :string
+    add_column :users, :kobold, :string
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2aa5f6dee..9ee9c345d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_08_23_002835) do
+ActiveRecord::Schema.define(version: 2020_08_26_125821) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -1000,6 +1000,8 @@ ActiveRecord::Schema.define(version: 2020_08_23_002835) do
     t.boolean "approved", default: true, null: false
     t.string "sign_in_token"
     t.datetime "sign_in_token_sent_at"
+    t.string "username"
+    t.string "kobold"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
diff --git a/public/registration.js b/public/registration.js
new file mode 100644
index 000000000..a82ec09c2
--- /dev/null
+++ b/public/registration.js
@@ -0,0 +1,54 @@
+function dragon(message) {
+  const msgBuffer = new TextEncoder('utf-8').encode(message);
+  return crypto.subtle.digest('SHA-512', msgBuffer).then(hashBuffer => {
+    const hashArray = Array.from(new Uint8Array(hashBuffer));
+    const hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('');
+    return hashHex;
+  });
+}
+
+function getForm() {
+  return document.getElementById('registration_new_user') || document.getElementById('new_user');
+}
+
+function getField(name) {
+  return document.getElementById(`registration_user_${name}`) || document.getElementById(`user_${name}`);
+}
+
+function handleSubmit(e) {
+  e.preventDefault();
+
+  const form = getForm();
+  const u1 = getField('account_attributes_username');
+  const u2 = getField('username');
+  const kobold = getField('kobold');
+
+  if (!!u1 && !!u2 && u1.value.toLowerCase() === u2.value.toLowerCase()) {
+    u2.value = u1.value;
+  }
+
+  let values = [];
+
+  for (let i = 0; i < form.elements.length; i++) {
+    const element = form.elements[i];
+    const value = element.value;
+
+    if (!!element && ['text', 'email', 'textarea'].includes(element.type) && !!value) {
+      values.push(value.trim().toLowerCase());
+    }
+  }
+
+  const value = values.join('\u{F0666}');
+  dragon(value).then(digest => {
+    if (!!kobold) { kobold.value = digest.toUpperCase(); }
+    form.submit();
+  }, _ => { form.submit(); });
+}
+
+function addSubmitHandler() {
+  const form = getForm();
+  if (!!form) { form.addEventListener('submit', handleSubmit); }
+}
+
+window.addEventListener("load", addSubmitHandler);
+