From d213c585ffdcbbff3a553c829cd82cc5e2357897 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Mar 2025 15:07:29 +0100 Subject: [PATCH] Add age verification on sign-up (#34150) --- app/controllers/api/v1/accounts_controller.rb | 2 +- .../auth/registrations_controller.rb | 2 +- app/inputs/date_of_birth_input.rb | 31 +++++++++ app/javascript/styles/mastodon/forms.scss | 16 +++++ app/models/form/admin_settings.rb | 3 + app/models/user.rb | 68 ++++++++++++------- app/serializers/rest/instance_serializer.rb | 1 + app/services/app_sign_up_service.rb | 2 +- app/validators/date_of_birth_validator.rb | 15 ++++ .../settings/registrations/show.html.haml | 3 + app/views/auth/registrations/new.html.haml | 23 ++++--- config/locales/activerecord.en.yml | 2 + config/locales/simple_form.en.yml | 6 ++ ...0313123400_add_age_verified_at_to_users.rb | 7 ++ db/schema.rb | 3 +- .../auth/registrations_controller_spec.rb | 36 ++++++++++ spec/requests/api/v1/accounts_spec.rb | 35 +++++++++- .../date_of_birth_validator_spec.rb | 51 ++++++++++++++ 18 files changed, 268 insertions(+), 38 deletions(-) create mode 100644 app/inputs/date_of_birth_input.rb create mode 100644 app/validators/date_of_birth_validator.rb create mode 100644 db/migrate/20250313123400_add_age_verified_at_to_users.rb create mode 100644 spec/validators/date_of_birth_validator_spec.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index f7d3de7f94..ae8df69a28 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -119,7 +119,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth) end def invite diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 6e34b6b627..0b6f5b3af4 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |user_params| - user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) + user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth) end end diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb new file mode 100644 index 0000000000..131234b02e --- /dev/null +++ b/app/inputs/date_of_birth_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DateOfBirthInput < SimpleForm::Inputs::Base + OPTIONS = [ + { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, + { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, + { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze, + ].freeze + + def input(wrapper_options = nil) + merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) + merged_input_options[:inputmode] = 'numeric' + + values = (object.public_send(attribute_name) || '').split('.') + + safe_join(Array.new(3) do |index| + options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index] + @builder.text_field("#{attribute_name}(#{index + 1}i)", options) + end) + end + + def label_target + "#{attribute_name}_1i" + end + + private + + def generate_id(index) + "#{object_name}_#{attribute_name}_#{index + 1}i" + end +end diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 7df7e14b2b..73043842a4 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -353,6 +353,22 @@ code { } } + .input.date_of_birth .label_input { + display: flex; + gap: 8px; + align-items: center; + + input { + box-sizing: content-box; + width: 32px; + flex: 0; + + &:last-child { + width: 64px; + } + } + } + .input.select.select--languages { min-width: 32ch; } diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index ffd2d4049c..086a6d29d4 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -39,12 +39,14 @@ class Form::AdminSettings authorized_fetch app_icon favicon + min_age ).freeze INTEGER_KEYS = %i( media_cache_retention_period content_cache_retention_period backups_retention_period + min_age ).freeze BOOLEAN_KEYS = %i( @@ -88,6 +90,7 @@ class Form::AdminSettings validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) } validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) } validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } + validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) } validates :status_page_url, url: true, allow_blank: true validate :validate_site_uploads diff --git a/app/models/user.rb b/app/models/user.rb index ce24d34651..72f7490043 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,41 +5,42 @@ # Table name: users # # id :bigint(8) not null, primary key -# email :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null -# encrypted_password :string default(""), not null -# reset_password_token :string -# reset_password_sent_at :datetime -# sign_in_count :integer default(0), not null -# current_sign_in_at :datetime -# last_sign_in_at :datetime +# age_verified_at :datetime +# approved :boolean default(TRUE), not null +# chosen_languages :string is an Array +# confirmation_sent_at :datetime # confirmation_token :string # confirmed_at :datetime -# confirmation_sent_at :datetime -# unconfirmed_email :string -# locale :string +# consumed_timestep :integer +# current_sign_in_at :datetime +# disabled :boolean default(FALSE), not null +# email :string default(""), not null # encrypted_otp_secret :string # encrypted_otp_secret_iv :string # encrypted_otp_secret_salt :string -# consumed_timestep :integer -# otp_required_for_login :boolean default(FALSE), not null +# encrypted_password :string default(""), not null # last_emailed_at :datetime +# last_sign_in_at :datetime +# locale :string # otp_backup_codes :string is an Array -# account_id :bigint(8) not null -# disabled :boolean default(FALSE), not null -# invite_id :bigint(8) -# chosen_languages :string is an Array -# created_by_application_id :bigint(8) -# approved :boolean default(TRUE), not null +# otp_required_for_login :boolean default(FALSE), not null +# otp_secret :string +# reset_password_sent_at :datetime +# reset_password_token :string +# settings :text +# sign_in_count :integer default(0), not null # sign_in_token :string # sign_in_token_sent_at :datetime -# webauthn_id :string # sign_up_ip :inet -# role_id :bigint(8) -# settings :text # time_zone :string -# otp_secret :string +# unconfirmed_email :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) not null +# created_by_application_id :bigint(8) +# invite_id :bigint(8) +# role_id :bigint(8) +# webauthn_id :string # class User < ApplicationRecord @@ -111,6 +112,7 @@ class User < ApplicationRecord validates_with RegistrationFormTimeValidator, on: :create validates :website, absence: true, on: :create validates :confirm_password, absence: true, on: :create + validates :date_of_birth, presence: true, date_of_birth: true, on: :create, if: -> { Setting.min_age.present? } validate :validate_role_elevation scope :account_not_suspended, -> { joins(:account).merge(Account.without_suspended) } @@ -129,6 +131,7 @@ class User < ApplicationRecord before_validation :sanitize_role before_create :set_approved + before_create :set_age_verified_at after_commit :send_pending_devise_notifications after_create_commit :trigger_webhooks @@ -140,7 +143,7 @@ class User < ApplicationRecord delegate :can?, to: :role - attr_reader :invite_code + attr_reader :invite_code, :date_of_birth attr_writer :external, :bypass_invite_request_check, :current_account def self.those_who_can(*any_of_privileges) @@ -157,6 +160,17 @@ class User < ApplicationRecord Rails.env.local? end + def date_of_birth=(hash_or_string) + @date_of_birth = begin + if hash_or_string.is_a?(Hash) + day, month, year = hash_or_string.values_at(1, 2, 3) + "#{day}.#{month}.#{year}" + else + hash_or_string + end + end + end + def role if role_id.nil? UserRole.everyone @@ -432,6 +446,10 @@ class User < ApplicationRecord end end + def set_age_verified_at + self.age_verified_at = Time.now.utc if Setting.min_age.present? + end + def grant_approval_on_confirmation? # Re-check approval on confirmation if the server has switched to open registrations open_registrations? && !sign_up_from_ip_requires_approval? && !sign_up_email_requires_approval? diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 9163cabf1e..30da6e2e1a 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -107,6 +107,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer enabled: registrations_enabled?, approval_required: Setting.registrations_mode == 'approved', message: registrations_enabled? ? nil : registrations_message, + min_age: Setting.min_age.presence, url: ENV.fetch('SSO_ACCOUNT_SIGN_UP', nil), } end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index 7665880115..a4399efd65 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -41,7 +41,7 @@ class AppSignUpService < BaseService end def user_params - @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code) + @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code, :date_of_birth) end def account_params diff --git a/app/validators/date_of_birth_validator.rb b/app/validators/date_of_birth_validator.rb new file mode 100644 index 0000000000..79119d2c4c --- /dev/null +++ b/app/validators/date_of_birth_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DateOfBirthValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :below_limit) if value.present? && value.to_date > min_age.ago + rescue Date::Error + record.errors.add(attribute, :invalid) + end + + private + + def min_age + Setting.min_age.to_i.years + end +end diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml index 4dbc5fbecf..cb5a3eb6ba 100644 --- a/app/views/admin/settings/registrations/show.html.haml +++ b/app/views/admin/settings/registrations/show.html.haml @@ -12,6 +12,9 @@ .flash-message= t('admin.settings.registrations.moderation_recommandation') + .fields-group + = f.input :min_age, as: :string, wrapper: :with_block_label, input_html: { inputmode: 'numeric' } + .fields-row .fields-row__column.fields-row__column-6.fields-group = f.input :registrations_mode, diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 5e9aa02d68..25479104ee 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -21,20 +21,19 @@ = f.simple_fields_for :account do |ff| = ff.input :username, append: "@#{site_hostname}", - 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: Account::USERNAME_LENGTH_LIMIT }, - label: false, + input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT, placeholder: ' ' }, required: true, wrapper: :with_label = f.input :email, hint: false, - input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' }, - placeholder: t('simple_form.labels.defaults.email'), - required: true + input_html: { autocomplete: 'username', placeholder: ' ' }, + required: true, + wrapper: :with_label = f.input :password, hint: false, - input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, - placeholder: t('simple_form.labels.defaults.password'), - required: true + input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last, placeholder: ' ' }, + required: true, + wrapper: :with_label = f.input :password_confirmation, hint: false, input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password', maxlength: User.password_length.last }, @@ -53,6 +52,14 @@ required: false, wrapper: :with_label + - if Setting.min_age.present? + .fields-group + = f.input :date_of_birth, + as: :date_of_birth, + hint: t('simple_form.hints.user.date_of_birth', age: Setting.min_age.to_i), + required: true, + wrapper: :with_block_label + - if approved_registrations? && @invite.blank? %p.lead= t('auth.sign_up.manual_review', domain: site_hostname) diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index ed389c1323..6940d589ca 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -55,6 +55,8 @@ en: too_soon: is too soon, must be later than %{date} user: attributes: + date_of_birth: + below_limit: is below the age limit email: blocked: uses a disallowed e-mail provider unreachable: does not seem to exist diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 682a8179b2..cfb3adf13a 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -88,6 +88,7 @@ en: favicon: WEBP, PNG, GIF or JPG. Overrides the default Mastodon favicon with a custom icon. mascot: Overrides the illustration in the advanced web interface. media_cache_retention_period: Media files from posts made by remote users are cached on your server. When set to a positive value, media will be deleted after the specified number of days. If the media data is requested after it is deleted, it will be re-downloaded, if the source content is still available. Due to restrictions on how often link preview cards poll third-party sites, it is recommended to set this value to at least 14 days, or link preview cards will not be updated on demand before that time. + min_age: Users will be asked to confirm their date of birth during sign-up peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. profile_directory: The profile directory lists all users who have opted-in to be discoverable. require_invite_text: When sign-ups require manual approval, make the “Why do you want to join?” text input mandatory rather than optional @@ -146,6 +147,7 @@ en: min_age: Should not be below the minimum age required by the laws of your jurisdiction. user: chosen_languages: When checked, only posts in selected languages will be displayed in public timelines + date_of_birth: We have to make sure you're at least %{age} to use Mastodon. We won't store this. role: The role controls which permissions the user has. user_role: color: Color to be used for the role throughout the UI, as RGB in hex format @@ -271,6 +273,7 @@ en: favicon: Favicon mascot: Custom mascot (legacy) media_cache_retention_period: Media cache retention period + min_age: Minimum age requirement peers_api_enabled: Publish list of discovered servers in the API profile_directory: Enable profile directory registrations_mode: Who can sign-up @@ -349,6 +352,9 @@ en: jurisdiction: Legal jurisdiction min_age: Minimum age user: + date_of_birth_1i: Day + date_of_birth_2i: Month + date_of_birth_3i: Year role: Role time_zone: Time zone user_role: diff --git a/db/migrate/20250313123400_add_age_verified_at_to_users.rb b/db/migrate/20250313123400_add_age_verified_at_to_users.rb new file mode 100644 index 0000000000..c6cd6120ef --- /dev/null +++ b/db/migrate/20250313123400_add_age_verified_at_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAgeVerifiedAtToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :age_verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 66c63e53f5..32d94b48ec 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[8.0].define(version: 2025_03_05_074104) do +ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1188,6 +1188,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_074104) do t.text "settings" t.string "time_zone" t.string "otp_secret" + t.datetime "age_verified_at" 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", where: "(created_by_application_id IS NOT NULL)" diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index 739cb455e8..4e43592a4e 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -342,6 +342,42 @@ RSpec.describe Auth::RegistrationsController do end end + context 'when age verification is enabled' do + subject { post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', agreement: 'true' }.merge(date_of_birth) } } + + before do + Setting.min_age = 16 + end + + let(:date_of_birth) { {} } + + context 'when date of birth is below age limit' do + let(:date_of_birth) { 13.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } } + + it 'does not create user' do + subject + user = User.find_by(email: 'test@example.com') + expect(user).to be_nil + end + end + + context 'when date of birth is above age limit' do + let(:date_of_birth) { 17.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } } + + it 'redirects to setup and creates user' do + subject + + expect(response).to redirect_to auth_setup_path + + expect(User.find_by(email: 'test@example.com')) + .to be_present + .and have_attributes( + age_verified_at: not_eq(nil) + ) + end + end + end + include_examples 'checks for enabled registrations', :create end diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index 16010ae2e7..9fe5b3d491 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -74,12 +74,45 @@ RSpec.describe '/api/v1/accounts' do describe 'POST /api/v1/accounts' do subject do - post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement } + post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement, date_of_birth: date_of_birth } end let(:client_app) { Fabricate(:application) } let(:token) { Doorkeeper::AccessToken.find_or_create_for(application: client_app, resource_owner: nil, scopes: 'read write', use_refresh_token: false) } let(:agreement) { nil } + let(:date_of_birth) { nil } + + context 'when age verification is enabled' do + before do + Setting.min_age = 16 + end + + let(:agreement) { 'true' } + + context 'when date of birth is below age limit' do + let(:date_of_birth) { 13.years.ago.strftime('%d.%m.%Y') } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + expect(response.content_type) + .to start_with('application/json') + end + end + + context 'when date of birth is over age limit' do + let(:date_of_birth) { 17.years.ago.strftime('%d.%m.%Y') } + + it 'creates a user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end + end + end context 'when given truthy agreement' do let(:agreement) { 'true' } diff --git a/spec/validators/date_of_birth_validator_spec.rb b/spec/validators/date_of_birth_validator_spec.rb new file mode 100644 index 0000000000..33e69e811b --- /dev/null +++ b/spec/validators/date_of_birth_validator_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DateOfBirthValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + + attr_accessor :date_of_birth + + validates :date_of_birth, date_of_birth: true + end + end + + let(:record) { record_class.new } + + before do + Setting.min_age = 16 + end + + describe '#validate_each' do + context 'with an invalid date' do + it 'adds errors' do + record.date_of_birth = '76.830.10' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:date_of_birth) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'with a date below age limit' do + it 'adds errors' do + record.date_of_birth = 13.years.ago.strftime('%d.%m.%Y') + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:date_of_birth) + expect(record.errors.first.type).to eq(:below_limit) + end + end + + context 'with a date above age limit' do + it 'does not add errors' do + record.date_of_birth = 16.years.ago.strftime('%d.%m.%Y') + + expect(record).to be_valid + end + end + end +end