From 87a0a6664e4bcde2ec3ac012308d9c7fb8e0d370 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Sun, 11 Aug 2019 12:02:21 -0600 Subject: [PATCH] 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 --- .../discourse/controllers/invites-show.js.es6 | 31 ++++- .../discourse/controllers/login.js.es6 | 28 ++-- .../invite-email-auth-validation.js.es6 | 38 ++++++ .../discourse/templates/invites/show.hbs | 121 ++++++++++++------ .../users/omniauth_callbacks_controller.rb | 8 +- config/locales/client.en.yml | 3 +- config/site_settings.yml | 4 + lib/guardian.rb | 2 +- 8 files changed, 181 insertions(+), 54 deletions(-) create mode 100644 app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6 diff --git a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 index 2664a95bd78..701a757661f 100644 --- a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 @@ -5,6 +5,7 @@ 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"; @@ -12,8 +13,11 @@ 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 => { + 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( "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 = {}; diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 19da455a26e..76f33c9c757 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -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; + invitesShowController.setProperties({ + accountEmail: options.email, + accountUsername: options.username, + accountName: options.name, + authOptions: Ember.Object.create(options) + }); + } else { + const createAccountController = this.createAccount; + createAccountController.setProperties({ + accountEmail: options.email, + accountUsername: options.username, + accountName: options.name, + authOptions: Ember.Object.create(options) + }); + showModal("createAccount"); + } } }); diff --git a/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6 b/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6 new file mode 100644 index 00000000000..733573deee0 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6 @@ -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() { + ""; + }, + + // Validate the name. + @computed("accountEmail", "authOptions.email", "authOptions.email_valid", "authOptions.auth_provider") + inviteEmailAuthValidation() { + if ( + !this.siteSettings.enable_invite_only_oauth || + (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") + ) + }) + }); + } +}); diff --git a/app/assets/javascripts/discourse/templates/invites/show.hbs b/app/assets/javascripts/discourse/templates/invites/show.hbs index 6b3b461a0cf..467d91887a1 100644 --- a/app/assets/javascripts/discourse/templates/invites/show.hbs +++ b/app/assets/javascripts/discourse/templates/invites/show.hbs @@ -14,55 +14,98 @@ {{else}}

{{i18n 'invites.invited_by'}}

{{user-info user=invitedBy}}

- -

{{{yourEmailMessage}}} +

+ {{{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}}

-
-
- - {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}} -  {{input-tip validation=usernameValidation id="username-validation"}} -
{{i18n 'user.username.instructions'}}
-
- - {{#if fullnameRequired}} -
- - {{input value=accountName id="new-account-name" name="name"}} -
{{nameInstructions}}
-
+ {{#if hasAuthOptions}} + {{#if inviteOnlyOauthEnabled }} + {{input-tip validation=inviteEmailAuthValidation id="account-email-validation"}} {{/if}} - -
- - {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}} -  {{input-tip validation=passwordValidation}} -
- {{passwordInstructions}} {{i18n 'invites.optional_description'}} -
- {{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}
+ +
+ + {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}} +  {{input-tip validation=usernameValidation id="username-validation"}} +
{{i18n 'user.username.instructions'}}
-
- {{#if userFields}} -
- {{#each userFields as |f|}} - {{user-field field=f.field value=f.value}} - {{/each}} + {{#if fullnameRequired}} +
+ + {{input value=accountName id="new-account-name" name="name"}} +
{{nameInstructions}}
+
+ {{/if}} + + {{#if userFields}} +
+ {{#each userFields as |f|}} + {{user-field field=f.field value=f.value}} + {{/each}} +
+ {{/if}} + + + + {{#if errorMessage}} +

+
{{errorMessage}}
+ {{/if}} + + {{/if}} + {{#unless inviteOnlyOauthEnabled}} +
+
+ + {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}} +  {{input-tip validation=usernameValidation id="username-validation"}} +
{{i18n 'user.username.instructions'}}
- {{/if}} - + {{#if fullnameRequired}} +
+ + {{input value=accountName id="new-account-name" name="name"}} +
{{nameInstructions}}
+
+ {{/if}} - {{#if errorMessage}} -

-
{{errorMessage}}
- {{/if}} -
+
+ + {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}} +  {{input-tip validation=passwordValidation}} +
+ {{passwordInstructions}} {{i18n 'invites.optional_description'}} +
+ {{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}
+
+
+ + {{#if userFields}} +
+ {{#each userFields as |f|}} + {{user-field field=f.field value=f.value}} + {{/each}} +
+ {{/if}} + + + + {{#if errorMessage}} +

+
{{errorMessage}}
+ {{/if}} + + {{/unless}} {{/if}}
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 82393bfa9a1..e9ed2d0f5e4 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -107,7 +107,7 @@ class Users::OmniauthCallbacksController < ApplicationController 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 @@ class Users::OmniauthCallbacksController < ApplicationController 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) || + (SiteSetting.invite_only? && (!@origin.include?('invites') && SiteSetting.enable_invite_only_oauth)) + end + end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index f0e8a4f6d43..37e196144e7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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." diff --git a/config/site_settings.yml b/config/site_settings.yml index 69655450336..cfaf9719484 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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 diff --git a/lib/guardian.rb b/lib/guardian.rb index e2d4c24e613..ece28e891ed 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -314,7 +314,7 @@ class Guardian 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?