discourse/app/controllers/invites_controller.rb

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

581 lines
17 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
require "csv"
2013-02-05 14:16:51 -05:00
class InvitesController < ApplicationController
requires_login only: %i[
create
retrieve
destroy
destroy_all_expired
resend_invite
resend_all_invites
upload_csv
]
skip_before_action :check_xhr, except: [:perform_accept_invitation]
skip_before_action :preload_json, except: [:show]
skip_before_action :redirect_to_login_if_required
before_action :ensure_invites_allowed, only: %i[show perform_accept_invitation]
before_action :ensure_new_registrations_allowed, only: %i[show perform_accept_invitation]
2013-02-05 14:16:51 -05:00
def show
expires_now
RateLimiter.new(nil, "invites-show-#{request.remote_ip}", 100, 1.minute).performed!
invite = Invite.find_by(invite_key: params[:id])
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-18 20:20:10 -04:00
if invite.present? && invite.redeemable?
show_invite(invite)
else
show_irredeemable_invite(invite)
end
rescue RateLimiter::LimitExceeded => e
flash.now[:error] = e.description
render layout: "no_ember"
end
def create
if params[:topic_id].present?
topic = Topic.find_by(id: params[:topic_id])
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
guardian.ensure_can_invite_to!(topic)
end
if params[:group_ids].present? || params[:group_names].present?
groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names])
end
guardian.ensure_can_invite_to_forum!(groups)
if !groups_can_see_topic?(groups, topic)
editable_topic_groups = topic.category.groups.filter { |g| guardian.can_edit_group?(g) }
return(
render_json_error(
I18n.t("invite.requires_groups", groups: editable_topic_groups.pluck(:name).join(", ")),
)
)
end
invite =
Invite.generate(
current_user,
email: params[:email],
domain: params[:domain],
skip_email: params[:skip_email],
invited_by: current_user,
custom_message: params[:custom_message],
max_redemptions_allowed: params[:max_redemptions_allowed],
topic_id: topic&.id,
group_ids: groups&.map(&:id),
expires_at: params[:expires_at],
invite_to_topic: params[:invite_to_topic],
)
if invite.present?
render_serialized(
invite,
InviteSerializer,
scope: guardian,
root: nil,
show_emails: params.has_key?(:email),
show_warnings: true,
)
else
render json: failed_json, status: 422
end
rescue Invite::UserExists => e
return render json: {}, status: 200 if SiteSetting.hide_email_address_taken?
render_json_error(e.message)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
end
def retrieve
params.require(:email)
invite = Invite.find_by(invited_by: current_user, email: params[:email])
raise Discourse::InvalidParameters.new(:email) if invite.blank?
guardian.ensure_can_invite_to_forum!(nil)
render_serialized(
invite,
InviteSerializer,
scope: guardian,
root: nil,
show_emails: params.has_key?(:email),
show_warnings: true,
)
end
def update
invite = Invite.find_by(invited_by: current_user, id: params[:id])
raise Discourse::InvalidParameters.new(:id) if invite.blank?
if params[:topic_id].present?
topic = Topic.find_by(id: params[:topic_id])
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
guardian.ensure_can_invite_to!(topic)
end
if params[:group_ids].present? || params[:group_names].present?
groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names])
end
2015-08-25 21:41:52 -04:00
guardian.ensure_can_invite_to_forum!(groups)
Invite.transaction do
if params.has_key?(:topic_id)
invite.topic_invites.destroy_all
invite.topic_invites.create!(topic_id: topic.id) if topic.present?
end
if params.has_key?(:group_ids) || params.has_key?(:group_names)
invite.invited_groups.destroy_all
if groups.present?
groups.each { |group| invite.invited_groups.find_or_create_by!(group_id: group.id) }
end
end
if !groups_can_see_topic?(invite.groups, invite.topics.first)
editable_topic_groups =
invite.topics.first.category.groups.filter { |g| guardian.can_edit_group?(g) }
return(
render_json_error(
I18n.t("invite.requires_groups", groups: editable_topic_groups.pluck(:name).join(", ")),
)
)
end
if params.has_key?(:email)
old_email = invite.email.presence
new_email = params[:email].presence
if new_email
if Invite
.where.not(id: invite.id)
.find_by(email: new_email.downcase, invited_by_id: current_user.id)
&.redeemable?
return(
render_json_error(
I18n.t("invite.invite_exists", email: CGI.escapeHTML(new_email)),
status: 409,
)
)
end
end
if old_email != new_email
invite.emailed_status =
if new_email && !params[:skip_email]
Invite.emailed_status_types[:pending]
else
Invite.emailed_status_types[:not_required]
end
end
invite.domain = nil if invite.email.present?
end
if params.has_key?(:domain)
invite.domain = params[:domain]
if invite.domain.present?
invite.email = nil
invite.emailed_status = Invite.emailed_status_types[:not_required]
end
end
if params[:send_email]
if invite.emailed_status != Invite.emailed_status_types[:pending]
begin
RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed!
rescue RateLimiter::LimitExceeded
return render_json_error(I18n.t("rate_limiter.slow_down"))
end
end
invite.emailed_status = Invite.emailed_status_types[:pending]
end
2015-08-25 21:41:52 -04:00
begin
invite.update!(
params.permit(:email, :custom_message, :max_redemptions_allowed, :expires_at),
)
rescue ActiveRecord::RecordInvalid => e
if SiteSetting.hide_email_address_taken? && e.record.email_already_exists?
return render json: {}, status: 200
end
return render_json_error(e.record.errors.full_messages.first)
end
end
if invite.emailed_status == Invite.emailed_status_types[:pending]
invite.update_column(:emailed_status, Invite.emailed_status_types[:sending])
Jobs.enqueue(:invite_email, invite_id: invite.id, invite_to_topic: params[:invite_to_topic])
end
render_serialized(
invite,
InviteSerializer,
scope: guardian,
root: nil,
show_emails: params.has_key?(:email),
show_warnings: true,
)
2015-08-25 21:41:52 -04:00
end
2013-02-05 14:16:51 -05:00
def destroy
params.require(:id)
2013-02-05 14:16:51 -05:00
invite = Invite.find_by(invited_by_id: current_user.id, id: params[:id])
raise Discourse::InvalidParameters.new(:id) if invite.blank?
2013-07-09 15:20:18 -04:00
invite.trash!(current_user)
2013-02-05 14:16:51 -05:00
render json: success_json
2013-02-05 14:16:51 -05:00
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-18 20:20:10 -04:00
# 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,
:email_token,
user_custom_fields: {
},
)
invite = Invite.find_by(invite_key: params[:id])
redeeming_user = current_user
if invite.present?
begin
attrs = { ip_address: request.remote_ip, session: session }
if redeeming_user
attrs[:redeeming_user] = redeeming_user
else
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
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-18 20:20:10 -04:00
user = invite.redeem(**attrs)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, Invite::UserExists => e
return render json: failed_json.merge(message: e.message), status: 412
end
if user.blank?
return render json: failed_json.merge(message: I18n.t("invite.not_found_json")), status: 404
end
log_on_user(user) if !redeeming_user && user.active? && user.guardian.can_access_forum?
user.update_timezone_if_missing(params[:timezone])
post_process_invite(user)
create_topic_invite_notifications(invite, user)
topic = invite.topics.first
response = {}
if user.present?
if user.active? && user.guardian.can_access_forum?
response[:message] = I18n.t("invite.existing_user_success") if redeeming_user
if user.guardian.can_see?(topic)
response[:redirect_to] = path(topic.relative_url)
else
response[:redirect_to] = path("/")
end
else
response[:message] = if user.active?
I18n.t("activation.approval_required")
else
I18n.t("invite.confirm_email")
end
cookies[:destination_url] = path(topic.relative_url) if user.guardian.can_see?(topic)
end
end
render json: success_json.merge(response)
else
render json: failed_json.merge(message: I18n.t("invite.not_found_json")), status: 404
end
end
def destroy_all_expired
guardian.ensure_can_destroy_all_invites!(current_user)
Invite
.where(invited_by: current_user)
.where("expires_at < ?", Time.zone.now)
.find_each { |invite| invite.trash!(current_user) }
render json: success_json
end
2014-10-06 14:48:56 -04:00
def resend_invite
params.require(:email)
2016-06-06 15:36:59 -04:00
RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed!
2014-10-06 14:48:56 -04:00
invite = Invite.find_by(invited_by_id: current_user.id, email: params[:email])
raise Discourse::InvalidParameters.new(:email) if invite.blank?
invite.resend_invite
render json: success_json
2016-06-06 15:36:59 -04:00
rescue RateLimiter::LimitExceeded
render_json_error(I18n.t("rate_limiter.slow_down"))
2014-10-06 14:48:56 -04:00
end
def resend_all_invites
guardian.ensure_can_resend_all_invites!(current_user)
begin
RateLimiter.new(
current_user,
"bulk-reinvite-per-day",
1,
1.day,
apply_limit_to_staff: true,
).performed!
rescue RateLimiter::LimitExceeded
return render_json_error(I18n.t("rate_limiter.slow_down"))
end
Invite
.pending(current_user)
.where("invites.email IS NOT NULL")
.find_each { |invite| invite.resend_invite }
render json: success_json
end
2016-12-04 11:06:35 -05:00
def upload_csv
2014-05-27 16:14:37 -04:00
guardian.ensure_can_bulk_invite_to_forum!(current_user)
hijack do
begin
file = params[:file] || params[:files].first
2016-12-04 11:06:35 -05:00
csv_header = nil
invites = []
CSV.foreach(file.tempfile, encoding: "bom|utf-8") do |row|
# Try to extract a CSV header, if it exists
if csv_header.nil?
if row[0] == "email"
csv_header = row
next
else
csv_header = %w[email groups topic_id]
end
end
invites.push(csv_header.zip(row).map.to_h.filter { |k, v| v.present? }) if row[0].present?
break if invites.count >= SiteSetting.max_bulk_invites
end
2019-06-12 05:05:21 -04:00
if invites.present?
Jobs.enqueue(:bulk_invite, invites: invites, current_user_id: current_user.id)
if invites.count >= SiteSetting.max_bulk_invites
render json:
failed_json.merge(
errors: [
I18n.t(
"bulk_invite.max_rows",
max_bulk_invites: SiteSetting.max_bulk_invites,
),
],
),
status: 422
2019-06-12 05:05:21 -04:00
else
render json: success_json
end
else
render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
end
2016-12-04 11:06:35 -05:00
end
2014-05-27 16:14:37 -04:00
end
end
private
2014-07-14 11:56:26 -04:00
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
email = invite.email if email_verified_by_link
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,
}
info[:different_external_email] = true if different_external_email
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[:existing_user_can_redeem_error] = existing_user_can_redeem_error(invite)
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
end
end
def ensure_new_registrations_allowed
unless SiteSetting.allow_new_registrations
flash[:error] = I18n.t("login.new_registrations_disabled")
render layout: "no_ember"
false
end
end
def groups_can_see_topic?(groups, topic)
if topic&.read_restricted_category?
topic_groups = topic.category.groups
return false if (groups & topic_groups).blank?
end
true
end
def post_process_invite(user)
user.enqueue_welcome_message("welcome_invite") if user.send_welcome_message
Group.refresh_automatic_groups!(:admins, :moderators, :staff) if user.staff?
if user.has_password?
if !user.active
email_token =
user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:signup])
EmailToken.enqueue_signup_email(email_token)
end
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 05:04:33 -05:00
elsif !SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins
Jobs.enqueue(:invite_password_instructions_email, username: user.username)
end
2018-06-07 01:28:18 -04:00
end
def create_topic_invite_notifications(invite, user)
invite.topics.each do |topic|
if user.guardian.can_see?(topic)
last_notification =
user
.notifications
.where(notification_type: Notification.types[:invited_to_topic])
.where(topic_id: topic.id)
.where(post_number: 1)
.where("created_at > ?", 1.hour.ago)
if !last_notification.exists?
topic.create_invite_notification!(
user,
Notification.types[:invited_to_topic],
invite.invited_by,
)
end
end
end
end
def existing_user_can_redeem_error(invite)
return if invite.can_be_redeemed_by?(current_user)
if invite.invited_users.exists?(user: current_user)
I18n.t("invite.existing_user_already_redemeed")
else
I18n.t("invite.existing_user_cannot_redeem")
end
end
2013-02-05 14:16:51 -05:00
end