From 1d3e5bdd24f25e1870f3d3bec1c81040a423f138 Mon Sep 17 00:00:00 2001 From: Fire Demon Date: Wed, 26 Aug 2020 13:56:20 -0500 Subject: [Spam Prevention] Add username confirmation and simple, frictionless anti-spam mechanism --- .../admin/pending_accounts_controller.rb | 6 +-- app/controllers/auth/registrations_controller.rb | 8 +++- app/models/user.rb | 30 +++++++++++- app/views/about/_registration.html.haml | 5 +- app/views/about/show.html.haml | 1 + app/views/admin/accounts/index.html.haml | 2 +- app/views/admin/pending_accounts/index.html.haml | 2 +- app/views/auth/registrations/new.html.haml | 5 +- app/workers/scheduler/user_cleanup_scheduler.rb | 5 ++ config/locales/simple_form.en-MP.yml | 3 +- ...00826125821_add_username_and_nospam_to_users.rb | 6 +++ db/schema.rb | 4 +- public/registration.js | 54 ++++++++++++++++++++++ 13 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20200826125821_add_username_and_nospam_to_users.rb create mode 100644 public/registration.js 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); + -- cgit