2019-05-02 18:17:27 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-02-05 14:16:51 -05:00
|
|
|
class SessionController < ApplicationController
|
2023-01-09 07:20:10 -05:00
|
|
|
before_action :check_local_login_allowed, only: %i[create forgot_password]
|
|
|
|
before_action :rate_limit_login, only: %i[create email_login]
|
2017-08-31 00:06:56 -04:00
|
|
|
skip_before_action :redirect_to_login_if_required
|
2023-01-09 07:20:10 -05:00
|
|
|
skip_before_action :preload_json,
|
|
|
|
:check_xhr,
|
|
|
|
only: %i[sso sso_login sso_provider destroy one_time_password]
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
skip_before_action :check_xhr, only: %i[second_factor_auth_show]
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
requires_login only: %i[second_factor_auth_show second_factor_auth_perform]
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
|
2022-05-17 14:06:08 -04:00
|
|
|
allow_in_staff_writes_only_mode :create
|
2022-09-30 15:12:49 -04:00
|
|
|
allow_in_staff_writes_only_mode :email_login
|
2022-05-17 14:06:08 -04:00
|
|
|
|
2017-03-13 07:19:42 -04:00
|
|
|
ACTIVATE_USER_KEY = "activate_user"
|
|
|
|
|
2013-07-29 01:13:13 -04:00
|
|
|
def csrf
|
|
|
|
render json: { csrf: form_authenticity_token }
|
|
|
|
end
|
|
|
|
|
2014-02-24 22:30:49 -05:00
|
|
|
def sso
|
2022-05-13 15:33:28 -04:00
|
|
|
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect?
|
|
|
|
|
2016-09-15 23:48:50 -04:00
|
|
|
destination_url = cookies[:destination_url] || session[:destination_url]
|
2023-01-09 07:20:10 -05:00
|
|
|
return_path = params[:return_path] || path("/")
|
2016-09-15 23:48:50 -04:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
if destination_url && return_path == path("/")
|
|
|
|
uri = URI.parse(destination_url)
|
2019-05-13 04:45:23 -04:00
|
|
|
return_path = "#{uri.path}#{uri.query ? "?#{uri.query}" : ""}"
|
2015-06-02 07:29:27 -04:00
|
|
|
end
|
|
|
|
|
2016-09-15 23:48:50 -04:00
|
|
|
session.delete(:destination_url)
|
|
|
|
cookies.delete(:destination_url)
|
|
|
|
|
2022-05-13 15:33:28 -04:00
|
|
|
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
|
2014-02-24 22:30:49 -05:00
|
|
|
end
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
def sso_provider(payload = nil, confirmed_2fa_during_login = false)
|
2022-05-13 15:33:28 -04:00
|
|
|
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect_provider
|
2020-02-12 18:03:25 -05:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
result =
|
|
|
|
run_second_factor!(
|
|
|
|
SecondFactor::Actions::DiscourseConnectProvider,
|
|
|
|
payload: payload,
|
|
|
|
confirmed_2fa_during_login: confirmed_2fa_during_login,
|
|
|
|
)
|
2018-05-11 18:41:27 -04:00
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
if result.second_factor_auth_skipped?
|
|
|
|
data = result.data
|
|
|
|
if data[:logout]
|
|
|
|
params[:return_url] = data[:return_sso_url]
|
|
|
|
destroy
|
2018-05-11 18:41:27 -04:00
|
|
|
return
|
|
|
|
end
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
if data[:no_current_user]
|
|
|
|
cookies[:sso_payload] = payload || request.query_string
|
2023-01-09 07:20:10 -05:00
|
|
|
redirect_to path("/login")
|
2020-02-03 12:53:14 -05:00
|
|
|
return
|
|
|
|
end
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
if request.xhr?
|
|
|
|
# for the login modal
|
|
|
|
cookies[:sso_destination_url] = data[:sso_redirect_url]
|
2014-11-26 01:25:54 -05:00
|
|
|
else
|
2022-03-21 10:28:52 -04:00
|
|
|
redirect_to data[:sso_redirect_url], allow_other_host: true
|
2014-11-26 01:25:54 -05:00
|
|
|
end
|
2022-04-13 08:04:09 -04:00
|
|
|
elsif result.no_second_factors_enabled?
|
|
|
|
if request.xhr?
|
|
|
|
# for the login modal
|
|
|
|
cookies[:sso_destination_url] = result.data[:sso_redirect_url]
|
|
|
|
else
|
2022-03-21 10:28:52 -04:00
|
|
|
redirect_to result.data[:sso_redirect_url], allow_other_host: true
|
2022-04-13 08:04:09 -04:00
|
|
|
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
|
2022-05-31 01:24:04 -04:00
|
|
|
rescue DiscourseConnectProvider::ParseError
|
2022-04-13 08:04:09 -04:00
|
|
|
# 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
|
2014-11-26 01:25:54 -05:00
|
|
|
end
|
|
|
|
|
2014-10-07 12:25:25 -04:00
|
|
|
# For use in development mode only when login options could be limited or disabled.
|
|
|
|
# NEVER allow this to work in production.
|
2018-03-27 23:31:43 -04:00
|
|
|
if !Rails.env.production?
|
2018-03-27 23:22:43 -04:00
|
|
|
skip_before_action :check_xhr, only: [:become]
|
2014-10-07 12:25:25 -04:00
|
|
|
|
2018-03-27 23:22:43 -04:00
|
|
|
def become
|
|
|
|
raise Discourse::InvalidAccess if Rails.env.production?
|
2022-05-13 11:52:01 -04:00
|
|
|
raise Discourse::ReadOnly if @readonly_mode
|
2019-06-02 20:02:10 -04:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
if ENV["DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE"] != "1"
|
|
|
|
render(content_type: "text/plain", inline: <<~TEXT)
|
2019-06-02 20:02:10 -04:00
|
|
|
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
|
|
|
|
|
2018-03-27 23:22:43 -04:00
|
|
|
user = User.find_by_username(params[:session_id])
|
|
|
|
raise "User #{params[:session_id]} not found" if user.blank?
|
|
|
|
|
|
|
|
log_on_user(user)
|
2022-12-22 11:03:27 -05:00
|
|
|
|
|
|
|
if params[:redirect] == "false"
|
|
|
|
render plain: "Signed in to #{params[:session_id]} successfully"
|
|
|
|
else
|
|
|
|
redirect_to path("/")
|
|
|
|
end
|
2018-03-27 23:22:43 -04:00
|
|
|
end
|
2014-10-07 12:25:25 -04:00
|
|
|
end
|
|
|
|
|
2014-02-24 22:30:49 -05:00
|
|
|
def sso_login
|
2022-05-13 15:33:28 -04:00
|
|
|
raise Discourse::NotFound unless SiteSetting.enable_discourse_connect
|
2022-05-17 14:06:08 -04:00
|
|
|
raise Discourse::ReadOnly if @readonly_mode && !staff_writes_only_mode?
|
2014-02-24 22:30:49 -05:00
|
|
|
|
2018-10-11 19:33:30 -04:00
|
|
|
params.require(:sso)
|
|
|
|
params.require(:sig)
|
|
|
|
|
2018-12-07 10:01:44 -05:00
|
|
|
begin
|
2022-01-06 07:28:46 -05:00
|
|
|
sso = DiscourseConnect.parse(request.query_string, secure_session: secure_session)
|
|
|
|
rescue DiscourseConnect::ParseError => e
|
2023-01-09 07:20:10 -05:00
|
|
|
connect_verbose_warn do
|
|
|
|
"Verbose SSO log: Signature parse error\n\n#{e.message}\n\n#{sso&.diagnostics}"
|
|
|
|
end
|
2018-12-07 10:01:44 -05:00
|
|
|
|
|
|
|
# Do NOT pass the error text to the client, it would give them the correct signature
|
2021-02-08 05:04:33 -05:00
|
|
|
return render_sso_error(text: I18n.t("discourse_connect.login_error"), status: 422)
|
2018-12-07 10:01:44 -05:00
|
|
|
end
|
|
|
|
|
2014-02-24 22:30:49 -05:00
|
|
|
if !sso.nonce_valid?
|
2022-05-13 13:19:44 -04:00
|
|
|
connect_verbose_warn { "Verbose SSO log: #{sso.nonce_error}\n\n#{sso.diagnostics}" }
|
2021-02-08 05:04:33 -05:00
|
|
|
return render_sso_error(text: I18n.t("discourse_connect.timeout_expired"), status: 419)
|
2015-02-25 15:59:11 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
2023-01-09 07:20:10 -05:00
|
|
|
connect_verbose_warn do
|
|
|
|
"Verbose SSO log: IP address is blocked #{request.remote_ip}\n\n#{sso.diagnostics}"
|
|
|
|
end
|
2021-02-08 05:04:33 -05:00
|
|
|
return render_sso_error(text: I18n.t("discourse_connect.unknown_error"), status: 500)
|
2014-02-24 22:30:49 -05:00
|
|
|
end
|
|
|
|
|
2014-02-25 17:58:30 -05:00
|
|
|
return_path = sso.return_path
|
2014-02-24 22:30:49 -05:00
|
|
|
sso.expire_nonce!
|
|
|
|
|
2014-11-23 18:02:22 -05:00
|
|
|
begin
|
2021-03-18 20:20:10 -04:00
|
|
|
invite = validate_invitiation!(sso)
|
|
|
|
|
2015-02-23 15:58:45 -05:00
|
|
|
if user = sso.lookup_or_create_user(request.remote_ip)
|
2022-05-17 14:06:08 -04:00
|
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
2015-02-23 15:58:45 -05:00
|
|
|
|
2017-11-14 00:52:00 -05:00
|
|
|
if user.suspended?
|
2019-04-24 02:38:56 -04:00
|
|
|
render_sso_error(text: failed_to_login(user)[:error], status: 403)
|
2017-11-14 00:52:00 -05:00
|
|
|
return
|
|
|
|
end
|
|
|
|
|
2022-06-02 10:10:48 -04:00
|
|
|
if SiteSetting.must_approve_users? && !user.approved?
|
2023-01-09 07:20:10 -05:00
|
|
|
redeem_invitation(invite, sso, user) if invite.present? && user.invited_user.blank?
|
2022-06-02 10:10:48 -04:00
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
if SiteSetting.discourse_connect_not_approved_url.present?
|
2022-03-21 10:28:52 -04:00
|
|
|
redirect_to SiteSetting.discourse_connect_not_approved_url, allow_other_host: true
|
2015-05-27 00:06:45 -04:00
|
|
|
else
|
2021-02-08 05:04:33 -05:00
|
|
|
render_sso_error(text: I18n.t("discourse_connect.account_not_approved"), status: 403)
|
2015-05-27 00:06:45 -04:00
|
|
|
end
|
2015-05-11 08:17:32 -04:00
|
|
|
return
|
2021-03-18 20:20:10 -04:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
# 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)
|
2021-03-18 20:20:10 -04:00
|
|
|
elsif invite.present? && user.invited_user.blank?
|
2022-11-01 12:33:32 -04:00
|
|
|
redeem_invitation(invite, sso, user)
|
2021-03-18 20:20:10 -04:00
|
|
|
|
|
|
|
# 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("/")
|
2015-05-15 13:01:30 -04:00
|
|
|
elsif !user.active?
|
|
|
|
activation = UserActivator.new(user, request, session, cookies)
|
|
|
|
activation.finish
|
|
|
|
session["user_created_message"] = activation.message
|
2021-03-18 20:20:10 -04:00
|
|
|
return redirect_to(users_account_created_path)
|
2014-11-23 18:02:22 -05:00
|
|
|
else
|
2021-03-18 20:20:10 -04:00
|
|
|
login_sso_user(sso, user)
|
2014-11-23 18:02:22 -05:00
|
|
|
end
|
2015-01-22 12:20:17 -05:00
|
|
|
|
|
|
|
# If it's not a relative URL check the host
|
2023-01-09 07:20:10 -05:00
|
|
|
if return_path !~ %r{^/[^/]}
|
2015-01-22 12:20:17 -05:00
|
|
|
begin
|
|
|
|
uri = URI(return_path)
|
2018-11-09 01:03:42 -05:00
|
|
|
if (uri.hostname == Discourse.current_hostname)
|
2019-03-24 18:02:42 -04:00
|
|
|
return_path = uri.to_s
|
2021-02-08 05:04:33 -05:00
|
|
|
elsif !SiteSetting.discourse_connect_allows_all_return_paths
|
2018-11-09 01:03:42 -05:00
|
|
|
return_path = path("/")
|
|
|
|
end
|
2023-01-09 07:20:10 -05:00
|
|
|
rescue StandardError
|
2015-03-08 20:45:36 -04:00
|
|
|
return_path = path("/")
|
2015-01-22 12:20:17 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-03-24 18:02:42 -04:00
|
|
|
# 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
|
2023-01-09 07:20:10 -05:00
|
|
|
return_path = path("/") if return_path.include?(path("/session/sso"))
|
2018-11-08 22:27:36 -05:00
|
|
|
|
2022-03-21 10:28:52 -04:00
|
|
|
redirect_to return_path, allow_other_host: true
|
2014-02-25 18:27:39 -05:00
|
|
|
else
|
2021-02-08 05:04:33 -05:00
|
|
|
render_sso_error(text: I18n.t("discourse_connect.not_found"), status: 500)
|
2014-02-25 18:27:39 -05:00
|
|
|
end
|
2016-03-23 13:30:38 -04:00
|
|
|
rescue ActiveRecord::RecordInvalid => e
|
2022-05-13 13:19:44 -04:00
|
|
|
connect_verbose_warn { <<~TEXT }
|
2017-04-12 23:39:26 -04:00
|
|
|
Verbose SSO log: Record was invalid: #{e.record.class.name} #{e.record.id}
|
|
|
|
#{e.record.errors.to_h}
|
|
|
|
|
|
|
|
Attributes:
|
2022-01-06 07:28:46 -05:00
|
|
|
#{e.record.attributes.slice(*DiscourseConnectBase::ACCESSORS.map(&:to_s))}
|
2017-04-12 23:39:26 -04:00
|
|
|
|
|
|
|
SSO Diagnostics:
|
|
|
|
#{sso.diagnostics}
|
2022-05-13 13:19:44 -04:00
|
|
|
TEXT
|
2017-03-21 14:37:21 -04:00
|
|
|
|
|
|
|
text = nil
|
|
|
|
|
|
|
|
# If there's a problem with the email we can explain that
|
2020-11-23 06:06:08 -05:00
|
|
|
if (e.record.is_a?(User) && e.record.errors[:primary_email].present?)
|
2017-03-21 15:37:46 -04:00
|
|
|
if e.record.email.blank?
|
2021-02-08 05:04:33 -05:00
|
|
|
text = I18n.t("discourse_connect.no_email")
|
2017-03-21 15:37:46 -04:00
|
|
|
else
|
2023-01-09 07:20:10 -05:00
|
|
|
text =
|
|
|
|
I18n.t("discourse_connect.email_error", email: ERB::Util.html_escape(e.record.email))
|
2017-03-21 15:37:46 -04:00
|
|
|
end
|
2017-03-21 14:37:21 -04:00
|
|
|
end
|
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
render_sso_error(text: text || I18n.t("discourse_connect.unknown_error"), status: 500)
|
2022-01-06 07:28:46 -05:00
|
|
|
rescue DiscourseConnect::BlankExternalId
|
2021-02-08 05:04:33 -05:00
|
|
|
render_sso_error(text: I18n.t("discourse_connect.blank_id_error"), status: 500)
|
2021-03-18 20:20:10 -04:00
|
|
|
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)
|
2014-11-23 18:02:22 -05:00
|
|
|
rescue => e
|
2019-05-13 04:45:23 -04:00
|
|
|
message = +"Failed to create or lookup user: #{e}."
|
2017-11-30 02:08:53 -05:00
|
|
|
message << " "
|
|
|
|
message << " #{sso.diagnostics}"
|
|
|
|
message << " "
|
|
|
|
message << " #{e.backtrace.join("\n")}"
|
2016-02-24 15:57:01 -05:00
|
|
|
|
|
|
|
Rails.logger.error(message)
|
2014-11-23 18:02:22 -05:00
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
render_sso_error(text: I18n.t("discourse_connect.unknown_error"), status: 500)
|
2014-02-24 22:30:49 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-03-18 20:20:10 -04:00
|
|
|
def login_sso_user(sso, user)
|
2023-01-09 07:20:10 -05:00
|
|
|
connect_verbose_warn do
|
|
|
|
"Verbose SSO log: User was logged on #{user.username}\n\n#{sso.diagnostics}"
|
|
|
|
end
|
2021-03-18 20:20:10 -04:00
|
|
|
log_on_user(user) if user.id != current_user&.id
|
|
|
|
end
|
|
|
|
|
2013-02-05 14:16:51 -05:00
|
|
|
def create
|
2013-06-06 03:14:32 -04:00
|
|
|
params.require(:login)
|
|
|
|
params.require(:password)
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2014-09-11 15:22:11 -04:00
|
|
|
return invalid_credentials if params[:password].length > User.max_password_length
|
|
|
|
|
2021-02-03 18:03:30 -05:00
|
|
|
user = User.find_by_username_or_email(normalized_login_param)
|
2022-05-17 14:06:08 -04:00
|
|
|
|
|
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
|
|
|
|
2021-02-03 18:03:30 -05:00
|
|
|
rate_limit_second_factor!(user)
|
|
|
|
|
|
|
|
if user.present?
|
2013-11-15 10:27:43 -05:00
|
|
|
# If their password is correct
|
|
|
|
unless user.confirm_password?(params[:password])
|
|
|
|
invalid_credentials
|
|
|
|
return
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
|
|
|
# If the site requires user approval and the user is not approved yet
|
2013-11-15 10:27:43 -05:00
|
|
|
if login_not_approved_for?(user)
|
2017-04-20 11:17:24 -04:00
|
|
|
render json: login_not_approved
|
2013-02-05 14:16:51 -05:00
|
|
|
return
|
|
|
|
end
|
2013-11-27 20:39:59 -05:00
|
|
|
|
2014-01-21 16:53:46 -05:00
|
|
|
# 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)
|
2013-11-27 20:39:59 -05:00
|
|
|
else
|
|
|
|
invalid_credentials
|
|
|
|
return
|
2013-11-15 10:27:43 -05:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
if payload = login_error_check(user)
|
2020-01-15 05:27:12 -05:00
|
|
|
return render json: payload
|
|
|
|
end
|
2018-02-20 01:44:51 -05:00
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
second_factor_auth_result = authenticate_second_factor(user)
|
2023-01-09 07:20:10 -05:00
|
|
|
return render(json: @second_factor_failure_payload) if !second_factor_auth_result.ok
|
2019-10-01 22:08:41 -04:00
|
|
|
|
2022-05-17 14:06:08 -04:00
|
|
|
if user.active && user.email_confirmed?
|
|
|
|
login(user, second_factor_auth_result)
|
|
|
|
else
|
|
|
|
not_activated(user)
|
|
|
|
end
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
def email_login_info
|
|
|
|
token = params[:token]
|
2021-11-25 02:34:39 -05:00
|
|
|
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
2020-01-16 20:25:31 -05:00
|
|
|
user = matched_token&.user
|
2019-06-12 10:37:26 -04:00
|
|
|
|
2020-01-16 20:25:31 -05:00
|
|
|
check_local_login_allowed(user: user, check_login_via_email: true)
|
2020-01-12 21:10:07 -05:00
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
if matched_token
|
2023-01-09 07:20:10 -05:00
|
|
|
response = { can_login: true, token: token, token_email: matched_token.email }
|
2019-06-12 10:37:26 -04:00
|
|
|
|
2019-10-01 22:08:41 -04:00
|
|
|
matched_user = matched_token.user
|
|
|
|
if matched_user&.totp_enabled?
|
2019-06-12 10:37:26 -04:00
|
|
|
response.merge!(
|
|
|
|
second_factor_required: true,
|
2023-01-09 07:20:10 -05:00
|
|
|
backup_codes_enabled: matched_user&.backup_codes_enabled?,
|
2019-10-01 22:08:41 -04:00
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
if matched_user&.security_keys_enabled?
|
2020-01-09 19:45:56 -05:00
|
|
|
Webauthn.stage_challenge(matched_user, secure_session)
|
2019-10-01 22:08:41 -04:00
|
|
|
response.merge!(
|
2023-01-09 07:20:10 -05:00
|
|
|
Webauthn.allowed_credentials(matched_user, secure_session).merge(
|
|
|
|
security_key_required: true,
|
|
|
|
),
|
2019-06-12 10:37:26 -04:00
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
render json: response
|
|
|
|
else
|
|
|
|
render json: {
|
2023-01-09 07:20:10 -05:00
|
|
|
can_login: false,
|
|
|
|
error: I18n.t("email_login.invalid_token", base_url: Discourse.base_url),
|
|
|
|
}
|
2019-06-12 10:37:26 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
def email_login
|
2018-02-20 01:44:51 -05:00
|
|
|
token = params[:token]
|
2021-11-25 02:34:39 -05:00
|
|
|
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
2020-01-16 20:25:31 -05:00
|
|
|
user = matched_token&.user
|
2018-02-20 01:44:51 -05:00
|
|
|
|
2020-01-16 20:25:31 -05:00
|
|
|
check_local_login_allowed(user: user, check_login_via_email: true)
|
2020-01-12 21:10:07 -05:00
|
|
|
|
2021-02-03 18:03:30 -05:00
|
|
|
rate_limit_second_factor!(user)
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
if user.present? && !authenticate_second_factor(user).ok
|
2020-01-15 05:27:12 -05:00
|
|
|
return render(json: @second_factor_failure_payload)
|
2018-02-21 02:46:53 -05:00
|
|
|
end
|
|
|
|
|
2021-11-25 02:34:39 -05:00
|
|
|
if user = EmailToken.confirm(token, scope: EmailToken.scopes[:email_login])
|
2017-04-20 11:17:24 -04:00
|
|
|
if login_not_approved_for?(user)
|
2019-06-12 10:37:26 -04:00
|
|
|
return render json: login_not_approved
|
2017-04-20 11:17:24 -04:00
|
|
|
elsif payload = login_error_check(user)
|
2019-06-12 10:37:26 -04:00
|
|
|
return render json: payload
|
2017-04-20 11:17:24 -04:00
|
|
|
else
|
2022-09-30 15:12:49 -04:00
|
|
|
raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff?
|
2020-04-10 14:19:39 -04:00
|
|
|
user.update_timezone_if_missing(params[:timezone])
|
2017-04-20 11:17:24 -04:00
|
|
|
log_on_user(user)
|
2019-06-12 10:37:26 -04:00
|
|
|
return render json: success_json
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
2015-03-02 12:13:10 -05:00
|
|
|
end
|
2018-02-20 01:44:51 -05:00
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
render json: { error: I18n.t("email_login.invalid_token", base_url: Discourse.base_url) }
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
2019-04-01 13:18:53 -04:00
|
|
|
def one_time_password
|
2019-12-03 04:05:53 -05:00
|
|
|
@otp_username = otp_username = Discourse.redis.get "otp_#{params[:token]}"
|
2019-04-01 13:18:53 -04:00
|
|
|
|
|
|
|
if otp_username && user = User.find_by_username(otp_username)
|
2019-06-12 13:32:13 -04:00
|
|
|
if current_user&.username == otp_username
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.del "otp_#{params[:token]}"
|
2019-06-12 13:32:13 -04:00
|
|
|
return redirect_to path("/")
|
|
|
|
elsif request.post?
|
|
|
|
log_on_user(user)
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.del "otp_#{params[:token]}"
|
2019-06-12 13:32:13 -04:00
|
|
|
return redirect_to path("/")
|
|
|
|
else
|
|
|
|
# Display the form
|
|
|
|
end
|
2019-04-01 13:18:53 -04:00
|
|
|
else
|
2023-01-09 07:20:10 -05:00
|
|
|
@error = I18n.t("user_api_key.invalid_token")
|
2019-04-01 13:18:53 -04:00
|
|
|
end
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
render layout: "no_ember", locals: { hide_auth_buttons: true }
|
2019-04-01 13:18:53 -04:00
|
|
|
end
|
|
|
|
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
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?,
|
2023-01-09 07:20:10 -05:00
|
|
|
allowed_methods: challenge[:allowed_methods],
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
)
|
|
|
|
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
|
2023-01-09 07:20:10 -05:00
|
|
|
json[:description] = challenge[:description] if challenge[:description]
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
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
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
format.json { render json: json, status: status_code }
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
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
|
2023-01-09 07:20:10 -05:00
|
|
|
json =
|
|
|
|
failed_json.merge(
|
|
|
|
ok: false,
|
|
|
|
error: I18n.t(error_key),
|
|
|
|
reason: "challenge_not_found_or_expired",
|
|
|
|
)
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
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
|
2023-01-09 07:20:10 -05:00
|
|
|
error_json =
|
|
|
|
second_factor_auth_result
|
|
|
|
.to_h
|
|
|
|
.deep_symbolize_keys
|
|
|
|
.slice(:ok, :error, :reason)
|
|
|
|
.merge(failed_json)
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
render json: error_json, status: 400
|
|
|
|
return
|
|
|
|
end
|
|
|
|
end
|
|
|
|
render json: {
|
2023-01-09 07:20:10 -05:00
|
|
|
ok: true,
|
|
|
|
callback_method: challenge[:callback_method],
|
|
|
|
callback_path: challenge[:callback_path],
|
|
|
|
redirect_url: challenge[:redirect_url],
|
|
|
|
},
|
|
|
|
status: 200
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
end
|
|
|
|
|
2013-02-05 14:16:51 -05:00
|
|
|
def forgot_password
|
2013-06-06 03:14:32 -04:00
|
|
|
params.require(:login)
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2020-05-07 23:30:16 -04:00
|
|
|
if ScreenedIpAddress.should_block?(request.remote_ip)
|
|
|
|
return render_json_error(I18n.t("login.reset_not_allowed_from_ip_address"))
|
|
|
|
end
|
|
|
|
|
2014-08-17 20:55:30 -04:00
|
|
|
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!
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
user =
|
|
|
|
if SiteSetting.hide_email_address_taken && !current_user&.staff?
|
|
|
|
if !EmailAddressValidator.valid_value?(normalized_login_param)
|
|
|
|
raise Discourse::InvalidParameters.new(:login)
|
|
|
|
end
|
|
|
|
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
|
2020-05-07 23:30:16 -04:00
|
|
|
|
|
|
|
if user
|
|
|
|
RateLimiter.new(nil, "forgot-password-login-day-#{user.username}", 6, 1.day).performed!
|
2023-01-09 07:20:10 -05:00
|
|
|
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,
|
|
|
|
)
|
2022-01-20 03:04:45 -05:00
|
|
|
else
|
2023-01-09 07:20:10 -05:00
|
|
|
RateLimiter.new(
|
|
|
|
nil,
|
|
|
|
"forgot-password-login-hour-#{normalized_login_param}",
|
|
|
|
5,
|
|
|
|
1.hour,
|
|
|
|
).performed!
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
2014-09-10 22:04:44 -04:00
|
|
|
|
2018-02-14 18:46:33 -05:00
|
|
|
json = success_json
|
2022-01-20 03:04:45 -05:00
|
|
|
json[:user_found] = user.present? if !SiteSetting.hide_email_address_taken
|
2014-09-10 22:04:44 -04:00
|
|
|
render json: json
|
2014-08-17 20:55:30 -04:00
|
|
|
rescue RateLimiter::LimitExceeded
|
|
|
|
render_json_error(I18n.t("rate_limiter.slow_down"))
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
2014-02-05 13:46:24 -05:00
|
|
|
def current
|
|
|
|
if current_user.present?
|
|
|
|
render_serialized(current_user, CurrentUserSerializer)
|
|
|
|
else
|
2017-08-31 00:06:56 -04:00
|
|
|
render body: nil, status: 404
|
2014-02-05 13:46:24 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-02-05 14:16:51 -05:00
|
|
|
def destroy
|
2020-11-11 10:47:42 -05:00
|
|
|
redirect_url = params[:return_url].presence || SiteSetting.logout_redirect.presence
|
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
sso = SiteSetting.enable_discourse_connect
|
2023-01-09 07:20:10 -05:00
|
|
|
only_one_authenticator =
|
|
|
|
!SiteSetting.enable_local_logins && Discourse.enabled_authenticators.length == 1
|
2020-12-11 04:44:16 -05:00
|
|
|
if SiteSetting.login_required && (sso || only_one_authenticator)
|
2020-11-11 10:47:42 -05:00
|
|
|
# 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("/")
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
event_data = {
|
|
|
|
redirect_url: redirect_url,
|
|
|
|
user: current_user,
|
|
|
|
client_ip: request&.ip,
|
|
|
|
user_agent: request&.user_agent,
|
|
|
|
}
|
2020-11-11 10:47:42 -05:00
|
|
|
DiscourseEvent.trigger(:before_session_destroy, event_data)
|
|
|
|
redirect_url = event_data[:redirect_url]
|
|
|
|
|
2013-08-27 01:56:12 -04:00
|
|
|
reset_session
|
2013-10-09 00:10:37 -04:00
|
|
|
log_off_user
|
2016-06-16 21:27:52 -04:00
|
|
|
if request.xhr?
|
2023-01-09 07:20:10 -05:00
|
|
|
render json: { redirect_url: redirect_url }
|
2016-06-16 21:27:52 -04:00
|
|
|
else
|
2022-03-21 10:28:52 -04:00
|
|
|
redirect_to redirect_url, allow_other_host: true
|
2016-06-16 21:27:52 -04:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
2020-10-01 19:01:40 -04:00
|
|
|
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: {
|
2023-01-09 07:20:10 -05:00
|
|
|
value: honeypot_value,
|
|
|
|
challenge: challenge_value,
|
|
|
|
expires_in: SecureSession.expiry,
|
|
|
|
}
|
2020-10-01 19:01:40 -04:00
|
|
|
end
|
|
|
|
|
2022-05-02 11:15:32 -04:00
|
|
|
def scopes
|
|
|
|
if is_api?
|
|
|
|
key = request.env[Auth::DefaultCurrentUserProvider::HEADER_API_KEY]
|
|
|
|
api_key = ApiKey.active.with_key(key).first
|
2023-01-09 07:20:10 -05:00
|
|
|
render_serialized(api_key.api_key_scopes, ApiKeyScopeSerializer, root: "scopes")
|
2022-05-02 11:15:32 -04:00
|
|
|
else
|
|
|
|
render body: nil, status: 404
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2017-08-16 01:43:05 -04:00
|
|
|
protected
|
2013-11-15 10:27:43 -05:00
|
|
|
|
2021-02-02 18:26:28 -05:00
|
|
|
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
|
|
|
|
|
2020-01-16 20:25:31 -05:00
|
|
|
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) ||
|
2023-01-09 07:20:10 -05:00
|
|
|
SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
2020-01-20 01:11:58 -05:00
|
|
|
raise Discourse::InvalidAccess, "SSO takes over local login or the local login is disallowed."
|
2017-08-16 01:43:05 -04:00
|
|
|
end
|
2014-03-26 00:39:44 -04:00
|
|
|
end
|
|
|
|
|
2017-08-16 01:43:05 -04:00
|
|
|
private
|
|
|
|
|
2022-05-13 13:19:44 -04:00
|
|
|
def connect_verbose_warn(&blk)
|
2023-01-09 07:20:10 -05:00
|
|
|
Rails.logger.warn(blk.call) if SiteSetting.verbose_discourse_connect_logging
|
2022-05-13 13:19:44 -04:00
|
|
|
end
|
|
|
|
|
2020-01-15 05:27:12 -05:00
|
|
|
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)
|
2022-04-13 08:04:09 -04:00
|
|
|
return second_factor_authentication_result
|
2020-01-15 05:27:12 -05:00
|
|
|
end
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
second_factor_authentication_result
|
2020-01-15 05:27:12 -05:00
|
|
|
end
|
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
def login_error_check(user)
|
|
|
|
return failed_to_login(user) if user.suspended?
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
return not_allowed_from_ip_address(user) if ScreenedIpAddress.should_block?(request.remote_ip)
|
2017-04-20 11:17:24 -04:00
|
|
|
|
|
|
|
if ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
|
2019-12-09 19:48:27 -05:00
|
|
|
admin_not_allowed_from_ip_address(user)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-11-15 10:27:43 -05:00
|
|
|
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
|
2017-04-20 11:17:24 -04:00
|
|
|
{ error: I18n.t("login.not_approved") }
|
2013-11-15 10:27:43 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def not_activated(user)
|
2017-03-13 08:20:25 -04:00
|
|
|
session[ACTIVATE_USER_KEY] = user.id
|
2013-11-15 10:27:43 -05:00
|
|
|
render json: {
|
2023-01-09 07:20:10 -05:00
|
|
|
error: I18n.t("login.not_activated"),
|
|
|
|
reason: "not_activated",
|
|
|
|
sent_to_email: user.find_email || user.email,
|
|
|
|
current_email: user.email,
|
|
|
|
}
|
2013-11-15 10:27:43 -05:00
|
|
|
end
|
|
|
|
|
2014-09-04 18:50:27 -04:00
|
|
|
def not_allowed_from_ip_address(user)
|
2017-04-20 11:17:24 -04:00
|
|
|
{ error: I18n.t("login.not_allowed_from_ip_address", username: user.username) }
|
2014-09-04 18:50:27 -04:00
|
|
|
end
|
|
|
|
|
2015-03-02 12:13:10 -05:00
|
|
|
def admin_not_allowed_from_ip_address(user)
|
2017-04-20 11:17:24 -04:00
|
|
|
{ error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username) }
|
2015-03-02 12:13:10 -05:00
|
|
|
end
|
|
|
|
|
2013-11-15 10:27:43 -05:00
|
|
|
def failed_to_login(user)
|
2023-01-09 07:20:10 -05:00
|
|
|
{ error: user.suspended_message, reason: "suspended" }
|
2013-11-15 10:27:43 -05:00
|
|
|
end
|
|
|
|
|
2022-04-13 08:04:09 -04:00
|
|
|
def login(user, second_factor_auth_result)
|
2017-03-13 07:19:42 -04:00
|
|
|
session.delete(ACTIVATE_USER_KEY)
|
2019-11-24 19:49:27 -05:00
|
|
|
user.update_timezone_if_missing(params[:timezone])
|
2013-11-15 10:27:43 -05:00
|
|
|
log_on_user(user)
|
2014-11-26 01:25:54 -05:00
|
|
|
|
2018-05-11 18:41:27 -04:00
|
|
|
if payload = cookies.delete(:sso_payload)
|
2023-01-09 07:20:10 -05:00
|
|
|
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]
|
|
|
|
)
|
2022-04-13 08:04:09 -04:00
|
|
|
sso_provider(payload, confirmed_2fa_during_login)
|
2017-08-31 00:06:56 -04:00
|
|
|
else
|
|
|
|
render_serialized(user, UserSerializer)
|
2014-11-26 01:25:54 -05:00
|
|
|
end
|
2013-11-15 10:27:43 -05:00
|
|
|
end
|
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
def rate_limit_login
|
|
|
|
RateLimiter.new(
|
|
|
|
nil,
|
|
|
|
"login-hr-#{request.remote_ip}",
|
|
|
|
SiteSetting.max_logins_per_ip_per_hour,
|
2023-01-09 07:20:10 -05:00
|
|
|
1.hour,
|
2017-04-20 11:17:24 -04:00
|
|
|
).performed!
|
|
|
|
|
|
|
|
RateLimiter.new(
|
|
|
|
nil,
|
|
|
|
"login-min-#{request.remote_ip}",
|
|
|
|
SiteSetting.max_logins_per_ip_per_minute,
|
2023-01-09 07:20:10 -05:00
|
|
|
1.minute,
|
2017-04-20 11:17:24 -04:00
|
|
|
).performed!
|
|
|
|
end
|
|
|
|
|
2017-03-21 14:04:25 -04:00
|
|
|
def render_sso_error(status:, text:)
|
|
|
|
@sso_error = text
|
2023-01-09 07:20:10 -05:00
|
|
|
render status: status, layout: "no_ember"
|
2017-03-21 14:04:25 -04:00
|
|
|
end
|
2019-07-24 17:18:27 -04:00
|
|
|
|
|
|
|
# extension to allow plugins to customize the SSO URL
|
|
|
|
def sso_url(sso)
|
|
|
|
sso.to_url
|
|
|
|
end
|
2021-03-18 20:20:10 -04:00
|
|
|
|
|
|
|
# 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?
|
2022-11-13 21:02:06 -05:00
|
|
|
if invite.is_email_invite? && sso.email != invite.email
|
2021-03-18 20:20:10 -04:00
|
|
|
raise Invite::ValidationFailed.new(I18n.t("invite.not_matching_email"))
|
|
|
|
end
|
|
|
|
elsif invite.expired?
|
2023-01-09 07:20:10 -05:00
|
|
|
raise Invite::ValidationFailed.new(I18n.t("invite.expired", base_url: Discourse.base_url))
|
2021-03-18 20:20:10 -04:00
|
|
|
elsif invite.redeemed?
|
2023-01-09 07:20:10 -05:00
|
|
|
raise Invite::ValidationFailed.new(
|
|
|
|
I18n.t(
|
|
|
|
"invite.not_found_template",
|
|
|
|
site_name: SiteSetting.title,
|
|
|
|
base_url: Discourse.base_url,
|
|
|
|
),
|
|
|
|
)
|
2021-03-18 20:20:10 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
invite
|
|
|
|
end
|
|
|
|
|
2022-11-01 12:33:32 -04:00
|
|
|
def redeem_invitation(invite, sso, redeeming_user)
|
2021-03-18 20:20:10 -04:00
|
|
|
InviteRedeemer.new(
|
|
|
|
invite: invite,
|
|
|
|
username: sso.username,
|
|
|
|
name: sso.name,
|
|
|
|
ip_address: request.remote_ip,
|
|
|
|
session: session,
|
2022-11-01 12:33:32 -04:00
|
|
|
email: sso.email,
|
2023-01-09 07:20:10 -05:00
|
|
|
redeeming_user: redeeming_user,
|
2021-03-18 20:20:10 -04:00
|
|
|
).redeem
|
|
|
|
secure_session["invite-key"] = nil
|
|
|
|
|
2023-01-09 07:20:10 -05:00
|
|
|
# note - more specific errors are handled in the sso_login method
|
2021-03-18 20:20:10 -04:00
|
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
|
|
|
Rails.logger.warn("SSO invite redemption failed: #{e}")
|
|
|
|
raise Invite::RedemptionFailed
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|