mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 20:34:32 +08:00
24347ace10
`Upload#url` is more likely and can change from time to time. When it does changes, we don't want to have to look through multiple tables to ensure that the URLs are all up to date. Instead, we simply associate uploads properly to `UserProfile` so that it does not have to replicate the URLs in the table.
483 lines
15 KiB
Ruby
483 lines
15 KiB
Ruby
require_dependency 'rate_limiter'
|
|
require_dependency 'single_sign_on'
|
|
require_dependency 'single_sign_on_provider'
|
|
require_dependency 'url_helper'
|
|
|
|
class SessionController < ApplicationController
|
|
class LocalLoginNotAllowed < StandardError; end
|
|
rescue_from LocalLoginNotAllowed do
|
|
render body: nil, status: 500
|
|
end
|
|
|
|
before_action :check_local_login_allowed, only: %i(create forgot_password email_login)
|
|
before_action :rate_limit_login, only: %i(create email_login)
|
|
skip_before_action :redirect_to_login_if_required
|
|
skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy email_login one_time_password)
|
|
|
|
ACTIVATE_USER_KEY = "activate_user"
|
|
|
|
def csrf
|
|
render json: { csrf: form_authenticity_token }
|
|
end
|
|
|
|
def sso
|
|
destination_url = cookies[:destination_url] || session[:destination_url]
|
|
return_path = params[:return_path] || path('/')
|
|
|
|
if destination_url && return_path == path('/')
|
|
uri = URI::parse(destination_url)
|
|
return_path = "#{uri.path}#{uri.query ? "?" << uri.query : ""}"
|
|
end
|
|
|
|
session.delete(:destination_url)
|
|
cookies.delete(:destination_url)
|
|
|
|
if SiteSetting.enable_sso?
|
|
sso = DiscourseSingleSignOn.generate_sso(return_path)
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn("Verbose SSO log: Started SSO process\n\n#{sso.diagnostics}")
|
|
end
|
|
redirect_to sso.to_url
|
|
else
|
|
render body: nil, status: 404
|
|
end
|
|
end
|
|
|
|
def sso_provider(payload = nil)
|
|
payload ||= request.query_string
|
|
|
|
if SiteSetting.enable_sso_provider
|
|
sso = SingleSignOnProvider.parse(payload)
|
|
|
|
if sso.return_sso_url.blank?
|
|
render plain: "return_sso_url is blank, it must be provided", status: 400
|
|
return
|
|
end
|
|
|
|
if current_user
|
|
sso.name = current_user.name
|
|
sso.username = current_user.username
|
|
sso.email = current_user.email
|
|
sso.external_id = current_user.id.to_s
|
|
sso.admin = current_user.admin?
|
|
sso.moderator = current_user.moderator?
|
|
sso.groups = current_user.groups.pluck(:name).join(",")
|
|
|
|
if current_user.uploaded_avatar.present?
|
|
base_url = Discourse.store.external? ? "#{Discourse.store.absolute_base_url}/" : Discourse.base_url
|
|
avatar_url = "#{base_url}#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}"
|
|
sso.avatar_url = UrlHelper.absolute Discourse.store.cdn_url(avatar_url)
|
|
end
|
|
|
|
if current_user.user_profile.profile_background_upload.present?
|
|
sso.profile_background_url = UrlHelper.absolute(upload_cdn_path(
|
|
current_user.user_profile.profile_background_upload.url
|
|
))
|
|
end
|
|
|
|
if current_user.user_profile.card_background_upload.present?
|
|
sso.card_background_url = UrlHelper.absolute(upload_cdn_path(
|
|
current_user.user_profile.card_background_upload.url
|
|
))
|
|
end
|
|
|
|
if request.xhr?
|
|
cookies[:sso_destination_url] = sso.to_url(sso.return_sso_url)
|
|
else
|
|
redirect_to sso.to_url(sso.return_sso_url)
|
|
end
|
|
else
|
|
cookies[:sso_payload] = request.query_string
|
|
redirect_to path('/login')
|
|
end
|
|
else
|
|
render body: nil, status: 404
|
|
end
|
|
end
|
|
|
|
# For use in development mode only when login options could be limited or disabled.
|
|
# NEVER allow this to work in production.
|
|
if !Rails.env.production?
|
|
skip_before_action :check_xhr, only: [:become]
|
|
|
|
def become
|
|
raise Discourse::InvalidAccess if Rails.env.production?
|
|
user = User.find_by_username(params[:session_id])
|
|
raise "User #{params[:session_id]} not found" if user.blank?
|
|
|
|
log_on_user(user)
|
|
redirect_to path("/")
|
|
end
|
|
end
|
|
|
|
def sso_login
|
|
raise Discourse::NotFound.new unless SiteSetting.enable_sso
|
|
|
|
params.require(:sso)
|
|
params.require(:sig)
|
|
|
|
begin
|
|
sso = DiscourseSingleSignOn.parse(request.query_string)
|
|
rescue DiscourseSingleSignOn::ParseError => e
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn("Verbose SSO log: Signature parse error\n\n#{e.message}\n\n#{sso&.diagnostics}")
|
|
end
|
|
|
|
# Do NOT pass the error text to the client, it would give them the correct signature
|
|
return render_sso_error(text: I18n.t("sso.login_error"), status: 422)
|
|
end
|
|
|
|
if !sso.nonce_valid?
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn("Verbose SSO log: Nonce has already expired\n\n#{sso.diagnostics}")
|
|
end
|
|
return render_sso_error(text: I18n.t("sso.timeout_expired"), status: 419)
|
|
end
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn("Verbose SSO log: IP address is blocked #{request.remote_ip}\n\n#{sso.diagnostics}")
|
|
end
|
|
return render_sso_error(text: I18n.t("sso.unknown_error"), status: 500)
|
|
end
|
|
|
|
return_path = sso.return_path
|
|
sso.expire_nonce!
|
|
|
|
begin
|
|
if user = sso.lookup_or_create_user(request.remote_ip)
|
|
|
|
if user.suspended?
|
|
render_sso_error(text: failed_to_login(user)[:error], status: 403)
|
|
return
|
|
end
|
|
|
|
if SiteSetting.must_approve_users? && !user.approved?
|
|
if SiteSetting.sso_not_approved_url.present?
|
|
redirect_to SiteSetting.sso_not_approved_url
|
|
else
|
|
render_sso_error(text: I18n.t("sso.account_not_approved"), status: 403)
|
|
end
|
|
return
|
|
elsif !user.active?
|
|
activation = UserActivator.new(user, request, session, cookies)
|
|
activation.finish
|
|
session["user_created_message"] = activation.message
|
|
redirect_to(users_account_created_path) && (return)
|
|
else
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn("Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}")
|
|
end
|
|
if user.id != current_user&.id
|
|
log_on_user user
|
|
end
|
|
end
|
|
|
|
# If it's not a relative URL check the host
|
|
if return_path !~ /^\/[^\/]/
|
|
begin
|
|
uri = URI(return_path)
|
|
if (uri.hostname == Discourse.current_hostname)
|
|
return_path = uri.to_s
|
|
elsif !SiteSetting.sso_allows_all_return_paths
|
|
return_path = path("/")
|
|
end
|
|
rescue
|
|
return_path = path("/")
|
|
end
|
|
end
|
|
|
|
# this can be done more surgically with a regex
|
|
# but it the edge case of never supporting redirects back to
|
|
# any url with `/session/sso` in it anywhere is reasonable
|
|
if return_path.include?(path("/session/sso"))
|
|
return_path = path("/")
|
|
end
|
|
|
|
redirect_to return_path
|
|
else
|
|
render_sso_error(text: I18n.t("sso.not_found"), status: 500)
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
|
|
if SiteSetting.verbose_sso_logging
|
|
Rails.logger.warn(<<~EOF)
|
|
Verbose SSO log: Record was invalid: #{e.record.class.name} #{e.record.id}
|
|
#{e.record.errors.to_h}
|
|
|
|
Attributes:
|
|
#{e.record.attributes.slice(*SingleSignOn::ACCESSORS.map(&:to_s))}
|
|
|
|
SSO Diagnostics:
|
|
#{sso.diagnostics}
|
|
EOF
|
|
end
|
|
|
|
text = nil
|
|
|
|
# If there's a problem with the email we can explain that
|
|
if (e.record.is_a?(User) && e.record.errors[:email].present?)
|
|
if e.record.email.blank?
|
|
text = I18n.t("sso.no_email")
|
|
else
|
|
text = I18n.t("sso.email_error", email: ERB::Util.html_escape(e.record.email))
|
|
end
|
|
end
|
|
|
|
render_sso_error(text: text || I18n.t("sso.unknown_error"), status: 500)
|
|
|
|
rescue => e
|
|
message = "Failed to create or lookup user: #{e}."
|
|
message << " "
|
|
message << " #{sso.diagnostics}"
|
|
message << " "
|
|
message << " #{e.backtrace.join("\n")}"
|
|
|
|
Rails.logger.error(message)
|
|
|
|
render_sso_error(text: I18n.t("sso.unknown_error"), status: 500)
|
|
end
|
|
end
|
|
|
|
def create
|
|
unless params[:second_factor_token].blank?
|
|
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
|
end
|
|
|
|
params.require(:login)
|
|
params.require(:password)
|
|
|
|
return invalid_credentials if params[:password].length > User.max_password_length
|
|
|
|
login = params[:login].strip
|
|
login = login[1..-1] if login[0] == "@"
|
|
|
|
if user = User.find_by_username_or_email(login)
|
|
|
|
# If their password is correct
|
|
unless user.confirm_password?(params[:password])
|
|
invalid_credentials
|
|
return
|
|
end
|
|
|
|
# If the site requires user approval and the user is not approved yet
|
|
if login_not_approved_for?(user)
|
|
render json: login_not_approved
|
|
return
|
|
end
|
|
|
|
# User signed on with username and password, so let's prevent the invite link
|
|
# from being used to log in (if one exists).
|
|
Invite.invalidate_for_email(user.email)
|
|
else
|
|
invalid_credentials
|
|
return
|
|
end
|
|
|
|
if payload = login_error_check(user)
|
|
render json: payload
|
|
else
|
|
if user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
|
|
return render json: failed_json.merge(
|
|
error: I18n.t("login.invalid_second_factor_code"),
|
|
reason: "invalid_second_factor",
|
|
backup_enabled: user.backup_codes_enabled?
|
|
)
|
|
end
|
|
|
|
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
|
|
end
|
|
end
|
|
|
|
def email_login
|
|
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
|
|
second_factor_token = params[:second_factor_token]
|
|
second_factor_method = params[:second_factor_method].to_i
|
|
token = params[:token]
|
|
valid_token = !!EmailToken.valid_token_format?(token)
|
|
user = EmailToken.confirmable(token)&.user
|
|
|
|
if valid_token && user&.totp_enabled?
|
|
if !second_factor_token.present?
|
|
@second_factor_required = true
|
|
@backup_codes_enabled = true if user&.backup_codes_enabled?
|
|
return render layout: 'no_ember'
|
|
elsif !user.authenticate_second_factor(second_factor_token, second_factor_method)
|
|
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
|
|
@error = I18n.t('login.invalid_second_factor_code')
|
|
return render layout: 'no_ember'
|
|
end
|
|
end
|
|
|
|
if user = EmailToken.confirm(token)
|
|
if login_not_approved_for?(user)
|
|
@error = login_not_approved[:error]
|
|
elsif payload = login_error_check(user)
|
|
@error = payload[:error]
|
|
else
|
|
log_on_user(user)
|
|
return redirect_to path("/")
|
|
end
|
|
else
|
|
@error = I18n.t('email_login.invalid_token')
|
|
end
|
|
|
|
render layout: 'no_ember'
|
|
end
|
|
|
|
def one_time_password
|
|
otp_username = $redis.get "otp_#{params[:token]}"
|
|
|
|
if otp_username && user = User.find_by_username(otp_username)
|
|
log_on_user(user)
|
|
$redis.del "otp_#{params[:token]}"
|
|
return redirect_to path("/")
|
|
else
|
|
@error = I18n.t('user_api_key.invalid_token')
|
|
end
|
|
|
|
render layout: 'no_ember'
|
|
end
|
|
|
|
def forgot_password
|
|
params.require(:login)
|
|
|
|
RateLimiter.new(nil, "forgot-password-hr-#{request.remote_ip}", 6, 1.hour).performed!
|
|
RateLimiter.new(nil, "forgot-password-min-#{request.remote_ip}", 3, 1.minute).performed!
|
|
|
|
RateLimiter.new(nil, "forgot-password-login-hour-#{params[:login].to_s[0..100]}", 12, 1.hour).performed!
|
|
RateLimiter.new(nil, "forgot-password-login-min-#{params[:login].to_s[0..100]}", 3, 1.minute).performed!
|
|
|
|
user = User.find_by_username_or_email(params[:login])
|
|
user_presence = user.present? && user.human? && !user.staged
|
|
if user_presence
|
|
email_token = user.email_tokens.create(email: user.email)
|
|
Jobs.enqueue(:critical_user_email, type: :forgot_password, user_id: user.id, email_token: email_token.token)
|
|
end
|
|
|
|
json = success_json
|
|
unless SiteSetting.hide_email_address_taken
|
|
json[:user_found] = user_presence
|
|
end
|
|
|
|
render json: json
|
|
|
|
rescue RateLimiter::LimitExceeded
|
|
render_json_error(I18n.t("rate_limiter.slow_down"))
|
|
end
|
|
|
|
def current
|
|
if current_user.present?
|
|
render_serialized(current_user, CurrentUserSerializer)
|
|
else
|
|
render body: nil, status: 404
|
|
end
|
|
end
|
|
|
|
def destroy
|
|
reset_session
|
|
log_off_user
|
|
if request.xhr?
|
|
render body: nil
|
|
else
|
|
redirect_to (params[:return_url] || path("/"))
|
|
end
|
|
end
|
|
|
|
protected
|
|
|
|
def check_local_login_allowed
|
|
if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
|
|
raise LocalLoginNotAllowed, "SSO takes over local login or the local login is disallowed."
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def login_error_check(user)
|
|
return failed_to_login(user) if user.suspended?
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
return not_allowed_from_ip_address(user)
|
|
end
|
|
|
|
if ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
|
|
return admin_not_allowed_from_ip_address(user)
|
|
end
|
|
end
|
|
|
|
def login_not_approved_for?(user)
|
|
SiteSetting.must_approve_users? && !user.approved? && !user.admin?
|
|
end
|
|
|
|
def invalid_credentials
|
|
render json: { error: I18n.t("login.incorrect_username_email_or_password") }
|
|
end
|
|
|
|
def login_not_approved
|
|
{ error: I18n.t("login.not_approved") }
|
|
end
|
|
|
|
def not_activated(user)
|
|
session[ACTIVATE_USER_KEY] = user.id
|
|
render json: {
|
|
error: I18n.t("login.not_activated"),
|
|
reason: 'not_activated',
|
|
sent_to_email: user.find_email || user.email,
|
|
current_email: user.email
|
|
}
|
|
end
|
|
|
|
def not_allowed_from_ip_address(user)
|
|
{ error: I18n.t("login.not_allowed_from_ip_address", username: user.username) }
|
|
end
|
|
|
|
def admin_not_allowed_from_ip_address(user)
|
|
{ error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username) }
|
|
end
|
|
|
|
def failed_to_login(user)
|
|
message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended"
|
|
|
|
{
|
|
error: I18n.t(message,
|
|
date: I18n.l(user.suspended_till, format: :date_only),
|
|
reason: Rack::Utils.escape_html(user.suspend_reason)
|
|
),
|
|
reason: 'suspended'
|
|
}
|
|
end
|
|
|
|
def login(user)
|
|
session.delete(ACTIVATE_USER_KEY)
|
|
log_on_user(user)
|
|
|
|
if payload = cookies.delete(:sso_payload)
|
|
sso_provider(payload)
|
|
else
|
|
render_serialized(user, UserSerializer)
|
|
end
|
|
end
|
|
|
|
def rate_limit_login
|
|
RateLimiter.new(
|
|
nil,
|
|
"login-hr-#{request.remote_ip}",
|
|
SiteSetting.max_logins_per_ip_per_hour,
|
|
1.hour
|
|
).performed!
|
|
|
|
RateLimiter.new(
|
|
nil,
|
|
"login-min-#{request.remote_ip}",
|
|
SiteSetting.max_logins_per_ip_per_minute,
|
|
1.minute
|
|
).performed!
|
|
end
|
|
|
|
def render_sso_error(status:, text:)
|
|
@sso_error = text
|
|
render status: status, layout: 'no_ember'
|
|
end
|
|
end
|