SECURITY: Fix invite link validation (stable) (#18818)

See https://github.com/discourse/discourse/security/advisories/GHSA-x8w7-rwmr-w278

Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
David Taylor 2022-11-01 16:50:14 +00:00 committed by GitHub
parent ec9734bc42
commit 7e4e8c8ad2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 492 additions and 208 deletions

View File

@ -1,4 +1,4 @@
import { alias, notEmpty, or, readOnly } from "@ember/object/computed";
import { alias, bool, not, readOnly } from "@ember/object/computed";
import Controller, { inject as controller } from "@ember/controller";
import DiscourseURL from "discourse/lib/url";
import EmberObject from "@ember/object";
@ -29,24 +29,20 @@ export default Controller.extend(
invitedBy: readOnly("model.invited_by"),
email: alias("model.email"),
accountEmail: alias("email"),
existingUserId: readOnly("model.existing_user_id"),
existingUserCanRedeem: readOnly("model.existing_user_can_redeem"),
existingUserRedeeming: bool("existingUserId"),
hiddenEmail: alias("model.hidden_email"),
emailVerifiedByLink: alias("model.email_verified_by_link"),
differentExternalEmail: alias("model.different_external_email"),
accountUsername: alias("model.username"),
passwordRequired: notEmpty("accountPassword"),
passwordRequired: not("externalAuthsOnly"),
successMessage: null,
errorMessage: null,
userFields: null,
authOptions: null,
inviteImageUrl: getUrl("/images/envelope.svg"),
isInviteLink: readOnly("model.is_invite_link"),
submitDisabled: or(
"emailValidation.failed",
"usernameValidation.failed",
"passwordValidation.failed",
"nameValidation.failed",
"userFieldsValidation.failed"
),
rejectedEmails: null,
init() {
@ -81,6 +77,15 @@ export default Controller.extend(
});
},
@discourseComputed("existingUserId")
subheaderMessage(existingUserId) {
if (existingUserId) {
return I18n.t("invites.existing_user_can_redeem");
} else {
return I18n.t("create_account.subheader_title");
}
},
@discourseComputed("email")
yourEmailMessage(email) {
return I18n.t("invites.your_email", { email });
@ -100,21 +105,69 @@ export default Controller.extend(
);
},
@discourseComputed("externalAuthsOnly", "discourseConnectEnabled")
showSocialLoginAvailable(externalAuthsOnly, discourseConnectEnabled) {
return !externalAuthsOnly && !discourseConnectEnabled;
@discourseComputed(
"emailValidation.failed",
"usernameValidation.failed",
"passwordValidation.failed",
"nameValidation.failed",
"userFieldsValidation.failed",
"existingUserRedeeming",
"existingUserCanRedeem"
)
submitDisabled(
emailValidationFailed,
usernameValidationFailed,
passwordValidationFailed,
nameValidationFailed,
userFieldsValidationFailed,
existingUserRedeeming,
existingUserCanRedeem
) {
if (existingUserRedeeming) {
return !existingUserCanRedeem;
}
return (
emailValidationFailed ||
usernameValidationFailed ||
passwordValidationFailed ||
nameValidationFailed ||
userFieldsValidationFailed
);
},
@discourseComputed(
"externalAuthsEnabled",
"externalAuthsOnly",
"discourseConnectEnabled"
)
showSocialLoginAvailable(
externalAuthsEnabled,
externalAuthsOnly,
discourseConnectEnabled
) {
return (
externalAuthsEnabled && !externalAuthsOnly && !discourseConnectEnabled
);
},
@discourseComputed(
"externalAuthsOnly",
"authOptions",
"emailValidation.failed"
"emailValidation.failed",
"existingUserRedeeming"
)
shouldDisplayForm(externalAuthsOnly, authOptions, emailValidationFailed) {
shouldDisplayForm(
externalAuthsOnly,
authOptions,
emailValidationFailed,
existingUserRedeeming
) {
return (
(this.siteSettings.enable_local_logins ||
(externalAuthsOnly && authOptions && !emailValidationFailed)) &&
!this.siteSettings.enable_discourse_connect
!this.siteSettings.enable_discourse_connect &&
!existingUserRedeeming
);
},

View File

@ -4,7 +4,7 @@
<h1 class="login-title">{{welcomeTitle}}</h1>
<img src={{wavingHandURL}} alt="" class="waving-hand">
{{#unless successMessage}}
<p class="login-subheader">{{i18n "create_account.subheader_title"}}</p>
<p class="login-subheader">{{loginSubheader}}</p>
{{/unless}}
</div>
@ -132,6 +132,13 @@
{{/if}}
</form>
{{/if}}
{{#if this.existingUserRedeeming}}
{{#if this.existingUserCanRedeem}}
<DButton @class="btn-primary" @action={{action "submit"}} @type="submit" @disabled={{this.submitDisabled}} @label="invites.accept_invite" />
{{else}}
<div class="alert alert-error">{{i18n "invites.existing_user_cannot_redeem"}}</div>
{{/if}}
{{/if}}
{{/if}}
</div>
</div>

View File

@ -119,7 +119,13 @@ acceptance("Invite accept", function (needs) {
);
await fillIn("#new-account-email", "john.doe@example.com");
assert.not(
assert.ok(
exists(".invites-show .btn-primary:disabled"),
"submit is disabled because password is not filled"
);
await fillIn("#new-account-password", "top$ecret");
assert.notOk(
exists(".invites-show .btn-primary:disabled"),
"submit is enabled"
);

View File

@ -12,7 +12,6 @@ class InvitesController < ApplicationController
before_action :ensure_invites_allowed, only: [:show, :perform_accept_invitation]
before_action :ensure_new_registrations_allowed, only: [:show, :perform_accept_invitation]
before_action :ensure_not_logged_in, only: :perform_accept_invitation
def show
expires_now
@ -22,90 +21,9 @@ class InvitesController < ApplicationController
invite = Invite.find_by(invite_key: params[:id])
if invite.present? && invite.redeemable?
if current_user
redeemed = false
begin
invite.redeem(email: current_user.email)
redeemed = true
rescue ActiveRecord::RecordNotSaved, Invite::UserExists
# This is not ideal but `Invite#redeem` raises either `Invite::UserExists` or `ActiveRecord::RecordNotSaved`
# error when it fails to redeem the invite. If redemption fails for a logged in user, we will just ignore it.
end
if redeemed && (topic = invite.topics.first) && current_user.guardian.can_see?(topic)
create_topic_invite_notifications(invite, current_user)
return redirect_to(topic.url)
end
return redirect_to(path('/'))
end
email = Email.obfuscate(invite.email)
# Show email if the user already authenticated their email
different_external_email = false
if session[:authentication]
auth_result = Auth::Result.from_session_data(session[:authentication], user: nil)
if invite.email == auth_result.email
email = invite.email
else
different_external_email = true
end
end
email_verified_by_link = invite.email_token.present? && params[:t] == invite.email_token
if email_verified_by_link
email = invite.email
end
hidden_email = email != invite.email
if hidden_email || invite.email.nil?
username = ""
else
username = UserNameSuggester.suggest(invite.email)
end
info = {
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
email: email,
hidden_email: hidden_email,
username: username,
is_invite_link: invite.is_invite_link?,
email_verified_by_link: email_verified_by_link
}
if different_external_email
info[:different_external_email] = true
end
if staged_user = User.where(staged: true).with_email(invite.email).first
info[:username] = staged_user.username
info[:user_fields] = staged_user.user_fields
end
store_preloaded("invite_info", MultiJson.dump(info))
secure_session["invite-key"] = invite.invite_key
render layout: 'application'
show_invite(invite)
else
flash.now[:error] = if invite.blank?
I18n.t('invite.not_found', base_url: Discourse.base_url)
elsif invite.redeemed?
if invite.is_invite_link?
I18n.t('invite.not_found_template_link', site_name: SiteSetting.title, base_url: Discourse.base_url)
else
I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
end
elsif invite.expired?
I18n.t('invite.expired', base_url: Discourse.base_url)
end
render layout: 'no_ember'
show_irredeemable_invite(invite)
end
rescue RateLimiter::LimitExceeded => e
flash.now[:error] = e.description
@ -266,24 +184,33 @@ class InvitesController < ApplicationController
params.permit(:email, :username, :name, :password, :timezone, :email_token, user_custom_fields: {})
invite = Invite.find_by(invite_key: params[:id])
redeeming_user = current_user
if invite.present?
begin
attrs = {
username: params[:username],
name: params[:name],
password: params[:password],
user_custom_fields: params[:user_custom_fields],
ip_address: request.remote_ip,
session: session
}
if invite.is_invite_link?
params.require(:email)
attrs[:email] = params[:email]
if redeeming_user
attrs[:redeeming_user] = redeeming_user
else
attrs[:email] = invite.email
attrs[:email_token] = params[:email_token] if params[:email_token].present?
attrs[:username] = params[:username]
attrs[:name] = params[:name]
attrs[:password] = params[:password]
attrs[:user_custom_fields] = params[:user_custom_fields]
# If the invite is not scoped to an email then we allow the
# user to provide it themselves
if invite.is_invite_link?
params.require(:email)
attrs[:email] = params[:email]
else
# Otherwise we always use the email from the invitation.
attrs[:email] = invite.email
attrs[:email_token] = params[:email_token] if params[:email_token].present?
end
end
user = invite.redeem(**attrs)
@ -295,7 +222,10 @@ class InvitesController < ApplicationController
return render json: failed_json.merge(message: I18n.t('invite.not_found_json')), status: 404
end
log_on_user(user) if user.active? && user.guardian.can_access_forum?
if !redeeming_user && user.active? && user.guardian.can_access_forum?
log_on_user(user)
end
user.update_timezone_if_missing(params[:timezone])
post_process_invite(user)
create_topic_invite_notifications(invite, user)
@ -305,6 +235,10 @@ class InvitesController < ApplicationController
if user.present?
if user.active? && user.guardian.can_access_forum?
if redeeming_user
response[:message] = I18n.t("invite.existing_user_success")
end
if user.guardian.can_see?(topic)
response[:redirect_to] = path(topic.relative_url)
else
@ -407,6 +341,84 @@ class InvitesController < ApplicationController
private
def show_invite(invite)
email = Email.obfuscate(invite.email)
# Show email if the user already authenticated their email
different_external_email = false
if session[:authentication]
auth_result = Auth::Result.from_session_data(session[:authentication], user: nil)
if invite.email == auth_result.email
email = invite.email
else
different_external_email = true
end
end
email_verified_by_link = invite.email_token.present? && params[:t] == invite.email_token
if email_verified_by_link
email = invite.email
end
hidden_email = email != invite.email
if hidden_email || invite.email.nil?
username = ""
else
username = UserNameSuggester.suggest(invite.email)
end
info = {
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
email: email,
hidden_email: hidden_email,
username: username,
is_invite_link: invite.is_invite_link?,
email_verified_by_link: email_verified_by_link
}
if different_external_email
info[:different_external_email] = true
end
if staged_user = User.where(staged: true).with_email(invite.email).first
info[:username] = staged_user.username
info[:user_fields] = staged_user.user_fields
end
if current_user
info[:existing_user_id] = current_user.id
info[:existing_user_can_redeem] = invite.can_be_redeemed_by?(current_user)
info[:email] = current_user.email
info[:username] = current_user.username
end
store_preloaded("invite_info", MultiJson.dump(info))
secure_session["invite-key"] = invite.invite_key
render layout: 'application'
end
def show_irredeemable_invite(invite)
flash.now[:error] = \
if invite.blank?
I18n.t('invite.not_found', base_url: Discourse.base_url)
elsif invite.redeemed?
if invite.is_invite_link?
I18n.t('invite.not_found_template_link', site_name: SiteSetting.title, base_url: Discourse.base_url)
else
I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
end
elsif invite.expired?
I18n.t('invite.expired', base_url: Discourse.base_url)
end
render layout: 'no_ember'
end
def ensure_invites_allowed
if (!SiteSetting.enable_local_logins && Discourse.enabled_auth_providers.count == 0 && !SiteSetting.enable_discourse_connect)
raise Discourse::NotFound
@ -421,14 +433,6 @@ class InvitesController < ApplicationController
end
end
def ensure_not_logged_in
if current_user
flash[:error] = I18n.t("login.already_logged_in")
render layout: 'no_ember'
false
end
end
def post_process_invite(user)
user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message

View File

@ -183,7 +183,7 @@ class SessionController < ApplicationController
if SiteSetting.must_approve_users? && !user.approved?
if invite.present? && user.invited_user.blank?
redeem_invitation(invite, sso)
redeem_invitation(invite, sso, user)
end
if SiteSetting.discourse_connect_not_approved_url.present?
@ -197,7 +197,7 @@ class SessionController < ApplicationController
# 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)
redeem_invitation(invite, sso, user)
# we directly call user.activate here instead of going
# through the UserActivator path because we assume the account
@ -660,14 +660,15 @@ class SessionController < ApplicationController
invite
end
def redeem_invitation(invite, sso)
def redeem_invitation(invite, sso, redeeming_user)
InviteRedeemer.new(
invite: invite,
username: sso.username,
name: sso.name,
ip_address: request.remote_ip,
session: session,
email: sso.email
email: sso.email,
redeeming_user: redeeming_user
).redeem
secure_session["invite-key"] = nil

View File

@ -69,7 +69,7 @@ class EmailToken < ActiveRecord::Base
user.create_reviewable if !skip_reviewable
user.set_automatic_groups
DiscourseEvent.trigger(:user_confirmed_email, user)
Invite.redeem_from_email(user.email)
Invite.redeem_for_existing_user(user)
user.reload
end

View File

@ -82,6 +82,22 @@ class Invite < ActiveRecord::Base
end
end
def email_matches?(email)
email.downcase == self.email.downcase
end
def domain_matches?(email)
_, domain = email.split('@')
self.domain == domain
end
def can_be_redeemed_by?(user)
return false if !self.redeemable?
return true if self.email.blank? && self.domain.blank?
return true if self.email.present? && email_matches?(user.email)
self.domain.present? && domain_matches?(user.email)
end
def expired?
expires_at < Time.zone.now
end
@ -166,7 +182,7 @@ class Invite < ActiveRecord::Base
invite.reload
end
def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil)
def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil, redeeming_user: nil)
return if !redeemable?
email = self.email if email.blank? && !is_invite_link?
@ -180,10 +196,20 @@ class Invite < ActiveRecord::Base
user_custom_fields: user_custom_fields,
ip_address: ip_address,
session: session,
email_token: email_token
email_token: email_token,
redeeming_user: redeeming_user
).redeem
end
def self.redeem_for_existing_user(user)
invite = Invite.find_by(email: Email.downcase(user.email))
if invite.present? && invite.redeemable?
InviteRedeemer.new(invite: invite, redeeming_user: user).redeem
end
invite
end
# Deprecated: Replaced by redeem_for_existing_user in tests-passed
def self.redeem_from_email(email)
invite = Invite.find_by(email: Email.downcase(email))
InviteRedeemer.new(invite: invite, email: invite.email).redeem if invite

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, :session, :email_token, keyword_init: true) do
InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, :session, :email_token, :redeeming_user, keyword_init: true) do
def redeem
Invite.transaction do
if can_redeem_invite? && mark_invite_redeemed
@ -82,42 +82,37 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
private
def can_redeem_invite?
# Invite has already been redeemed
return false if !invite.redeemable?
# Invite has already been redeemed by anyone.
if !invite.is_invite_link? && InvitedUser.exists?(invite_id: invite.id)
return false
end
validate_invite_email!
# Email will not be present if we are claiming an invite link, which
# does not have an email or domain scope on the invitation.
if email.present? || redeeming_user.present?
email_to_check = redeeming_user&.email || email
existing_user = get_existing_user
if invite.email.present? && !invite.email_matches?(email_to_check)
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.not_matching_email'))
end
if existing_user.present? && InvitedUser.exists?(user_id: existing_user.id, invite_id: invite.id)
if invite.domain.present? && !invite.domain_matches?(email_to_check)
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed'))
end
end
# Anon user is trying to redeem an invitation, if an existing user already
# redeemed it then we cannot redeem now.
redeeming_user ||= User.where(admin: false, staged: false).find_by_email(email)
if redeeming_user.present? && InvitedUser.exists?(user_id: redeeming_user.id, invite_id: invite.id)
return false
end
true
end
def validate_invite_email!
return if email.blank?
if invite.email.present? && email.downcase != invite.email.downcase
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.not_matching_email'))
end
if invite.domain.present?
username, domain = email.split('@')
if domain.present? && invite.domain != domain
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed'))
end
end
end
def invited_user
@invited_user ||= get_invited_user
end
def process_invitation
add_to_private_topics_if_invited
add_user_to_groups
@ -136,10 +131,20 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
@invited_user_record.present?
end
def get_invited_user
result = get_existing_user
def invited_user
return @invited_user if defined?(@invited_user)
result ||= InviteRedeemer.create_user_from_invite(
# The redeeming user is an already logged in user or a user who is
# activating their account who is redeeming the invite,
# which is valid for existing users to be invited to topics or groups.
if redeeming_user.present?
@invited_user = redeeming_user
return @invited_user
end
# If there was no logged in user then we must attempt to create
# one based on the provided params.
invited_user ||= InviteRedeemer.create_user_from_invite(
email: email,
invite: invite,
username: username,
@ -150,12 +155,9 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
session: session,
email_token: email_token
)
result.send_welcome_message = false
result
end
def get_existing_user
User.where(admin: false, staged: false).find_by_email(email)
invited_user.send_welcome_message = false
@invited_user = invited_user
@invited_user
end
def add_to_private_topics_if_invited

View File

@ -1988,6 +1988,8 @@ en:
name_label: "Name"
password_label: "Password"
optional_description: "(optional)"
existing_user_can_redeem: "Redeem your invitation to a topic or group."
existing_user_cannot_redeem: "This invitation cannot be redeemed. Please ask the person who invited you to send you a new invitation."
password_reset:
continue: "Continue to %{site_name}"

View File

@ -261,6 +261,7 @@ en:
invalid_access: "You are not permitted to view the requested resource."
requires_groups: "Invite saved. To give access to the specified topic, add one of the following groups: %{groups}."
domain_not_allowed: "Your email cannot be used to redeem this invite."
existing_user_success: "Invite redeemed successfully"
bulk_invite:
file_should_be_csv: "The uploaded file should be of csv format."

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
class SecurityLogOutInviteRedemptionInvitedUsers < ActiveRecord::Migration[6.1]
def up
# On the stable branch, 20200311135425 is the closest migration before the vulnerability was introduced
vulnerable_since = DB.query_single("SELECT created_at FROM schema_migration_details WHERE version='20200311135425'")[0]
DB.exec(<<~SQL, vulnerable_since: vulnerable_since)
DELETE FROM user_auth_tokens
WHERE user_id IN (
SELECT DISTINCT user_id
FROM invited_users
JOIN users ON invited_users.user_id = users.id
WHERE invited_users.redeemed_at > :vulnerable_since
)
SQL
DB.exec(<<~SQL, vulnerable_since: vulnerable_since)
DELETE FROM user_api_keys
WHERE user_id IN (
SELECT DISTINCT user_id
FROM invited_users
JOIN users ON invited_users.user_id = users.id
WHERE invited_users.redeemed_at > :vulnerable_since
)
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -244,6 +244,40 @@ describe InviteRedeemer do
expect(invite.invited_users.first).to be_present
end
it "raises an error if the email does not match the invite email" do
redeemer = InviteRedeemer.new(invite: invite, email: "blah@test.com", username: username, name: name)
expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email"))
end
context "when a redeeming user is passed in" do
fab!(:redeeming_user) { Fabricate(:user, email: "foobar@example.com") }
it "raises an error if the email does not match the invite email" do
redeeming_user.update!(email: "foo@bar.com")
redeemer = InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user)
expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.not_matching_email"))
end
end
context 'with domain' do
fab!(:invite) { Fabricate(:invite, email: nil, domain: "test.com") }
it "raises an error if the email domain does not match the invite domain" do
redeemer = InviteRedeemer.new(invite: invite, email: "blah@somesite.com", username: username, name: name)
expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.domain_not_allowed"))
end
context "when a redeeming user is passed in" do
fab!(:redeeming_user) { Fabricate(:user, email: "foo@test.com") }
it "raises an error if the user's email domain does not match the invite domain" do
redeeming_user.update!(email: "foo@bar.com")
redeemer = InviteRedeemer.new(invite: invite, redeeming_user: redeeming_user)
expect { redeemer.redeem }.to raise_error(ActiveRecord::RecordNotSaved, I18n.t("invite.domain_not_allowed"))
end
end
end
context 'invite_link' do
fab!(:invite_link) { Fabricate(:invite, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) }
let(:invite_redeemer) { InviteRedeemer.new(invite: invite_link, email: 'foo@example.com') }
@ -275,6 +309,21 @@ describe InviteRedeemer do
another_user = another_invite_redeemer.redeem
expect(another_user.is_a?(User)).to eq(true)
end
it "raises an error if the email is already being used by an existing user" do
Fabricate(:user, email: 'foo@example.com')
expect { invite_redeemer.redeem }.to raise_error(ActiveRecord::RecordInvalid, /Primary email has already been taken/)
end
context "when a redeeming user is passed in" do
fab!(:redeeming_user) { Fabricate(:user, email: 'foo@example.com') }
it "does not create a new user" do
expect do
InviteRedeemer.new(invite: invite_link, redeeming_user: redeeming_user).redeem
end.not_to change { User.count }
end
end
end
end

View File

@ -327,19 +327,38 @@ describe Invite do
end
end
describe '#redeem_from_email' do
describe '#redeem_for_existing_user' do
fab!(:invite) { Fabricate(:invite, email: 'test@example.com') }
fab!(:user) { Fabricate(:user, email: invite.email) }
it 'redeems the invite from email' do
Invite.redeem_from_email(user.email)
Invite.redeem_for_existing_user(user)
expect(invite.reload).to be_redeemed
end
it 'does not redeem the invite if email does not match' do
Invite.redeem_from_email('test2@example.com')
user.update!(email: 'test2@example.com')
Invite.redeem_for_existing_user(user)
expect(invite.reload).not_to be_redeemed
end
it 'does not work with expired invites' do
invite.update!(expires_at: 1.day.ago)
Invite.redeem_for_existing_user(user)
expect(invite).not_to be_redeemed
end
it 'does not work with deleted invites' do
invite.trash!
Invite.redeem_for_existing_user(user)
expect(invite).not_to be_redeemed
end
it 'does not work with invalidated invites' do
invite.update!(invalidated_at: 1.day.ago)
Invite.redeem_for_existing_user(user)
expect(invite).not_to be_redeemed
end
end
context 'scopes' do

View File

@ -71,63 +71,66 @@ describe InvitesController do
sign_in(user)
end
it "redeems the invite when user's email matches invite's email before redirecting to secured topic url" do
it "shows the accept invite page when user's email matches the invite email" do
invite.update_columns(email: user.email)
group.add_owner(invite.invited_by)
secured_category = Fabricate(:category)
secured_category.permissions = { group.name => :full }
secured_category.save!
get "/invites/#{invite.invite_key}"
expect(response.status).to eq(200)
expect(response.body).not_to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url))
topic = Fabricate(:topic, category: secured_category)
TopicInvite.create!(invite: invite, topic: topic)
InvitedGroup.create!(invite: invite, group: group)
expect do
get "/invites/#{invite.invite_key}"
end.to change { InvitedUser.exists?(invite: invite, user: user) }.to(true)
expect(response).to redirect_to(topic.url)
expect(user.reload.groups).to include(group)
expect(Notification.exists?(user: user, notification_type: Notification.types[:invited_to_topic], topic: topic))
.to eq(true)
expect(Notification.exists?(user: invite.invited_by, notification_type: Notification.types[:invitee_accepted]))
.to eq(true)
expect(response.body).to have_tag('div#data-preloaded') do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
invite_info = JSON.parse(json['invite_info'])
expect(invite_info['username']).to eq(user.username)
expect(invite_info['email']).to eq(user.email)
expect(invite_info['existing_user_id']).to eq(user.id)
expect(invite_info['existing_user_can_redeem']).to eq(true)
end
end
it "redeems the invite when user's email domain matches the domain an invite link is restricted to" do
it "shows the accept invite page when user's email domain matches the domain an invite link is restricted to" do
invite.update!(email: nil, domain: 'discourse.org')
user.update!(email: "someguy@discourse.org")
topic = Fabricate(:topic)
TopicInvite.create!(invite: invite, topic: topic)
group.add_owner(invite.invited_by)
InvitedGroup.create!(invite: invite, group: group)
expect do
get "/invites/#{invite.invite_key}"
end.to change { InvitedUser.exists?(invite: invite, user: user) }.to(true)
get "/invites/#{invite.invite_key}"
expect(response.status).to eq(200)
expect(response.body).not_to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url))
expect(response).to redirect_to(topic.url)
expect(user.reload.groups).to include(group)
expect(response.body).to have_tag('div#data-preloaded') do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
invite_info = JSON.parse(json['invite_info'])
expect(invite_info['username']).to eq(user.username)
expect(invite_info['email']).to eq(user.email)
expect(invite_info['existing_user_id']).to eq(user.id)
expect(invite_info['existing_user_can_redeem']).to eq(true)
end
end
it "redirects to root if a logged in user tries to view an invite link restricted to a certain domain but user's email domain does not match" do
it "does not allow the user to accept the invite when their email domain does not match the domain of the invite" do
user.update!(email: "someguy@discourse.com")
invite.update!(email: nil, domain: 'discourse.org')
expect { get "/invites/#{invite.invite_key}" }.to change { InvitedUser.count }.by(0)
get "/invites/#{invite.invite_key}"
expect(response.status).to eq(200)
expect(response).to redirect_to("/")
expect(response.body).to have_tag('div#data-preloaded') do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
invite_info = JSON.parse(json['invite_info'])
expect(invite_info['existing_user_can_redeem']).to eq(false)
end
end
it "redirects to root if a tries to view an invite meant for a specific email that is not the user's" do
it "does not allow the user to accept the invite when their email does not match the invite" do
invite.update_columns(email: "notuseremail@discourse.org")
expect { get "/invites/#{invite.invite_key}" }.to change { InvitedUser.count }.by(0)
get "/invites/#{invite.invite_key}"
expect(response.status).to eq(200)
expect(response).to redirect_to("/")
expect(response.body).to have_tag('div#data-preloaded') do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
invite_info = JSON.parse(json['invite_info'])
expect(invite_info['existing_user_can_redeem']).to eq(false)
end
end
end
@ -739,8 +742,9 @@ describe InvitesController do
fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) }
it 'sends an activation email and does not activate the user' do
expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' } }
.not_to change { UserAuthToken.count }
expect {
put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' }
}.not_to change { UserAuthToken.count }
expect(response.status).to eq(200)
expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email'))
@ -761,6 +765,24 @@ describe InvitesController do
expect(job_args['user_id']).to eq(invited_user.id)
expect(EmailToken.hash_token(job_args['email_token'])).to eq(tokens.first.token_hash)
end
it "does not automatically log in the user if their email matches an existing user's and shows an error" do
Fabricate(:user, email: 'test@example.com')
put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' }
expect(session[:current_user_id]).to be_blank
expect(response.status).to eq(412)
expect(response.parsed_body['message']).to include("Primary email has already been taken")
expect(invite.reload.redemption_count).to eq(0)
end
it "does not automatically log in the user if their email matches an existing admin's and shows an error" do
Fabricate(:admin, email: 'test@example.com')
put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' }
expect(session[:current_user_id]).to be_blank
expect(response.status).to eq(412)
expect(response.parsed_body['message']).to include("Primary email has already been taken")
expect(invite.reload.redemption_count).to eq(0)
end
end
context 'new registrations are disabled' do
@ -778,17 +800,77 @@ describe InvitesController do
end
end
context 'user is already logged in' do
context 'when user is already logged in' do
fab!(:invite) { Fabricate(:invite, email: 'test@example.com') }
fab!(:user) { sign_in(Fabricate(:user)) }
fab!(:user) { Fabricate(:user, email: 'test@example.com') }
fab!(:group) { Fabricate(:group) }
it 'does not redeem the invite' do
before { sign_in(user) }
it 'redeems the invitation and creates the invite accepted notification' do
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
expect(response.status).to eq(200)
expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success"))
invite.reload
expect(invite.invited_users).to be_blank
expect(invite.redeemed?).to be_falsey
expect(response.body).to include(I18n.t('login.already_logged_in', current_user: user.username))
expect(invite.invited_users.first.user).to eq(user)
expect(invite.redeemed?).to be_truthy
expect(
Notification.exists?(
user: invite.invited_by, notification_type: Notification.types[:invitee_accepted]
)
).to eq(true)
end
it 'redirects to the first topic the user was invited to and creates the topic notification' do
topic = Fabricate(:topic)
TopicInvite.create!(invite: invite, topic: topic)
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
expect(response.status).to eq(200)
expect(response.parsed_body['redirect_to']).to eq(topic.relative_url)
expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1)
end
it "adds the user to the groups specified on the invite and allows them to access the secure topic" do
group.add_owner(invite.invited_by)
secured_category = Fabricate(:category)
secured_category.permissions = { group.name => :full }
secured_category.save!
topic = Fabricate(:topic, category: secured_category)
TopicInvite.create!(invite: invite, topic: topic)
InvitedGroup.create!(invite: invite, group: group)
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
expect(response.status).to eq(200)
expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success"))
expect(response.parsed_body['redirect_to']).to eq(topic.relative_url)
invite.reload
expect(invite.redeemed?).to be_truthy
expect(user.reload.groups).to include(group)
expect(Notification.where(notification_type: Notification.types[:invited_to_topic], topic: topic).count).to eq(1)
end
it "does not try to log in the user automatically" do
expect do
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
end.not_to change { UserAuthToken.count }
expect(response.status).to eq(200)
expect(response.parsed_body["message"]).to eq(I18n.t("invite.existing_user_success"))
end
it "errors if the user's email doesn't match the invite email" do
user.update!(email: "blah@test.com")
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
expect(response.status).to eq(412)
expect(response.parsed_body["message"]).to eq(I18n.t("invite.not_matching_email"))
end
it "errors if the user's email domain doesn't match the invite domain" do
user.update!(email: "blah@test.com")
invite.update!(email: nil, domain: "example.com")
put "/invites/show/#{invite.invite_key}.json", params: { id: invite.invite_key }
expect(response.status).to eq(412)
expect(response.parsed_body["message"]).to eq(I18n.t("invite.domain_not_allowed"))
end
end