mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 18:48:33 +08:00
a414520742
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.
793 lines
26 KiB
Ruby
793 lines
26 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class SessionController < ApplicationController
|
|
before_action :check_local_login_allowed, only: %i(create forgot_password)
|
|
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 one_time_password)
|
|
|
|
skip_before_action :check_xhr, only: %i(second_factor_auth_show)
|
|
|
|
requires_login only: [:second_factor_auth_show, :second_factor_auth_perform]
|
|
|
|
allow_in_staff_writes_only_mode :create
|
|
allow_in_staff_writes_only_mode :email_login
|
|
|
|
ACTIVATE_USER_KEY = "activate_user"
|
|
|
|
def csrf
|
|
render json: { csrf: form_authenticity_token }
|
|
end
|
|
|
|
def sso
|
|
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect?
|
|
|
|
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)
|
|
|
|
sso = DiscourseConnect.generate_sso(return_path, secure_session: secure_session)
|
|
connect_verbose_warn { "Verbose SSO log: Started SSO process\n\n#{sso.diagnostics}" }
|
|
redirect_to sso_url(sso), allow_other_host: true
|
|
end
|
|
|
|
def sso_provider(payload = nil, confirmed_2fa_during_login = false)
|
|
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect_provider
|
|
|
|
result = run_second_factor!(
|
|
SecondFactor::Actions::DiscourseConnectProvider,
|
|
payload: payload,
|
|
confirmed_2fa_during_login: confirmed_2fa_during_login
|
|
)
|
|
|
|
if result.second_factor_auth_skipped?
|
|
data = result.data
|
|
if data[:logout]
|
|
params[:return_url] = data[:return_sso_url]
|
|
destroy
|
|
return
|
|
end
|
|
|
|
if data[:no_current_user]
|
|
cookies[:sso_payload] = payload || request.query_string
|
|
redirect_to path('/login')
|
|
return
|
|
end
|
|
|
|
if request.xhr?
|
|
# for the login modal
|
|
cookies[:sso_destination_url] = data[:sso_redirect_url]
|
|
else
|
|
redirect_to data[:sso_redirect_url], allow_other_host: true
|
|
end
|
|
elsif result.no_second_factors_enabled?
|
|
if request.xhr?
|
|
# for the login modal
|
|
cookies[:sso_destination_url] = result.data[:sso_redirect_url]
|
|
else
|
|
redirect_to result.data[:sso_redirect_url], allow_other_host: true
|
|
end
|
|
elsif result.second_factor_auth_completed?
|
|
redirect_url = result.data[:sso_redirect_url]
|
|
render json: success_json.merge(redirect_url: redirect_url)
|
|
end
|
|
rescue DiscourseConnectProvider::BlankSecret
|
|
render plain: I18n.t("discourse_connect.missing_secret"), status: 400
|
|
rescue DiscourseConnectProvider::ParseError
|
|
# Do NOT pass the error text to the client, it would give them the correct signature
|
|
render plain: I18n.t("discourse_connect.login_error"), status: 422
|
|
rescue DiscourseConnectProvider::BlankReturnUrl
|
|
render plain: "return_sso_url is blank, it must be provided", status: 400
|
|
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?
|
|
raise Discourse::ReadOnly if @readonly_mode
|
|
|
|
if ENV['DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE'] != "1"
|
|
render(content_type: 'text/plain', inline: <<~TEXT)
|
|
To enable impersonating any user without typing passwords set the following ENV var
|
|
|
|
export DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE=1
|
|
|
|
You can do that in your bashrc of bash profile file or the script you use to launch the web server
|
|
TEXT
|
|
|
|
return
|
|
end
|
|
|
|
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 unless SiteSetting.enable_discourse_connect
|
|
raise Discourse::ReadOnly if @readonly_mode && !staff_writes_only_mode?
|
|
|
|
params.require(:sso)
|
|
params.require(:sig)
|
|
|
|
begin
|
|
sso = DiscourseConnect.parse(request.query_string, secure_session: secure_session)
|
|
rescue DiscourseConnect::ParseError => e
|
|
connect_verbose_warn { "Verbose SSO log: Signature parse error\n\n#{e.message}\n\n#{sso&.diagnostics}" }
|
|
|
|
# Do NOT pass the error text to the client, it would give them the correct signature
|
|
return render_sso_error(text: I18n.t("discourse_connect.login_error"), status: 422)
|
|
end
|
|
|
|
if !sso.nonce_valid?
|
|
connect_verbose_warn { "Verbose SSO log: #{sso.nonce_error}\n\n#{sso.diagnostics}" }
|
|
return render_sso_error(text: I18n.t("discourse_connect.timeout_expired"), status: 419)
|
|
end
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
connect_verbose_warn { "Verbose SSO log: IP address is blocked #{request.remote_ip}\n\n#{sso.diagnostics}" }
|
|
return render_sso_error(text: I18n.t("discourse_connect.unknown_error"), status: 500)
|
|
end
|
|
|
|
return_path = sso.return_path
|
|
sso.expire_nonce!
|
|
|
|
begin
|
|
invite = validate_invitiation!(sso)
|
|
|
|
if user = sso.lookup_or_create_user(request.remote_ip)
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
|
|
|
if user.suspended?
|
|
render_sso_error(text: failed_to_login(user)[:error], status: 403)
|
|
return
|
|
end
|
|
|
|
if SiteSetting.must_approve_users? && !user.approved?
|
|
if invite.present? && user.invited_user.blank?
|
|
redeem_invitation(invite, sso, user)
|
|
end
|
|
|
|
if SiteSetting.discourse_connect_not_approved_url.present?
|
|
redirect_to SiteSetting.discourse_connect_not_approved_url, allow_other_host: true
|
|
else
|
|
render_sso_error(text: I18n.t("discourse_connect.account_not_approved"), status: 403)
|
|
end
|
|
return
|
|
|
|
# we only want to redeem the invite if
|
|
# the user has not already redeemed an invite
|
|
# (covers the same SSO user visiting an invite link)
|
|
elsif invite.present? && user.invited_user.blank?
|
|
redeem_invitation(invite, sso, user)
|
|
|
|
# we directly call user.activate here instead of going
|
|
# through the UserActivator path because we assume the account
|
|
# is valid from the SSO provider's POV and do not need to
|
|
# send an activation email to the user
|
|
user.activate
|
|
login_sso_user(sso, user)
|
|
|
|
topic = invite.topics.first
|
|
return_path = topic.present? ? path(topic.relative_url) : path("/")
|
|
elsif !user.active?
|
|
activation = UserActivator.new(user, request, session, cookies)
|
|
activation.finish
|
|
session["user_created_message"] = activation.message
|
|
return redirect_to(users_account_created_path)
|
|
else
|
|
login_sso_user(sso, user)
|
|
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.discourse_connect_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, allow_other_host: true
|
|
else
|
|
render_sso_error(text: I18n.t("discourse_connect.not_found"), status: 500)
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
|
|
connect_verbose_warn { <<~TEXT }
|
|
Verbose SSO log: Record was invalid: #{e.record.class.name} #{e.record.id}
|
|
#{e.record.errors.to_h}
|
|
|
|
Attributes:
|
|
#{e.record.attributes.slice(*DiscourseConnectBase::ACCESSORS.map(&:to_s))}
|
|
|
|
SSO Diagnostics:
|
|
#{sso.diagnostics}
|
|
TEXT
|
|
|
|
text = nil
|
|
|
|
# If there's a problem with the email we can explain that
|
|
if (e.record.is_a?(User) && e.record.errors[:primary_email].present?)
|
|
if e.record.email.blank?
|
|
text = I18n.t("discourse_connect.no_email")
|
|
else
|
|
text = I18n.t("discourse_connect.email_error", email: ERB::Util.html_escape(e.record.email))
|
|
end
|
|
end
|
|
|
|
render_sso_error(text: text || I18n.t("discourse_connect.unknown_error"), status: 500)
|
|
rescue DiscourseConnect::BlankExternalId
|
|
render_sso_error(text: I18n.t("discourse_connect.blank_id_error"), status: 500)
|
|
rescue Invite::ValidationFailed => e
|
|
render_sso_error(text: e.message, status: 400)
|
|
rescue Invite::RedemptionFailed => e
|
|
render_sso_error(text: I18n.t("discourse_connect.invite_redeem_failed"), status: 412)
|
|
rescue Invite::UserExists => e
|
|
render_sso_error(text: e.message, status: 412)
|
|
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("discourse_connect.unknown_error"), status: 500)
|
|
end
|
|
end
|
|
|
|
def login_sso_user(sso, user)
|
|
connect_verbose_warn { "Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}" }
|
|
log_on_user(user) if user.id != current_user&.id
|
|
end
|
|
|
|
def create
|
|
params.require(:login)
|
|
params.require(:password)
|
|
|
|
return invalid_credentials if params[:password].length > User.max_password_length
|
|
|
|
user = User.find_by_username_or_email(normalized_login_param)
|
|
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
|
|
|
rate_limit_second_factor!(user)
|
|
|
|
if user.present?
|
|
|
|
# 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)
|
|
return render json: payload
|
|
end
|
|
|
|
second_factor_auth_result = authenticate_second_factor(user)
|
|
if !second_factor_auth_result.ok
|
|
return render(json: @second_factor_failure_payload)
|
|
end
|
|
|
|
if user.active && user.email_confirmed?
|
|
login(user, second_factor_auth_result)
|
|
else
|
|
not_activated(user)
|
|
end
|
|
end
|
|
|
|
def email_login_info
|
|
token = params[:token]
|
|
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
|
user = matched_token&.user
|
|
|
|
check_local_login_allowed(user: user, check_login_via_email: true)
|
|
|
|
if matched_token
|
|
response = {
|
|
can_login: true,
|
|
token: token,
|
|
token_email: matched_token.email
|
|
}
|
|
|
|
matched_user = matched_token.user
|
|
if matched_user&.totp_enabled?
|
|
response.merge!(
|
|
second_factor_required: true,
|
|
backup_codes_enabled: matched_user&.backup_codes_enabled?
|
|
)
|
|
end
|
|
|
|
if matched_user&.security_keys_enabled?
|
|
Webauthn.stage_challenge(matched_user, secure_session)
|
|
response.merge!(
|
|
Webauthn.allowed_credentials(matched_user, secure_session).merge(security_key_required: true)
|
|
)
|
|
end
|
|
|
|
render json: response
|
|
else
|
|
render json: {
|
|
can_login: false,
|
|
error: I18n.t('email_login.invalid_token')
|
|
}
|
|
end
|
|
end
|
|
|
|
def email_login
|
|
token = params[:token]
|
|
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
|
user = matched_token&.user
|
|
|
|
check_local_login_allowed(user: user, check_login_via_email: true)
|
|
|
|
rate_limit_second_factor!(user)
|
|
|
|
if user.present? && !authenticate_second_factor(user).ok
|
|
return render(json: @second_factor_failure_payload)
|
|
end
|
|
|
|
if user = EmailToken.confirm(token, scope: EmailToken.scopes[:email_login])
|
|
if login_not_approved_for?(user)
|
|
return render json: login_not_approved
|
|
elsif payload = login_error_check(user)
|
|
return render json: payload
|
|
else
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
|
user.update_timezone_if_missing(params[:timezone])
|
|
log_on_user(user)
|
|
return render json: success_json
|
|
end
|
|
end
|
|
|
|
render json: { error: I18n.t('email_login.invalid_token') }
|
|
end
|
|
|
|
def one_time_password
|
|
@otp_username = otp_username = Discourse.redis.get "otp_#{params[:token]}"
|
|
|
|
if otp_username && user = User.find_by_username(otp_username)
|
|
if current_user&.username == otp_username
|
|
Discourse.redis.del "otp_#{params[:token]}"
|
|
return redirect_to path("/")
|
|
elsif request.post?
|
|
log_on_user(user)
|
|
Discourse.redis.del "otp_#{params[:token]}"
|
|
return redirect_to path("/")
|
|
else
|
|
# Display the form
|
|
end
|
|
else
|
|
@error = I18n.t('user_api_key.invalid_token')
|
|
end
|
|
|
|
render layout: 'no_ember', locals: { hide_auth_buttons: true }
|
|
end
|
|
|
|
def second_factor_auth_show
|
|
user = current_user
|
|
|
|
nonce = params.require(:nonce)
|
|
challenge = nil
|
|
error_key = nil
|
|
status_code = 200
|
|
begin
|
|
challenge = SecondFactor::AuthManager.find_second_factor_challenge(nonce, secure_session)
|
|
rescue SecondFactor::BadChallenge => exception
|
|
error_key = exception.error_translation_key
|
|
status_code = exception.status_code
|
|
end
|
|
|
|
json = {}
|
|
if challenge
|
|
json.merge!(
|
|
totp_enabled: user.totp_enabled?,
|
|
backup_enabled: user.backup_codes_enabled?,
|
|
allowed_methods: challenge[:allowed_methods]
|
|
)
|
|
if user.security_keys_enabled?
|
|
Webauthn.stage_challenge(user, secure_session)
|
|
json.merge!(Webauthn.allowed_credentials(user, secure_session))
|
|
json[:security_keys_enabled] = true
|
|
else
|
|
json[:security_keys_enabled] = false
|
|
end
|
|
if challenge[:description]
|
|
json[:description] = challenge[:description]
|
|
end
|
|
else
|
|
json[:error] = I18n.t(error_key)
|
|
end
|
|
|
|
respond_to do |format|
|
|
format.html do
|
|
store_preloaded("2fa_challenge_data", MultiJson.dump(json))
|
|
raise ApplicationController::RenderEmpty.new
|
|
end
|
|
|
|
format.json do
|
|
render json: json, status: status_code
|
|
end
|
|
end
|
|
end
|
|
|
|
def second_factor_auth_perform
|
|
nonce = params.require(:nonce)
|
|
challenge = nil
|
|
error_key = nil
|
|
status_code = 200
|
|
begin
|
|
challenge = SecondFactor::AuthManager.find_second_factor_challenge(nonce, secure_session)
|
|
rescue SecondFactor::BadChallenge => exception
|
|
error_key = exception.error_translation_key
|
|
status_code = exception.status_code
|
|
end
|
|
|
|
if error_key
|
|
json = failed_json.merge(
|
|
ok: false,
|
|
error: I18n.t(error_key),
|
|
reason: "challenge_not_found_or_expired"
|
|
)
|
|
render json: failed_json.merge(json), status: status_code
|
|
return
|
|
end
|
|
|
|
# no proper error messages for these cases because the only way they can
|
|
# happen is if someone is messing with us.
|
|
# the first one can only happen if someone disables a 2FA method after
|
|
# they're redirected to the 2fa page and then uses the same method they've
|
|
# disabled.
|
|
second_factor_method = params[:second_factor_method].to_i
|
|
if !current_user.valid_second_factor_method_for_user?(second_factor_method)
|
|
raise Discourse::InvalidAccess.new
|
|
end
|
|
# and this happens if someone tries to use a 2FA method that's not accepted
|
|
# for the action they're trying to perform. e.g. using backup codes to
|
|
# grant someone admin status.
|
|
if !challenge[:allowed_methods].include?(second_factor_method)
|
|
raise Discourse::InvalidAccess.new
|
|
end
|
|
|
|
if !challenge[:successful]
|
|
rate_limit_second_factor!(current_user)
|
|
second_factor_auth_result = current_user.authenticate_second_factor(params, secure_session)
|
|
if second_factor_auth_result.ok
|
|
challenge[:successful] = true
|
|
challenge[:generated_at] += 1.minute.to_i
|
|
secure_session["current_second_factor_auth_challenge"] = challenge.to_json
|
|
else
|
|
error_json = second_factor_auth_result
|
|
.to_h
|
|
.deep_symbolize_keys
|
|
.slice(:ok, :error, :reason)
|
|
.merge(failed_json)
|
|
render json: error_json, status: 400
|
|
return
|
|
end
|
|
end
|
|
render json: {
|
|
ok: true,
|
|
callback_method: challenge[:callback_method],
|
|
callback_path: challenge[:callback_path],
|
|
redirect_url: challenge[:redirect_url]
|
|
}, status: 200
|
|
end
|
|
|
|
def forgot_password
|
|
params.require(:login)
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
return render_json_error(I18n.t("login.reset_not_allowed_from_ip_address"))
|
|
end
|
|
|
|
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!
|
|
|
|
user = if SiteSetting.hide_email_address_taken && !current_user&.staff?
|
|
raise Discourse::InvalidParameters.new(:login) if !EmailAddressValidator.valid_value?(normalized_login_param)
|
|
User.real.where(staged: false).find_by_email(Email.downcase(normalized_login_param))
|
|
else
|
|
User.real.where(staged: false).find_by_username_or_email(normalized_login_param)
|
|
end
|
|
|
|
if user
|
|
RateLimiter.new(nil, "forgot-password-login-day-#{user.username}", 6, 1.day).performed!
|
|
email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:password_reset])
|
|
Jobs.enqueue(:critical_user_email, type: "forgot_password", user_id: user.id, email_token: email_token.token)
|
|
else
|
|
RateLimiter.new(nil, "forgot-password-login-hour-#{normalized_login_param}", 5, 1.hour).performed!
|
|
end
|
|
|
|
json = success_json
|
|
json[:user_found] = user.present? if !SiteSetting.hide_email_address_taken
|
|
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
|
|
redirect_url = params[:return_url].presence || SiteSetting.logout_redirect.presence
|
|
|
|
sso = SiteSetting.enable_discourse_connect
|
|
only_one_authenticator = !SiteSetting.enable_local_logins && Discourse.enabled_authenticators.length == 1
|
|
if SiteSetting.login_required && (sso || only_one_authenticator)
|
|
# In this situation visiting most URLs will start the auth process again
|
|
# Go to the `/login` page to avoid an immediate redirect
|
|
redirect_url ||= path("/login")
|
|
end
|
|
|
|
redirect_url ||= path("/")
|
|
|
|
event_data = { redirect_url: redirect_url, user: current_user, client_ip: request&.ip, user_agent: request&.user_agent }
|
|
DiscourseEvent.trigger(:before_session_destroy, event_data)
|
|
redirect_url = event_data[:redirect_url]
|
|
|
|
reset_session
|
|
log_off_user
|
|
if request.xhr?
|
|
render json: {
|
|
redirect_url: redirect_url
|
|
}
|
|
else
|
|
redirect_to redirect_url, allow_other_host: true
|
|
end
|
|
end
|
|
|
|
def get_honeypot_value
|
|
secure_session.set(HONEYPOT_KEY, honeypot_value, expires: 1.hour)
|
|
secure_session.set(CHALLENGE_KEY, challenge_value, expires: 1.hour)
|
|
|
|
render json: {
|
|
value: honeypot_value,
|
|
challenge: challenge_value,
|
|
expires_in: SecureSession.expiry
|
|
}
|
|
end
|
|
|
|
def scopes
|
|
if is_api?
|
|
key = request.env[Auth::DefaultCurrentUserProvider::HEADER_API_KEY]
|
|
api_key = ApiKey.active.with_key(key).first
|
|
render_serialized(api_key.api_key_scopes, ApiKeyScopeSerializer, root: 'scopes')
|
|
else
|
|
render body: nil, status: 404
|
|
end
|
|
end
|
|
|
|
protected
|
|
|
|
def normalized_login_param
|
|
login = params[:login].to_s
|
|
if login.present?
|
|
login = login[1..-1] if login[0] == "@"
|
|
User.normalize_username(login.strip)[0..100]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def check_local_login_allowed(user: nil, check_login_via_email: false)
|
|
# admin-login can get around enabled SSO/disabled local logins
|
|
return if user&.admin?
|
|
|
|
if (check_login_via_email && !SiteSetting.enable_local_logins_via_email) ||
|
|
SiteSetting.enable_discourse_connect ||
|
|
!SiteSetting.enable_local_logins
|
|
raise Discourse::InvalidAccess, "SSO takes over local login or the local login is disallowed."
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def connect_verbose_warn(&blk)
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.warn(blk.call)
|
|
end
|
|
end
|
|
|
|
def authenticate_second_factor(user)
|
|
second_factor_authentication_result = user.authenticate_second_factor(params, secure_session)
|
|
if !second_factor_authentication_result.ok
|
|
failure_payload = second_factor_authentication_result.to_h
|
|
if user.security_keys_enabled?
|
|
Webauthn.stage_challenge(user, secure_session)
|
|
failure_payload.merge!(Webauthn.allowed_credentials(user, secure_session))
|
|
end
|
|
@second_factor_failure_payload = failed_json.merge(failure_payload)
|
|
return second_factor_authentication_result
|
|
end
|
|
|
|
second_factor_authentication_result
|
|
end
|
|
|
|
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)
|
|
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)
|
|
{
|
|
error: user.suspended_message,
|
|
reason: 'suspended'
|
|
}
|
|
end
|
|
|
|
def login(user, second_factor_auth_result)
|
|
session.delete(ACTIVATE_USER_KEY)
|
|
user.update_timezone_if_missing(params[:timezone])
|
|
log_on_user(user)
|
|
|
|
if payload = cookies.delete(:sso_payload)
|
|
confirmed_2fa_during_login = (
|
|
second_factor_auth_result&.ok &&
|
|
second_factor_auth_result.used_2fa_method.present? &&
|
|
second_factor_auth_result.used_2fa_method != UserSecondFactor.methods[:backup_codes]
|
|
)
|
|
sso_provider(payload, confirmed_2fa_during_login)
|
|
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
|
|
|
|
# extension to allow plugins to customize the SSO URL
|
|
def sso_url(sso)
|
|
sso.to_url
|
|
end
|
|
|
|
# the invite_key will be present if set in InvitesController
|
|
# when the user visits an /invites/xxxx link; however we do
|
|
# not want to complete the SSO process of creating a user
|
|
# and redeeming the invite if the invite is not redeemable or
|
|
# for the wrong user
|
|
def validate_invitiation!(sso)
|
|
invite_key = secure_session["invite-key"]
|
|
return if invite_key.blank?
|
|
|
|
invite = Invite.find_by(invite_key: invite_key)
|
|
|
|
if invite.blank?
|
|
raise Invite::ValidationFailed.new(I18n.t("invite.not_found", base_url: Discourse.base_url))
|
|
end
|
|
|
|
if invite.redeemable?
|
|
if invite.is_email_invite? && sso.email != invite.email
|
|
raise Invite::ValidationFailed.new(I18n.t("invite.not_matching_email"))
|
|
end
|
|
elsif invite.expired?
|
|
raise Invite::ValidationFailed.new(I18n.t('invite.expired', base_url: Discourse.base_url))
|
|
elsif invite.redeemed?
|
|
raise Invite::ValidationFailed.new(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url))
|
|
end
|
|
|
|
invite
|
|
end
|
|
|
|
def redeem_invitation(invite, sso, redeeming_user)
|
|
InviteRedeemer.new(
|
|
invite: invite,
|
|
username: sso.username,
|
|
name: sso.name,
|
|
ip_address: request.remote_ip,
|
|
session: session,
|
|
email: sso.email,
|
|
redeeming_user: redeeming_user
|
|
).redeem
|
|
secure_session["invite-key"] = nil
|
|
|
|
# note - more specific errors are handled in the sso_login method
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
|
Rails.logger.warn("SSO invite redemption failed: #{e}")
|
|
raise Invite::RedemptionFailed
|
|
end
|
|
end
|