FEATURE: Allow using invites when DiscourseConnect SSO is enabled (#12419)

This PR allows invitations to be used when the DiscourseConnect SSO is enabled for a site (`enable_discourse_connect`) and local logins are disabled. Previously invites could not be accepted with SSO enabled simply because we did not have the code paths to handle that logic.

The invitation methods that are supported include:

* Inviting people to groups via email address
* Inviting people to topics via email address
* Using invitation links generated by the Invite Users UI in the /my/invited/pending route

The flow works like this:

1. User visits an invite URL
2. The normal invitation validations (redemptions/expiry) happen at that point
3. We store the invite key in a secure session
4. The user clicks "Accept Invitation and Continue" (see below)
5. The user is redirected to /session/sso then to the SSO provider URL then back to /session/sso_login
6. We retrieve the invite based on the invite key in secure session. We revalidate the invitation. We show an error to the user if it is not valid. An additional check here for invites with an email specified is to check the SSO email matches the invite email
7. If the invite is OK we create the user via the normal SSO methods
8. We redeem the invite and activate the user. We clear the invite key in secure session.
9. If the invite had a topic we redirect the user there, otherwise we redirect to /

Note that we decided for SSO-based invites the `must_approve_users` site setting is ignored, because the invite is a form of pre-approval, and because regular non-staff users cannot send out email invites or generally invite to the forum in this case.

Also deletes some group invite checks as per https://github.com/discourse/discourse/pull/12353
This commit is contained in:
Martin Brennan 2021-03-19 10:20:10 +10:00 committed by GitHub
parent aee7ef0dc9
commit 355d51afde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 339 additions and 144 deletions

View File

@ -63,6 +63,11 @@ export default Controller.extend(
this.setProperties(props);
},
@discourseComputed
discourseConnectEnabled() {
return this.siteSettings.enable_discourse_connect;
},
@discourseComputed
welcomeTitle() {
return I18n.t("invites.welcome_to", {
@ -83,10 +88,17 @@ export default Controller.extend(
@discourseComputed
externalAuthsOnly() {
return (
!this.siteSettings.enable_local_logins && this.externalAuthsEnabled
!this.siteSettings.enable_local_logins &&
this.externalAuthsEnabled &&
!this.siteSettings.enable_discourse_connect
);
},
@discourseComputed("externalAuthsOnly", "discourseConnectEnabled")
showSocialLoginAvailable(externalAuthsOnly, discourseConnectEnabled) {
return !externalAuthsOnly && !discourseConnectEnabled;
},
@discourseComputed(
"externalAuthsOnly",
"authOptions",
@ -170,6 +182,9 @@ export default Controller.extend(
@discourseComputed
wavingHandURL: () => wavingHandURL(),
@discourseComputed
ssoPath: () => getUrl("/session/sso"),
actions: {
submit() {
const userFields = this.userFields;

View File

@ -21,15 +21,16 @@
<p>{{user-info user=invitedBy}}</p>
{{#unless isInviteLink}}
<p>
<p class="email-message">
{{html-safe yourEmailMessage}}
{{#unless externalAuthsOnly}}
{{#if showSocialLoginAvailable}}
{{i18n "invites.social_login_available"}}
{{/unless}}
{{/if}}
</p>
{{/unless}}
{{#if externalAuthsOnly}}
{{! authOptions are present once the user has followed the OmniAuth flow (e.g. twitter/google/etc) }}
{{#if authOptions}}
{{#unless isInviteLink}}
{{input-tip validation=emailValidation id="account-email-validation"}}
@ -39,6 +40,12 @@
{{/if}}
{{/if}}
{{#if discourseConnectEnabled}}
<a class="btn btn-primary discourse-connect raw-link" href={{ssoPath}}>
{{i18n "continue"}}
</a>
{{/if}}
{{#if shouldDisplayForm}}
<form>
{{#if isInviteLink}}

View File

@ -170,6 +170,53 @@ acceptance("Invite accept when local login is disabled", function (needs) {
});
});
acceptance(
"Invite accept when DiscourseConnect SSO is enabled and local login is disabled",
function (needs) {
needs.settings({
enable_local_logins: false,
enable_discourse_connect: true,
});
test("invite link", async function (assert) {
preloadInvite({ link: true });
await visit("/invites/myvalidinvitetoken");
assert.ok(
!exists(".btn-social.facebook"),
"does not show Facebook login button"
);
assert.ok(!exists("form"), "does not display the form");
assert.ok(
!exists(".email-message"),
"does not show the email message with the prefilled email"
);
assert.ok(exists(".discourse-connect"), "shows the Continue button");
});
test("email invite link", async function (assert) {
preloadInvite();
await visit("/invites/myvalidinvitetoken");
assert.ok(
!exists(".btn-social.facebook"),
"does not show Facebook login button"
);
assert.ok(!exists("form"), "does not display the form");
assert.ok(
exists(".email-message"),
"shows the email message with the prefilled email"
);
assert.ok(exists(".discourse-connect"), "shows the Continue button");
assert.ok(
queryAll(".email-message").text().includes("foobar@example.com")
);
});
}
);
acceptance("Invite link with authentication data", function (needs) {
needs.settings({ enable_local_logins: false });

View File

@ -336,14 +336,6 @@ class GroupsController < ApplicationController
raise Discourse::InvalidParameters.new(I18n.t("groups.errors.usernames_or_emails_required"))
end
if emails.any?
if SiteSetting.enable_discourse_connect?
raise Discourse::InvalidParameters.new(I18n.t("groups.errors.no_invites_with_discourse_connect"))
elsif !SiteSetting.enable_local_logins?
raise Discourse::InvalidParameters.new(I18n.t("groups.errors.no_invites_without_local_logins"))
end
end
if users.length > ADD_MEMBERS_LIMIT
return render_json_error(
I18n.t("groups.errors.adding_too_many_users", count: ADD_MEMBERS_LIMIT)

View File

@ -16,7 +16,7 @@ class InvitesController < ApplicationController
expires_now
invite = Invite.find_by(invite_key: params[:id])
if invite.present? && !invite.expired? && !invite.redeemed?
if invite.present? && invite.redeemable?
email = Email.obfuscate(invite.email)
# Show email if the user already authenticated their email
@ -34,14 +34,16 @@ class InvitesController < ApplicationController
is_invite_link: invite.is_invite_link?
))
secure_session["invite-key"] = invite.invite_key
render layout: 'application'
else
flash.now[:error] = if invite&.expired?
I18n.t('invite.expired', base_url: Discourse.base_url)
elsif invite&.redeemed?
I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
else
flash.now[:error] = if invite.blank?
I18n.t('invite.not_found', base_url: Discourse.base_url)
elsif invite.redeemed?
I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
elsif invite.expired?
I18n.t('invite.expired', base_url: Discourse.base_url)
end
render layout: 'no_ember'
@ -165,6 +167,8 @@ class InvitesController < ApplicationController
render json: success_json
end
# For DiscourseConnect SSO, all invite acceptance is done
# via the SessionController#sso_login route
def perform_accept_invitation
params.require(:id)
params.permit(:email, :username, :name, :password, :timezone, user_custom_fields: {})
@ -190,7 +194,7 @@ class InvitesController < ApplicationController
invite.email
end
user = invite.redeem(attrs)
user = invite.redeem(**attrs)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
return render json: failed_json.merge(errors: e.record&.errors&.to_hash, message: I18n.t('invite.error_message')), status: 412
rescue Invite::UserExists => e
@ -296,7 +300,7 @@ class InvitesController < ApplicationController
private
def ensure_invites_allowed
if SiteSetting.enable_discourse_connect || (!SiteSetting.enable_local_logins && Discourse.enabled_auth_providers.count == 0)
if (!SiteSetting.enable_local_logins && Discourse.enabled_auth_providers.count == 0 && !SiteSetting.enable_discourse_connect)
raise Discourse::NotFound
end
end

View File

@ -172,6 +172,8 @@ class SessionController < ApplicationController
sso.expire_nonce!
begin
invite = validate_invitiation!(sso)
if user = sso.lookup_or_create_user(request.remote_ip)
if user.suspended?
@ -179,25 +181,38 @@ class SessionController < ApplicationController
return
end
if SiteSetting.must_approve_users? && !user.approved?
# users logging in via SSO using an invite do not need to be approved,
# they are already pre-approved because they have been invited
if SiteSetting.must_approve_users? && !user.approved? && invite.blank?
if SiteSetting.discourse_connect_not_approved_url.present?
redirect_to SiteSetting.discourse_connect_not_approved_url
else
render_sso_error(text: I18n.t("discourse_connect.account_not_approved"), status: 403)
end
return
# we only want to redeem the invite if
# the user has not already redeemed an invite
# (covers the same SSO user visiting an invite link)
elsif invite.present? && user.invited_user.blank?
redeem_invitation(invite, sso)
# we directly call user.activate here instead of going
# through the UserActivator path because we assume the account
# is valid from the SSO provider's POV and do not need to
# send an activation email to the user
user.activate
login_sso_user(sso, user)
topic = invite.topics.first
return_path = topic.present? ? path(topic.relative_url) : path("/")
elsif !user.active?
activation = UserActivator.new(user, request, session, cookies)
activation.finish
session["user_created_message"] = activation.message
redirect_to(users_account_created_path) && (return)
return redirect_to(users_account_created_path)
else
if SiteSetting.verbose_discourse_connect_logging
Rails.logger.warn("Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}")
end
if user.id != current_user&.id
log_on_user user
end
login_sso_user(sso, user)
end
# If it's not a relative URL check the host
@ -252,11 +267,14 @@ class SessionController < ApplicationController
end
render_sso_error(text: text || I18n.t("discourse_connect.unknown_error"), status: 500)
rescue DiscourseSingleSignOn::BlankExternalId
render_sso_error(text: I18n.t("discourse_connect.blank_id_error"), status: 500)
rescue Invite::ValidationFailed => e
render_sso_error(text: e.message, status: 400)
rescue Invite::RedemptionFailed => e
render_sso_error(text: I18n.t("discourse_connect.invite_redeem_failed"), status: 412)
rescue Invite::UserExists => e
render_sso_error(text: e.message, status: 412)
rescue => e
message = +"Failed to create or lookup user: #{e}."
message << " "
@ -270,6 +288,13 @@ class SessionController < ApplicationController
end
end
def login_sso_user(sso, user)
if SiteSetting.verbose_discourse_connect_logging
Rails.logger.warn("Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}")
end
log_on_user(user) if user.id != current_user&.id
end
def create
params.require(:login)
params.require(:password)
@ -612,4 +637,49 @@ class SessionController < ApplicationController
def sso_url(sso)
sso.to_url
end
# the invite_key will be present if set in InvitesController
# when the user visits an /invites/xxxx link; however we do
# not want to complete the SSO process of creating a user
# and redeeming the invite if the invite is not redeemable or
# for the wrong user
def validate_invitiation!(sso)
invite_key = secure_session["invite-key"]
return if invite_key.blank?
invite = Invite.find_by(invite_key: invite_key)
if invite.blank?
raise Invite::ValidationFailed.new(I18n.t("invite.not_found", base_url: Discourse.base_url))
end
if invite.redeemable?
if !invite.is_invite_link? && sso.email != invite.email
raise Invite::ValidationFailed.new(I18n.t("invite.not_matching_email"))
end
elsif invite.expired?
raise Invite::ValidationFailed.new(I18n.t('invite.expired', base_url: Discourse.base_url))
elsif invite.redeemed?
raise Invite::ValidationFailed.new(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url))
end
invite
end
def redeem_invitation(invite, sso)
InviteRedeemer.new(
invite: invite,
username: sso.username,
name: sso.name,
ip_address: request.remote_ip,
session: session,
email: sso.email
).redeem
secure_session["invite-key"] = nil
# note - more specific errors are handled in the sso_login method
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.warn("SSO invite redemption failed: #{e}")
raise Invite::RedemptionFailed
end
end

View File

@ -2,6 +2,8 @@
class Invite < ActiveRecord::Base
class UserExists < StandardError; end
class RedemptionFailed < StandardError; end
class ValidationFailed < StandardError; end
include RateLimiter::OnCreateRecord
include Trashable
@ -31,7 +33,6 @@ class Invite < ActiveRecord::Base
validates :email, email: true, allow_blank: true
validate :ensure_max_redemptions_allowed
validate :user_doesnt_already_exist
validate :ensure_no_invalid_email_invites
before_create do
self.invite_key ||= SecureRandom.hex
@ -68,6 +69,10 @@ class Invite < ActiveRecord::Base
email.blank?
end
def redeemable?
!redeemed? && !expired? && !destroyed? && link_valid?
end
def redeemed?
if is_invite_link?
redemption_count >= max_redemptions_allowed
@ -163,20 +168,23 @@ class Invite < ActiveRecord::Base
end
def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
if !expired? && !destroyed? && link_valid?
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?
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
return if !redeemable?
if is_invite_link? && UserEmail.exists?(email: email)
raise UserExists.new I18n.t("invite_link.email_taken")
end
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,
session: session
).redeem
end
def self.redeem_from_email(email)
@ -254,14 +262,6 @@ class Invite < ActiveRecord::Base
end
end
end
def ensure_no_invalid_email_invites
return if email.blank?
if SiteSetting.enable_discourse_connect?
errors.add(:email, I18n.t("invite.disabled_errors.discourse_connect_enabled"))
end
end
end
# == Schema Information

View File

@ -33,7 +33,7 @@ class UserActivator
if !user.active?
EmailActivator
elsif SiteSetting.must_approve_users? && !(invite.present? && !invite.expired? && !invite.destroyed? && invite.link_valid?)
elsif SiteSetting.must_approve_users? && !(invite.present? && invite.redeemable?)
ApprovalActivator
else
LoginActivator

View File

@ -229,6 +229,7 @@ en:
expired: "Your invite token has expired. Please <a href='%{base_url}/about'>contact staff</a>."
not_found: "Your invite token is invalid. Please <a href='%{base_url}/about'>contact staff</a>."
not_found_json: "Your invite token is invalid. Please contact staff."
not_matching_email: "Your email address and the email address associated with the invite token do not match. Please contact staff."
not_found_template: |
<p>Your invite to <a href="%{base_url}">%{site_name}</a> has already been redeemed.</p>
@ -2359,6 +2360,7 @@ en:
blank_id_error: "The `external_id` is required but was blank"
email_error: "An account could not be registered with the email address <b>%{email}</b>. Please contact the site's administrator."
missing_secret: "Authentication failed due to missing secret. Contact the site administrators to fix this problem."
invite_redeem_failed: "Invite redemption failed. Please contact the site's administrator."
original_poster: "Original Poster"
most_posts: "Most Posts"

View File

@ -351,14 +351,21 @@ class Guardian
end
def can_invite_to_forum?(groups = nil)
authenticated? &&
(SiteSetting.max_invites_per_day.to_i > 0 || is_staff?) &&
!SiteSetting.enable_discourse_connect &&
(
(!SiteSetting.must_approve_users? && @user.has_trust_level?(SiteSetting.min_trust_level_to_allow_invite.to_i)) ||
is_staff?
) &&
(groups.blank? || is_admin? || groups.all? { |g| can_edit_group?(g) })
return false if !authenticated?
invites_available = SiteSetting.max_invites_per_day.to_i.positive?
trust_level_requirement_met = !SiteSetting.must_approve_users? && @user.has_trust_level?(SiteSetting.min_trust_level_to_allow_invite.to_i)
if !is_staff?
return false if !invites_available
return false if !trust_level_requirement_met
end
if groups.present?
return is_admin? || groups.all? { |g| can_edit_group?(g) }
end
true
end
def can_invite_to?(object, groups = nil)
@ -390,7 +397,8 @@ class Guardian
def can_invite_via_email?(object)
return false unless can_invite_to?(object)
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins && (!SiteSetting.must_approve_users? || is_staff?)
(SiteSetting.enable_local_logins || SiteSetting.enable_discourse_connect) &&
(!SiteSetting.must_approve_users? || is_staff?)
end
def can_bulk_invite_to_forum?(user)

View File

@ -117,6 +117,10 @@ class SingleSignOn
OpenSSL::HMAC.hexdigest("sha256", secret, payload)
end
def to_json
self.to_h.to_json
end
def to_url(base_url = nil)
base = "#{base_url || sso_url}"
"#{base}#{base.include?('?') ? '&' : '?'}#{payload}"
@ -128,6 +132,10 @@ class SingleSignOn
end
def unsigned_payload
Rack::Utils.build_query(self.to_h)
end
def to_h
payload = {}
ACCESSORS.each do |k|
@ -139,7 +147,6 @@ class SingleSignOn
payload["custom.#{k}"] = v.to_s
end
Rack::Utils.build_query(payload)
payload
end
end

View File

@ -511,14 +511,6 @@ describe Guardian do
expect(Guardian.new(user).can_invite_to_forum?).to be_falsey
end
it 'returns false when DiscourseConnect is enabled' do
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(moderator).can_invite_to_forum?).to be_falsey
end
context 'with groups' do
let(:groups) { [group, another_group] }
@ -691,13 +683,13 @@ describe Guardian do
expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_truthy
end
it 'returns false for all users when sso is enabled' do
it 'returns true for all users when sso is enabled' do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_falsey
expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_falsey
expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_falsey
expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_truthy
expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_truthy
expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_truthy
end
it 'returns false for all users when local logins are disabled' do

View File

@ -50,17 +50,6 @@ describe Invite do
end
end
context "DiscourseConnect validation" do
it "prevents creating an email invite when DiscourseConnect is enabled" do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
invite = Fabricate.build(:invite, email: "test@mail.com")
expect(invite).not_to be_valid
expect(invite.errors.details[:email].first[:error]).to eq(I18n.t("invite.disabled_errors.discourse_connect_enabled"))
end
end
context '#create' do
context 'saved' do
subject { Fabricate(:invite) }

View File

@ -1374,23 +1374,6 @@ describe GroupsController do
expect(response.status).to eq(200)
end
it "rejects unknown emails when DiscourseConnect is enabled" do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
put "/groups/#{group.id}/members.json", params: { emails: "newuser@example.com" }
expect(response.status).to eq(400)
expect(response.parsed_body["error_type"]).to eq("invalid_parameters")
end
it "rejects unknown emails when local logins are disabled" do
SiteSetting.enable_local_logins = false
put "/groups/#{group.id}/members.json", params: { emails: "newuser@example.com" }
expect(response.status).to eq(400)
expect(response.parsed_body["error_type"]).to eq("invalid_parameters")
end
it "will find users by email, and invite the correct user" do
new_user = Fabricate(:user)
expect(new_user.group_ids.include?(group.id)).to eq(false)

View File

@ -51,6 +51,13 @@ describe InvitesController do
expect(CGI.unescapeHTML(body)).to_not include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url))
end
it "stores the invite key in the secure session if invite exists" do
get "/invites/#{invite.invite_key}"
expect(response.status).to eq(200)
invite_key = read_secure_session["invite-key"]
expect(invite_key).to eq(invite.invite_key)
end
it "returns error if invite has already been redeemed" do
Fabricate(:invited_user, invite: invite, user: Fabricate(:user))
get "/invites/#{invite.invite_key}"
@ -382,16 +389,6 @@ describe InvitesController do
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" }

View File

@ -659,7 +659,7 @@ RSpec.describe SessionController do
def sso_for_ip_specs
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -694,7 +694,7 @@ RSpec.describe SessionController do
it 'respects email restrictions' do
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -708,7 +708,7 @@ RSpec.describe SessionController do
it 'allows you to create an admin account' do
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -735,7 +735,7 @@ RSpec.describe SessionController do
it 'redirects to a non-relative url' do
sso = get_sso("#{Discourse.base_url}/b/")
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -748,7 +748,7 @@ RSpec.describe SessionController do
SiteSetting.discourse_connect_allows_all_return_paths = true
sso = get_sso('https://gusundtrout.com')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -759,7 +759,7 @@ RSpec.describe SessionController do
it 'redirects to root if the host of the return_path is different' do
sso = get_sso('//eviltrout.com')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -770,7 +770,7 @@ RSpec.describe SessionController do
it 'redirects to root if the host of the return_path is different' do
sso = get_sso('http://eviltrout.com')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -783,7 +783,7 @@ RSpec.describe SessionController do
group = Fabricate(:group, name: :bob, automatic_membership_email_domains: 'bob.com')
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -820,11 +820,106 @@ RSpec.describe SessionController do
expect(logged_on_user.custom_fields["bla"]).to eq(nil)
end
context "when an invitation is used" do
let(:invite) { Fabricate(:invite, email: invite_email, invited_by: Fabricate(:admin)) }
let(:invite_email) { nil }
def login_with_sso_and_invite(invite_key = invite.invite_key)
write_secure_session("invite-key", invite_key)
sso = get_sso("/")
sso.external_id = "666"
sso.email = "bob@bob.com"
sso.name = "Sam Saffron"
sso.username = "sam"
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
end
it "errors if the invite key is invalid" do
login_with_sso_and_invite("wrong")
expect(response.status).to eq(400)
expect(response.body).to include(I18n.t("invite.not_found", base_url: Discourse.base_url))
expect(invite.reload.redeemed?).to eq(false)
expect(User.find_by_email("bob@bob.com")).to eq(nil)
end
it "errors if the invite has expired" do
invite.update!(expires_at: 3.days.ago)
login_with_sso_and_invite
expect(response.status).to eq(400)
expect(response.body).to include(I18n.t("invite.expired", base_url: Discourse.base_url))
expect(invite.reload.redeemed?).to eq(false)
expect(User.find_by_email("bob@bob.com")).to eq(nil)
end
it "errors if the invite has been redeemed already" do
invite.update!(max_redemptions_allowed: 1, redemption_count: 1)
login_with_sso_and_invite
expect(response.status).to eq(400)
expect(response.body).to include(I18n.t("invite.not_found_template", site_name: SiteSetting.title, base_url: Discourse.base_url))
expect(invite.reload.redeemed?).to eq(true)
expect(User.find_by_email("bob@bob.com")).to eq(nil)
end
it "errors if the invite is for a specific email and that email does not match the sso email" do
invite.update!(email: "someotheremail@dave.com")
login_with_sso_and_invite
expect(response.status).to eq(400)
expect(response.body).to include(I18n.t("invite.not_matching_email", base_url: Discourse.base_url))
expect(invite.reload.redeemed?).to eq(false)
expect(User.find_by_email("bob@bob.com")).to eq(nil)
end
it "allows you to create an account and redeems the invite successfully, clearing the invite-key session" do
login_with_sso_and_invite
expect(response.status).to eq(302)
expect(response).to redirect_to("/")
expect(invite.reload.redeemed?).to eq(true)
user = User.find_by_email("bob@bob.com")
expect(user.active).to eq(true)
expect(session[:current_user_id]).to eq(user.id)
expect(read_secure_session["invite-key"]).to eq(nil)
end
it "allows you to create an account and redeems the invite successfully even if must_approve_users is enabled" do
SiteSetting.must_approve_users = true
login_with_sso_and_invite
expect(response.status).to eq(302)
expect(response).to redirect_to("/")
expect(invite.reload.redeemed?).to eq(true)
user = User.find_by_email("bob@bob.com")
expect(user.active).to eq(true)
end
it "redirects to the topic associated to the invite" do
topic_invite = TopicInvite.create!(invite: invite, topic: Fabricate(:topic))
login_with_sso_and_invite
expect(response.status).to eq(302)
expect(response).to redirect_to(topic_invite.topic.relative_url)
end
it "adds the user to the appropriate invite groups" do
invited_group = InvitedGroup.create!(invite: invite, group: Fabricate(:group))
login_with_sso_and_invite
expect(invite.reload.redeemed?).to eq(true)
user = User.find_by_email("bob@bob.com")
expect(GroupUser.exists?(user: user, group: invited_group.group)).to eq(true)
end
end
context 'when sso emails are not trusted' do
context 'if you have not activated your account' do
it 'does not log you in' do
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'
@ -838,7 +933,7 @@ RSpec.describe SessionController do
it 'sends an activation email' do
sso = get_sso('/a/')
sso.external_id = '666' # the number of the beast
sso.external_id = '666'
sso.email = 'bob@bob.com'
sso.name = 'Sam Saffron'
sso.username = 'sam'

View File

@ -1757,25 +1757,6 @@ describe UsersController do
end
end
context 'when DiscourseConnect has been enabled' do
before do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
end
it 'explains why invites are disabled to staff users' do
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])
get "/u/#{inviter.username}/invited/pending.json"
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to include(I18n.t(
'invite.disabled_errors.discourse_connect_enabled'
))
end
end
context 'with redeemed invites' do
it 'returns invites' do
sign_in(Fabricate(:moderator))

View File

@ -46,4 +46,10 @@ module IntegrationHelpers
SecureSession.new(session[:secure_session_id])
end
def write_secure_session(key, value)
secure_session = read_secure_session
secure_session[key] = value
secure_session
end
end