mirror of
https://github.com/discourse/discourse.git
synced 2025-05-30 06:48:47 +08:00
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:
@ -1116,45 +1116,45 @@ describe SessionController do
|
||||
|
||||
describe '#sso_provider' do
|
||||
let(:headers) { { host: Discourse.current_hostname } }
|
||||
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
|
||||
fab!(:user) { Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) }
|
||||
|
||||
before do
|
||||
stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return(
|
||||
status: 200,
|
||||
body: lambda { |request| file_from_fixtures("logo.png") }
|
||||
)
|
||||
|
||||
SiteSetting.enable_discourse_connect_provider = true
|
||||
SiteSetting.enable_discourse_connect = false
|
||||
SiteSetting.enable_local_logins = true
|
||||
SiteSetting.discourse_connect_provider_secrets = [
|
||||
"*|secret,forAll",
|
||||
"*.rainbow|wrongSecretForOverRainbow",
|
||||
"www.random.site|secretForRandomSite",
|
||||
"somewhere.over.rainbow|secretForOverRainbow",
|
||||
].join("\n")
|
||||
|
||||
@sso = DiscourseConnectProvider.new
|
||||
@sso.nonce = "mynonce"
|
||||
@sso.return_sso_url = "http://somewhere.over.rainbow/sso"
|
||||
|
||||
@user = user
|
||||
group = Fabricate(:group)
|
||||
group.add(@user)
|
||||
|
||||
@user.create_user_avatar!
|
||||
UserAvatar.import_url_for_user(logo_fixture, @user)
|
||||
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false)
|
||||
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true)
|
||||
|
||||
@user.reload
|
||||
@user.user_avatar.reload
|
||||
@user.user_profile.reload
|
||||
EmailToken.update_all(confirmed: true)
|
||||
end
|
||||
|
||||
describe 'can act as an SSO provider' do
|
||||
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
|
||||
|
||||
before do
|
||||
stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return(
|
||||
status: 200,
|
||||
body: lambda { |request| file_from_fixtures("logo.png") }
|
||||
)
|
||||
|
||||
SiteSetting.enable_discourse_connect_provider = true
|
||||
SiteSetting.enable_discourse_connect = false
|
||||
SiteSetting.enable_local_logins = true
|
||||
SiteSetting.discourse_connect_provider_secrets = [
|
||||
"*|secret,forAll",
|
||||
"*.rainbow|wrongSecretForOverRainbow",
|
||||
"www.random.site|secretForRandomSite",
|
||||
"somewhere.over.rainbow|secretForOverRainbow",
|
||||
].join("\n")
|
||||
|
||||
@sso = DiscourseConnectProvider.new
|
||||
@sso.nonce = "mynonce"
|
||||
@sso.return_sso_url = "http://somewhere.over.rainbow/sso"
|
||||
|
||||
@user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true)
|
||||
group = Fabricate(:group)
|
||||
group.add(@user)
|
||||
|
||||
@user.create_user_avatar!
|
||||
UserAvatar.import_url_for_user(logo_fixture, @user)
|
||||
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false)
|
||||
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true)
|
||||
|
||||
@user.reload
|
||||
@user.user_avatar.reload
|
||||
@user.user_profile.reload
|
||||
EmailToken.update_all(confirmed: true)
|
||||
end
|
||||
|
||||
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"))
|
||||
|
||||
@ -1185,6 +1185,8 @@ describe SessionController do
|
||||
expect(sso2.avatar_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.confirmed_2fa).to eq(nil)
|
||||
expect(sso2.no_2fa_methods).to eq(nil)
|
||||
end
|
||||
|
||||
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.profile_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
|
||||
|
||||
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.profile_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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
describe '#create' do
|
||||
@ -2317,7 +2470,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
get "/session/2fa.json", params: { nonce: nonce }
|
||||
expect(response.status).to eq(200)
|
||||
@ -2330,7 +2483,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
get "/session/2fa.json", params: { nonce: nonce }
|
||||
expect(response.status).to eq(200)
|
||||
@ -2352,7 +2505,7 @@ describe SessionController do
|
||||
enabled: true
|
||||
)
|
||||
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"]
|
||||
get "/session/2fa.json", params: { nonce: nonce }
|
||||
expect(response.status).to eq(200)
|
||||
@ -2379,7 +2532,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
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"]
|
||||
post "/session/2fa.json", params: {
|
||||
nonce: nonce,
|
||||
@ -2406,7 +2559,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
token = ROTP::TOTP.new(user_second_factor.data).now
|
||||
user_second_factor.destroy!
|
||||
@ -2419,7 +2572,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
|
||||
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["callback_method"]).to eq("POST")
|
||||
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 }
|
||||
expect(response.status).to eq(200)
|
||||
@ -2442,7 +2595,7 @@ describe SessionController do
|
||||
end
|
||||
|
||||
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"]
|
||||
|
||||
token = ROTP::TOTP.new(user_second_factor.data).now.to_i
|
||||
|
Reference in New Issue
Block a user