discourse/app/models/invite_redeemer.rb
Martin Brennan a414520742
SECURITY: Prevent email from being nil in InviteRedeemer (#19004)
This commit adds some protections in InviteRedeemer to ensure that email
can never be nil, which could cause issues with inviting the invited
person to private topics since there was an incorrect inner join.

If the email is nil and the invite is scoped to an email, we just use
that invite.email unconditionally.  If a redeeming_user (an existing
user) is passed in when redeeming an email, we use their email to
override the passed in email.  Otherwise we just use the passed in
email.  We now raise an error after all this if the email is still nil.
This commit also adds some tests to catch the private topic fix, and
some general improvements and comments around the invite code.

This commit also includes a migration to delete TopicAllowedUser records
for users who were mistakenly added to topics as part of the invite
redemption process.
2022-11-14 12:02:06 +10:00

296 lines
9.2 KiB
Ruby

# frozen_string_literal: true
# NOTE: There are a _lot_ of complicated rules and conditions for our
# invite system, and the code is spread out through a lot of places.
# Tread lightly and read carefully when modifying this code. You may
# also want to look at:
#
# * InvitesController
# * SessionController
# * Invite model
# * User model
#
# Invites that are scoped to a specific email (email IS NOT NULL on the Invite
# model) have different rules to invites that are considered an "invite link",
# (email IS NULL) on the Invite model.
class InviteRedeemer
attr_reader :invite,
:email,
:username,
:name,
:password,
:user_custom_fields,
:ip_address,
:session,
:email_token,
:redeeming_user
def initialize(
invite:,
email: nil,
username: nil,
name: nil,
password: nil,
user_custom_fields: nil,
ip_address: nil,
session: nil,
email_token: nil,
redeeming_user: nil)
@invite = invite
@username = username
@name = name
@password = password
@user_custom_fields = user_custom_fields
@ip_address = ip_address
@session = session
@email_token = email_token
@redeeming_user = redeeming_user
ensure_email_is_present!(email)
end
def redeem
Invite.transaction do
if can_redeem_invite? && mark_invite_redeemed
process_invitation
invited_user
end
end
end
# The email must be present in some form since many of the methods
# for processing + redemption rely on it. If it's still nil after
# these checks then we have hit an edge case and should not proceed!
def ensure_email_is_present!(email)
if email.blank?
Rails.logger.warn(
"email param was blank in InviteRedeemer for invite ID #{@invite.id}. The `redeeming_user` was #{@redeeming_user.present? ? "(ID: #{@redeeming_user.id})" : "not"} present.",
)
end
if email.blank? && @invite.is_email_invite?
@email = @invite.email
elsif @redeeming_user.present?
@email = @redeeming_user.email
else
@email = email
end
raise Discourse::InvalidParameters if @email.blank?
end
# This will _never_ be called if there is a redeeming_user being passed
# in to InviteRedeemer -- see invited_user below.
def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil)
if username && UsernameValidator.new(username).valid_format? && User.username_available?(username, email)
available_username = username
else
available_username = UserNameSuggester.suggest(email)
end
user = User.where(staged: true).with_email(email.strip.downcase).first
user.unstage! if user
user ||= User.new
user.attributes = {
email: email,
username: available_username,
name: name || available_username,
active: false,
trust_level: SiteSetting.default_invitee_trust_level,
ip_address: ip_address,
registration_ip_address: ip_address
}
if (!SiteSetting.must_approve_users && SiteSetting.invite_only) ||
(SiteSetting.must_approve_users? && EmailValidator.can_auto_approve_user?(user.email))
ReviewableUser.set_approved_fields!(user, Discourse.system_user)
end
user_fields = UserField.all
if user_custom_fields.present? && user_fields.present?
field_params = user_custom_fields || {}
fields = user.custom_fields
user_fields.each do |f|
field_val = field_params[f.id.to_s]
fields["#{User::USER_FIELD_PREFIX}#{f.id}"] = field_val[0...UserField.max_length] unless field_val.blank?
end
user.custom_fields = fields
end
user.moderator = true if invite.moderator? && invite.invited_by.staff?
if password
user.password = password
user.password_required!
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!
authenticator.finish
if invite.emailed_status != Invite.emailed_status_types[:not_required] &&
email == invite.email &&
invite.email_token.present? &&
email_token == invite.email_token
user.activate
end
User.find(user.id)
end
private
def can_redeem_invite?
return false if !invite.redeemable?
return false if email.blank?
# Invite scoped to email has already been redeemed by anyone.
if invite.is_email_invite? && InvitedUser.exists?(invite_id: invite.id)
return false
end
# The email will be present for either an invite link (where the user provides
# us the email manually) or for an invite scoped to an email, where we
# prefill the email and do not let the user modify it.
#
# Note that an invite link can also have a domain scope which must be checked.
email_to_check = redeeming_user&.email || email
if invite.email.present? && !invite.email_matches?(email_to_check)
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.not_matching_email'))
end
if invite.domain.present? && !invite.domain_matches?(email_to_check)
raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed'))
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
# Note that the invited_user is returned by #redeemed, so other places
# (e.g. the InvitesController) can perform further actions on it, this
# is why things like send_welcome_message are set without being saved
# on the model.
def invited_user
return @invited_user if defined?(@invited_user)
# 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,
name: name,
password: password,
user_custom_fields: user_custom_fields,
ip_address: ip_address,
session: session,
email_token: email_token
)
invited_user.send_welcome_message = false
@invited_user = invited_user
@invited_user
end
def process_invitation
add_to_private_topics_if_invited
add_user_to_groups
send_welcome_message
notify_invitee
end
def mark_invite_redeemed
@invited_user_record = InvitedUser.create!(invite_id: invite.id, redeemed_at: Time.zone.now)
if @invited_user_record.present?
Invite.increment_counter(:redemption_count, invite.id)
delete_duplicate_invites
end
@invited_user_record.present?
end
def add_to_private_topics_if_invited
# Should not happen because of ensure_email_is_present!, but better to cover bases.
return if email.blank?
topic_ids = TopicInvite.joins(:invite)
.joins(:topic)
.where("topics.archetype = ?", Archetype::private_message)
.where("invites.email = ?", email)
.pluck(:topic_id)
topic_ids.each do |id|
if !TopicAllowedUser.exists?(user_id: invited_user.id, topic_id: id)
TopicAllowedUser.create!(user_id: invited_user.id, topic_id: id)
end
end
end
def add_user_to_groups
guardian = Guardian.new(invite.invited_by)
new_group_ids = invite.groups.pluck(:id) - invited_user.group_users.pluck(:group_id)
new_group_ids.each do |id|
group = Group.find_by(id: id)
if guardian.can_edit_group?(group)
invited_user.group_users.create!(group_id: group.id)
GroupActionLogger.new(invite.invited_by, group).log_add_user_to_group(invited_user)
DiscourseEvent.trigger(:user_added_to_group, invited_user, group, automatic: false)
end
end
end
def send_welcome_message
@invited_user_record.update!(user_id: invited_user.id)
invited_user.send_welcome_message = true
end
def notify_invitee
return if invite.invited_by.blank?
invite.invited_by.notifications.create!(
notification_type: Notification.types[:invitee_accepted],
data: { display_username: invited_user.username }.to_json
)
end
def delete_duplicate_invites
# Should not happen because of ensure_email_is_present!, but better to cover bases.
return if email.blank?
Invite
.where('invites.max_redemptions_allowed = 1')
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
.where('invited_users.user_id IS NULL')
.where('invites.email = ? AND invites.id != ?', email, invite.id)
.delete_all
end
end