Skip to content
Permalink
Browse files

FEATURE: External auth when redeeming invites

This feature (when enabled) will allow for invite_only sites to require
external authentication before they can redeem an invite.

- Created hidden site setting to toggle this
- Enables sending invites with local logins disabled
- OAuth button added to invite form
- Requires OAuth email address to match invite email address
- Prevents redeeming invite if OAuth authentication fails
  • Loading branch information...
oblakeerickson committed Aug 11, 2019
1 parent 3503758 commit 87a0a6664e4bcde2ec3ac012308d9c7fb8e0d370
@@ -5,15 +5,19 @@ import { ajax } from "discourse/lib/ajax";
import PasswordValidation from "discourse/mixins/password-validation";
import UsernameValidation from "discourse/mixins/username-validation";
import NameValidation from "discourse/mixins/name-validation";
import InviteEmailAuthValidation from "discourse/mixins/invite-email-auth-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import { findAll as findLoginMethods } from "discourse/models/login-method";

export default Ember.Controller.extend(
PasswordValidation,
UsernameValidation,
NameValidation,
InviteEmailAuthValidation,
UserFieldsValidation,
{
login: Ember.inject.controller(),

invitedBy: Ember.computed.alias("model.invited_by"),
email: Ember.computed.alias("model.email"),
accountUsername: Ember.computed.alias("model.username"),
@@ -22,6 +26,7 @@ export default Ember.Controller.extend(
errorMessage: null,
userFields: null,
inviteImageUrl: getUrl("/images/envelope.svg"),
hasAuthOptions: Ember.computed.notEmpty("authOptions"),

@computed
welcomeTitle() {
@@ -35,24 +40,40 @@ export default Ember.Controller.extend(
return I18n.t("invites.your_email", { email: email });
},

authProviderDisplayName(providerName) {
const matchingProvider = findLoginMethods().find(provider => {

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

Small tip, you can shorten this to

const matchingProvider = findLoginMethods().find(p => p.name === providerName)
return provider.name === providerName;
});
return matchingProvider
? matchingProvider.get("prettyName")
: providerName;
},

@computed
externalAuthsEnabled() {
return findLoginMethods().length > 0;
},

@computed
inviteOnlyOauthEnabled() {
return this.siteSettings.enable_invite_only_oauth;
},

@computed(

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

This whole computed property can be replaced with the Ember.computed.or "alias"

submitDisabled: Ember.computed.or("usernameValidation.failed", "passwordValidation.failed", ...)
"usernameValidation.failed",
"passwordValidation.failed",
"nameValidation.failed",
"userFieldsValidation.failed"
"userFieldsValidation.failed",
"inviteEmailAuthValidation.failed",
)
submitDisabled(
usernameFailed,
passwordFailed,
nameFailed,
userFieldsFailed
userFieldsFailed,
inviteEmailAuthFailed,
) {
return usernameFailed || passwordFailed || nameFailed || userFieldsFailed;
return usernameFailed || passwordFailed || nameFailed || userFieldsFailed || inviteEmailAuthFailed;
},

@computed
@@ -63,6 +84,10 @@ export default Ember.Controller.extend(
},

actions: {
externalLogin(provider) {
this.login.send("externalLogin", provider);
},

submit() {
const userFields = this.userFields;
let userCustomFields = {};
@@ -20,6 +20,7 @@ const AuthErrors = [

export default Ember.Controller.extend(ModalFunctionality, {
createAccount: Ember.inject.controller(),
invitesShow: Ember.inject.controller(),
forgotPassword: Ember.inject.controller(),
application: Ember.inject.controller(),

@@ -353,14 +354,23 @@ export default Ember.Controller.extend(ModalFunctionality, {
return;
}

const createAccountController = this.createAccount;
createAccountController.setProperties({
accountEmail: options.email,
accountUsername: options.username,
accountName: options.name,
authOptions: Ember.Object.create(options)
});

showModal("createAccount");
if (this.siteSettings.enable_invite_only_oauth) {
const invitesShowController = this.invitesShow;

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

Any reasons why you're declaring this variable? Seems like it's only being used the line below?

invitesShowController.setProperties({
accountEmail: options.email,
accountUsername: options.username,
accountName: options.name,
authOptions: Ember.Object.create(options)
});
} else {
const createAccountController = this.createAccount;

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

Ditto ^^

createAccountController.setProperties({
accountEmail: options.email,
accountUsername: options.username,
accountName: options.name,
authOptions: Ember.Object.create(options)
});
showModal("createAccount");
}
}
});
@@ -0,0 +1,38 @@
import InputValidation from "discourse/models/input-validation";
import { default as computed } from "ember-addons/ember-computed-decorators";

export default Ember.Mixin.create({
@computed()
nameInstructions() {
"";

This comment has been minimized.

Copy link
@eviltrout

eviltrout Aug 12, 2019

Member

This is odd because the empty string is not returned.

This comment has been minimized.

Copy link
@oblakeerickson

oblakeerickson Aug 12, 2019

Author Member

Ohh yea, I forgot I did that and then moved on to bigger problems. I'll clean that up.

},

// Validate the name.
@computed("accountEmail", "authOptions.email", "authOptions.email_valid", "authOptions.auth_provider")
inviteEmailAuthValidation() {
if (
!this.siteSettings.enable_invite_only_oauth ||

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

I find this quite hard to read. Any way we could extract part of this condition into a temporary (but adequately named) variable?

(this.siteSettings.enable_invite_only_oauth &&
this.get("authOptions.email") === this.email &&
this.get("authOptions.email_valid"))
) {
return InputValidation.create({
ok: true,
reason: I18n.t("user.email.authenticated", {
provider: this.authProviderDisplayName(
this.get("authOptions.auth_provider")
)
})
});
}

return InputValidation.create({
failed: true,
reason: I18n.t("user.email.invite_email_auth_invalid", {
provider: this.authProviderDisplayName(
this.get("authOptions.auth_provider")
)
})
});
}
});
@@ -14,55 +14,98 @@
{{else}}
<p>{{i18n 'invites.invited_by'}}</p>
<p>{{user-info user=invitedBy}}</p>

<p>{{{yourEmailMessage}}}
<p>
{{{yourEmailMessage}}}
{{#if inviteOnlyOauthEnabled }}
{{login-buttons externalLogin=(action "externalLogin")}}
{{/if}}
{{#if externalAuthsEnabled}}
{{i18n 'invites.social_login_available'}}
{{#unless inviteOnlyOauthEnabled}}
{{i18n 'invites.social_login_available'}}
{{/unless}}
{{/if}}
</p>

<form>
<div class="input username-input">
<label>{{i18n 'user.username.title'}}</label>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
<div class="instructions">{{i18n 'user.username.instructions'}}</div>
</div>

{{#if fullnameRequired}}
<div class="input name-input">
<label>{{i18n 'invites.name_label'}}</label>
{{input value=accountName id="new-account-name" name="name"}}
<div class="instructions">{{nameInstructions}}</div>
</div>
{{#if hasAuthOptions}}
{{#if inviteOnlyOauthEnabled }}
{{input-tip validation=inviteEmailAuthValidation id="account-email-validation"}}
{{/if}}
<form>
<div class="input username-input">
<label>{{i18n 'user.username.title'}}</label>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
<div class="instructions">{{i18n 'user.username.instructions'}}</div>
</div>

{{#if fullnameRequired}}
<div class="input name-input">
<label>{{i18n 'invites.name_label'}}</label>
{{input value=accountName id="new-account-name" name="name"}}
<div class="instructions">{{nameInstructions}}</div>
</div>
{{/if}}

{{#if userFields}}
<div class='user-fields'>
{{#each userFields as |f|}}
{{user-field field=f.field value=f.value}}
{{/each}}
</div>
{{/if}}

<div class="input password-input">
<label>{{i18n 'invites.password_label'}}</label>
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
&nbsp;{{input-tip validation=passwordValidation}}
<div class="instructions">
{{passwordInstructions}} {{i18n 'invites.optional_description'}}
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}">
{{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}</div>
<button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button>

{{#if errorMessage}}
<br/><br/>
<div class='alert alert-error'>{{errorMessage}}</div>
{{/if}}
</form>
{{/if}}
{{#unless inviteOnlyOauthEnabled}}
<form>
<div class="input username-input">
<label>{{i18n 'user.username.title'}}</label>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
<div class="instructions">{{i18n 'user.username.instructions'}}</div>
</div>
</div>

{{#if userFields}}
<div class='user-fields'>
{{#each userFields as |f|}}
{{user-field field=f.field value=f.value}}
{{/each}}
{{#if fullnameRequired}}
<div class="input name-input">
<label>{{i18n 'invites.name_label'}}</label>
{{input value=accountName id="new-account-name" name="name"}}
<div class="instructions">{{nameInstructions}}</div>
</div>
{{/if}}

<div class="input password-input">
<label>{{i18n 'invites.password_label'}}</label>
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
&nbsp;{{input-tip validation=passwordValidation}}
<div class="instructions">
{{passwordInstructions}} {{i18n 'invites.optional_description'}}
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}">
{{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}</div>
</div>
</div>
{{/if}}

<button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button>
{{#if userFields}}
<div class='user-fields'>
{{#each userFields as |f|}}
{{user-field field=f.field value=f.value}}
{{/each}}
</div>
{{/if}}

{{#if errorMessage}}
<br/><br/>
<div class='alert alert-error'>{{errorMessage}}</div>
{{/if}}
</form>
<button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button>

{{#if errorMessage}}
<br/><br/>
<div class='alert alert-error'>{{errorMessage}}</div>
{{/if}}
</form>
{{/unless}}
{{/if}}
</div>
</div>
@@ -107,7 +107,7 @@ def self.find_authenticator(name)
def complete_response_data
if @auth_result.user
user_found(@auth_result.user)
elsif SiteSetting.invite_only?
elsif invite_required?
@auth_result.requires_invite = true
else
session[:authentication] = @auth_result.session_data
@@ -155,4 +155,10 @@ def user_found(user)
end
end

# If invite_only and enable_invite_only_oauth allow the user to authenticate if coming from the invite page
def invite_required?
(SiteSetting.invite_only? && !SiteSetting.enable_invite_only_oauth) ||

This comment has been minimized.

Copy link
@ZogStriP

ZogStriP Aug 12, 2019

Member

Same as on the client-side. Can we either extract part of the condition into temporary variables or use "return early" to ease the understanding of that method?

(SiteSetting.invite_only? && (!@origin.include?('invites') && SiteSetting.enable_invite_only_oauth))
end

end
@@ -1013,6 +1013,7 @@ en:
ok: "We will email you to confirm"
invalid: "Please enter a valid email address"
authenticated: "Your email has been authenticated by {{provider}}"
invite_email_auth_invalid: "Your invitation email does not match the email from {{provider}}"
frequency_immediately: "We'll email you immediately if you haven't read the thing we're emailing you about."
frequency:
one: "We'll only email you if we haven't seen you in the last minute."
@@ -1025,7 +1026,7 @@ en:
cancel: "Cancel"
not_connected: "(not connected)"
confirm_modal_title: "Connect %{provider} Account"
confirm_description:
confirm_description:
account_specific: "Your %{provider} account '%{account_description}' will be used for authentication."
generic: "Your %{provider} account will be used for authentication."

@@ -327,6 +327,10 @@ login:
enable_local_logins:
client: true
default: true
enable_invite_only_oauth:
client: true
default: false
hidden: true
enable_local_logins_via_email:
client: true
default: true
@@ -314,7 +314,7 @@ def can_invite_to_forum?(groups = nil)
authenticated? &&
(SiteSetting.max_invites_per_day.to_i > 0 || is_staff?) &&
!SiteSetting.enable_sso &&
SiteSetting.enable_local_logins &&
(SiteSetting.enable_invite_only_oauth || SiteSetting.enable_local_logins) &&
(
(!SiteSetting.must_approve_users? && @user.has_trust_level?(TrustLevel[2])) ||
is_staff?

1 comment on commit 87a0a66

Please sign in to comment.
You can’t perform that action at this time.