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

@ -194,7 +194,11 @@ export default Controller.extend({
type: response.callback_method, type: response.callback_method,
data: { second_factor_nonce: this.nonce }, data: { second_factor_nonce: this.nonce },
}) })
.then(() => DiscourseURL.routeTo(response.redirect_path)) .then((callbackResponse) => {
const redirectUrl =
callbackResponse.redirect_url || response.redirect_url;
DiscourseURL.routeTo(redirectUrl);
})
.catch((error) => this.displayError(extractError(error))); .catch((error) => this.displayError(extractError(error)));
}) })
.catch((error) => { .catch((error) => {

View File

@ -87,7 +87,7 @@ acceptance("Second Factor Auth Page", function (needs) {
ok: true, ok: true,
callback_method: "PUT", callback_method: "PUT",
callback_path: "/callback-path", callback_path: "/callback-path",
redirect_path: "/", redirect_url: "/",
}, },
]; ];
} }
@ -291,7 +291,7 @@ acceptance("Second Factor Auth Page", function (needs) {
assert.equal( assert.equal(
currentURL(), currentURL(),
"/", "/",
"user has been redirected to the redirect_path" "user has been redirected to the redirect_url"
); );
assert.equal(callbackCount, 1, "callback request has been performed"); assert.equal(callbackCount, 1, "callback request has been performed");
}); });

View File

@ -251,9 +251,13 @@ class ApplicationController < ActionController::Base
end end
rescue_from SecondFactor::AuthManager::SecondFactorRequired do |e| rescue_from SecondFactor::AuthManager::SecondFactorRequired do |e|
if request.xhr?
render json: { render json: {
second_factor_challenge_nonce: e.nonce second_factor_challenge_nonce: e.nonce
}, status: 403 }, status: 403
else
redirect_to session_2fa_path(nonce: e.nonce)
end
end end
rescue_from SecondFactor::BadChallenge do |e| rescue_from SecondFactor::BadChallenge do |e|
@ -482,6 +486,11 @@ class ApplicationController < ActionController::Base
end end
def guardian def guardian
# sometimes we log on a user in the middle of a request so we should throw
# away the cached guardian instance when we do that
if (@guardian&.user).blank? && current_user.present?
@guardian = Guardian.new(current_user, request)
end
@guardian ||= Guardian.new(current_user, request) @guardian ||= Guardian.new(current_user, request)
end end
@ -978,13 +987,15 @@ class ApplicationController < ActionController::Base
end end
end end
def run_second_factor!(action_class) def run_second_factor!(action_class, action_data = nil)
action = action_class.new(guardian) action = action_class.new(guardian, request, action_data)
manager = SecondFactor::AuthManager.new(guardian, action) manager = SecondFactor::AuthManager.new(guardian, action)
yield(manager) if block_given? yield(manager) if block_given?
result = manager.run!(request, params, secure_session) result = manager.run!(request, params, secure_session)
if !result.no_second_factors_enabled? && !result.second_factor_auth_completed? if !result.no_second_factors_enabled? &&
!result.second_factor_auth_completed? &&
!result.second_factor_auth_skipped?
# should never happen, but I want to know if somehow it does! (osama) # should never happen, but I want to know if somehow it does! (osama)
raise "2fa process ended up in a bad state!" raise "2fa process ended up in a bad state!"
end end

View File

@ -39,77 +39,56 @@ class SessionController < ApplicationController
end end
end end
def sso_provider(payload = nil) def sso_provider(payload = nil, confirmed_2fa_during_login = false)
if SiteSetting.enable_discourse_connect_provider if !SiteSetting.enable_discourse_connect_provider
begin render body: nil, status: 404
if !payload
params.require(:sso)
payload = request.query_string
end
sso = DiscourseConnectProvider.parse(payload)
rescue DiscourseConnectProvider::BlankSecret
render plain: I18n.t("discourse_connect.missing_secret"), status: 400
return
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
# 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
return return
end end
if sso.return_sso_url.blank? result = run_second_factor!(
render plain: "return_sso_url is blank, it must be provided", status: 400 SecondFactor::Actions::DiscourseConnectProvider,
return payload: payload,
end confirmed_2fa_during_login: confirmed_2fa_during_login
)
if sso.logout if result.second_factor_auth_skipped?
params[:return_url] = sso.return_sso_url data = result.data
if data[:logout]
params[:return_url] = data[:return_sso_url]
destroy destroy
return return
end end
if current_user if data[:no_current_user]
sso.name = current_user.name cookies[:sso_payload] = payload || request.query_string
sso.username = current_user.username redirect_to path('/login')
sso.email = current_user.email return
sso.external_id = current_user.id.to_s
sso.admin = current_user.admin?
sso.moderator = current_user.moderator?
sso.groups = current_user.groups.pluck(:name).join(",")
if current_user.uploaded_avatar.present?
base_url = Discourse.store.external? ? "#{Discourse.store.absolute_base_url}/" : Discourse.base_url
avatar_url = "#{base_url}#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}"
sso.avatar_url = UrlHelper.absolute Discourse.store.cdn_url(avatar_url)
end
if current_user.user_profile.profile_background_upload.present?
sso.profile_background_url = UrlHelper.absolute(upload_cdn_path(
current_user.user_profile.profile_background_upload.url
))
end
if current_user.user_profile.card_background_upload.present?
sso.card_background_url = UrlHelper.absolute(upload_cdn_path(
current_user.user_profile.card_background_upload.url
))
end end
if request.xhr? if request.xhr?
cookies[:sso_destination_url] = sso.to_url(sso.return_sso_url) # for the login modal
cookies[:sso_destination_url] = data[:sso_redirect_url]
else else
redirect_to sso.to_url(sso.return_sso_url) redirect_to data[:sso_redirect_url]
end end
elsif result.no_second_factors_enabled?
if request.xhr?
# for the login modal
cookies[:sso_destination_url] = result.data[:sso_redirect_url]
else else
cookies[:sso_payload] = request.query_string redirect_to result.data[:sso_redirect_url]
redirect_to path('/login')
end end
else elsif result.second_factor_auth_completed?
render body: nil, status: 404 redirect_url = result.data[:sso_redirect_url]
render json: success_json.merge(redirect_url: redirect_url)
end 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 end
# For use in development mode only when login options could be limited or disabled. # For use in development mode only when login options could be limited or disabled.
@ -334,11 +313,12 @@ class SessionController < ApplicationController
return render json: payload return render json: payload
end end
if !authenticate_second_factor(user) second_factor_auth_result = authenticate_second_factor(user)
if !second_factor_auth_result.ok
return render(json: @second_factor_failure_payload) return render(json: @second_factor_failure_payload)
end end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user) (user.active && user.email_confirmed?) ? login(user, second_factor_auth_result) : not_activated(user)
end end
def email_login_info def email_login_info
@ -388,7 +368,7 @@ class SessionController < ApplicationController
rate_limit_second_factor!(user) rate_limit_second_factor!(user)
if user.present? && !authenticate_second_factor(user) if user.present? && !authenticate_second_factor(user).ok
return render(json: @second_factor_failure_payload) return render(json: @second_factor_failure_payload)
end end
@ -534,7 +514,7 @@ class SessionController < ApplicationController
ok: true, ok: true,
callback_method: challenge[:callback_method], callback_method: challenge[:callback_method],
callback_path: challenge[:callback_path], callback_path: challenge[:callback_path],
redirect_path: challenge[:redirect_path] redirect_url: challenge[:redirect_url]
}, status: 200 }, status: 200
end end
@ -651,10 +631,10 @@ class SessionController < ApplicationController
failure_payload.merge!(Webauthn.allowed_credentials(user, secure_session)) failure_payload.merge!(Webauthn.allowed_credentials(user, secure_session))
end end
@second_factor_failure_payload = failed_json.merge(failure_payload) @second_factor_failure_payload = failed_json.merge(failure_payload)
return false return second_factor_authentication_result
end end
true second_factor_authentication_result
end end
def login_error_check(user) def login_error_check(user)
@ -706,13 +686,18 @@ class SessionController < ApplicationController
} }
end end
def login(user) def login(user, second_factor_auth_result)
session.delete(ACTIVATE_USER_KEY) session.delete(ACTIVATE_USER_KEY)
user.update_timezone_if_missing(params[:timezone]) user.update_timezone_if_missing(params[:timezone])
log_on_user(user) log_on_user(user)
if payload = cookies.delete(:sso_payload) if payload = cookies.delete(:sso_payload)
sso_provider(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 else
render_serialized(user, UserSerializer) render_serialized(user, UserSerializer)
end end

View File

@ -6,7 +6,14 @@ module SecondFactorManager
extend ActiveSupport::Concern extend ActiveSupport::Concern
SecondFactorAuthenticationResult = Struct.new( SecondFactorAuthenticationResult = Struct.new(
:ok, :error, :reason, :backup_enabled, :security_key_enabled, :totp_enabled, :multiple_second_factor_methods :ok,
:error,
:reason,
:backup_enabled,
:security_key_enabled,
:totp_enabled,
:multiple_second_factor_methods,
:used_2fa_method,
) )
def create_totp(opts = {}) def create_totp(opts = {})
@ -112,11 +119,26 @@ module SecondFactorManager
case second_factor_method case second_factor_method
when UserSecondFactor.methods[:totp] when UserSecondFactor.methods[:totp]
return authenticate_totp(second_factor_token) ? ok_result : invalid_totp_or_backup_code_result if authenticate_totp(second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:totp]
return ok_result
else
return invalid_totp_or_backup_code_result
end
when UserSecondFactor.methods[:backup_codes] when UserSecondFactor.methods[:backup_codes]
return authenticate_backup_code(second_factor_token) ? ok_result : invalid_totp_or_backup_code_result if authenticate_backup_code(second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:backup_codes]
return ok_result
else
return invalid_totp_or_backup_code_result
end
when UserSecondFactor.methods[:security_key] when UserSecondFactor.methods[:security_key]
return authenticate_security_key(secure_session, second_factor_token) ? ok_result : invalid_security_key_result if authenticate_security_key(secure_session, second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:security_key]
return ok_result
else
return invalid_security_key_result
end
end end
# if we have gotten down to this point without being # if we have gotten down to this point without being

View File

@ -2590,6 +2590,8 @@ en:
actions: actions:
grant_admin: grant_admin:
description: "For additional security measures, you need to confirm your 2FA before %{username} is granted admin access." description: "For additional security measures, you need to confirm your 2FA before %{username} is granted admin access."
discourse_connect_provider:
description: "%{hostname} has requested that you confirm your 2FA. You'll be redirected back to the site once you confirm your 2FA."
admin: admin:
email: email:
sent_test: "sent!" sent_test: "sent!"

View File

@ -11,23 +11,26 @@ class DiscourseConnectBase
avatar_url avatar_url
bio bio
card_background_url card_background_url
confirmed_2fa
email email
external_id external_id
groups groups
locale locale
locale_force_update locale_force_update
location
logout logout
name name
no_2fa_methods
nonce nonce
profile_background_url profile_background_url
remove_groups remove_groups
require_2fa
require_activation require_activation
return_sso_url return_sso_url
suppress_welcome_message suppress_welcome_message
title title
username username
website website
location
} }
FIXNUMS = [] FIXNUMS = []
@ -35,9 +38,12 @@ class DiscourseConnectBase
BOOLS = %i{ BOOLS = %i{
admin admin
avatar_force_update avatar_force_update
confirmed_2fa
locale_force_update locale_force_update
logout logout
moderator moderator
no_2fa_methods
require_2fa
require_activation require_activation
suppress_welcome_message suppress_welcome_message
} }

View File

@ -2,6 +2,7 @@
class DiscourseConnectProvider < DiscourseConnectBase class DiscourseConnectProvider < DiscourseConnectBase
class BlankSecret < RuntimeError; end class BlankSecret < RuntimeError; end
class BlankReturnUrl < RuntimeError; end
def self.parse(payload, sso_secret = nil) def self.parse(payload, sso_secret = nil)
set_return_sso_url(payload) set_return_sso_url(payload)

View File

@ -3,11 +3,21 @@
module SecondFactor::Actions module SecondFactor::Actions
class Base class Base
include Rails.application.routes.url_helpers 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 @guardian = guardian
@current_user = guardian.user @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 end
def no_second_factors_enabled!(params) 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) def no_second_factors_enabled!(params)
user = find_user(params[:user_id]) user = find_user(params[:user_id])
AdminConfirmation.new(user, current_user).create_confirmation AdminConfirmation.new(user, current_user).create_confirmation
nil
end end
def second_factor_auth_required!(params) def second_factor_auth_required!(params)
@ -15,7 +16,7 @@ module SecondFactor::Actions
) )
{ {
callback_params: { user_id: user.id }, 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 description: description
} }
end end
@ -24,6 +25,7 @@ module SecondFactor::Actions
user = find_user(callback_params[:user_id]) user = find_user(callback_params[:user_id])
user.grant_admin! user.grant_admin!
StaffActionLogger.new(current_user).log_grant_admin(user) StaffActionLogger.new(current_user).log_grant_admin(user)
nil
end end
private 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 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 available in all controllers. This method takes a single argument which is a
class that inherits from the `SecondFactor::Actions::Base` class and implements 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): 1. no_second_factors_enabled!(params):
This method corresponds to outcome (1) above, i.e. it's called when the user 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 finish the action once 2fa is completed. Everything in this Hash must be
serializable to JSON. serializable to JSON.
:redirect_path => relative subfolder-aware path that the user should be :redirect_url => where the user should be redirected after they confirm 2fa.
redirected to after the action is finished. When this key is omitted, the A relative path (must be subfolder-aware) is a valid value for this key.
redirect path is set to the homepage (/).
:description => optional action-specific description message that's shown on :description => optional action-specific description message that's shown on
the 2FA page. the 2FA page.
@ -68,6 +67,20 @@ the following methods:
The `callback_params` param of this method is the `callback_params` Hash from The `callback_params` param of this method is the `callback_params` Hash from
the return value of the previous method. 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 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 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 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 which outcome the auth manager has picked and render a different response based
on the outcome. 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: For a real example where the auth manager is used, please refer to:
* `SecondFactor::Actions::GrantAdmin` action class. This is a class that * The `lib/second_factor/actions` directory where all existing actions live.
inherits `SecondFactor::Actions::Base` and implements the 3 methods mentioned
above.
* `Admin::UsersController#grant_admin` controller action. * `Admin::UsersController#grant_admin` controller action.
* `SessionController#sso_provider` controller action.
=end =end
class SecondFactor::AuthManager class SecondFactor::AuthManager
@ -144,12 +162,15 @@ class SecondFactor::AuthManager
end end
def run!(request, params, secure_session) def run!(request, params, secure_session)
if !allowed_methods.any? { |m| @current_user.valid_second_factor_method_for_user?(m) } if nonce = params[:second_factor_nonce].presence
@action.no_second_factors_enabled!(params) data = verify_second_factor_auth_completed(nonce, secure_session)
create_result(:no_second_factor) create_result(:second_factor_auth_completed, data)
elsif nonce = params[:second_factor_nonce].presence elsif @action.skip_second_factor_auth?(params)
verify_second_factor_auth_completed(nonce, secure_session) data = @action.second_factor_auth_skipped!(params)
create_result(:second_factor_auth_completed) 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 else
nonce = initiate_second_factor_auth(params, secure_session, request) nonce = initiate_second_factor_auth(params, secure_session, request)
raise SecondFactorRequired.new(nonce: nonce) raise SecondFactorRequired.new(nonce: nonce)
@ -162,19 +183,20 @@ class SecondFactor::AuthManager
config = @action.second_factor_auth_required!(params) config = @action.second_factor_auth_required!(params)
nonce = SecureRandom.alphanumeric(32) nonce = SecureRandom.alphanumeric(32)
callback_params = config[:callback_params] || {} callback_params = config[:callback_params] || {}
redirect_path = config[:redirect_path] || GlobalPath.path("").presence || "/"
challenge = { challenge = {
nonce: nonce, nonce: nonce,
callback_method: request.request_method, callback_method: config[:callback_method] || request.request_method,
callback_path: request.path, callback_path: config[:callback_path] || request.path,
callback_params: callback_params, callback_params: callback_params,
redirect_path: redirect_path,
allowed_methods: allowed_methods.to_a, allowed_methods: allowed_methods.to_a,
generated_at: Time.zone.now.to_i generated_at: Time.zone.now.to_i
} }
if config[:description] if config[:description]
challenge[:description] = config[:description] challenge[:description] = config[:description]
end end
if config[:redirect_url].present?
challenge[:redirect_url] = config[:redirect_url]
end
secure_session["current_second_factor_auth_challenge"] = challenge.to_json secure_session["current_second_factor_auth_challenge"] = challenge.to_json
nonce nonce
end end
@ -190,7 +212,8 @@ class SecondFactor::AuthManager
secure_session["current_second_factor_auth_challenge"] = nil secure_session["current_second_factor_auth_challenge"] = nil
callback_params = challenge[:callback_params] callback_params = challenge[:callback_params]
@action.second_factor_auth_completed!(callback_params) data = @action.second_factor_auth_completed!(callback_params)
data
end end
def add_method(id) def add_method(id)
@ -201,7 +224,7 @@ class SecondFactor::AuthManager
end end
end end
def create_result(status) def create_result(status, data = nil)
SecondFactor::AuthManagerResult.new(status) SecondFactor::AuthManagerResult.new(status, data)
end end
end end

View File

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

View File

@ -172,6 +172,10 @@ RSpec.describe SecondFactorManager do
it "returns OK, because it doesn't need to authenticate" do it "returns OK, because it doesn't need to authenticate" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "keeps used_2fa_method nil because no authentication is done" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(nil)
end
end end
context "when only security key is enabled" do context "when only security key is enabled" do
@ -186,6 +190,10 @@ RSpec.describe SecondFactorManager do
it "returns OK" do it "returns OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to security keys" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:security_key])
end
end end
context "when security key params are invalid" do context "when security key params are invalid" do
@ -204,6 +212,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("webauthn.validation.not_found_error")) expect(result.error).to eq(I18n.t("webauthn.validation.not_found_error"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
end end
@ -223,6 +232,10 @@ RSpec.describe SecondFactorManager do
it "returns OK" do it "returns OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to totp" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:totp])
end
end end
context "when totp is invalid" do context "when totp is invalid" do
@ -236,6 +249,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.invalid_second_factor_code")) expect(result.error).to eq(I18n.t("login.invalid_second_factor_code"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
end end
@ -254,6 +268,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.invalid_second_factor_method")) expect(result.error).to eq(I18n.t("login.invalid_second_factor_method"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
@ -273,6 +288,10 @@ RSpec.describe SecondFactorManager do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to totp" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:totp])
end
context "when the user does not have TOTP enabled" do context "when the user does not have TOTP enabled" do
let(:token) { 'test' } let(:token) { 'test' }
before do before do
@ -283,6 +302,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method")) expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
end end
@ -302,6 +322,10 @@ RSpec.describe SecondFactorManager do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to security keys" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:security_key])
end
context "when the user does not have security keys enabled" do context "when the user does not have security keys enabled" do
before do before do
user.security_keys.destroy_all user.security_keys.destroy_all
@ -311,6 +335,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method")) expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
end end
@ -332,6 +357,10 @@ RSpec.describe SecondFactorManager do
it "validates codes OK" do it "validates codes OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to backup codes" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:backup_codes])
end
end end
context "when backup codes disabled" do context "when backup codes disabled" do
@ -343,6 +372,7 @@ RSpec.describe SecondFactorManager do
result = user.authenticate_second_factor(params, secure_session) result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false) expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method")) expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
expect(result.used_2fa_method).to eq(nil)
end end
end end
end end
@ -354,6 +384,10 @@ RSpec.describe SecondFactorManager do
it "validates the security key OK" do it "validates the security key OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to security keys" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:security_key])
end
end end
context "when totp params are provided" do context "when totp params are provided" do
@ -367,6 +401,10 @@ RSpec.describe SecondFactorManager do
it "validates totp OK" do it "validates totp OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true) expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end end
it "sets used_2fa_method to totp" do
expect(user.authenticate_second_factor(params, secure_session).used_2fa_method).to eq(UserSecondFactor.methods[:totp])
end
end end
end end
end end

View File

@ -0,0 +1,247 @@
# frozen_string_literal: true
describe SecondFactor::Actions::DiscourseConnectProvider do
fab!(:user) { Fabricate(:user) }
sso_secret = "mysecretmyprecious"
let!(:sso) do
sso = ::DiscourseConnectProvider.new
sso.nonce = "mysecurenonce"
sso.return_sso_url = "http://hobbit.shire.com/sso"
sso.sso_secret = sso_secret
sso.require_2fa = true
sso
end
before do
SiteSetting.enable_discourse_connect_provider = true
SiteSetting.discourse_connect_provider_secrets = "hobbit.shire.com|#{sso_secret}"
end
def params(hash)
ActionController::Parameters.new(hash)
end
def create_request(query_string)
ActionDispatch::TestRequest.create({
"REQUEST_METHOD" => "GET",
"PATH_INFO" => "/",
"QUERY_STRING" => query_string
})
end
def params_from_payload(payload)
ActionController::Parameters.new(Rack::Utils.parse_query(payload))
end
def create_instance(user, request = nil, opts = nil)
request ||= create_request
SecondFactor::Actions::DiscourseConnectProvider.new(Guardian.new(user), request, opts)
end
describe "#skip_second_factor_auth?" do
it "returns true if there's no current_user" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(nil, request)
expect(action.skip_second_factor_auth?(params)).to eq(true)
end
it "returns true if SSO is for logout" do
sso.logout = true
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
expect(action.skip_second_factor_auth?(params)).to eq(true)
end
it "returns true if SSO doesn't require 2fa" do
sso.require_2fa = false
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
expect(action.skip_second_factor_auth?(params)).to eq(true)
end
it "returns true if 2fa has been confirmed during login" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request, confirmed_2fa_during_login: true)
expect(action.skip_second_factor_auth?(params)).to eq(true)
end
it "returns falsey value otherwise" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
expect(action.skip_second_factor_auth?(params)).to be_falsey
end
end
describe "#second_factor_auth_skipped!" do
before { sso.require_2fa = false }
it "returns a hash with logout: true and return_sso_url without no payload if the SSO is for logout" do
sso.logout = true
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
expect(action.second_factor_auth_skipped!(params)).to eq({
logout: true,
return_sso_url: "http://hobbit.shire.com/sso"
})
end
it "returns a hash with no_current_user: true if there's no current_user" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(nil, request)
expect(action.second_factor_auth_skipped!(params)).to eq({
no_current_user: true
})
end
it "returns sso_redirect_url to the SSO website with payload that indicates confirmed 2FA if confirmed_2fa_during_login is true" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request, confirmed_2fa_during_login: true)
output = action.second_factor_auth_skipped!(params)
expect(output.keys).to contain_exactly(:sso_redirect_url)
expect(output[:sso_redirect_url]).to start_with("http://hobbit.shire.com/sso")
response_payload = ::DiscourseConnectProvider.parse(URI(output[:sso_redirect_url]).query)
expect(response_payload.confirmed_2fa).to eq(true)
expect(response_payload.no_2fa_methods).to eq(nil)
expect(response_payload.username).to eq(user.username)
expect(response_payload.email).to eq(user.email)
end
it "returns sso_redirect_url to the SSO website with payload that doesn't indicate confirmed 2FA" do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
output = action.second_factor_auth_skipped!(params)
expect(output.keys).to contain_exactly(:sso_redirect_url)
expect(output[:sso_redirect_url]).to start_with("http://hobbit.shire.com/sso")
response_payload = ::DiscourseConnectProvider.parse(URI(output[:sso_redirect_url]).query)
expect(response_payload.confirmed_2fa).to eq(nil)
expect(response_payload.no_2fa_methods).to eq(nil)
expect(response_payload.username).to eq(user.username)
expect(response_payload.email).to eq(user.email)
end
it "prioritizes the SSO logout case over the no current_user case" do
sso.logout = true
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(nil, request)
expect(action.second_factor_auth_skipped!(params)).to eq({
logout: true,
return_sso_url: "http://hobbit.shire.com/sso"
})
end
end
describe "#no_second_factors_enabled!" do
let(:output) do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
action.no_second_factors_enabled!(params)
end
let(:response_payload) do
::DiscourseConnectProvider.parse(URI(output[:sso_redirect_url]).query)
end
it "returns a hash with just sso_redirect_url" do
expect(output.keys).to contain_exactly(:sso_redirect_url)
end
it "the sso_redirect_url is the SSO site" do
expect(output[:sso_redirect_url]).to start_with("http://hobbit.shire.com/sso")
end
it "the response payload indicates the user has no 2fa methods" do
expect(response_payload.no_2fa_methods).to eq(true)
end
it "the response payload of the sso_redirect_url doesn't indicate the user has confirmed 2fa" do
expect(response_payload.confirmed_2fa).to eq(nil)
end
it "the response payload contains the user details" do
expect(response_payload.username).to eq(user.username)
expect(response_payload.email).to eq(user.email)
end
end
describe "#second_factor_auth_required!" do
let(:output) do
request = create_request(sso.payload)
params = params_from_payload(sso.payload)
action = create_instance(user, request)
action.second_factor_auth_required!(params)
end
it "includes the payload in the callback_params" do
expect(output[:callback_params]).to eq({ payload: sso.payload })
end
it "sets the callback_path to the SSO provider endpoint" do
expect(output[:callback_path]).to eq("/session/sso_provider")
end
it "sets the callback_method to the HTTP method of SSO provider endpoint" do
expect(output[:callback_method]).to eq("GET")
end
it "includes a description" do
expect(output[:description]).to eq(I18n.t(
"second_factor_auth.actions.discourse_connect_provider.description",
hostname: "hobbit.shire.com",
))
end
end
describe "#second_factor_auth_completed!" do
let(:output) do
request = create_request("")
params = params_from_payload("")
action = create_instance(user, request)
action.second_factor_auth_completed!(payload: sso.payload)
end
let(:response_payload) do
::DiscourseConnectProvider.parse(URI(output[:sso_redirect_url]).query)
end
it "gets the payload from callback_params and not the request params" do
wrong_sso = ::DiscourseConnectProvider.new
wrong_sso.nonce = "mysecurenonceWRONG"
wrong_sso.return_sso_url = "http://wrong.shire.com/sso"
wrong_sso.sso_secret = "mysecretmypreciousWRONG"
wrong_sso.require_2fa = true
request = create_request(wrong_sso.payload)
params = params_from_payload(wrong_sso.payload)
action = create_instance(user, request)
redirect_url = action.second_factor_auth_completed!(payload: sso.payload)[:sso_redirect_url]
response_payload = ::DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(response_payload.return_sso_url).to eq("http://hobbit.shire.com/sso")
expect(response_payload.nonce).to eq("mysecurenonce")
expect(response_payload.sso_secret).to eq(sso_secret)
end
it "the response payload of the sso_redirect_url indicates the user has confirmed 2fa" do
expect(response_payload.confirmed_2fa).to eq(true)
end
it "the response payload of the sso_redirect_url doesn't include no_2fa_methods" do
expect(response_payload.no_2fa_methods).to eq(nil)
end
it "the response payload contains the user details" do
expect(response_payload.username).to eq(user.username)
expect(response_payload.email).to eq(user.email)
end
end
end

View File

@ -18,8 +18,16 @@ describe SecondFactor::Actions::GrantAdmin do
ActionController::Parameters.new(hash) ActionController::Parameters.new(hash)
end end
def create_instance(user) def create_request(request_method: "GET", path: "/")
SecondFactor::Actions::GrantAdmin.new(Guardian.new(user)) ActionDispatch::TestRequest.create({
"REQUEST_METHOD" => request_method,
"PATH_INFO" => path
})
end
def create_instance(user, request = nil)
request ||= create_request
SecondFactor::Actions::GrantAdmin.new(Guardian.new(user), request)
end end
describe "#no_second_factors_enabled!" do describe "#no_second_factors_enabled!" do
@ -40,11 +48,11 @@ describe SecondFactor::Actions::GrantAdmin do
end end
describe "#second_factor_auth_required!" do describe "#second_factor_auth_required!" do
it "returns a hash with callback_params, redirect_path and a description" do it "returns a hash with callback_params, redirect_url and a description" do
instance = create_instance(admin) instance = create_instance(admin)
hash = instance.second_factor_auth_required!(params({ user_id: user.id })) hash = instance.second_factor_auth_required!(params({ user_id: user.id }))
expect(hash[:callback_params]).to eq({ user_id: user.id }) expect(hash[:callback_params]).to eq({ user_id: user.id })
expect(hash[:redirect_path]).to eq("/admin/users/#{user.id}/#{user.username}") expect(hash[:redirect_url]).to eq("/admin/users/#{user.id}/#{user.username}")
expect(hash[:description]).to eq( expect(hash[:description]).to eq(
I18n.t( I18n.t(
"second_factor_auth.actions.grant_admin.description", "second_factor_auth.actions.grant_admin.description",

View File

@ -16,12 +16,17 @@ describe SecondFactor::AuthManager do
SecondFactor::AuthManager.new(guardian, action) SecondFactor::AuthManager.new(guardian, action)
end end
def create_action def create_action(request = nil)
TestSecondFactorAction.new(guardian) request ||= create_request
TestSecondFactorAction.new(guardian, request)
end end
def stage_challenge(successful:) def stage_challenge(successful:)
action = create_action request = create_request(
request_method: "POST",
path: "/abc/xyz"
)
action = create_action(request)
action.expects(:no_second_factors_enabled!).never action.expects(:no_second_factors_enabled!).never
action action
.expects(:second_factor_auth_required!) .expects(:second_factor_auth_required!)
@ -29,23 +34,21 @@ describe SecondFactor::AuthManager do
.returns({ callback_params: { call_me_back: 4314 } }) .returns({ callback_params: { call_me_back: 4314 } })
.once .once
manager = create_manager(action) manager = create_manager(action)
request = create_request(
request_method: "POST",
path: "/abc/xyz"
)
secure_session = {} secure_session = {}
begin expect {
manager.run!(request, { random_param: 'hello' }, secure_session) manager.run!(request, { random_param: 'hello' }, secure_session)
rescue SecondFactor::AuthManager::SecondFactorRequired }.to raise_error(SecondFactor::AuthManager::SecondFactorRequired) do |ex|
# expected expect(ex.nonce).to be_present
end end
challenge = JSON challenge = JSON
.parse(secure_session["current_second_factor_auth_challenge"]) .parse(secure_session["current_second_factor_auth_challenge"])
.deep_symbolize_keys .deep_symbolize_keys
challenge[:successful] = successful if successful
challenge[:successful] = true
secure_session["current_second_factor_auth_challenge"] = challenge.to_json secure_session["current_second_factor_auth_challenge"] = challenge.to_json
end
[challenge[:nonce], secure_session] [challenge[:nonce], secure_session]
end end
@ -76,32 +79,26 @@ describe SecondFactor::AuthManager do
manager = create_manager(action) manager = create_manager(action)
manager.run!(create_request, { hello_world: 331 }, {}) manager.run!(create_request, { hello_world: 331 }, {})
end end
it 'calls the no_second_factors_enabled! method of the action even if a nonce is present in the params' do
action = create_action
params = { second_factor_nonce: SecureRandom.hex }
action.expects(:no_second_factors_enabled!).with(params).once
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
manager.run!(create_request, params, {})
end
end end
it "initiates the 2FA process and stages a challenge in secure session when there is no nonce in params" do it "initiates the 2FA process and stages a challenge in secure session when there is no nonce in params" do
action = create_action
action.expects(:no_second_factors_enabled!).never
action
.expects(:second_factor_auth_required!)
.with({ expect_me: 131 })
.returns({ callback_params: { call_me_back: 4314 }, redirect_path: "/gg", description: "hello world!" })
.once
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
request = create_request( request = create_request(
request_method: "POST", request_method: "POST",
path: "/abc/xyz" path: "/abc/xyz"
) )
action = create_action(request)
action.expects(:no_second_factors_enabled!).never
action
.expects(:second_factor_auth_required!)
.with({ expect_me: 131 })
.returns(
callback_params: { call_me_back: 4314 },
redirect_url: "/gg",
description: "hello world!"
)
.once
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
secure_session = {} secure_session = {}
expect { expect {
manager.run!(request, { expect_me: 131 }, secure_session) manager.run!(request, { expect_me: 131 }, secure_session)
@ -111,61 +108,46 @@ describe SecondFactor::AuthManager do
expect(challenge[:nonce]).to be_present expect(challenge[:nonce]).to be_present
expect(challenge[:callback_method]).to eq("POST") expect(challenge[:callback_method]).to eq("POST")
expect(challenge[:callback_path]).to eq("/abc/xyz") expect(challenge[:callback_path]).to eq("/abc/xyz")
expect(challenge[:redirect_path]).to eq("/gg") expect(challenge[:redirect_url]).to eq("/gg")
expect(challenge[:allowed_methods]).to eq(manager.allowed_methods.to_a) expect(challenge[:allowed_methods]).to eq(manager.allowed_methods.to_a)
expect(challenge[:callback_params]).to eq({ call_me_back: 4314 }) expect(challenge[:callback_params]).to eq({ call_me_back: 4314 })
expect(challenge[:description]).to eq("hello world!") expect(challenge[:description]).to eq("hello world!")
end end
it "sets the redirect_path to the root path if second_factor_auth_required! doesn't specify a redirect_path" do it "prefers callback_method and callback_path from the output of the action's second_factor_auth_required! method if they're present" do
action = create_action
action.expects(:no_second_factors_enabled!).never
action
.expects(:second_factor_auth_required!)
.with({ expect_me: 131 })
.returns({ callback_params: { call_me_back: 4314 } })
.once
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
request = create_request( request = create_request(
request_method: "POST", request_method: "POST",
path: "/abc/xyz" path: "/abc/xyz"
) )
secure_session = {} action = create_action(request)
expect {
manager.run!(request, { expect_me: 131 }, secure_session)
}.to raise_error(SecondFactor::AuthManager::SecondFactorRequired)
json = secure_session["current_second_factor_auth_challenge"]
challenge = JSON.parse(json).deep_symbolize_keys
expect(challenge[:redirect_path]).to eq("/")
set_subfolder("/community")
action = create_action
action.expects(:no_second_factors_enabled!).never
action action
.expects(:second_factor_auth_required!) .expects(:second_factor_auth_required!)
.with({ expect_me: 131 }) .with({})
.returns({ callback_params: { call_me_back: 4314 } }) .returns(
.once callback_params: { call_me_back: 4314 },
action.expects(:second_factor_auth_completed!).never callback_method: "PUT",
manager = create_manager(action) callback_path: "/test/443"
request = create_request(
request_method: "POST",
path: "/abc/xyz"
) )
.once
manager = create_manager(action)
secure_session = {} secure_session = {}
expect { expect {
manager.run!(request, { expect_me: 131 }, secure_session) manager.run!(request, {}, secure_session)
}.to raise_error(SecondFactor::AuthManager::SecondFactorRequired) }.to raise_error(SecondFactor::AuthManager::SecondFactorRequired)
json = secure_session["current_second_factor_auth_challenge"] json = secure_session["current_second_factor_auth_challenge"]
challenge = JSON.parse(json).deep_symbolize_keys challenge = JSON.parse(json).deep_symbolize_keys
expect(challenge[:redirect_path]).to eq("/community") expect(challenge[:callback_method]).to eq("PUT")
expect(challenge[:callback_path]).to eq("/test/443")
end end
it "calls the second_factor_auth_completed! method of the action if the challenge is successful and not expired" do it "calls the second_factor_auth_completed! method of the action if the challenge is successful and not expired" do
nonce, secure_session = stage_challenge(successful: true) nonce, secure_session = stage_challenge(successful: true)
action = create_action request = create_request(
request_method: "POST",
path: "/abc/xyz"
)
action = create_action(request)
action.expects(:no_second_factors_enabled!).never action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never action.expects(:second_factor_auth_required!).never
@ -174,25 +156,21 @@ describe SecondFactor::AuthManager do
.with({ call_me_back: 4314 }) .with({ call_me_back: 4314 })
.once .once
manager = create_manager(action) manager = create_manager(action)
request = create_request(
request_method: "POST",
path: "/abc/xyz"
)
manager.run!(request, { second_factor_nonce: nonce }, secure_session) manager.run!(request, { second_factor_nonce: nonce }, secure_session)
end end
it "does not call the second_factor_auth_completed! method of the action if the challenge is not marked successful" do it "does not call the second_factor_auth_completed! method of the action if the challenge is not marked successful" do
nonce, secure_session = stage_challenge(successful: false) nonce, secure_session = stage_challenge(successful: false)
action = create_action
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
request = create_request( request = create_request(
request_method: "POST", request_method: "POST",
path: "/abc/xyz" path: "/abc/xyz"
) )
action = create_action(request)
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
expect { expect {
manager.run!(request, { second_factor_nonce: nonce }, secure_session) manager.run!(request, { second_factor_nonce: nonce }, secure_session)
}.to raise_error(SecondFactor::BadChallenge) do |ex| }.to raise_error(SecondFactor::BadChallenge) do |ex|
@ -203,15 +181,15 @@ describe SecondFactor::AuthManager do
it "does not call the second_factor_auth_completed! method of the action if the challenge is expired" do it "does not call the second_factor_auth_completed! method of the action if the challenge is expired" do
nonce, secure_session = stage_challenge(successful: true) nonce, secure_session = stage_challenge(successful: true)
action = create_action
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
request = create_request( request = create_request(
request_method: "POST", request_method: "POST",
path: "/abc/xyz" path: "/abc/xyz"
) )
action = create_action(request)
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
expect { expect {
@ -220,5 +198,69 @@ describe SecondFactor::AuthManager do
expect(ex.error_translation_key).to eq("second_factor_auth.challenge_expired") expect(ex.error_translation_key).to eq("second_factor_auth.challenge_expired")
end end
end end
it "calls second_factor_auth_skipped! if skip_second_factor_auth? return true" do
action = create_action
params = { a: 1 }
action.expects(:skip_second_factor_auth?).with(params).returns(true).once
action.expects(:second_factor_auth_skipped!).with(params).once
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).never
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
manager.run!(action.request, params, {})
end
it "doesn't call second_factor_auth_skipped! if skip_second_factor_auth? return false" do
action = create_action
params = { a: 1 }
action.expects(:skip_second_factor_auth?).with(params).returns(false).once
action.expects(:second_factor_auth_skipped!).never
action.expects(:no_second_factors_enabled!).never
action.expects(:second_factor_auth_required!).with(params).returns({}).once
action.expects(:second_factor_auth_completed!).never
manager = create_manager(action)
expect {
manager.run!(action.request, params, {})
}.to raise_error(SecondFactor::AuthManager::SecondFactorRequired) do |ex|
expect(ex.nonce).to be_present
end
end
context "returned results object" do
it "has the correct status and contains the return value of the action hook that's called" do
action = create_action
action.expects(:skip_second_factor_auth?).with({}).returns(true).once
action.expects(:second_factor_auth_skipped!).with({}).returns("yeah whatever").once
manager = create_manager(action)
results = manager.run!(action.request, {}, {})
expect(results.data).to eq("yeah whatever")
expect(results.second_factor_auth_skipped?).to eq(true)
nonce, secure_session = stage_challenge(successful: true)
request = create_request(
request_method: "POST",
path: "/abc/xyz"
)
action = create_action(request)
action
.expects(:second_factor_auth_completed!)
.with({ call_me_back: 4314 })
.returns({ eviltrout: "goodbye :(" })
.once
manager = create_manager(action)
results = manager.run!(request, { second_factor_nonce: nonce }, secure_session)
expect(results.data).to eq({ eviltrout: "goodbye :(" })
expect(results.second_factor_auth_completed?).to eq(true)
user_totp.destroy!
action = create_action
action.expects(:no_second_factors_enabled!).with({}).returns("NOTHING WORKS").once
manager = create_manager(action)
results = manager.run!(action.request, {}, {})
expect(results.data).to eq("NOTHING WORKS")
expect(results.no_second_factors_enabled?).to eq(true)
end
end
end end
end end

View File

@ -384,7 +384,7 @@ RSpec.describe Admin::UsersController do
it 'asks the acting admin for second factor if it is enabled' do it 'asks the acting admin for second factor if it is enabled' do
Fabricate(:user_second_factor_totp, user: admin) Fabricate(:user_second_factor_totp, user: admin)
put "/admin/users/#{another_user.id}/grant_admin.json" put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true
expect(response.parsed_body["second_factor_challenge_nonce"]).to be_present expect(response.parsed_body["second_factor_challenge_nonce"]).to be_present
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
@ -393,7 +393,7 @@ RSpec.describe Admin::UsersController do
it 'grants admin if second factor is correct' do it 'grants admin if second factor is correct' do
user_second_factor = Fabricate(:user_second_factor_totp, user: admin) user_second_factor = Fabricate(:user_second_factor_totp, user: admin)
put "/admin/users/#{another_user.id}/grant_admin.json" put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
expect(nonce).to be_present expect(nonce).to be_present
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
@ -408,7 +408,7 @@ RSpec.describe Admin::UsersController do
expect(res["ok"]).to eq(true) expect(res["ok"]).to eq(true)
expect(res["callback_method"]).to eq("PUT") expect(res["callback_method"]).to eq("PUT")
expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json")
expect(res["redirect_path"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}")
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
put res["callback_path"], params: { put res["callback_path"], params: {
@ -421,7 +421,7 @@ RSpec.describe Admin::UsersController do
it 'does not grant admin if second factor auth is not successful' do it 'does not grant admin if second factor auth is not successful' do
user_second_factor = Fabricate(:user_second_factor_totp, user: admin) user_second_factor = Fabricate(:user_second_factor_totp, user: admin)
put "/admin/users/#{another_user.id}/grant_admin.json" put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
expect(nonce).to be_present expect(nonce).to be_present
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
@ -446,7 +446,7 @@ RSpec.describe Admin::UsersController do
it 'does not grant admin if the acting admin loses permission in the middle of the process' do it 'does not grant admin if the acting admin loses permission in the middle of the process' do
user_second_factor = Fabricate(:user_second_factor_totp, user: admin) user_second_factor = Fabricate(:user_second_factor_totp, user: admin)
put "/admin/users/#{another_user.id}/grant_admin.json" put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
expect(nonce).to be_present expect(nonce).to be_present
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
@ -461,7 +461,7 @@ RSpec.describe Admin::UsersController do
expect(res["ok"]).to eq(true) expect(res["ok"]).to eq(true)
expect(res["callback_method"]).to eq("PUT") expect(res["callback_method"]).to eq("PUT")
expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json") expect(res["callback_path"]).to eq("/admin/users/#{another_user.id}/grant_admin.json")
expect(res["redirect_path"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}") expect(res["redirect_url"]).to eq("/admin/users/#{another_user.id}/#{another_user.username}")
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)
admin.update!(admin: false) admin.update!(admin: false)
@ -476,7 +476,7 @@ RSpec.describe Admin::UsersController do
Fabricate(:user_second_factor_totp, user: admin) Fabricate(:user_second_factor_totp, user: admin)
Fabricate(:user_second_factor_backup, user: admin) Fabricate(:user_second_factor_backup, user: admin)
put "/admin/users/#{another_user.id}/grant_admin.json" put "/admin/users/#{another_user.id}/grant_admin.json", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
expect(nonce).to be_present expect(nonce).to be_present
expect(another_user.reload.admin).to eq(false) expect(another_user.reload.admin).to eq(false)

View File

@ -1116,9 +1116,8 @@ describe SessionController do
describe '#sso_provider' do describe '#sso_provider' do
let(:headers) { { host: Discourse.current_hostname } } let(:headers) { { host: Discourse.current_hostname } }
describe 'can act as an SSO provider' do
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" } let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
fab!(:user) { Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) }
before do before do
stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return(
@ -1140,7 +1139,7 @@ describe SessionController do
@sso.nonce = "mynonce" @sso.nonce = "mynonce"
@sso.return_sso_url = "http://somewhere.over.rainbow/sso" @sso.return_sso_url = "http://somewhere.over.rainbow/sso"
@user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) @user = user
group = Fabricate(:group) group = Fabricate(:group)
group.add(@user) group.add(@user)
@ -1155,6 +1154,7 @@ describe SessionController do
EmailToken.update_all(confirmed: true) EmailToken.update_all(confirmed: true)
end end
describe 'can act as an SSO provider' do
it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow")) get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
@ -1185,6 +1185,8 @@ describe SessionController do
expect(sso2.avatar_url).to start_with(Discourse.base_url) expect(sso2.avatar_url).to start_with(Discourse.base_url)
expect(sso2.profile_background_url).to start_with(Discourse.base_url) expect(sso2.profile_background_url).to start_with(Discourse.base_url)
expect(sso2.card_background_url).to start_with(Discourse.base_url) expect(sso2.card_background_url).to start_with(Discourse.base_url)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end end
it "it fails to log in if secret is wrong" do it "it fails to log in if secret is wrong" do
@ -1236,6 +1238,8 @@ describe SessionController do
expect(sso2.avatar_url).to start_with(Discourse.base_url) expect(sso2.avatar_url).to start_with(Discourse.base_url)
expect(sso2.profile_background_url).to start_with(Discourse.base_url) expect(sso2.profile_background_url).to start_with(Discourse.base_url)
expect(sso2.card_background_url).to start_with(Discourse.base_url) expect(sso2.card_background_url).to start_with(Discourse.base_url)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end end
it 'handles non local content correctly' do it 'handles non local content correctly' do
@ -1292,6 +1296,8 @@ describe SessionController do
expect(sso2.avatar_url).to start_with("#{SiteSetting.s3_cdn_url}/original") expect(sso2.avatar_url).to start_with("#{SiteSetting.s3_cdn_url}/original")
expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url) expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url)
expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url) expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end end
it "successfully logs out and redirects user to return_sso_url when the user is logged in" do it "successfully logs out and redirects user to return_sso_url when the user is logged in" do
@ -1320,6 +1326,153 @@ describe SessionController do
expect(response.cookies["_t"]).to be_blank expect(response.cookies["_t"]).to be_blank
end end
end end
describe 'can act as a 2FA provider' do
fab!(:user_totp) { Fabricate(:user_second_factor_totp, user: user) }
before { @sso.require_2fa = true }
it 'requires the user to confirm 2FA before they are redirected to the SSO return URL' do
sign_in(user)
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
uri = URI(response.location)
expect(uri.hostname).to eq(Discourse.current_hostname)
expect(uri.path).to eq("/session/2fa")
nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
expect(nonce).to be_present
# attempt no. 1 to bypass 2fa
get "/session/sso_provider", params: {
second_factor_nonce: nonce
}
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed")
)
# attempt no. 2 to bypass 2fa
get "/session/sso_provider", params: {
second_factor_nonce: nonce
}.merge(Rack::Utils.parse_query(@sso.payload("secretForOverRainbow")))
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed")
)
# confirm 2fa
post "/session/2fa.json", params: {
nonce: nonce,
second_factor_token: ROTP::TOTP.new(user_totp.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
expect(response.parsed_body["ok"]).to eq(true)
expect(response.parsed_body["callback_method"]).to eq("GET")
expect(response.parsed_body["callback_path"]).to eq("/session/sso_provider")
expect(response.parsed_body["redirect_url"]).to be_blank
get "/session/sso_provider", params: {
second_factor_nonce: nonce
}
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq("OK")
redirect_url = response.parsed_body["redirect_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(true)
expect(sso.no_2fa_methods).to eq(nil)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
it "doesn't accept backup codes" do
backup_codes = user.generate_backup_codes
sign_in(user)
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
uri = URI(response.location)
expect(uri.hostname).to eq(Discourse.current_hostname)
expect(uri.path).to eq("/session/2fa")
nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
expect(nonce).to be_present
post "/session/2fa.json", params: {
nonce: nonce,
second_factor_token: backup_codes.sample,
second_factor_method: UserSecondFactor.methods[:backup_codes]
}
expect(response.status).to eq(403)
get "/session/sso_provider", params: {
second_factor_nonce: nonce
}
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed")
)
end
context 'when the user has no 2fa methods' do
before { user_totp.destroy!; user.reload }
it 'redirects the user back to the SSO return url and indicates in the payload that they do not have 2fa methods' do
sign_in(user)
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response.status).to eq(302)
redirect_url = response.location
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(nil)
expect(sso.no_2fa_methods).to eq(true)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
end
context 'when there is no logged in user' do
it "redirects the user to login first" do
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response.status).to eq(302)
expect(response.location).to eq("http://#{Discourse.current_hostname}/login")
end
it "doesn't make the user confirm 2fa twice if they've just logged in and confirmed 2fa while doing so" do
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
post "/session.json", params: {
login: user.username,
password: "myfrogs123ADMIN",
second_factor_token: ROTP::TOTP.new(user_totp.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}, xhr: true, headers: headers
expect(response.status).to eq(204)
# the frontend will take care of actually redirecting the user
redirect_url = response.cookies["sso_destination_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(true)
expect(sso.no_2fa_methods).to eq(nil)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
it "doesn't indicate the user has confirmed 2fa after they've logged in if they have no 2fa methods" do
user_totp.destroy!
user.reload
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
post "/session.json", params: {
login: user.username,
password: "myfrogs123ADMIN",
}, xhr: true, headers: headers
redirect_url = response.cookies["sso_destination_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(nil)
expect(sso.no_2fa_methods).to eq(true)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
end
end
end end
describe '#create' do describe '#create' do
@ -2317,7 +2470,7 @@ describe SessionController do
end end
it 'returns 401 if the challenge nonce has expired' do it 'returns 401 if the challenge nonce has expired' do
post "/session/2fa/test-action" post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce } get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -2330,7 +2483,7 @@ describe SessionController do
end end
it 'responds with challenge data' do it 'responds with challenge data' do
post "/session/2fa/test-action" post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce } get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -2352,7 +2505,7 @@ describe SessionController do
enabled: true enabled: true
) )
Fabricate(:user_second_factor_backup, user: user) Fabricate(:user_second_factor_backup, user: user)
post "/session/2fa/test-action", params: { allow_backup_codes: true } post "/session/2fa/test-action", params: { allow_backup_codes: true }, xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce } get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -2379,7 +2532,7 @@ describe SessionController do
end end
it 'returns 401 if the challenge nonce has expired' do it 'returns 401 if the challenge nonce has expired' do
post "/session/2fa/test-action" post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
@ -2395,7 +2548,7 @@ describe SessionController do
it 'returns 403 if the 2FA method is not allowed' do it 'returns 403 if the 2FA method is not allowed' do
Fabricate(:user_second_factor_backup, user: user) Fabricate(:user_second_factor_backup, user: user)
post "/session/2fa/test-action" post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
post "/session/2fa.json", params: { post "/session/2fa.json", params: {
nonce: nonce, nonce: nonce,
@ -2406,7 +2559,7 @@ describe SessionController do
end end
it 'returns 403 if the user disables the 2FA method in the middle of the 2FA process' do it 'returns 403 if the user disables the 2FA method in the middle of the 2FA process' do
post "/session/2fa/test-action" post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
token = ROTP::TOTP.new(user_second_factor.data).now token = ROTP::TOTP.new(user_second_factor.data).now
user_second_factor.destroy! user_second_factor.destroy!
@ -2419,7 +2572,7 @@ describe SessionController do
end end
it 'marks the challenge as successful if the 2fa succeeds' do it 'marks the challenge as successful if the 2fa succeeds' do
post "/session/2fa/test-action", params: { redirect_path: "/ggg" } post "/session/2fa/test-action", params: { redirect_url: "/ggg" }, xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
token = ROTP::TOTP.new(user_second_factor.data).now token = ROTP::TOTP.new(user_second_factor.data).now
@ -2433,7 +2586,7 @@ describe SessionController do
expect(response.parsed_body["ok"]).to eq(true) expect(response.parsed_body["ok"]).to eq(true)
expect(response.parsed_body["callback_method"]).to eq("POST") expect(response.parsed_body["callback_method"]).to eq("POST")
expect(response.parsed_body["callback_path"]).to eq("/session/2fa/test-action") expect(response.parsed_body["callback_path"]).to eq("/session/2fa/test-action")
expect(response.parsed_body["redirect_path"]).to eq("/ggg") expect(response.parsed_body["redirect_url"]).to eq("/ggg")
post "/session/2fa/test-action", params: { second_factor_nonce: nonce } post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -2442,7 +2595,7 @@ describe SessionController do
end end
it 'does not mark the challenge as successful if the 2fa fails' do it 'does not mark the challenge as successful if the 2fa fails' do
post "/session/2fa/test-action", params: { redirect_path: "/ggg" } post "/session/2fa/test-action", params: { redirect_url: "/ggg" }, xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"] nonce = response.parsed_body["second_factor_challenge_nonce"]
token = ROTP::TOTP.new(user_second_factor.data).now.to_i token = ROTP::TOTP.new(user_second_factor.data).now.to_i

View File

@ -6,7 +6,7 @@ class TestSecondFactorAction < SecondFactor::Actions::Base
def second_factor_auth_required!(params) def second_factor_auth_required!(params)
{ {
redirect_path: params[:redirect_path], redirect_url: params[:redirect_url],
callback_params: { callback_params: {
saved_param_1: params[:saved_param_1], saved_param_1: params[:saved_param_1],
saved_param_2: params[:saved_param_2] saved_param_2: params[:saved_param_2]