discourse/app/controllers/invites_controller.rb
David Taylor f25eda13fa
FIX: Make UI match server behavior for external-auth invites (#13113)
There are two methods which the server uses to verify an invite is being redeemed with a matching email:
  1) The email token, supplied via a `?t=` parameter
  2) The validity of the email, as provided by the auth provider

Only one of these needs to be true for the invite to be redeemed successfully on the server. The frontend logic was previously only checking (2). This commit updates the frontend logic to match the server.

This commit does not affect the invite redemption logic. It only affects the 'show' endpoint, and the UI.
2021-05-26 09:47:44 +01:00

393 lines
13 KiB
Ruby

# frozen_string_literal: true
require 'csv'
class InvitesController < ApplicationController
requires_login only: [: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: [:show, :perform_accept_invitation]
before_action :ensure_new_registrations_allowed, only: [:show, :perform_accept_invitation]
before_action :ensure_not_logged_in, only: [:show, :perform_accept_invitation]
def show
expires_now
RateLimiter.new(nil, "invites-show-#{request.remote_ip}", 100, 1.minute).performed!
invite = Invite.find_by(invite_key: params[:id])
if invite.present? && invite.redeemable?
email = Email.obfuscate(invite.email)
# Show email if the user already authenticated their email
if session[:authentication]
auth_result = Auth::Result.from_session_data(session[:authentication], user: nil)
if invite.email == auth_result.email
email = invite.email
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
info = {
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
email: email,
hidden_email: hidden_email,
username: hidden_email ? '' : UserNameSuggester.suggest(invite.email),
is_invite_link: invite.is_invite_link?,
email_verified_by_link: email_verified_by_link
}
if staged_user = User.where(staged: true).with_email(invite.email).first
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'
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'
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)
begin
invite = Invite.generate(current_user,
email: params[:email],
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],
)
if invite.present?
render_serialized(invite, InviteSerializer, scope: guardian, root: nil, show_emails: params.has_key?(:email))
else
render json: failed_json, status: 422
end
rescue Invite::UserExists => e
render_json_error(e.message)
rescue ActiveRecord::RecordInvalid => e
render_json_error(e.record.errors.full_messages.first)
end
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))
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
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
groups.each { |group| invite.invited_groups.find_or_create_by!(group_id: group.id) } if groups.present?
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: 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
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
begin
invite.update!(params.permit(:email, :custom_message, :max_redemptions_allowed, :expires_at))
rescue ActiveRecord::RecordInvalid => e
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))
end
def destroy
params.require(:id)
invite = Invite.find_by(invited_by_id: current_user.id, id: params[:id])
raise Discourse::InvalidParameters.new(:id) if invite.blank?
invite.trash!(current_user)
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, :email_token, user_custom_fields: {})
invite = Invite.find_by(invite_key: params[:id])
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]
else
attrs[:email] = invite.email
attrs[:email_token] = params[:email_token] if params[:email_token].present?
end
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 user.active?
user.update_timezone_if_missing(params[:timezone])
post_process_invite(user)
topic = invite.topics.first
response = {}
if user.present? && user.active?
response[:redirect_to] = topic.present? ? path(topic.relative_url) : path("/")
elsif user.present?
response[:message] = I18n.t('invite.confirm_email')
cookies[:destination_url] = path(topic.relative_url) if topic.present?
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
def resend_invite
params.require(:email)
RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed!
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
rescue RateLimiter::LimitExceeded
render_json_error(I18n.t("rate_limiter.slow_down"))
end
def resend_all_invites
guardian.ensure_can_resend_all_invites!(current_user)
Invite
.left_outer_joins(:invited_users)
.where(invited_by: current_user)
.where('invites.email IS NOT NULL')
.where('invited_users.user_id IS NULL')
.group('invites.id')
.find_each { |invite| invite.resend_invite }
render json: success_json
end
def upload_csv
guardian.ensure_can_bulk_invite_to_forum!(current_user)
hijack do
begin
file = params[:file] || params[:files].first
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 = ["email", "groups", "topic_id"]
end
end
if row[0].present?
invites.push(csv_header.zip(row).map.to_h.filter { |k, v| v.present? })
end
break if invites.count >= SiteSetting.max_bulk_invites
end
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
else
render json: success_json
end
else
render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
end
end
end
end
private
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 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
Group.refresh_automatic_groups!(:admins, :moderators, :staff) if user.staff?
if user.has_password?
send_activation_email(user) unless user.active
elsif !SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins
Jobs.enqueue(:invite_password_instructions_email, username: user.username)
end
end
def send_activation_email(user)
email_token = user.email_tokens.create!(email: user.email)
Jobs.enqueue(:critical_user_email,
type: :signup,
user_id: user.id,
email_token: email_token.token
)
end
end