From a1d660d9515fa446fea85bfea3b4c9b96ac75320 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Sun, 15 Mar 2020 21:17:28 +1100 Subject: [PATCH] FEATURE: optional global invite_code for account registration On some sites when bootstrapping communities it is helpful to bootstrap with a "light weight" invite code. Use the site setting `invite_code` to set a global invite code. In this case the administrator can share the code with a community which is very easy to remember and then anyone who has that code can easily register accounts. People without the invite code are not allowed account registration. Global invite codes are less secure than indevidual codes, in that they tend to leak in the community however in some cases when starting a brand new community the security guarantees of invites are not needed. --- .../discourse/controllers/create-account.js | 9 ++++-- .../javascripts/discourse/models/user.js | 26 +++++++++------ .../templates/modal/create-account.hbs | 11 +++++++ app/controllers/users_controller.rb | 5 +++ app/models/site_setting.rb | 5 +++ config/locales/client.en.yml | 4 +++ config/locales/server.en.yml | 2 ++ config/site_settings.yml | 1 + spec/requests/users_controller_spec.rb | 32 ++++++++++++------- 9 files changed, 72 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/create-account.js b/app/assets/javascripts/discourse/controllers/create-account.js index 3d93a61393e..b0567c07b4e 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js +++ b/app/assets/javascripts/discourse/controllers/create-account.js @@ -40,6 +40,7 @@ export default Controller.extend( hasAuthOptions: notEmpty("authOptions"), canCreateLocal: setting("enable_local_logins"), showCreateForm: or("hasAuthOptions", "canCreateLocal"), + requireInviteCode: setting("require_invite_code"), resetForm() { // We wrap the fields in a structure so we can assign a value @@ -66,7 +67,8 @@ export default Controller.extend( "usernameValidation.failed", "passwordValidation.failed", "userFieldsValidation.failed", - "formSubmitted" + "formSubmitted", + "inviteCode" ) submitDisabled() { if (this.formSubmitted) return true; @@ -78,6 +80,8 @@ export default Controller.extend( return true; if (this.get("userFieldsValidation.failed")) return true; + if (this.requireInviteCode && !this.inviteCode) return true; + return false; }, @@ -225,7 +229,8 @@ export default Controller.extend( "accountEmail", "accountPassword", "accountUsername", - "accountChallenge" + "accountChallenge", + "inviteCode" ); attrs["accountPasswordConfirm"] = this.accountHoneypot; diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 026b27926a0..a7d9ce8b294 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -909,17 +909,23 @@ User.reopenClass(Singleton, { }, createAccount(attrs) { + let data = { + name: attrs.accountName, + email: attrs.accountEmail, + password: attrs.accountPassword, + username: attrs.accountUsername, + password_confirmation: attrs.accountPasswordConfirm, + challenge: attrs.accountChallenge, + user_fields: attrs.userFields, + timezone: moment.tz.guess() + }; + + if (attrs.inviteCode) { + data.invite_code = attrs.inviteCode; + } + return ajax(userPath(), { - data: { - name: attrs.accountName, - email: attrs.accountEmail, - password: attrs.accountPassword, - username: attrs.accountUsername, - password_confirmation: attrs.accountPasswordConfirm, - challenge: attrs.accountChallenge, - user_fields: attrs.userFields, - timezone: moment.tz.guess() - }, + data, type: "POST" }); } diff --git a/app/assets/javascripts/discourse/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/templates/modal/create-account.hbs index 5c77b2a0a63..bd03f39f241 100644 --- a/app/assets/javascripts/discourse/templates/modal/create-account.hbs +++ b/app/assets/javascripts/discourse/templates/modal/create-account.hbs @@ -89,6 +89,17 @@ + {{#if requireInviteCode }} + + + + {{input value=inviteCode id="inviteCode"}} + + + + + {{/if}} + {{plugin-outlet name="create-account-after-password" noTags=true args=(hash accountName=accountName diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 837f19b8281..6acf7651eb4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -410,6 +410,7 @@ class UsersController < ApplicationController def create params.require(:email) params.require(:username) + params.require(:invite_code) if SiteSetting.require_invite_code params.permit(:user_fields) unless SiteSetting.allow_new_registrations @@ -424,6 +425,10 @@ class UsersController < ApplicationController return fail_with("login.email_too_long") end + if SiteSetting.require_invite_code && SiteSetting.invite_code != params[:invite_code] + return fail_with("login.wrong_invite_code") + end + if clashing_with_existing_route?(params[:username]) || User.reserved_username?(params[:username]) return fail_with("login.reserved_username") end diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index bf82dbe8fa5..7925f91ef1c 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -173,6 +173,11 @@ class SiteSetting < ActiveRecord::Base SiteSetting::Upload end + def self.require_invite_code + invite_code.present? + end + client_settings << :require_invite_code + %i{ site_logo_url site_logo_small_url diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a276cc86143..7c2a761c6a1 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1132,6 +1132,10 @@ en: password_confirmation: title: "Password Again" + invite_code: + title: "Invite Code" + instructions: "Account registration requires an invite code" + auth_tokens: title: "Recently Used Devices" ip: "IP" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fd440ec3b60..ab280dab929 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1530,6 +1530,7 @@ en: markdown_typographer_quotation_marks: "List of double and single quotes replacement pairs" post_undo_action_window_mins: "Number of minutes users are allowed to undo recent actions on a post (like, flag, etc)." must_approve_users: "Staff must approve all new user accounts before they are allowed to access the site." + invite_code: "User must type this code to be allowed account registration, ignored when empty" approve_suspect_users: "Add suspicious users to the review queue. Suspicious users have entered a bio/website but have no reading activity." pending_users_reminder_delay: "Notify moderators if new users have been waiting for approval for longer than this many hours. Set to -1 to disable notifications." maximum_session_age: "User will remain logged in for n hours since last visit" @@ -2382,6 +2383,7 @@ en: new_registrations_disabled: "New account registrations are not allowed at this time." password_too_long: "Passwords are limited to 200 characters." email_too_long: "The email you provided is too long. Mailbox names must be no more than 254 characters, and domain names must be no more than 253 characters." + wrong_invite_code: "The invite code you entered was incorrect." reserved_username: "That username is not allowed." missing_user_field: "You have not completed all the user fields" auth_complete: "Authentication is complete." diff --git a/config/site_settings.yml b/config/site_settings.yml index 044d7f1de1a..52b7c6002db 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -336,6 +336,7 @@ login: must_approve_users: client: true default: false + invite_code: "" enable_local_logins: client: true default: true diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 38dfe21e960..43e41e55224 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -593,8 +593,8 @@ describe UsersController do email: @user.email } end - def post_user - post "/u.json", params: post_user_params + def post_user(extra_params = {}) + post "/u.json", params: post_user_params.merge(extra_params) end context 'when email params is missing' do @@ -616,17 +616,27 @@ describe UsersController do expect(User.find_by(username: @user.username).locale).to eq('fr') end + it 'requires invite code when specified' do + expect(SiteSetting.require_invite_code).to eq(false) + SiteSetting.invite_code = "abc" + expect(SiteSetting.require_invite_code).to eq(true) + + post_user(invite_code: "abcd") + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["success"]).to eq(false) + + post_user(invite_code: "abc") + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["success"]).to eq(true) + + end + context "when timezone is provided as a guess on signup" do - let(:post_user_params) do - { name: @user.name, - username: @user.username, - password: "strongpassword", - email: @user.email, - timezone: "Australia/Brisbane" } - end it "sets the timezone" do - post_user + post_user(timezone: "Australia/Brisbane") expect(response.status).to eq(200) expect(User.find_by(username: @user.username).user_option.timezone).to eq("Australia/Brisbane") end @@ -1440,7 +1450,7 @@ describe UsersController do inviter = Fabricate(:user, trust_level: 2) sign_in(inviter) invitee = Fabricate(:user) - invite = Fabricate(:invite, invited_by: inviter, user: invitee) + _invite = Fabricate(:invite, invited_by: inviter, user: invitee) get "/u/#{user.username}/invited_count.json" expect(response.status).to eq(200)