discourse/app/models/invite.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

387 lines
11 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2013-02-06 03:16:51 +08:00
class Invite < ActiveRecord::Base
class UserExists < StandardError
end
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
2021-03-19 08:20:10 +08:00
class RedemptionFailed < StandardError
end
class ValidationFailed < StandardError
end
include RateLimiter::OnCreateRecord
include Trashable
2013-02-07 23:45:24 +08:00
# TODO(2021-05-22): remove
self.ignored_columns = %w[user_id redeemed_at]
BULK_INVITE_EMAIL_LIMIT = 200
DOMAIN_REGEX =
/\A(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\z/
rate_limit :limit_invites_per_day
belongs_to :invited_by, class_name: "User"
2013-02-06 03:16:51 +08:00
has_many :invited_users
has_many :users, through: :invited_users
has_many :invited_groups
has_many :groups, through: :invited_groups
2013-02-06 03:16:51 +08:00
has_many :topic_invites
2013-02-07 23:45:24 +08:00
has_many :topics, through: :topic_invites, source: :topic
2013-02-06 03:16:51 +08:00
validates_presence_of :invited_by_id
validates :email, email: true, allow_blank: true
validate :ensure_max_redemptions_allowed
validate :valid_redemption_count
validate :valid_domain, if: :will_save_change_to_domain?
validate :user_doesnt_already_exist, if: :will_save_change_to_email?
validate :email_xor_domain
2013-02-06 03:16:51 +08:00
before_create do
self.invite_key ||= SecureRandom.base58(10)
self.expires_at ||= SiteSetting.invite_expiry_days.days.from_now
2013-02-06 03:16:51 +08:00
end
before_save do
self.email_token = email.present? ? SecureRandom.hex : nil if will_save_change_to_email?
end
2014-07-14 23:56:26 +08:00
before_validation { self.email = Email.downcase(email) unless email.nil? }
2013-02-06 03:16:51 +08:00
attribute :email_already_exists
2013-02-07 23:45:24 +08:00
def self.emailed_status_types
@emailed_status_types ||=
Enum.new(not_required: 0, pending: 1, bulk_pending: 2, sending: 3, sent: 4)
end
2013-02-06 03:16:51 +08:00
def user_doesnt_already_exist
self.email_already_exists = false
2013-02-06 03:16:51 +08:00
return if email.blank?
user = Invite.find_user_by_email(email)
if user && user.id != self.invited_users&.first&.user_id
self.email_already_exists = true
errors.add(:base, user_exists_error_msg(email))
2013-02-06 03:16:51 +08:00
end
end
def email_xor_domain
errors.add(:base, I18n.t("invite.email_xor_domain")) if email.present? && domain.present?
end
# Even if a domain is specified on the invite, it still counts as
# an invite link.
def is_invite_link?
self.email.blank?
end
# Email invites have specific behaviour and it's easier to visually
# parse is_email_invite? than !is_invite_link?
def is_email_invite?
self.email.present?
end
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
2021-03-19 08:20:10 +08:00
def redeemable?
!redeemed? && !expired? && !deleted_at? && !destroyed? && link_valid?
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
2021-03-19 08:20:10 +08:00
end
def redeemed_by_user?(redeeming_user)
self.invited_users.exists?(user: redeeming_user)
end
2013-02-06 03:16:51 +08:00
def redeemed?
if is_invite_link?
redemption_count >= max_redemptions_allowed
else
self.invited_users.count > 0
end
2013-02-06 03:16:51 +08:00
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 false if redeemed_by_user?(user)
return true if self.domain.blank? && self.email.blank?
return true if self.email.present? && email_matches?(user.email)
self.domain.present? && domain_matches?(user.email)
end
2013-02-06 03:16:51 +08:00
def expired?
expires_at < Time.zone.now
2013-02-06 03:16:51 +08:00
end
def link(with_email_token: false)
if with_email_token
"#{Discourse.base_url}/invites/#{invite_key}?t=#{email_token}"
else
"#{Discourse.base_url}/invites/#{invite_key}"
end
end
def link_valid?
invalidated_at.nil?
end
def self.generate(invited_by, opts = nil)
opts ||= {}
email = Email.downcase(opts[:email]) if opts[:email].present?
2014-05-09 09:45:18 +08:00
raise UserExists.new(new.user_exists_error_msg(email)) if find_user_by_email(email)
2014-05-09 09:45:18 +08:00
if email.present?
invite =
Invite
.with_deleted
.where(email: email, invited_by_id: invited_by.id)
.order("created_at DESC")
.first
if invite && (invite.expired? || invite.deleted_at)
invite.destroy
invite = nil
end
email_digest = Digest::SHA256.hexdigest(email)
RateLimiter.new(invited_by, "reinvites-per-day-#{email_digest}", 3, 1.day.to_i).performed!
end
emailed_status =
if opts[:skip_email] || invite&.emailed_status == emailed_status_types[:not_required]
emailed_status_types[:not_required]
elsif opts[:emailed_status].present?
opts[:emailed_status]
elsif email.present?
emailed_status_types[:pending]
else
emailed_status_types[:not_required]
end
if invite
invite.update_columns(
created_at: Time.zone.now,
updated_at: Time.zone.now,
expires_at: opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now,
emailed_status: emailed_status,
)
else
create_args =
opts.slice(:email, :domain, :moderator, :custom_message, :max_redemptions_allowed)
create_args[:invited_by] = invited_by
create_args[:email] = email
create_args[:emailed_status] = emailed_status
create_args[:expires_at] = opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now
invite = Invite.create!(create_args)
end
topic_id = opts[:topic]&.id || opts[:topic_id]
invite.topic_invites.find_or_create_by!(topic_id: topic_id) if topic_id.present?
2014-05-09 09:45:18 +08:00
group_ids = opts[:group_ids]
2014-05-09 09:45:18 +08:00
if group_ids.present?
group_ids.each { |group_id| invite.invited_groups.find_or_create_by!(group_id: group_id) }
2014-05-09 09:45:18 +08:00
end
if emailed_status == emailed_status_types[:pending]
invite.update_column(:emailed_status, emailed_status_types[:sending])
Jobs.enqueue(:invite_email, invite_id: invite.id, invite_to_topic: opts[:invite_to_topic])
end
2014-05-09 09:45:18 +08:00
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,
redeeming_user: nil
)
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
2021-03-19 08:20:10 +08:00
return if !redeemable?
InviteRedeemer.new(
invite: self,
email: email,
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,
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
2021-03-19 08:20:10 +08:00
).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
def self.find_user_by_email(email)
User.with_email(Email.downcase(email)).where(staged: false).first
end
def self.pending(inviter)
Invite
.distinct
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
.where(invited_by_id: inviter.id)
.where("redemption_count < max_redemptions_allowed")
.where("expires_at > ?", Time.zone.now)
.order("invites.updated_at DESC")
end
def self.expired(inviter)
Invite
.distinct
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
.where(invited_by_id: inviter.id)
.where("redemption_count < max_redemptions_allowed")
.where("expires_at < ?", Time.zone.now)
.order("invites.expires_at ASC")
end
def self.redeemed_users(inviter)
InvitedUser
.joins("LEFT JOIN invites ON invites.id = invited_users.invite_id")
.includes(user: :user_stat)
.where("invited_users.user_id IS NOT NULL")
.where("invites.invited_by_id = ?", inviter.id)
.order("invited_users.redeemed_at DESC")
.references("invite")
.references("user")
.references("user_stat")
end
def self.invalidate_for_email(email)
invite = Invite.find_by(email: Email.downcase(email))
invite.update!(invalidated_at: Time.zone.now) if invite
invite
end
2014-05-28 04:14:37 +08:00
2014-10-07 02:48:56 +08:00
def resend_invite
self.update_columns(
updated_at: Time.zone.now,
invalidated_at: nil,
expires_at: SiteSetting.invite_expiry_days.days.from_now,
)
2014-10-07 02:48:56 +08:00
Jobs.enqueue(:invite_email, invite_id: self.id)
end
def limit_invites_per_day
RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i)
end
2014-05-28 04:14:37 +08:00
def self.base_directory
File.join(
Rails.root,
"public",
"uploads",
"csv",
RailsMultisite::ConnectionManagement.current_db,
)
2014-05-28 04:14:37 +08:00
end
def ensure_max_redemptions_allowed
if self.max_redemptions_allowed.nil?
self.max_redemptions_allowed = 1
else
limit =
(
if invited_by&.staff?
SiteSetting.invite_link_max_redemptions_limit
else
SiteSetting.invite_link_max_redemptions_limit_users
end
)
if self.email.present? && self.max_redemptions_allowed != 1
errors.add(:max_redemptions_allowed, I18n.t("invite.max_redemptions_allowed_one"))
elsif !self.max_redemptions_allowed.between?(1, limit)
errors.add(
:max_redemptions_allowed,
I18n.t("invite_link.max_redemptions_limit", max_limit: limit),
)
end
end
end
def valid_redemption_count
if self.redemption_count > self.max_redemptions_allowed
errors.add(
:redemption_count,
I18n.t(
"invite.redemption_count_less_than_max",
max_redemptions_allowed: self.max_redemptions_allowed,
),
)
end
end
def valid_domain
return if self.domain.blank?
self.domain.downcase!
if self.domain !~ Invite::DOMAIN_REGEX
self.errors.add(:base, I18n.t("invite.domain_not_allowed"))
end
end
def user_exists_error_msg(email)
error_key = SiteSetting.hide_email_address_taken? ? "generic_error_response" : "user_exists"
I18n.t("invite.#{error_key}", email: CGI.escapeHTML(email))
end
2013-02-06 03:16:51 +08:00
end
# == Schema Information
#
# Table name: invites
#
# id :integer not null, primary key
# invite_key :string(32) not null
# email :string
# invited_by_id :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# deleted_at :datetime
# deleted_by_id :integer
# invalidated_at :datetime
# moderator :boolean default(FALSE), not null
# custom_message :text
# emailed_status :integer
# max_redemptions_allowed :integer default(1), not null
# redemption_count :integer default(0), not null
# expires_at :datetime not null
# email_token :string
# domain :string
#
# Indexes
#
# index_invites_on_email_and_invited_by_id (email,invited_by_id)
# index_invites_on_emailed_status (emailed_status)
# index_invites_on_invite_key (invite_key) UNIQUE
2019-12-30 14:13:27 +08:00
# index_invites_on_invited_by_id (invited_by_id)
#