mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 18:30:26 -06:00
eb5a3cfded
Discourse has the Discourse Connect Provider protocol that makes it possible to use a Discourse instance as an identity provider for external sites. As a natural extension to this protocol, this PR adds a new feature that makes it possible to use Discourse as a 2FA provider as well as an identity provider. The rationale for this change is that it's very difficult to implement 2FA support in a website and if you have multiple websites that need to have 2FA, it's unrealistic to build and maintain a separate 2FA implementation for each one. But with this change, you can piggyback on Discourse to take care of all the 2FA details for you for as many sites as you wish. To use Discourse as a 2FA provider, you'll need to follow this guide: https://meta.discourse.org/t/-/32974. It walks you through what you need to implement on your end/site and how to configure your Discourse instance. Once you're done, there is only one additional thing you need to do which is to include `require_2fa=true` in the payload that you send to Discourse. When Discourse sees `require_2fa=true`, it'll prompt the user to confirm their 2FA using whatever methods they've enabled (TOTP or security keys), and once they confirm they'll be redirected back to the return URL you've configured and the payload will contain `confirmed_2fa=true`. If the user has no 2FA methods enabled however, the payload will not contain `confirmed_2fa`, but it will contain `no_2fa_methods=true`. You'll need to be careful to re-run all the security checks and ensure the user can still access the resource on your site after they return from Discourse. This is very important because there's nothing that guarantees the user that will come back from Discourse after they confirm 2FA is the same user that you've redirected to Discourse. Internal ticket: t62183.
777 lines
25 KiB
Ruby
777 lines
25 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]
|
|
|
|
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_discourse_connect?
|
|
sso = DiscourseConnect.generate_sso(return_path, secure_session: secure_session)
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.warn("Verbose SSO log: Started SSO process\n\n#{sso.diagnostics}")
|
|
end
|
|
redirect_to sso_url(sso)
|
|
else
|
|
render body: nil, status: 404
|
|
end
|
|
end
|
|
|
|
def sso_provider(payload = nil, confirmed_2fa_during_login = false)
|
|
if !SiteSetting.enable_discourse_connect_provider
|
|
render body: nil, status: 404
|
|
return
|
|
end
|
|
|
|
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]
|
|
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]
|
|
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 => e
|
|
# 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?
|
|
|
|
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.new unless SiteSetting.enable_discourse_connect
|
|
|
|
params.require(:sso)
|
|
params.require(:sig)
|
|
|
|
begin
|
|
sso = DiscourseConnect.parse(request.query_string, secure_session: secure_session)
|
|
rescue DiscourseConnect::ParseError => e
|
|
if SiteSetting.verbose_discourse_connect_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("discourse_connect.login_error"), status: 422)
|
|
end
|
|
|
|
if !sso.nonce_valid?
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.warn("Verbose SSO log: #{sso.nonce_error}\n\n#{sso.diagnostics}")
|
|
end
|
|
return render_sso_error(text: I18n.t("discourse_connect.timeout_expired"), status: 419)
|
|
end
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
if SiteSetting.verbose_discourse_connect_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("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)
|
|
|
|
if user.suspended?
|
|
render_sso_error(text: failed_to_login(user)[:error], status: 403)
|
|
return
|
|
end
|
|
|
|
# users logging in via SSO using an invite do not need to be approved,
|
|
# they are already pre-approved because they have been invited
|
|
if SiteSetting.must_approve_users? && !user.approved? && invite.blank?
|
|
if SiteSetting.discourse_connect_not_approved_url.present?
|
|
redirect_to SiteSetting.discourse_connect_not_approved_url
|
|
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)
|
|
|
|
# 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
|
|
else
|
|
render_sso_error(text: I18n.t("discourse_connect.not_found"), status: 500)
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.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
|
|
end
|
|
|
|
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)
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.warn("Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}")
|
|
end
|
|
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)
|
|
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
|
|
|
|
(user.active && user.email_confirmed?) ? login(user, second_factor_auth_result) : not_activated(user)
|
|
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
|
|
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 }
|
|
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
|
|
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
|
|
|
|
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 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_invite_link? && 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)
|
|
InviteRedeemer.new(
|
|
invite: invite,
|
|
username: sso.username,
|
|
name: sso.name,
|
|
ip_address: request.remote_ip,
|
|
session: session,
|
|
email: sso.email
|
|
).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
|