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.
This commit is contained in:
Sam Saffron 2020-03-15 21:17:28 +11:00
parent a14313e9d0
commit a1d660d951
No known key found for this signature in database
GPG Key ID: B9606168D2FFD9F5
9 changed files with 72 additions and 23 deletions

View File

@ -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;

View File

@ -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"
});
}

View File

@ -89,6 +89,17 @@
</td>
</tr>
{{#if requireInviteCode }}
<tr class="invite-code">
<td><label for='invite-code'>{{i18n 'user.invite_code.title'}}</label></td>
<td>
{{input value=inviteCode id="inviteCode"}}
</td>
<td><label>{{i18n 'user.invite_code.instructions'}}</label></td>
</tr>
{{/if}}
{{plugin-outlet name="create-account-after-password"
noTags=true
args=(hash accountName=accountName

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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."

View File

@ -336,6 +336,7 @@ login:
must_approve_users:
client: true
default: false
invite_code: ""
enable_local_logins:
client: true
default: true

View File

@ -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)