FEATURE: Add 2FA support to the Discourse Connect Provider protocol (#16386)

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.
This commit is contained in:
Osama Sayegh
2022-04-13 15:04:09 +03:00
committed by GitHub
parent 78f7e8fe2f
commit eb5a3cfded
20 changed files with 899 additions and 243 deletions

View File

@ -3,11 +3,21 @@
module SecondFactor::Actions
class Base
include Rails.application.routes.url_helpers
attr_reader :current_user, :guardian
attr_reader :current_user, :guardian, :request
def initialize(guardian)
def initialize(guardian, request, opts = nil)
@guardian = guardian
@current_user = guardian.user
@request = request
@opts = HashWithIndifferentAccess.new(opts)
end
def skip_second_factor_auth?(params)
false
end
def second_factor_auth_skipped!(params)
raise NotImplementedError.new
end
def no_second_factors_enabled!(params)

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
module SecondFactor::Actions
class DiscourseConnectProvider < Base
def skip_second_factor_auth?(params)
sso = get_sso(payload(params))
!current_user || sso.logout || !sso.require_2fa || @opts[:confirmed_2fa_during_login]
end
def second_factor_auth_skipped!(params)
sso = get_sso(payload(params))
return { logout: true, return_sso_url: sso.return_sso_url } if sso.logout
return { no_current_user: true } if !current_user
populate_user_data(sso)
sso.confirmed_2fa = true if @opts[:confirmed_2fa_during_login]
{ sso_redirect_url: sso.to_url(sso.return_sso_url) }
end
def no_second_factors_enabled!(params)
sso = get_sso(payload(params))
populate_user_data(sso)
sso.no_2fa_methods = true
{ sso_redirect_url: sso.to_url(sso.return_sso_url) }
end
def second_factor_auth_required!(params)
pl = payload(params)
sso = get_sso(pl)
hostname = URI(sso.return_sso_url).hostname
{
callback_params: { payload: pl },
callback_path: session_sso_provider_path,
callback_method: "GET",
description: I18n.t(
"second_factor_auth.actions.discourse_connect_provider.description",
hostname: hostname,
)
}
end
def second_factor_auth_completed!(callback_params)
sso = get_sso(callback_params[:payload])
populate_user_data(sso)
sso.confirmed_2fa = true
{ sso_redirect_url: sso.to_url(sso.return_sso_url) }
end
private
def payload(params)
return @opts[:payload] if @opts[:payload]
params.require(:sso)
request.query_string
end
def populate_user_data(sso)
sso.name = current_user.name
sso.username = current_user.username
sso.email = current_user.email
sso.external_id = current_user.id.to_s
sso.admin = current_user.admin?
sso.moderator = current_user.moderator?
sso.groups = current_user.groups.pluck(:name).join(",")
if current_user.uploaded_avatar.present?
base_url = Discourse.store.external? ? "#{Discourse.store.absolute_base_url}/" : Discourse.base_url
avatar_url = "#{base_url}#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}"
sso.avatar_url = UrlHelper.absolute Discourse.store.cdn_url(avatar_url)
end
if current_user.user_profile.profile_background_upload.present?
sso.profile_background_url = UrlHelper.absolute(GlobalPath.upload_cdn_path(
current_user.user_profile.profile_background_upload.url
))
end
if current_user.user_profile.card_background_upload.present?
sso.card_background_url = UrlHelper.absolute(GlobalPath.upload_cdn_path(
current_user.user_profile.card_background_upload.url
))
end
end
def get_sso(payload)
sso = ::DiscourseConnectProvider.parse(payload)
raise ::DiscourseConnectProvider::BlankReturnUrl.new if sso.return_sso_url.blank?
sso
rescue ::DiscourseConnectProvider::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
raise
end
end
end

View File

@ -5,6 +5,7 @@ module SecondFactor::Actions
def no_second_factors_enabled!(params)
user = find_user(params[:user_id])
AdminConfirmation.new(user, current_user).create_confirmation
nil
end
def second_factor_auth_required!(params)
@ -15,7 +16,7 @@ module SecondFactor::Actions
)
{
callback_params: { user_id: user.id },
redirect_path: admin_user_show_path(id: user.id, username: user.username),
redirect_url: admin_user_show_path(id: user.id, username: user.username),
description: description
}
end
@ -24,6 +25,7 @@ module SecondFactor::Actions
user = find_user(callback_params[:user_id])
user.grant_admin!
StaffActionLogger.new(current_user).log_grant_admin(user)
nil
end
private

View File

@ -27,7 +27,7 @@ To use the auth manager for requiring 2fa for an action, it needs to be invoked
from the controller action using the `run_second_factor!` method which is
available in all controllers. This method takes a single argument which is a
class that inherits from the `SecondFactor::Actions::Base` class and implements
the following methods:
at least the following methods:
1. no_second_factors_enabled!(params):
This method corresponds to outcome (1) above, i.e. it's called when the user
@ -48,9 +48,8 @@ the following methods:
finish the action once 2fa is completed. Everything in this Hash must be
serializable to JSON.
:redirect_path => relative subfolder-aware path that the user should be
redirected to after the action is finished. When this key is omitted, the
redirect path is set to the homepage (/).
:redirect_url => where the user should be redirected after they confirm 2fa.
A relative path (must be subfolder-aware) is a valid value for this key.
:description => optional action-specific description message that's shown on
the 2FA page.
@ -68,6 +67,20 @@ the following methods:
The `callback_params` param of this method is the `callback_params` Hash from
the return value of the previous method.
There are 2 additionals methods in the base class that can be overridden, but
they're optional:
4. skip_second_factor_auth?(params):
This method returns false by default. As the name implies, this method can be
used to skip the 2FA for the action entirely. For example, if your action
deletes a user, then you may want to require 2FA only if the deleted user has
more than a specific number of posts. If you override this method in your
action, you must implement the following method as well.
5. second_factor_auth_skipped!(params):
This method is called when the `skip_second_factor_auth?` method above
returns true.
If there are permission/security checks that the current user must pass in
order to perform the 2fa-protected action, it's important to run the checks in
all of the 3 methods of the action class and raise errors if the user doesn't
@ -79,14 +92,19 @@ which is an instance of `SecondFactor::AuthManagerResult`, can be used to know
which outcome the auth manager has picked and render a different response based
on the outcome.
The results object also has a `data` method that returns the return value of
the hook/method of your action class. For example, if
`second_factor_auth_required!` is called and it returns a hash object, you can
get that hash object by calling the `data` method of the results object.
For a real example where the auth manager is used, please refer to:
* `SecondFactor::Actions::GrantAdmin` action class. This is a class that
inherits `SecondFactor::Actions::Base` and implements the 3 methods mentioned
above.
* The `lib/second_factor/actions` directory where all existing actions live.
* `Admin::UsersController#grant_admin` controller action.
* `SessionController#sso_provider` controller action.
=end
class SecondFactor::AuthManager
@ -144,12 +162,15 @@ class SecondFactor::AuthManager
end
def run!(request, params, secure_session)
if !allowed_methods.any? { |m| @current_user.valid_second_factor_method_for_user?(m) }
@action.no_second_factors_enabled!(params)
create_result(:no_second_factor)
elsif nonce = params[:second_factor_nonce].presence
verify_second_factor_auth_completed(nonce, secure_session)
create_result(:second_factor_auth_completed)
if nonce = params[:second_factor_nonce].presence
data = verify_second_factor_auth_completed(nonce, secure_session)
create_result(:second_factor_auth_completed, data)
elsif @action.skip_second_factor_auth?(params)
data = @action.second_factor_auth_skipped!(params)
create_result(:second_factor_auth_skipped, data)
elsif !allowed_methods.any? { |m| @current_user.valid_second_factor_method_for_user?(m) }
data = @action.no_second_factors_enabled!(params)
create_result(:no_second_factor, data)
else
nonce = initiate_second_factor_auth(params, secure_session, request)
raise SecondFactorRequired.new(nonce: nonce)
@ -162,19 +183,20 @@ class SecondFactor::AuthManager
config = @action.second_factor_auth_required!(params)
nonce = SecureRandom.alphanumeric(32)
callback_params = config[:callback_params] || {}
redirect_path = config[:redirect_path] || GlobalPath.path("").presence || "/"
challenge = {
nonce: nonce,
callback_method: request.request_method,
callback_path: request.path,
callback_method: config[:callback_method] || request.request_method,
callback_path: config[:callback_path] || request.path,
callback_params: callback_params,
redirect_path: redirect_path,
allowed_methods: allowed_methods.to_a,
generated_at: Time.zone.now.to_i
}
if config[:description]
challenge[:description] = config[:description]
end
if config[:redirect_url].present?
challenge[:redirect_url] = config[:redirect_url]
end
secure_session["current_second_factor_auth_challenge"] = challenge.to_json
nonce
end
@ -190,7 +212,8 @@ class SecondFactor::AuthManager
secure_session["current_second_factor_auth_challenge"] = nil
callback_params = challenge[:callback_params]
@action.second_factor_auth_completed!(callback_params)
data = @action.second_factor_auth_completed!(callback_params)
data
end
def add_method(id)
@ -201,7 +224,7 @@ class SecondFactor::AuthManager
end
end
def create_result(status)
SecondFactor::AuthManagerResult.new(status)
def create_result(status, data = nil)
SecondFactor::AuthManagerResult.new(status, data)
end
end

View File

@ -4,15 +4,18 @@ class SecondFactor::AuthManagerResult
STATUSES = {
no_second_factor: 1,
second_factor_auth_completed: 2,
second_factor_auth_skipped: 3,
}.freeze
private_constant :STATUSES
attr_reader :data
def initialize(status)
def initialize(status, data)
if !STATUSES.key?(status)
raise ArgumentError.new("#{status.inspect} is not a valid status. Allowed statuses: #{STATUSES.inspect}")
end
@status_id = STATUSES[status]
@data = data
end
def no_second_factors_enabled?
@ -22,4 +25,8 @@ class SecondFactor::AuthManagerResult
def second_factor_auth_completed?
@status_id == STATUSES[:second_factor_auth_completed]
end
def second_factor_auth_skipped?
@status_id == STATUSES[:second_factor_auth_skipped]
end
end