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"), hasAuthOptions: notEmpty("authOptions"),
canCreateLocal: setting("enable_local_logins"), canCreateLocal: setting("enable_local_logins"),
showCreateForm: or("hasAuthOptions", "canCreateLocal"), showCreateForm: or("hasAuthOptions", "canCreateLocal"),
requireInviteCode: setting("require_invite_code"),
resetForm() { resetForm() {
// We wrap the fields in a structure so we can assign a value // We wrap the fields in a structure so we can assign a value
@ -66,7 +67,8 @@ export default Controller.extend(
"usernameValidation.failed", "usernameValidation.failed",
"passwordValidation.failed", "passwordValidation.failed",
"userFieldsValidation.failed", "userFieldsValidation.failed",
"formSubmitted" "formSubmitted",
"inviteCode"
) )
submitDisabled() { submitDisabled() {
if (this.formSubmitted) return true; if (this.formSubmitted) return true;
@ -78,6 +80,8 @@ export default Controller.extend(
return true; return true;
if (this.get("userFieldsValidation.failed")) return true; if (this.get("userFieldsValidation.failed")) return true;
if (this.requireInviteCode && !this.inviteCode) return true;
return false; return false;
}, },
@ -225,7 +229,8 @@ export default Controller.extend(
"accountEmail", "accountEmail",
"accountPassword", "accountPassword",
"accountUsername", "accountUsername",
"accountChallenge" "accountChallenge",
"inviteCode"
); );
attrs["accountPasswordConfirm"] = this.accountHoneypot; attrs["accountPasswordConfirm"] = this.accountHoneypot;

View File

@ -909,17 +909,23 @@ User.reopenClass(Singleton, {
}, },
createAccount(attrs) { 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(), { return ajax(userPath(), {
data: { 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()
},
type: "POST" type: "POST"
}); });
} }

View File

@ -89,6 +89,17 @@
</td> </td>
</tr> </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" {{plugin-outlet name="create-account-after-password"
noTags=true noTags=true
args=(hash accountName=accountName args=(hash accountName=accountName

View File

@ -410,6 +410,7 @@ class UsersController < ApplicationController
def create def create
params.require(:email) params.require(:email)
params.require(:username) params.require(:username)
params.require(:invite_code) if SiteSetting.require_invite_code
params.permit(:user_fields) params.permit(:user_fields)
unless SiteSetting.allow_new_registrations unless SiteSetting.allow_new_registrations
@ -424,6 +425,10 @@ class UsersController < ApplicationController
return fail_with("login.email_too_long") return fail_with("login.email_too_long")
end 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]) if clashing_with_existing_route?(params[:username]) || User.reserved_username?(params[:username])
return fail_with("login.reserved_username") return fail_with("login.reserved_username")
end end

View File

@ -173,6 +173,11 @@ class SiteSetting < ActiveRecord::Base
SiteSetting::Upload SiteSetting::Upload
end end
def self.require_invite_code
invite_code.present?
end
client_settings << :require_invite_code
%i{ %i{
site_logo_url site_logo_url
site_logo_small_url site_logo_small_url

View File

@ -1132,6 +1132,10 @@ en:
password_confirmation: password_confirmation:
title: "Password Again" title: "Password Again"
invite_code:
title: "Invite Code"
instructions: "Account registration requires an invite code"
auth_tokens: auth_tokens:
title: "Recently Used Devices" title: "Recently Used Devices"
ip: "IP" ip: "IP"

View File

@ -1530,6 +1530,7 @@ en:
markdown_typographer_quotation_marks: "List of double and single quotes replacement pairs" 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)." 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." 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." 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." 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" 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." new_registrations_disabled: "New account registrations are not allowed at this time."
password_too_long: "Passwords are limited to 200 characters." 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." 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." reserved_username: "That username is not allowed."
missing_user_field: "You have not completed all the user fields" missing_user_field: "You have not completed all the user fields"
auth_complete: "Authentication is complete." auth_complete: "Authentication is complete."

View File

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

View File

@ -593,8 +593,8 @@ describe UsersController do
email: @user.email } email: @user.email }
end end
def post_user def post_user(extra_params = {})
post "/u.json", params: post_user_params post "/u.json", params: post_user_params.merge(extra_params)
end end
context 'when email params is missing' do 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') expect(User.find_by(username: @user.username).locale).to eq('fr')
end 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 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 it "sets the timezone" do
post_user post_user(timezone: "Australia/Brisbane")
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(User.find_by(username: @user.username).user_option.timezone).to eq("Australia/Brisbane") expect(User.find_by(username: @user.username).user_option.timezone).to eq("Australia/Brisbane")
end end
@ -1440,7 +1450,7 @@ describe UsersController do
inviter = Fabricate(:user, trust_level: 2) inviter = Fabricate(:user, trust_level: 2)
sign_in(inviter) sign_in(inviter)
invitee = Fabricate(:user) 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" get "/u/#{user.username}/invited_count.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)