FEATURE: Allow invites redemption with Omniauth providers.

This commit is contained in:
Alan Guo Xiang Tan 2021-03-02 15:13:04 +08:00
parent ebe4896e48
commit ce04db8610
21 changed files with 621 additions and 139 deletions

View File

@ -1,5 +1,5 @@
import { alias, notEmpty, or, readOnly } from "@ember/object/computed"; import { alias, notEmpty, or, readOnly } from "@ember/object/computed";
import Controller from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
@ -14,6 +14,7 @@ import { emailValid } from "discourse/lib/utilities";
import { findAll as findLoginMethods } from "discourse/models/login-method"; import { findAll as findLoginMethods } from "discourse/models/login-method";
import getUrl from "discourse-common/lib/get-url"; import getUrl from "discourse-common/lib/get-url";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { wavingHandURL } from "discourse/lib/waving-hand-url";
export default Controller.extend( export default Controller.extend(
PasswordValidation, PasswordValidation,
@ -21,6 +22,8 @@ export default Controller.extend(
NameValidation, NameValidation,
UserFieldsValidation, UserFieldsValidation,
{ {
createAccount: controller(),
invitedBy: readOnly("model.invited_by"), invitedBy: readOnly("model.invited_by"),
email: alias("model.email"), email: alias("model.email"),
accountUsername: alias("model.username"), accountUsername: alias("model.username"),
@ -28,6 +31,7 @@ export default Controller.extend(
successMessage: null, successMessage: null,
errorMessage: null, errorMessage: null,
userFields: null, userFields: null,
authOptions: null,
inviteImageUrl: getUrl("/images/envelope.svg"), inviteImageUrl: getUrl("/images/envelope.svg"),
isInviteLink: readOnly("model.is_invite_link"), isInviteLink: readOnly("model.is_invite_link"),
submitDisabled: or( submitDisabled: or(
@ -45,6 +49,20 @@ export default Controller.extend(
this.rejectedEmails = []; this.rejectedEmails = [];
}, },
authenticationComplete(options) {
const props = {
accountUsername: options.username,
accountName: options.name,
authOptions: EmberObject.create(options),
};
if (this.isInviteLink) {
props.email = options.email;
}
this.setProperties(props);
},
@discourseComputed @discourseComputed
welcomeTitle() { welcomeTitle() {
return I18n.t("invites.welcome_to", { return I18n.t("invites.welcome_to", {
@ -62,6 +80,25 @@ export default Controller.extend(
return findLoginMethods().length > 0; return findLoginMethods().length > 0;
}, },
@discourseComputed
externalAuthsOnly() {
return (
!this.siteSettings.enable_local_logins && this.externalAuthsEnabled
);
},
@discourseComputed(
"externalAuthsOnly",
"authOptions",
"emailValidation.failed"
)
shouldDisplayForm(externalAuthsOnly, authOptions, emailValidationFailed) {
return (
this.siteSettings.enable_local_logins ||
(externalAuthsOnly && authOptions && !emailValidationFailed)
);
},
@discourseComputed @discourseComputed
fullnameRequired() { fullnameRequired() {
return ( return (
@ -69,8 +106,18 @@ export default Controller.extend(
); );
}, },
@discourseComputed("email", "rejectedEmails.[]") @discourseComputed(
emailValidation(email, rejectedEmails) { "email",
"rejectedEmails.[]",
"authOptions.email",
"authOptions.email_valid"
)
emailValidation(
email,
rejectedEmails,
externalAuthEmail,
externalAuthEmailValid
) {
// If blank, fail without a reason // If blank, fail without a reason
if (isEmpty(email)) { if (isEmpty(email)) {
return EmberObject.create({ return EmberObject.create({
@ -85,6 +132,28 @@ export default Controller.extend(
}); });
} }
if (externalAuthEmail) {
const provider = this.createAccount.authProviderDisplayName(
this.get("authOptions.auth_provider")
);
if (externalAuthEmail === email && externalAuthEmailValid) {
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.authenticated", {
provider,
}),
});
} else {
return EmberObject.create({
failed: true,
reason: I18n.t("user.email.invite_auth_email_invalid", {
provider,
}),
});
}
}
if (emailValid(email)) { if (emailValid(email)) {
return EmberObject.create({ return EmberObject.create({
ok: true, ok: true,
@ -98,6 +167,9 @@ export default Controller.extend(
}); });
}, },
@discourseComputed
wavingHandURL: () => wavingHandURL(),
actions: { actions: {
submit() { submit() {
const userFields = this.userFields; const userFields = this.userFields;
@ -158,6 +230,12 @@ export default Controller.extend(
this.set("errorMessage", extractError(error)); this.set("errorMessage", extractError(error));
}); });
}, },
externalLogin(provider) {
provider.doLogin({
origin: window.location.href,
});
},
}, },
} }
); );

View File

@ -13,10 +13,14 @@ export default {
if (lastAuthResult) { if (lastAuthResult) {
const router = container.lookup("router:main"); const router = container.lookup("router:main");
router.one("didTransition", () => { router.one("didTransition", () => {
const controllerName =
router.currentPath === "invites.show" ? "invites-show" : "login";
next(() => { next(() => {
let loginController = container.lookup("controller:login"); let controller = container.lookup(`controller:${controllerName}`);
loginController.authenticationComplete(JSON.parse(lastAuthResult)); controller.authenticationComplete(JSON.parse(lastAuthResult));
}); });
}); });
} }

View File

@ -9,4 +9,4 @@
{{/if}} {{/if}}
{{b.title}} {{b.title}}
</button> </button>
{{/each}} {{/each}}

View File

@ -1,5 +1,9 @@
<div class="container invites-show clearfix"> <div class="container invites-show clearfix">
<h2>{{welcomeTitle}}</h2> <div class="login-welcome-header">
<h1 class="login-title">{{welcomeTitle}}</h1>
<img src={{wavingHandURL}} alt="" class="waving-hand">
<p class="login-subheader">{{i18n "create_account.subheader_title"}}</p>
</div>
<div class="two-col"> <div class="two-col">
<div class="col-image"> <div class="col-image">
@ -19,71 +23,84 @@
{{#unless isInviteLink}} {{#unless isInviteLink}}
<p> <p>
{{html-safe yourEmailMessage}} {{html-safe yourEmailMessage}}
{{#if externalAuthsEnabled}} {{#unless externalAuthsOnly}}
{{i18n "invites.social_login_available"}} {{i18n "invites.social_login_available"}}
{{/if}} {{/unless}}
</p> </p>
{{/unless}} {{/unless}}
<form> {{#if externalAuthsOnly}}
{{#if authOptions}}
{{#if isInviteLink}} {{#unless isInviteLink}}
<div class="input email-input">
<label>{{i18n "user.email.title"}}</label>
{{input type="email" value=email id="new-account-email" name="email" autofocus="autofocus"}}
{{input-tip validation=emailValidation id="account-email-validation"}} {{input-tip validation=emailValidation id="account-email-validation"}}
<div class="instructions">{{i18n "user.email.instructions"}}</div> {{/unless}}
</div> {{else}}
{{login-buttons externalLogin=(action "externalLogin")}}
{{/if}} {{/if}}
{{/if}}
<div class="input username-input"> {{#if shouldDisplayForm}}
<label>{{i18n "user.username.title"}}</label> <form>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}} {{#if isInviteLink}}
{{input-tip validation=usernameValidation id="username-validation"}} <div class="input email-input">
<div class="instructions">{{i18n "user.username.instructions"}}</div> <label>{{i18n "user.email.title"}}</label>
</div> {{input type="email" value=email id="new-account-email" name="email" autofocus="autofocus" disabled=externalAuthsOnly}}
{{input-tip validation=emailValidation id="account-email-validation"}}
{{#if fullnameRequired}} <div class="instructions">{{i18n "user.email.instructions"}}</div>
<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}}
{{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>
{{/if}}
<div class="input username-input">
<label>{{i18n "user.username.title"}}</label>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
{{input-tip validation=usernameValidation id="username-validation"}}
<div class="instructions">{{i18n "user.username.instructions"}}</div>
</div> </div>
</div>
{{#if userFields}} {{#if fullnameRequired}}
<div class="user-fields"> <div class="input name-input">
{{#each userFields as |f|}} <label>{{i18n "invites.name_label"}}</label>
{{user-field field=f.field value=f.value}} {{input value=accountName id="new-account-name" name="name"}}
{{/each}} <div class="instructions">{{nameInstructions}}</div>
</div> </div>
{{/if}} {{/if}}
{{d-button {{#unless externalAuthsOnly}}
class="btn-primary" <div class="input password-input">
action=(action "submit") <label>{{i18n "invites.password_label"}}</label>
type="submit" {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
disabled=submitDisabled {{input-tip validation=passwordValidation}}
label="invites.accept_invite" <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>
{{/unless}}
{{#if errorMessage}} {{#if userFields}}
<br><br> <div class="user-fields">
<div class="alert alert-error">{{errorMessage}}</div> {{#each userFields as |f|}}
{{/if}} {{user-field field=f.field value=f.value}}
</form> {{/each}}
</div>
{{/if}}
{{d-button
class="btn-primary"
action=(action "submit")
type="submit"
disabled=submitDisabled
label="invites.accept_invite"
}}
{{#if errorMessage}}
<br><br>
<div class="alert alert-error">{{errorMessage}}</div>
{{/if}}
</form>
{{/if}}
{{/if}} {{/if}}
</div> </div>
</div> </div>

View File

@ -173,4 +173,4 @@
{{plugin-outlet name="create-account-after-modal-footer" tagName=""}} {{plugin-outlet name="create-account-after-modal-footer" tagName=""}}
{{/if}} {{/if}}
{{/unless}} {{/unless}}
{{/create-account}} {{/create-account}}

View File

@ -5,12 +5,39 @@ import {
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { fillIn, visit } from "@ember/test-helpers"; import { fillIn, visit } from "@ember/test-helpers";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import I18n from "I18n";
import { test } from "qunit"; import { test } from "qunit";
acceptance("Invite Accept", function (needs) { acceptance("Invite accept", function (needs) {
needs.settings({ full_name_required: true }); needs.settings({ full_name_required: true });
test("Invite Acceptance Page", async function (assert) { test("email invite link", async function (assert) {
PreloadStore.store("invite_info", {
invited_by: {
id: 123,
username: "foobar",
avatar_template: "/user_avatar/localhost/neil/{size}/25_1.png",
name: "foobar",
title: "team",
},
email: "foobar@example.com",
username: "invited",
is_invite_link: false,
});
await visit("/invites/myvalidinvitetoken");
assert.ok(
queryAll(".col-form")
.text()
.includes(I18n.t("invites.social_login_available")),
"shows social login hint"
);
assert.ok(!exists("#new-account-email"), "hides the email input");
});
test("invite link", async function (assert) {
PreloadStore.store("invite_info", { PreloadStore.store("invite_info", {
invited_by: { invited_by: {
id: 123, id: 123,
@ -84,3 +111,175 @@ acceptance("Invite Accept", function (needs) {
); );
}); });
}); });
acceptance("Invite accept when local login is disabled", function (needs) {
needs.settings({ enable_local_logins: false });
const preloadStore = function (isInviteLink) {
const info = {
invited_by: {
id: 123,
username: "foobar",
avatar_template: "/user_avatar/localhost/neil/{size}/25_1.png",
name: "foobar",
title: "team",
},
username: "invited",
};
if (isInviteLink) {
info.email = "null";
info.is_invite_link = true;
} else {
info.email = "foobar@example.com";
info.is_invite_link = false;
}
PreloadStore.store("invite_info", info);
};
test("invite link", async function (assert) {
preloadStore(true);
await visit("/invites/myvalidinvitetoken");
assert.ok(exists(".btn-social.facebook"), "shows Facebook login button");
assert.ok(!exists("form"), "does not display the form");
});
test("invite link with authentication data", async function (assert) {
preloadStore(true);
// Simulate authticated with Facebook
const node = document.createElement("meta");
node.dataset.authenticationData = JSON.stringify({
auth_provider: "facebook",
email: "blah@example.com",
email_valid: true,
username: "foobar",
name: "barfoo",
});
node.id = "data-authentication";
document.querySelector("head").appendChild(node);
await visit("/invites/myvalidinvitetoken");
assert.ok(
!exists(".btn-social.facebook"),
"does not show Facebook login button"
);
assert.ok(!exists("#new-account-password"), "does not show password field");
assert.ok(
exists("#new-account-email[disabled]"),
"email field is disabled"
);
assert.equal(
queryAll("#account-email-validation").text().trim(),
I18n.t("user.email.authenticated", { provider: "Facebook" })
);
assert.equal(
queryAll("#new-account-username").val(),
"foobar",
"username is prefilled"
);
assert.equal(
queryAll("#new-account-name").val(),
"barfoo",
"name is prefilled"
);
document
.querySelector("head")
.removeChild(document.getElementById("data-authentication"));
});
test("email invite link", async function (assert) {
preloadStore(false);
await visit("/invites/myvalidinvitetoken");
assert.ok(exists(".btn-social.facebook"), "shows Facebook login button");
assert.ok(!exists("form"), "does not display the form");
});
test("email invite link with authentication data when email does not match", async function (assert) {
preloadStore(false);
// Simulate authticated with Facebook
const node = document.createElement("meta");
node.dataset.authenticationData = JSON.stringify({
auth_provider: "facebook",
email: "blah@example.com",
email_valid: true,
username: "foobar",
name: "barfoo",
});
node.id = "data-authentication";
document.querySelector("head").appendChild(node);
await visit("/invites/myvalidinvitetoken");
assert.equal(
queryAll("#account-email-validation").text().trim(),
I18n.t("user.email.invite_auth_email_invalid", { provider: "Facebook" })
);
assert.ok(!exists("form"), "does not display the form");
document
.querySelector("head")
.removeChild(document.getElementById("data-authentication"));
});
test("email invite link with authentication data", async function (assert) {
preloadStore(false);
// Simulate authticated with Facebook
const node = document.createElement("meta");
node.dataset.authenticationData = JSON.stringify({
auth_provider: "facebook",
email: "foobar@example.com",
email_valid: true,
username: "foobar",
name: "barfoo",
});
node.id = "data-authentication";
document.querySelector("head").appendChild(node);
await visit("/invites/myvalidinvitetoken");
assert.ok(
!exists(".btn-social.facebook"),
"does not show Facebook login button"
);
assert.ok(!exists("#new-account-password"), "does not show password field");
assert.ok(!exists("#new-account-email"), "does not show email field");
assert.equal(
queryAll("#account-email-validation").text().trim(),
I18n.t("user.email.authenticated", { provider: "Facebook" })
);
assert.equal(
queryAll("#new-account-username").val(),
"foobar",
"username is prefilled"
);
assert.equal(
queryAll("#new-account-name").val(),
"barfoo",
"name is prefilled"
);
document
.querySelector("head")
.removeChild(document.getElementById("data-authentication"));
});
});

View File

@ -33,7 +33,8 @@
// Create Account + Login // Create Account + Login
.d-modal.create-account, .d-modal.create-account,
.d-modal.login-modal { .d-modal.login-modal,
.invites-show {
.modal-inner-container { .modal-inner-container {
position: relative; position: relative;
} }
@ -267,6 +268,14 @@
.two-col { .two-col {
position: relative; position: relative;
display: flex; display: flex;
margin-top: 5px;
}
#login-buttons {
.btn {
background-color: var(--primary-low);
color: var(--primary);
}
} }
.col-image { .col-image {

View File

@ -8,6 +8,7 @@ class InvitesController < ApplicationController
skip_before_action :preload_json, except: [:show] skip_before_action :preload_json, except: [:show]
skip_before_action :redirect_to_login_if_required skip_before_action :redirect_to_login_if_required
before_action :ensure_invites_allowed, only: [:show, :perform_accept_invitation]
before_action :ensure_new_registrations_allowed, only: [:show, :perform_accept_invitation] before_action :ensure_new_registrations_allowed, only: [:show, :perform_accept_invitation]
before_action :ensure_not_logged_in, only: [:show, :perform_accept_invitation] before_action :ensure_not_logged_in, only: [:show, :perform_accept_invitation]
@ -168,7 +169,8 @@ class InvitesController < ApplicationController
name: params[:name], name: params[:name],
password: params[:password], password: params[:password],
user_custom_fields: params[:user_custom_fields], user_custom_fields: params[:user_custom_fields],
ip_address: request.remote_ip ip_address: request.remote_ip,
session: session
} }
attrs[:email] = attrs[:email] =
@ -284,6 +286,12 @@ class InvitesController < ApplicationController
private private
def ensure_invites_allowed
if SiteSetting.enable_discourse_connect || (!SiteSetting.enable_local_logins && Discourse.enabled_auth_providers.count == 0)
raise Discourse::NotFound
end
end
def ensure_new_registrations_allowed def ensure_new_registrations_allowed
unless SiteSetting.allow_new_registrations unless SiteSetting.allow_new_registrations
flash[:error] = I18n.t('login.new_registrations_disabled') flash[:error] = I18n.t('login.new_registrations_disabled')

View File

@ -119,7 +119,12 @@ class Users::OmniauthCallbacksController < ApplicationController
end end
def invite_required? def invite_required?
SiteSetting.invite_only? if SiteSetting.invite_only?
path = Discourse.route_for(@origin)
return true unless path
return true if path[:controller] != "invites" && path[:action] != "show"
!Invite.exists?(invite_key: path[:id])
end
end end
def user_found(user) def user_found(user)

View File

@ -447,8 +447,6 @@ class UsersController < ApplicationController
elsif current_user&.staff? elsif current_user&.staff?
message = if SiteSetting.enable_discourse_connect message = if SiteSetting.enable_discourse_connect
I18n.t("invite.disabled_errors.discourse_connect_enabled") I18n.t("invite.disabled_errors.discourse_connect_enabled")
elsif !SiteSetting.enable_local_logins
I18n.t("invite.disabled_errors.local_logins_disabled")
end end
render_invite_error(message) render_invite_error(message)

View File

@ -162,11 +162,20 @@ class Invite < ActiveRecord::Base
invite.reload invite.reload
end end
def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil) def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
if !expired? && !destroyed? && link_valid? if !expired? && !destroyed? && link_valid?
raise UserExists.new I18n.t("invite_link.email_taken") if is_invite_link? && UserEmail.exists?(email: email) raise UserExists.new I18n.t("invite_link.email_taken") if is_invite_link? && UserEmail.exists?(email: email)
email = self.email if email.blank? && !is_invite_link? email = self.email if email.blank? && !is_invite_link?
InviteRedeemer.new(invite: self, email: email, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address).redeem InviteRedeemer.new(
invite: self,
email: email,
username: username,
name: name,
password: password,
user_custom_fields: user_custom_fields,
ip_address: ip_address,
session: session
).redeem
end end
end end
@ -251,8 +260,6 @@ class Invite < ActiveRecord::Base
if SiteSetting.enable_discourse_connect? if SiteSetting.enable_discourse_connect?
errors.add(:email, I18n.t("invite.disabled_errors.discourse_connect_enabled")) errors.add(:email, I18n.t("invite.disabled_errors.discourse_connect_enabled"))
elsif !SiteSetting.enable_local_logins?
errors.add(:email, I18n.t("invite.disabled_errors.local_logins_disabled"))
end end
end end
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, keyword_init: true) do InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, :session, keyword_init: true) do
def redeem def redeem
Invite.transaction do Invite.transaction do
@ -14,7 +14,7 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
end end
# extracted from User cause it is very specific to invites # extracted from User cause it is very specific to invites
def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil) def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
user = User.where(staged: true).with_email(email.strip.downcase).first user = User.where(staged: true).with_email(email.strip.downcase).first
user.unstage! if user user.unstage! if user
@ -61,7 +61,20 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
user.password_required! user.password_required!
end end
authenticator = UserAuthenticator.new(user, session, require_password: false)
if !authenticator.has_authenticator? && !SiteSetting.enable_local_logins
raise ActiveRecord::RecordNotSaved.new(I18n.t("login.incorrect_username_email_or_password"))
end
authenticator.start
if authenticator.email_valid? && !authenticator.authenticated?
raise ActiveRecord::RecordNotSaved.new(I18n.t("login.incorrect_username_email_or_password"))
end
user.save! user.save!
authenticator.finish
if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email
user.email_tokens.create!(email: user.email) user.email_tokens.create!(email: user.email)
@ -110,7 +123,16 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
def get_invited_user def get_invited_user
result = get_existing_user result = get_existing_user
result ||= InviteRedeemer.create_user_from_invite(email: email, invite: invite, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address) result ||= InviteRedeemer.create_user_from_invite(
email: email,
invite: invite,
username: username,
name: name,
password: password,
user_custom_fields: user_custom_fields,
ip_address: ip_address,
session: session
)
result.send_welcome_message = false result.send_welcome_message = false
result result
end end

View File

@ -2,20 +2,21 @@
class UserAuthenticator class UserAuthenticator
def initialize(user, session, authenticator_finder = Users::OmniauthCallbacksController) def initialize(user, session, authenticator_finder: Users::OmniauthCallbacksController, require_password: true)
@user = user @user = user
@session = session @session = session
if session[:authentication] && session[:authentication].is_a?(Hash) if session&.dig(:authentication) && session[:authentication].is_a?(Hash)
@auth_result = Auth::Result.from_session_data(session[:authentication], user: user) @auth_result = Auth::Result.from_session_data(session[:authentication], user: user)
end end
@authenticator_finder = authenticator_finder @authenticator_finder = authenticator_finder
@require_password = require_password
end end
def start def start
if authenticated? if authenticated?
@user.active = true @user.active = true
@auth_result.apply_user_attributes! @auth_result.apply_user_attributes!
else elsif @require_password
@user.password_required! @user.password_required!
end end
@ -31,7 +32,7 @@ class UserAuthenticator
authenticator.after_create_account(@user, @auth_result) authenticator.after_create_account(@user, @auth_result)
confirm_email confirm_email
end end
@session[:authentication] = @auth_result = nil if @session[:authentication] @session[:authentication] = @auth_result = nil if @session&.dig(:authentication)
end end
def email_valid? def email_valid?

View File

@ -1293,6 +1293,7 @@ en:
required: "Please enter an email address" required: "Please enter an email address"
invalid: "Please enter a valid email address" invalid: "Please enter a valid email address"
authenticated: "Your email has been authenticated by %{provider}" authenticated: "Your email has been authenticated by %{provider}"
invite_auth_email_invalid: "Your invitation email does not match the email authenticated by %{provider}"
frequency_immediately: "We'll email you immediately if you haven't read the thing we're emailing you about." frequency_immediately: "We'll email you immediately if you haven't read the thing we're emailing you about."
frequency: frequency:
one: "We'll only email you if we haven't seen you in the last minute." one: "We'll only email you if we haven't seen you in the last minute."

View File

@ -241,7 +241,6 @@ en:
cant_invite_to_group: "You are not allowed to invite users to specified group(s). Make sure you are owner of the group(s) you are trying to invite to." cant_invite_to_group: "You are not allowed to invite users to specified group(s). Make sure you are owner of the group(s) you are trying to invite to."
disabled_errors: disabled_errors:
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled." discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
local_logins_disabled: "Invites are disabled because the 'enable local logins' setting is disabled."
invalid_access: "You are not permitted to view the requested resource." invalid_access: "You are not permitted to view the requested resource."
bulk_invite: bulk_invite:
@ -1681,7 +1680,7 @@ en:
discourse_connect_not_approved_url: "Redirect unapproved DiscourseConnect accounts to this URL" discourse_connect_not_approved_url: "Redirect unapproved DiscourseConnect accounts to this URL"
discourse_connect_allows_all_return_paths: "Do not restrict the domain for return_paths provided by DiscourseConnect (by default return path must be on current site)" discourse_connect_allows_all_return_paths: "Do not restrict the domain for return_paths provided by DiscourseConnect (by default return path must be on current site)"
enable_local_logins: "Enable local username and password login based accounts. This must be enabled for invites to work. WARNING: if disabled, you may be unable to log in if you have not previously configured at least one alternate login method." enable_local_logins: "Enable local username and password login based accounts. WARNING: if disabled, you may be unable to log in if you have not previously configured at least one alternate login method."
enable_local_logins_via_email: "Allow users to request a one-click login link to be sent to them via email." enable_local_logins_via_email: "Allow users to request a one-click login link to be sent to them via email."
allow_new_registrations: "Allow new user registrations. Uncheck this to prevent anyone from creating a new account." allow_new_registrations: "Allow new user registrations. Uncheck this to prevent anyone from creating a new account."
enable_signup_cta: "Show a notice to returning anonymous users prompting them to sign up for an account." enable_signup_cta: "Show a notice to returning anonymous users prompting them to sign up for an account."

View File

@ -354,7 +354,6 @@ class Guardian
authenticated? && authenticated? &&
(SiteSetting.max_invites_per_day.to_i > 0 || is_staff?) && (SiteSetting.max_invites_per_day.to_i > 0 || is_staff?) &&
!SiteSetting.enable_discourse_connect && !SiteSetting.enable_discourse_connect &&
SiteSetting.enable_local_logins &&
( (
(!SiteSetting.must_approve_users? && @user.has_trust_level?(SiteSetting.min_trust_level_to_allow_invite.to_i)) || (!SiteSetting.must_approve_users? && @user.has_trust_level?(SiteSetting.min_trust_level_to_allow_invite.to_i)) ||
is_staff? is_staff?
@ -395,9 +394,7 @@ class Guardian
end end
def can_bulk_invite_to_forum?(user) def can_bulk_invite_to_forum?(user)
user.admin? && user.admin? && !SiteSetting.enable_discourse_connect
!SiteSetting.enable_discourse_connect &&
SiteSetting.enable_local_logins
end end
def can_resend_all_invites?(user) def can_resend_all_invites?(user)

View File

@ -511,8 +511,10 @@ describe Guardian do
expect(Guardian.new(user).can_invite_to_forum?).to be_falsey expect(Guardian.new(user).can_invite_to_forum?).to be_falsey
end end
it 'returns false when the local logins are disabled' do it 'returns false when DiscourseConnect is enabled' do
SiteSetting.enable_local_logins = false SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
expect(Guardian.new(user).can_invite_to_forum?).to be_falsey expect(Guardian.new(user).can_invite_to_forum?).to be_falsey
expect(Guardian.new(moderator).can_invite_to_forum?).to be_falsey expect(Guardian.new(moderator).can_invite_to_forum?).to be_falsey
end end

View File

@ -374,6 +374,97 @@ describe InvitesController do
expect(invite.redeemed?).to be_truthy expect(invite.redeemed?).to be_truthy
end end
it 'returns the right response when local login is disabled and no external auth is configured' do
SiteSetting.enable_local_logins = false
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(404)
end
it 'returns the right response when DiscourseConnect is enabled' do
invite
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(404)
end
describe 'with authentication session' do
let(:authenticated_email) { "foobar@example.com" }
before do
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google_oauth2',
uid: '12345',
info: OmniAuth::AuthHash::InfoHash.new(
email: authenticated_email,
name: 'First Last'
),
extra: {
raw_info: OmniAuth::AuthHash.new(
email_verified: true,
email: authenticated_email,
family_name: "Last",
given_name: "First",
gender: "male",
name: "First Last",
)
},
)
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2]
SiteSetting.enable_google_oauth2_logins = true
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
end
after do
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil
OmniAuth.config.test_mode = false
end
it 'should associate the invited user with authenticator records' do
invite.update!(email: authenticated_email)
SiteSetting.auth_overrides_name = true
expect do
put "/invites/show/#{invite.invite_key}.json",
params: { name: 'somename' }
expect(response.status).to eq(200)
end.to change { User.with_email(authenticated_email).exists? }.to(true)
user = User.find_by_email(authenticated_email)
expect(user.name).to eq('First Last')
expect(user.user_associated_accounts.first.provider_name)
.to eq("google_oauth2")
end
it 'returns the right response even if local logins has been disabled' do
SiteSetting.enable_local_logins = false
invite.update!(email: authenticated_email)
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(200)
end
it 'returns the right response if authenticated email does not match invite email' do
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(412)
end
end
context 'when redeem returns a user' do context 'when redeem returns a user' do
fab!(:user) { Fabricate(:coding_horror) } fab!(:user) { Fabricate(:coding_horror) }
@ -447,27 +538,6 @@ describe InvitesController do
expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(1) expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(1)
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
end end
it "does not send password reset email if sso is enabled" do
invite # create the invite before enabling SSO
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(200)
expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0)
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
end
it "does not send password reset email if local login is disabled" do
invite # create the invite before enabling SSO
SiteSetting.enable_local_logins = false
put "/invites/show/#{invite.invite_key}.json"
expect(response.status).to eq(200)
expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0)
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
end
end end
context "with password" do context "with password" do

View File

@ -12,6 +12,7 @@ RSpec.describe Users::OmniauthCallbacksController do
after do after do
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil
Rails.application.env_config["omniauth.origin"] = nil
OmniAuth.config.test_mode = false OmniAuth.config.test_mode = false
end end
@ -221,6 +222,48 @@ RSpec.describe Users::OmniauthCallbacksController do
data = JSON.parse(cookies[:authentication_data]) data = JSON.parse(cookies[:authentication_data])
expect(data["destination_url"]).to eq(destination_url) expect(data["destination_url"]).to eq(destination_url)
end end
describe 'when site is invite_only' do
before do
SiteSetting.invite_only = true
end
it 'should return the right response without any origin' do
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
data = JSON.parse(response.cookies["authentication_data"])
expect(data["requires_invite"]).to eq(true)
end
it 'returns the right response for an invalid origin' do
Rails.application.env_config["omniauth.origin"] = "/invitesinvites"
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
end
it 'should return the right response when origin is invites page' do
origin = Rails.application.routes.url_helpers.invite_url(
Fabricate(:invite).invite_key,
host: Discourse.base_url
)
Rails.application.env_config["omniauth.origin"] = origin
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
expect(response).to redirect_to(origin)
data = JSON.parse(response.cookies["authentication_data"])
expect(data["requires_invite"]).to eq(nil)
end
end
end end
describe 'when user has been verified' do describe 'when user has been verified' do

View File

@ -1755,20 +1755,24 @@ describe UsersController do
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
end end
end
context 'when local logins are disabled' do context 'when DiscourseConnect has been enabled' do
it 'explains why invites are disabled to staff users' do before do
SiteSetting.enable_local_logins = false SiteSetting.discourse_connect_url = "https://www.example.com/sso"
inviter = sign_in(Fabricate(:admin)) SiteSetting.enable_discourse_connect = true
Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) end
get "/u/#{inviter.username}/invited/pending.json" it 'explains why invites are disabled to staff users' do
expect(response.status).to eq(200) inviter = sign_in(Fabricate(:admin))
Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required])
expect(response.parsed_body['error']).to include(I18n.t( get "/u/#{inviter.username}/invited/pending.json"
'invite.disabled_errors.local_logins_disabled' expect(response.status).to eq(200)
))
end expect(response.parsed_body['error']).to include(I18n.t(
'invite.disabled_errors.discourse_connect_enabled'
))
end end
end end

View File

@ -2,30 +2,48 @@
require 'rails_helper' require 'rails_helper'
def github_auth(email_valid)
{
email: "user53@discourse.org",
username: "joedoe546",
email_valid: email_valid,
omit_username: nil,
name: "Joe Doe 546",
authenticator_name: "github",
extra_data: {
provider: "github",
uid: "100"
},
skip_email_validation: false
}
end
describe UserAuthenticator do describe UserAuthenticator do
def github_auth(email_valid)
{
email: "user53@discourse.org",
username: "joedoe546",
email_valid: email_valid,
omit_username: nil,
name: "Joe Doe 546",
authenticator_name: "github",
extra_data: {
provider: "github",
uid: "100"
},
skip_email_validation: false
}
end
before do
SiteSetting.enable_github_logins = true
end
describe "#start" do
describe 'without authentication session' do
it "should apply the right user attributes" do
user = User.new
UserAuthenticator.new(user, {}).start
expect(user.password_required?).to eq(true)
end
it "allows password requirement to be skipped" do
user = User.new
UserAuthenticator.new(user, {}, require_password: false).start
expect(user.password_required?).to eq(false)
end
end
end
context "#finish" do context "#finish" do
fab!(:group) { Fabricate(:group, automatic_membership_email_domains: "discourse.org") } fab!(:group) { Fabricate(:group, automatic_membership_email_domains: "discourse.org") }
before do
SiteSetting.enable_github_logins = true
end
it "confirms email and adds the user to appropraite groups based on email" do it "confirms email and adds the user to appropraite groups based on email" do
user = Fabricate(:user, email: "user53@discourse.org") user = Fabricate(:user, email: "user53@discourse.org")
expect(group.usernames).not_to include(user.username) expect(group.usernames).not_to include(user.username)