2019-04-29 20:27:42 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2019-12-16 19:33:51 -05:00
|
|
|
require 'rotp'
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2021-11-25 02:34:39 -05:00
|
|
|
describe SessionController do
|
|
|
|
let(:user) { Fabricate(:user) }
|
|
|
|
let(:email_token) { Fabricate(:email_token, user: user) }
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
shared_examples 'failed to continue local login' do
|
|
|
|
it 'should return the right response' do
|
2018-06-05 03:29:17 -04:00
|
|
|
expect(response).not_to be_successful
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
describe '#email_login_info' do
|
2021-11-25 02:34:39 -05:00
|
|
|
let(:email_token) { Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login]) }
|
|
|
|
|
2019-06-17 10:59:41 -04:00
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins_via_email = true
|
|
|
|
end
|
|
|
|
|
2020-01-12 21:10:07 -05:00
|
|
|
context "when local logins via email disabled" do
|
|
|
|
before { SiteSetting.enable_local_logins_via_email = false }
|
|
|
|
|
|
|
|
it "only works for admins" do
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2020-01-16 20:25:31 -05:00
|
|
|
|
|
|
|
user.update(admin: true)
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-16 20:25:31 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context "when SSO enabled" do
|
|
|
|
before do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
2020-01-16 20:25:31 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
it "only works for admins" do
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2020-01-12 21:10:07 -05:00
|
|
|
|
|
|
|
user.update(admin: true)
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-12 21:10:07 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-06-17 10:59:41 -04:00
|
|
|
context 'missing token' do
|
|
|
|
it 'returns the right response' do
|
2019-06-17 11:17:10 -04:00
|
|
|
get "/session/email-login"
|
2019-06-17 10:59:41 -04:00
|
|
|
expect(response.status).to eq(404)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
context 'valid token' do
|
|
|
|
it 'returns information' do
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["can_login"]).to eq(true)
|
|
|
|
expect(response.parsed_body["second_factor_required"]).to eq(nil)
|
2019-06-12 10:37:26 -04:00
|
|
|
|
|
|
|
# Does not log in the user
|
|
|
|
expect(session[:current_user_id]).to be_nil
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'fails when local logins via email is disabled' do
|
|
|
|
SiteSetting.enable_local_logins_via_email = false
|
|
|
|
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2019-06-12 10:37:26 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it 'fails when local logins is disabled' do
|
|
|
|
SiteSetting.enable_local_logins = false
|
|
|
|
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2019-06-12 10:37:26 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
context 'user has 2-factor logins' do
|
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
|
|
|
|
|
|
|
|
it "includes that information in the response" do
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body_parsed = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body_parsed["can_login"]).to eq(true)
|
|
|
|
expect(response_body_parsed["second_factor_required"]).to eq(true)
|
|
|
|
expect(response_body_parsed["backup_codes_enabled"]).to eq(true)
|
2019-06-12 10:37:26 -04:00
|
|
|
end
|
|
|
|
end
|
2019-10-01 22:08:41 -04:00
|
|
|
|
|
|
|
context 'user has security key enabled' do
|
|
|
|
let!(:user_security_key) { Fabricate(:user_security_key, user: user) }
|
|
|
|
|
|
|
|
it "includes that information in the response" do
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body_parsed = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body_parsed["can_login"]).to eq(true)
|
|
|
|
expect(response_body_parsed["security_key_required"]).to eq(true)
|
|
|
|
expect(response_body_parsed["second_factor_required"]).to eq(nil)
|
|
|
|
expect(response_body_parsed["backup_codes_enabled"]).to eq(nil)
|
|
|
|
expect(response_body_parsed["allowed_credential_ids"]).to eq([user_security_key.credential_id])
|
2019-10-01 22:08:41 -04:00
|
|
|
secure_session = SecureSession.new(session["secure_session_id"])
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body_parsed["challenge"]).to eq(Webauthn.challenge(user, secure_session))
|
|
|
|
expect(Webauthn.rp_id(user, secure_session)).to eq(Discourse.current_hostname)
|
2019-10-01 22:08:41 -04:00
|
|
|
end
|
|
|
|
end
|
2019-06-12 10:37:26 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#email_login' do
|
2021-11-25 02:34:39 -05:00
|
|
|
let(:email_token) { Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login]) }
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins_via_email = true
|
|
|
|
end
|
|
|
|
|
2020-01-12 21:10:07 -05:00
|
|
|
context "when local logins via email disabled" do
|
|
|
|
before { SiteSetting.enable_local_logins_via_email = false }
|
|
|
|
|
|
|
|
it "only works for admins" do
|
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2020-01-12 21:10:07 -05:00
|
|
|
|
|
|
|
user.update(admin: true)
|
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-12 21:10:07 -05:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
context 'missing token' do
|
|
|
|
it 'returns the right response' do
|
|
|
|
post "/session/email-login"
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
context 'invalid token' do
|
|
|
|
it 'returns the right response' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/adasdad.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2017-04-20 11:17:24 -04:00
|
|
|
I18n.t('email_login.invalid_token')
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'when token has expired' do
|
|
|
|
it 'should return the right response' do
|
|
|
|
email_token.update!(created_at: 999.years.ago)
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2017-04-20 11:17:24 -04:00
|
|
|
I18n.t('email_login.invalid_token')
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'valid token' do
|
|
|
|
it 'returns success' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["success"]).to eq("OK")
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it 'fails when local logins via email is disabled' do
|
|
|
|
SiteSetting.enable_local_logins_via_email = false
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it 'fails when local logins is disabled' do
|
|
|
|
SiteSetting.enable_local_logins = false
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
2020-01-20 01:11:58 -05:00
|
|
|
expect(response.status).to eq(403)
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it "doesn't log in the user when not approved" do
|
|
|
|
SiteSetting.must_approve_users = true
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("login.not_approved"))
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
context "when admin IP address is not valid" do
|
|
|
|
before do
|
|
|
|
Fabricate(:screened_ip_address,
|
|
|
|
ip_address: "111.111.11.11",
|
|
|
|
action_type: ScreenedIpAddress.actions[:allow_admin]
|
|
|
|
)
|
|
|
|
|
2020-07-26 20:23:54 -04:00
|
|
|
SiteSetting.use_admin_ip_allowlist = true
|
2017-04-20 11:17:24 -04:00
|
|
|
user.update!(admin: true)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns the right response' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2017-04-20 11:17:24 -04:00
|
|
|
I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)
|
|
|
|
)
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context "when IP address is blocked" do
|
|
|
|
let(:permitted_ip_address) { '111.234.23.11' }
|
|
|
|
|
|
|
|
before do
|
|
|
|
Fabricate(:screened_ip_address,
|
|
|
|
ip_address: permitted_ip_address,
|
|
|
|
action_type: ScreenedIpAddress.actions[:block]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns the right response' do
|
|
|
|
ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(permitted_ip_address)
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2017-04-20 11:17:24 -04:00
|
|
|
I18n.t("login.not_allowed_from_ip_address", username: user.username)
|
|
|
|
)
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-04-10 14:19:39 -04:00
|
|
|
context "when timezone param is provided" do
|
|
|
|
it "sets the user_option timezone for the user" do
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: { timezone: "Australia/Melbourne" }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-04-10 14:19:39 -04:00
|
|
|
expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2017-04-20 11:17:24 -04:00
|
|
|
it "fails when user is suspended" do
|
|
|
|
user.update!(
|
|
|
|
suspended_till: 2.days.from_now,
|
|
|
|
suspended_at: Time.zone.now
|
|
|
|
)
|
|
|
|
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json"
|
2017-04-20 11:17:24 -04:00
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2019-06-12 10:37:26 -04:00
|
|
|
I18n.t("login.suspended", date: I18n.l(user.suspended_till, format: :date_only)
|
2017-04-20 11:17:24 -04:00
|
|
|
))
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
2017-12-21 20:18:12 -05:00
|
|
|
|
|
|
|
context 'user has 2-factor logins' do
|
2018-06-28 04:12:32 -04:00
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
|
2017-12-21 20:18:12 -05:00
|
|
|
|
|
|
|
describe 'errors on incorrect 2-factor' do
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using totp method' do
|
|
|
|
it 'does not log in with incorrect two factor' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
2018-06-28 04:12:32 -04:00
|
|
|
second_factor_token: "0000",
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2019-06-12 10:37:26 -04:00
|
|
|
I18n.t("login.invalid_second_factor_code")
|
|
|
|
)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2018-06-28 04:12:32 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
context 'when using backup code method' do
|
|
|
|
it 'does not log in with incorrect backup code' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
2018-06-28 04:12:32 -04:00
|
|
|
second_factor_token: "0000",
|
|
|
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2019-06-12 10:37:26 -04:00
|
|
|
I18n.t("login.invalid_second_factor_code")
|
|
|
|
)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2018-06-28 04:12:32 -04:00
|
|
|
end
|
2017-12-21 20:18:12 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'allows successful 2-factor' do
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using totp method' do
|
|
|
|
it 'logs in correctly' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
2018-06-28 04:12:32 -04:00
|
|
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["success"]).to eq("OK")
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
2018-06-28 04:12:32 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
context 'when using backup code method' do
|
|
|
|
it 'logs in correctly' do
|
2019-06-12 10:37:26 -04:00
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
2018-06-28 04:12:32 -04:00
|
|
|
second_factor_token: "iAmValidBackupCode",
|
|
|
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
|
|
|
}
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["success"]).to eq("OK")
|
2019-06-12 10:37:26 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
2018-06-28 04:12:32 -04:00
|
|
|
end
|
2017-12-21 20:18:12 -05:00
|
|
|
end
|
|
|
|
end
|
2020-01-15 05:27:12 -05:00
|
|
|
|
|
|
|
context "if the security_key_param is provided but only TOTP is enabled" do
|
|
|
|
it "does not log in the user" do
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
|
|
|
second_factor_token: 'foo',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error"]).to eq(
|
2020-01-15 05:27:12 -05:00
|
|
|
I18n.t("login.invalid_second_factor_code")
|
|
|
|
)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
|
|
|
end
|
|
|
|
end
|
2017-12-21 20:18:12 -05:00
|
|
|
end
|
2020-01-09 19:45:56 -05:00
|
|
|
|
|
|
|
context "user has only security key enabled" do
|
|
|
|
let!(:user_security_key) do
|
|
|
|
Fabricate(
|
|
|
|
:user_security_key,
|
|
|
|
user: user,
|
|
|
|
credential_id: valid_security_key_data[:credential_id],
|
|
|
|
public_key: valid_security_key_data[:public_key]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
before do
|
|
|
|
simulate_localhost_webauthn_challenge
|
|
|
|
|
|
|
|
# store challenge in secure session by visiting the email login page
|
|
|
|
get "/session/email-login/#{email_token.token}.json"
|
|
|
|
end
|
|
|
|
|
|
|
|
context "when the security key params are blank and a random second factor token is provided" do
|
|
|
|
it "shows an error message and denies login" do
|
|
|
|
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
|
|
|
second_factor_token: "XXXXXXX",
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
2020-01-15 05:27:12 -05:00
|
|
|
'login.not_enabled_second_factor_method'
|
2020-01-09 19:45:56 -05:00
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
|
|
|
context "when the security key params are invalid" do
|
2020-02-19 14:04:38 -05:00
|
|
|
it "shows an error message and denies login" do
|
2020-01-09 19:45:56 -05:00
|
|
|
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_token: {
|
2020-01-09 19:45:56 -05:00
|
|
|
signature: 'bad_sig',
|
|
|
|
clientData: 'bad_clientData',
|
|
|
|
credentialId: 'bad_credential_id',
|
|
|
|
authenticatorData: 'bad_authenticator_data'
|
|
|
|
},
|
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body["failed"]).to eq("FAILED")
|
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
|
|
|
'webauthn.validation.not_found_error'
|
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
|
|
|
context "when the security key params are valid" do
|
|
|
|
it "logs the user in" do
|
|
|
|
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_token: valid_security_key_auth_post_data,
|
2020-01-09 19:45:56 -05:00
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-09 19:45:56 -05:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(user.user_auth_tokens.count).to eq(1)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2020-01-15 05:27:12 -05:00
|
|
|
|
|
|
|
context "user has security key and totp enabled" do
|
|
|
|
let!(:user_security_key) do
|
|
|
|
Fabricate(
|
|
|
|
:user_security_key,
|
|
|
|
user: user,
|
|
|
|
credential_id: valid_security_key_data[:credential_id],
|
|
|
|
public_key: valid_security_key_data[:public_key]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
|
|
|
|
it "doesnt allow logging in if the 2fa params are garbled" do
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: "blah"
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-15 05:27:12 -05:00
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
|
|
|
'login.invalid_second_factor_code'
|
|
|
|
))
|
|
|
|
end
|
|
|
|
|
|
|
|
it "doesnt allow login if both of the 2fa params are blank" do
|
|
|
|
post "/session/email-login/#{email_token.token}.json", params: {
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: ""
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-15 05:27:12 -05:00
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
|
|
|
'login.invalid_second_factor_code'
|
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|
|
|
|
end
|
2018-03-06 00:49:31 -05:00
|
|
|
|
|
|
|
context 'logoff support' do
|
|
|
|
it 'can log off users cleanly' do
|
|
|
|
user = Fabricate(:user)
|
|
|
|
sign_in(user)
|
|
|
|
|
|
|
|
UserAuthToken.destroy_all
|
|
|
|
|
|
|
|
# we need a route that will call current user
|
2021-09-14 08:18:01 -04:00
|
|
|
post '/drafts.json', params: {}
|
2018-03-06 00:49:31 -05:00
|
|
|
expect(response.headers['Discourse-Logged-Out']).to eq("1")
|
|
|
|
end
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
describe '#become' do
|
|
|
|
let!(:user) { Fabricate(:user) }
|
|
|
|
|
|
|
|
it "does not work when in production mode" do
|
|
|
|
Rails.env.stubs(:production?).returns(true)
|
|
|
|
get "/session/#{user.username}/become.json"
|
|
|
|
|
|
|
|
expect(response.status).to eq(403)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body["error_type"]).to eq("invalid_access")
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
end
|
|
|
|
|
2021-05-20 21:43:47 -04:00
|
|
|
it "works in development mode" do
|
2018-05-29 19:11:01 -04:00
|
|
|
Rails.env.stubs(:development?).returns(true)
|
|
|
|
get "/session/#{user.username}/become.json"
|
|
|
|
expect(response).to be_redirect
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-02-08 05:59:43 -05:00
|
|
|
describe '#sso' do
|
|
|
|
before do
|
|
|
|
SiteSetting.discourse_connect_url = "http://example.com/discourse_sso"
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
|
|
|
SiteSetting.discourse_connect_secret = "shjkfdhsfkjh"
|
|
|
|
end
|
|
|
|
|
|
|
|
it "redirects correctly" do
|
|
|
|
get "/session/sso"
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(response.location).to start_with(SiteSetting.discourse_connect_url)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
describe '#sso_login' do
|
|
|
|
before do
|
2018-06-19 10:25:10 -04:00
|
|
|
@sso_url = "http://example.com/discourse_sso"
|
2018-05-29 19:11:01 -04:00
|
|
|
@sso_secret = "shjkfdhsfkjh"
|
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_url = @sso_url
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
|
|
|
SiteSetting.discourse_connect_secret = @sso_secret
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
Fabricate(:admin)
|
|
|
|
end
|
|
|
|
|
|
|
|
let(:headers) { { host: Discourse.current_hostname } }
|
|
|
|
|
|
|
|
def get_sso(return_path)
|
|
|
|
nonce = SecureRandom.hex
|
2022-01-06 07:28:46 -05:00
|
|
|
dso = DiscourseConnect.new(secure_session: read_secure_session)
|
2018-05-29 19:11:01 -04:00
|
|
|
dso.nonce = nonce
|
|
|
|
dso.register_nonce(return_path)
|
|
|
|
|
2022-01-06 07:28:46 -05:00
|
|
|
sso = DiscourseConnectBase.new
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.nonce = nonce
|
|
|
|
sso.sso_secret = @sso_secret
|
|
|
|
sso
|
|
|
|
end
|
|
|
|
|
2021-05-20 21:43:47 -04:00
|
|
|
it 'does not create superfluous auth tokens when already logged in' do
|
2018-10-31 21:54:01 -04:00
|
|
|
user = Fabricate(:user)
|
|
|
|
sign_in(user)
|
|
|
|
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = user.email
|
|
|
|
sso.external_id = 'abc'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
expect do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user.id).to eq(user.id)
|
|
|
|
end.not_to change { UserAuthToken.count }
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2018-11-09 01:17:43 -05:00
|
|
|
it 'will never redirect back to /session/sso path' do
|
|
|
|
sso = get_sso("/session/sso?bla=1")
|
2018-11-08 22:27:36 -05:00
|
|
|
sso.email = user.email
|
|
|
|
sso.external_id = 'abc'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('/')
|
2018-11-09 01:03:42 -05:00
|
|
|
|
2018-11-09 01:17:43 -05:00
|
|
|
sso = get_sso("http://#{Discourse.current_hostname}/session/sso?bla=1")
|
2018-11-09 01:03:42 -05:00
|
|
|
sso.email = user.email
|
|
|
|
sso.external_id = 'abc'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('/')
|
|
|
|
|
2018-11-08 22:27:36 -05:00
|
|
|
end
|
|
|
|
|
2019-06-10 20:04:26 -04:00
|
|
|
it 'can handle invalid sso external ids due to blank' do
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = "test@test.com"
|
|
|
|
sso.external_id = ' '
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
2020-04-07 22:42:28 -04:00
|
|
|
messages = track_log_messages(level: Logger::WARN) do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
end
|
2019-06-10 20:04:26 -04:00
|
|
|
|
2020-04-07 22:42:28 -04:00
|
|
|
expect(messages.length).to eq(0)
|
2019-06-10 20:04:26 -04:00
|
|
|
expect(response.status).to eq(500)
|
2021-02-08 05:04:33 -05:00
|
|
|
expect(response.body).to include(I18n.t('discourse_connect.blank_id_error'))
|
2019-06-10 20:04:26 -04:00
|
|
|
end
|
|
|
|
|
2020-11-23 06:06:08 -05:00
|
|
|
it 'can handle invalid sso email validation errors' do
|
|
|
|
SiteSetting.blocked_email_domains = "test.com"
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = "test@test.com"
|
|
|
|
sso.external_id = '123'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
messages = track_log_messages(level: Logger::WARN) do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
end
|
|
|
|
|
|
|
|
expect(messages.length).to eq(0)
|
|
|
|
expect(response.status).to eq(500)
|
2021-02-08 05:04:33 -05:00
|
|
|
expect(response.body).to include(I18n.t("discourse_connect.email_error", email: ERB::Util.html_escape("test@test.com")))
|
2020-11-23 06:06:08 -05:00
|
|
|
end
|
|
|
|
|
2019-06-10 20:04:26 -04:00
|
|
|
it 'can handle invalid sso external ids due to banned word' do
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = "test@test.com"
|
|
|
|
sso.external_id = 'nil'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
expect(response.status).to eq(500)
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
it 'can take over an account' do
|
2020-04-08 02:33:50 -04:00
|
|
|
user = Fabricate(:user, email: 'bill@bill.com')
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = user.email
|
|
|
|
sso.external_id = 'abc'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
expect(response).to redirect_to('/')
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user.email).to eq(user.email)
|
|
|
|
expect(logged_on_user.single_sign_on_record.external_id).to eq("abc")
|
|
|
|
expect(logged_on_user.single_sign_on_record.external_username).to eq('sam')
|
2020-04-08 02:33:50 -04:00
|
|
|
|
|
|
|
# we are updating the email ... ensure auto group membership works
|
|
|
|
|
|
|
|
sign_out
|
|
|
|
|
|
|
|
SiteSetting.email_editable = false
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.auth_overrides_email = true
|
2020-04-08 02:33:50 -04:00
|
|
|
|
|
|
|
group = Fabricate(:group, name: :bob, automatic_membership_email_domains: 'jane.com')
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.email = "hello@jane.com"
|
|
|
|
sso.external_id = 'abc'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
|
|
|
|
expect(logged_on_user.email).to eq('hello@jane.com')
|
|
|
|
expect(group.users.count).to eq(1)
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def sso_for_ip_specs
|
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
sso
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'respects IP restrictions on create' do
|
|
|
|
ScreenedIpAddress.all.destroy_all
|
|
|
|
get "/"
|
2019-06-10 20:04:26 -04:00
|
|
|
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:block])
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
sso = sso_for_ip_specs
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'respects IP restrictions on login' do
|
|
|
|
ScreenedIpAddress.all.destroy_all
|
|
|
|
get "/"
|
|
|
|
sso = sso_for_ip_specs
|
2022-01-06 07:28:46 -05:00
|
|
|
DiscourseConnect.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user(request.remote_ip)
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
sso = sso_for_ip_specs
|
2019-06-10 20:04:26 -04:00
|
|
|
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:block])
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to be_blank
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'respects email restrictions' do
|
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
ScreenedEmail.block('bob@bob.com')
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'allows you to create an admin account' do
|
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
sso.custom_fields["shop_url"] = "http://my_shop.com"
|
|
|
|
sso.custom_fields["shop_name"] = "Sam"
|
|
|
|
sso.admin = true
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user.admin).to eq(true)
|
|
|
|
end
|
2019-03-24 18:02:42 -04:00
|
|
|
|
|
|
|
it 'does not redirect offsite' do
|
|
|
|
sso = get_sso("#{Discourse.base_url}//site.com/xyz")
|
|
|
|
sso.external_id = '666'
|
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to("#{Discourse.base_url}//site.com/xyz")
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
it 'redirects to a non-relative url' do
|
|
|
|
sso = get_sso("#{Discourse.base_url}/b/")
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('/b/')
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'redirects to random url if it is allowed' do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_allows_all_return_paths = true
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
sso = get_sso('https://gusundtrout.com')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('https://gusundtrout.com')
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'redirects to root if the host of the return_path is different' do
|
|
|
|
sso = get_sso('//eviltrout.com')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('/')
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'redirects to root if the host of the return_path is different' do
|
|
|
|
sso = get_sso('http://eviltrout.com')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response).to redirect_to('/')
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'allows you to create an account' do
|
2020-04-08 02:33:50 -04:00
|
|
|
group = Fabricate(:group, name: :bob, automatic_membership_email_domains: 'bob.com')
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
sso.custom_fields["shop_url"] = "http://my_shop.com"
|
|
|
|
sso.custom_fields["shop_name"] = "Sam"
|
|
|
|
|
|
|
|
events = DiscourseEvent.track_events do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
end
|
|
|
|
|
|
|
|
expect(events.map { |event| event[:event_name] }).to include(
|
|
|
|
:user_logged_in, :user_first_logged_in
|
|
|
|
)
|
|
|
|
|
|
|
|
expect(response).to redirect_to('/a/')
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
|
2020-04-08 02:33:50 -04:00
|
|
|
expect(group.users.where(id: logged_on_user.id).count).to eq(1)
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
# ensure nothing is transient
|
|
|
|
logged_on_user = User.find(logged_on_user.id)
|
|
|
|
|
|
|
|
expect(logged_on_user.admin).to eq(false)
|
|
|
|
expect(logged_on_user.email).to eq('bob@bob.com')
|
|
|
|
expect(logged_on_user.name).to eq('Sam Saffron')
|
|
|
|
expect(logged_on_user.username).to eq('sam')
|
|
|
|
|
|
|
|
expect(logged_on_user.single_sign_on_record.external_id).to eq("666")
|
|
|
|
expect(logged_on_user.single_sign_on_record.external_username).to eq('sam')
|
|
|
|
expect(logged_on_user.active).to eq(true)
|
|
|
|
expect(logged_on_user.custom_fields["shop_url"]).to eq("http://my_shop.com")
|
|
|
|
expect(logged_on_user.custom_fields["shop_name"]).to eq("Sam")
|
|
|
|
expect(logged_on_user.custom_fields["bla"]).to eq(nil)
|
|
|
|
end
|
|
|
|
|
2021-03-18 20:20:10 -04:00
|
|
|
context "when an invitation is used" do
|
|
|
|
let(:invite) { Fabricate(:invite, email: invite_email, invited_by: Fabricate(:admin)) }
|
|
|
|
let(:invite_email) { nil }
|
|
|
|
|
|
|
|
def login_with_sso_and_invite(invite_key = invite.invite_key)
|
|
|
|
write_secure_session("invite-key", invite_key)
|
|
|
|
sso = get_sso("/")
|
|
|
|
sso.external_id = "666"
|
|
|
|
sso.email = "bob@bob.com"
|
|
|
|
sso.name = "Sam Saffron"
|
|
|
|
sso.username = "sam"
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
end
|
|
|
|
|
|
|
|
it "errors if the invite key is invalid" do
|
|
|
|
login_with_sso_and_invite("wrong")
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
expect(response.body).to include(I18n.t("invite.not_found", base_url: Discourse.base_url))
|
|
|
|
expect(invite.reload.redeemed?).to eq(false)
|
|
|
|
expect(User.find_by_email("bob@bob.com")).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "errors if the invite has expired" do
|
|
|
|
invite.update!(expires_at: 3.days.ago)
|
|
|
|
login_with_sso_and_invite
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
expect(response.body).to include(I18n.t("invite.expired", base_url: Discourse.base_url))
|
|
|
|
expect(invite.reload.redeemed?).to eq(false)
|
|
|
|
expect(User.find_by_email("bob@bob.com")).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "errors if the invite has been redeemed already" do
|
|
|
|
invite.update!(max_redemptions_allowed: 1, redemption_count: 1)
|
|
|
|
login_with_sso_and_invite
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
expect(response.body).to include(I18n.t("invite.not_found_template", site_name: SiteSetting.title, base_url: Discourse.base_url))
|
|
|
|
expect(invite.reload.redeemed?).to eq(true)
|
|
|
|
expect(User.find_by_email("bob@bob.com")).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "errors if the invite is for a specific email and that email does not match the sso email" do
|
|
|
|
invite.update!(email: "someotheremail@dave.com")
|
|
|
|
login_with_sso_and_invite
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
expect(response.body).to include(I18n.t("invite.not_matching_email", base_url: Discourse.base_url))
|
|
|
|
expect(invite.reload.redeemed?).to eq(false)
|
|
|
|
expect(User.find_by_email("bob@bob.com")).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "allows you to create an account and redeems the invite successfully, clearing the invite-key session" do
|
|
|
|
login_with_sso_and_invite
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(response).to redirect_to("/")
|
|
|
|
expect(invite.reload.redeemed?).to eq(true)
|
|
|
|
|
|
|
|
user = User.find_by_email("bob@bob.com")
|
|
|
|
expect(user.active).to eq(true)
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(read_secure_session["invite-key"]).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "allows you to create an account and redeems the invite successfully even if must_approve_users is enabled" do
|
|
|
|
SiteSetting.must_approve_users = true
|
|
|
|
|
|
|
|
login_with_sso_and_invite
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(response).to redirect_to("/")
|
|
|
|
expect(invite.reload.redeemed?).to eq(true)
|
|
|
|
|
|
|
|
user = User.find_by_email("bob@bob.com")
|
|
|
|
expect(user.active).to eq(true)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "redirects to the topic associated to the invite" do
|
|
|
|
topic_invite = TopicInvite.create!(invite: invite, topic: Fabricate(:topic))
|
|
|
|
login_with_sso_and_invite
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(response).to redirect_to(topic_invite.topic.relative_url)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "adds the user to the appropriate invite groups" do
|
|
|
|
invited_group = InvitedGroup.create!(invite: invite, group: Fabricate(:group))
|
|
|
|
login_with_sso_and_invite
|
|
|
|
|
|
|
|
expect(invite.reload.redeemed?).to eq(true)
|
|
|
|
|
|
|
|
user = User.find_by_email("bob@bob.com")
|
|
|
|
expect(GroupUser.exists?(user: user, group: invited_group.group)).to eq(true)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
context 'when sso emails are not trusted' do
|
|
|
|
context 'if you have not activated your account' do
|
|
|
|
it 'does not log you in' do
|
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
sso.require_activation = true
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'sends an activation email' do
|
|
|
|
sso = get_sso('/a/')
|
2021-03-18 20:20:10 -04:00
|
|
|
sso.external_id = '666'
|
2018-05-29 19:11:01 -04:00
|
|
|
sso.email = 'bob@bob.com'
|
|
|
|
sso.name = 'Sam Saffron'
|
|
|
|
sso.username = 'sam'
|
|
|
|
sso.require_activation = true
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'if you have activated your account' do
|
|
|
|
it 'allows you to log in' do
|
|
|
|
sso = get_sso('/hello/world')
|
|
|
|
sso.external_id = '997'
|
|
|
|
sso.sso_url = "http://somewhere.over.com/sso_login"
|
|
|
|
sso.require_activation = true
|
|
|
|
|
|
|
|
user = Fabricate(:user)
|
|
|
|
user.create_single_sign_on_record(external_id: '997', last_payload: '')
|
|
|
|
user.stubs(:active?).returns(true)
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(user.id).to eq(logged_on_user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'allows login to existing account with valid nonce' do
|
|
|
|
sso = get_sso('/hello/world')
|
|
|
|
sso.external_id = '997'
|
|
|
|
sso.sso_url = "http://somewhere.over.com/sso_login"
|
|
|
|
|
|
|
|
user = Fabricate(:user)
|
|
|
|
user.create_single_sign_on_record(external_id: '997', last_payload: '')
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
user.single_sign_on_record.reload
|
|
|
|
expect(user.single_sign_on_record.last_payload).to eq(sso.unsigned_payload)
|
|
|
|
|
|
|
|
expect(response).to redirect_to('/hello/world')
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
|
|
|
|
expect(user.id).to eq(logged_on_user.id)
|
|
|
|
|
|
|
|
# nonce is bad now
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response.status).to eq(419)
|
|
|
|
end
|
|
|
|
|
2021-02-18 05:35:10 -05:00
|
|
|
it 'associates the nonce with the current session' do
|
|
|
|
sso = get_sso('/hello/world')
|
|
|
|
sso.external_id = '997'
|
|
|
|
sso.sso_url = "http://somewhere.over.com/sso_login"
|
|
|
|
|
|
|
|
user = Fabricate(:user)
|
|
|
|
user.create_single_sign_on_record(external_id: '997', last_payload: '')
|
|
|
|
|
|
|
|
# Establish a fresh session
|
|
|
|
cookies.to_hash.keys.each { |k| cookies.delete(k) }
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
expect(response.status).to eq(419)
|
|
|
|
end
|
|
|
|
|
2018-12-19 04:22:10 -05:00
|
|
|
context "when sso provider is enabled" do
|
|
|
|
before do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.enable_discourse_connect_provider = true
|
|
|
|
SiteSetting.discourse_connect_provider_secrets = [
|
2018-12-19 04:22:10 -05:00
|
|
|
"*|secret,forAll",
|
|
|
|
"*.rainbow|wrongSecretForOverRainbow",
|
|
|
|
"www.random.site|secretForRandomSite",
|
|
|
|
"somewhere.over.rainbow|secretForOverRainbow",
|
|
|
|
].join("\n")
|
|
|
|
end
|
|
|
|
|
|
|
|
it "doesn't break" do
|
|
|
|
sso = get_sso('/hello/world')
|
|
|
|
sso.external_id = '997'
|
|
|
|
sso.sso_url = "http://somewhere.over.com/sso_login"
|
|
|
|
sso.return_sso_url = "http://someurl.com"
|
|
|
|
|
|
|
|
user = Fabricate(:user)
|
|
|
|
user.create_single_sign_on_record(external_id: '997', last_payload: '')
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
|
|
|
|
|
|
|
|
user.single_sign_on_record.reload
|
|
|
|
expect(user.single_sign_on_record.last_payload).to eq(sso.unsigned_payload)
|
|
|
|
|
|
|
|
expect(response).to redirect_to('/hello/world')
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
|
|
|
|
expect(user.id).to eq(logged_on_user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-12-07 10:01:44 -05:00
|
|
|
it 'returns the correct error code for invalid signature' do
|
|
|
|
sso = get_sso('/hello/world')
|
|
|
|
sso.external_id = '997'
|
|
|
|
sso.sso_url = "http://somewhere.over.com/sso_login"
|
|
|
|
|
|
|
|
correct_params = Rack::Utils.parse_query(sso.payload)
|
|
|
|
get "/session/sso_login", params: correct_params.merge("sig": "thisisnotthesigyouarelookingfor"), headers: headers
|
|
|
|
expect(response.status).to eq(422)
|
|
|
|
expect(response.body).not_to include(correct_params["sig"]) # Check we didn't send the real sig back to the client
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to eq(nil)
|
|
|
|
|
|
|
|
correct_params = Rack::Utils.parse_query(sso.payload)
|
|
|
|
get "/session/sso_login", params: correct_params.merge("sig": "thisisasignaturewith@special!characters"), headers: headers
|
|
|
|
expect(response.status).to eq(422)
|
|
|
|
expect(response.body).not_to include(correct_params["sig"]) # Check we didn't send the real sig back to the client
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user).to eq(nil)
|
|
|
|
end
|
|
|
|
|
2018-10-15 01:03:53 -04:00
|
|
|
describe 'local attribute override from SSO payload' do
|
|
|
|
before do
|
|
|
|
SiteSetting.email_editable = false
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.auth_overrides_email = true
|
|
|
|
SiteSetting.auth_overrides_username = true
|
|
|
|
SiteSetting.auth_overrides_name = true
|
2018-10-15 01:03:53 -04:00
|
|
|
|
|
|
|
@user = Fabricate(:user)
|
|
|
|
|
|
|
|
@sso = get_sso('/hello/world')
|
|
|
|
@sso.external_id = '997'
|
|
|
|
|
|
|
|
@reversed_username = @user.username.reverse
|
|
|
|
@sso.username = @reversed_username
|
|
|
|
@sso.email = "#{@reversed_username}@garbage.org"
|
|
|
|
@reversed_name = @user.name.reverse
|
|
|
|
@sso.name = @reversed_name
|
|
|
|
|
|
|
|
@suggested_username = UserNameSuggester.suggest(@sso.username || @sso.name || @sso.email)
|
|
|
|
@suggested_name = User.suggest_name(@sso.name || @sso.username || @sso.email)
|
|
|
|
@user.create_single_sign_on_record(external_id: '997', last_payload: '')
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'stores the external attributes' do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers
|
|
|
|
@user.single_sign_on_record.reload
|
|
|
|
expect(@user.single_sign_on_record.external_username).to eq(@sso.username)
|
|
|
|
expect(@user.single_sign_on_record.external_email).to eq(@sso.email)
|
|
|
|
expect(@user.single_sign_on_record.external_name).to eq(@sso.name)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'overrides attributes' do
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user.username).to eq(@suggested_username)
|
|
|
|
expect(logged_on_user.email).to eq("#{@reversed_username}@garbage.org")
|
|
|
|
expect(logged_on_user.name).to eq(@sso.name)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'does not change matching attributes for an existing account' do
|
|
|
|
@sso.username = @user.username
|
|
|
|
@sso.name = @user.name
|
|
|
|
@sso.email = @user.email
|
|
|
|
|
|
|
|
get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers
|
|
|
|
|
|
|
|
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
|
|
|
|
expect(logged_on_user.username).to eq(@user.username)
|
|
|
|
expect(logged_on_user.name).to eq(@user.name)
|
|
|
|
expect(logged_on_user.email).to eq(@user.email)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#sso_provider' do
|
|
|
|
let(:headers) { { host: Discourse.current_hostname } }
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
describe 'can act as an SSO provider' do
|
2021-11-25 02:34:39 -05:00
|
|
|
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
before do
|
|
|
|
stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return(
|
|
|
|
status: 200,
|
|
|
|
body: lambda { |request| file_from_fixtures("logo.png") }
|
|
|
|
)
|
|
|
|
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.enable_discourse_connect_provider = true
|
|
|
|
SiteSetting.enable_discourse_connect = false
|
2018-05-29 19:11:01 -04:00
|
|
|
SiteSetting.enable_local_logins = true
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_provider_secrets = [
|
2018-10-24 15:23:18 -04:00
|
|
|
"*|secret,forAll",
|
|
|
|
"*.rainbow|wrongSecretForOverRainbow",
|
|
|
|
"www.random.site|secretForRandomSite",
|
|
|
|
"somewhere.over.rainbow|secretForOverRainbow",
|
|
|
|
].join("\n")
|
2018-05-29 19:11:01 -04:00
|
|
|
|
2022-01-06 07:28:46 -05:00
|
|
|
@sso = DiscourseConnectProvider.new
|
2018-05-29 19:11:01 -04:00
|
|
|
@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
|
2018-10-15 01:03:53 -04:00
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
expect(response).to redirect_to("/login")
|
|
|
|
|
|
|
|
post "/session.json",
|
2018-10-15 01:03:53 -04:00
|
|
|
params: { login: @user.username, password: "myfrogs123ADMIN" }, xhr: true, headers: headers
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
location = response.cookies["sso_destination_url"]
|
|
|
|
# javascript code will handle redirection of user to return_sso_url
|
|
|
|
expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/)
|
|
|
|
|
|
|
|
payload = location.split("?")[1]
|
2022-01-06 07:28:46 -05:00
|
|
|
sso2 = DiscourseConnectProvider.parse(payload)
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
expect(sso2.email).to eq(@user.email)
|
|
|
|
expect(sso2.name).to eq(@user.name)
|
|
|
|
expect(sso2.username).to eq(@user.username)
|
|
|
|
expect(sso2.external_id).to eq(@user.id.to_s)
|
|
|
|
expect(sso2.admin).to eq(true)
|
|
|
|
expect(sso2.moderator).to eq(false)
|
|
|
|
expect(sso2.groups).to eq(@user.groups.pluck(:name).join(","))
|
|
|
|
|
|
|
|
expect(sso2.avatar_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.profile_background_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.card_background_url.blank?).to_not eq(true)
|
|
|
|
|
|
|
|
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)
|
|
|
|
end
|
|
|
|
|
2018-10-15 01:03:53 -04:00
|
|
|
it "it fails to log in if secret is wrong" do
|
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForRandomSite"))
|
|
|
|
|
2020-02-12 18:03:25 -05:00
|
|
|
expect(response.status).to eq(422)
|
2018-10-15 01:03:53 -04:00
|
|
|
end
|
|
|
|
|
2019-07-26 10:37:23 -04:00
|
|
|
it "fails with a nice error message if secret is blank" do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_provider_secrets = ""
|
2022-01-06 07:28:46 -05:00
|
|
|
sso = DiscourseConnectProvider.new
|
2019-07-26 10:37:23 -04:00
|
|
|
sso.nonce = "mynonce"
|
|
|
|
sso.return_sso_url = "http://website.without.secret.com/sso"
|
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(sso.payload("aasdasdasd"))
|
|
|
|
expect(response.status).to eq(400)
|
2021-02-08 05:04:33 -05:00
|
|
|
expect(response.body).to eq(I18n.t("discourse_connect.missing_secret"))
|
2019-07-26 10:37:23 -04:00
|
|
|
end
|
|
|
|
|
2020-05-12 20:11:22 -04:00
|
|
|
it "returns a 422 if no return_sso_url" do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_provider_secrets = "abcdefghij"
|
2022-01-06 07:28:46 -05:00
|
|
|
sso = DiscourseConnectProvider.new
|
2020-05-12 20:11:22 -04:00
|
|
|
get "/session/sso_provider?sso=asdf&sig=abcdefghij"
|
|
|
|
expect(response.status).to eq(422)
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
it "successfully redirects user to return_sso_url when the user is logged in" do
|
|
|
|
sign_in(@user)
|
|
|
|
|
2018-10-15 01:03:53 -04:00
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
location = response.header["Location"]
|
|
|
|
expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/)
|
|
|
|
|
|
|
|
payload = location.split("?")[1]
|
2022-01-06 07:28:46 -05:00
|
|
|
sso2 = DiscourseConnectProvider.parse(payload)
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
expect(sso2.email).to eq(@user.email)
|
|
|
|
expect(sso2.name).to eq(@user.name)
|
|
|
|
expect(sso2.username).to eq(@user.username)
|
|
|
|
expect(sso2.external_id).to eq(@user.id.to_s)
|
|
|
|
expect(sso2.admin).to eq(true)
|
|
|
|
expect(sso2.moderator).to eq(false)
|
|
|
|
expect(sso2.groups).to eq(@user.groups.pluck(:name).join(","))
|
|
|
|
|
|
|
|
expect(sso2.avatar_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.profile_background_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.card_background_url.blank?).to_not eq(true)
|
|
|
|
|
|
|
|
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)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'handles non local content correctly' do
|
|
|
|
SiteSetting.avatar_sizes = "100|49"
|
2020-09-14 07:32:25 -04:00
|
|
|
setup_s3
|
2018-05-29 19:11:01 -04:00
|
|
|
SiteSetting.s3_cdn_url = "http://cdn.com"
|
|
|
|
|
2020-09-14 07:32:25 -04:00
|
|
|
stub_request(:any, /s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/).to_return(status: 200, body: "", headers: { referer: "fgdfds" })
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
@user.create_user_avatar!
|
2020-09-14 07:32:25 -04:00
|
|
|
upload = Fabricate(:upload, url: "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/something")
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
Fabricate(:optimized_image,
|
|
|
|
sha1: SecureRandom.hex << "A" * 8,
|
|
|
|
upload: upload,
|
|
|
|
width: 98,
|
|
|
|
height: 98,
|
2020-09-14 07:32:25 -04:00
|
|
|
url: "//s3-upload-bucket.s3.amazonaws.com/something/else"
|
2018-05-29 19:11:01 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
@user.update_columns(uploaded_avatar_id: upload.id)
|
2019-04-28 23:58:52 -04:00
|
|
|
|
|
|
|
upload1 = Fabricate(:upload_s3)
|
|
|
|
upload2 = Fabricate(:upload_s3)
|
|
|
|
|
|
|
|
@user.user_profile.update!(
|
|
|
|
profile_background_upload: upload1,
|
|
|
|
card_background_upload: upload2
|
2018-05-29 19:11:01 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
@user.reload
|
|
|
|
@user.user_avatar.reload
|
|
|
|
@user.user_profile.reload
|
|
|
|
|
|
|
|
sign_in(@user)
|
|
|
|
|
|
|
|
stub_request(:get, "http://cdn.com/something/else").to_return(
|
|
|
|
body: lambda { |request| File.new(Rails.root + 'spec/fixtures/images/logo.png') }
|
|
|
|
)
|
|
|
|
|
2018-10-15 01:03:53 -04:00
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
location = response.header["Location"]
|
|
|
|
# javascript code will handle redirection of user to return_sso_url
|
|
|
|
expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/)
|
|
|
|
|
|
|
|
payload = location.split("?")[1]
|
2022-01-06 07:28:46 -05:00
|
|
|
sso2 = DiscourseConnectProvider.parse(payload)
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
expect(sso2.avatar_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.profile_background_url.blank?).to_not eq(true)
|
|
|
|
expect(sso2.card_background_url.blank?).to_not eq(true)
|
|
|
|
|
2018-06-06 08:57:30 -04:00
|
|
|
expect(sso2.avatar_url).to start_with("#{SiteSetting.s3_cdn_url}/original")
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url)
|
|
|
|
expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url)
|
|
|
|
end
|
2020-02-03 12:53:14 -05:00
|
|
|
|
|
|
|
it "successfully logs out and redirects user to return_sso_url when the user is logged in" do
|
|
|
|
sign_in(@user)
|
|
|
|
|
|
|
|
@sso.logout = true
|
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
|
|
|
|
|
|
|
|
location = response.header["Location"]
|
|
|
|
expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso$/)
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
expect(response.cookies["_t"]).to be_blank
|
|
|
|
end
|
|
|
|
|
|
|
|
it "successfully logs out and redirects user to return_sso_url when the user is not logged in" do
|
|
|
|
@sso.logout = true
|
|
|
|
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
|
|
|
|
|
|
|
|
location = response.header["Location"]
|
|
|
|
expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso$/)
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
expect(response.cookies["_t"]).to be_blank
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#create' do
|
|
|
|
context 'local login is disabled' do
|
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins = false
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
it_behaves_like "failed to continue local login"
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'SSO is enabled' do
|
|
|
|
before do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
it_behaves_like "failed to continue local login"
|
|
|
|
end
|
|
|
|
|
2020-01-16 20:25:31 -05:00
|
|
|
context 'local login via email is disabled' do
|
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins_via_email = false
|
2022-03-25 11:44:12 -04:00
|
|
|
EmailToken.confirm(email_token.token)
|
2020-01-16 20:25:31 -05:00
|
|
|
end
|
|
|
|
it 'doesnt matter, logs in correctly' do
|
2020-04-07 22:42:28 -04:00
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
2020-01-16 20:25:31 -05:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-16 20:25:31 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
context 'when email is confirmed' do
|
|
|
|
before do
|
2021-11-25 02:34:39 -05:00
|
|
|
EmailToken.confirm(email_token.token)
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it "raises an error when the login isn't present" do
|
|
|
|
post "/session.json"
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'invalid password' do
|
|
|
|
it "should return an error with an invalid password" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'sssss'
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(
|
2018-05-29 19:11:01 -04:00
|
|
|
I18n.t("login.incorrect_username_email_or_password")
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'invalid password' do
|
|
|
|
it "should return an error with an invalid password if too long" do
|
|
|
|
User.any_instance.expects(:confirm_password?).never
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: ('s' * (User.max_password_length + 1))
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(
|
2018-05-29 19:11:01 -04:00
|
|
|
I18n.t("login.incorrect_username_email_or_password")
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'suspended user' do
|
|
|
|
it 'should return an error' do
|
|
|
|
user.suspended_till = 2.days.from_now
|
|
|
|
user.suspended_at = Time.now
|
|
|
|
user.save!
|
|
|
|
StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>")
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
2021-07-20 06:42:08 -04:00
|
|
|
expected_message = I18n.t('login.suspended_with_reason',
|
|
|
|
date: I18n.l(user.suspended_till, format: :date_only),
|
|
|
|
reason: Rack::Utils.escape_html(user.suspend_reason))
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2021-07-20 06:42:08 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(expected_message)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'when suspended forever should return an error without suspended till date' do
|
|
|
|
user.suspended_till = 101.years.from_now
|
|
|
|
user.suspended_at = Time.now
|
|
|
|
user.save!
|
|
|
|
StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>")
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
|
|
|
expected_message = I18n.t('login.suspended_with_reason_forever', reason: Rack::Utils.escape_html(user.suspend_reason))
|
|
|
|
expect(response.parsed_body['error']).to eq(expected_message)
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'deactivated user' do
|
|
|
|
it 'should return an error' do
|
|
|
|
user.active = false
|
|
|
|
user.save!
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(I18n.t('login.not_activated'))
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'success by username' do
|
|
|
|
it 'logs in correctly' do
|
|
|
|
events = DiscourseEvent.track_events do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(events.map { |event| event[:event_name] }).to contain_exactly(
|
|
|
|
:user_logged_in, :user_first_logged_in
|
|
|
|
)
|
|
|
|
|
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(user.user_auth_tokens.count).to eq(1)
|
FEATURE: Apply rate limits per user instead of IP for trusted users (#14706)
Currently, Discourse rate limits all incoming requests by the IP address they
originate from regardless of the user making the request. This can be
frustrating if there are multiple users using Discourse simultaneously while
sharing the same IP address (e.g. employees in an office).
This commit implements a new feature to make Discourse apply rate limits by
user id rather than IP address for users at or higher than the configured trust
level (1 is the default).
For example, let's say a Discourse instance is configured to allow 200 requests
per minute per IP address, and we have 10 users at trust level 4 using
Discourse simultaneously from the same IP address. Before this feature, the 10
users could only make a total of 200 requests per minute before they got rate
limited. But with the new feature, each user is allowed to make 200 requests
per minute because the rate limits are applied on user id rather than the IP
address.
The minimum trust level for applying user-id-based rate limits can be
configured by the `skip_per_ip_rate_limit_trust_level` global setting. The
default is 1, but it can be changed by either adding the
`DISCOURSE_SKIP_PER_IP_RATE_LIMIT_TRUST_LEVEL` environment variable with the
desired value to your `app.yml`, or changing the setting's value in the
`discourse.conf` file.
Requests made with API keys are still rate limited by IP address and the
relevant global settings that control API keys rate limits.
Before this commit, Discourse's auth cookie (`_t`) was simply a 32 characters
string that Discourse used to lookup the current user from the database and the
cookie contained no additional information about the user. However, we had to
change the cookie content in this commit so we could identify the user from the
cookie without making a database query before the rate limits logic and avoid
introducing a bottleneck on busy sites.
Besides the 32 characters auth token, the cookie now includes the user id,
trust level and the cookie's generation date, and we encrypt/sign the cookie to
prevent tampering.
Internal ticket number: t54739.
2021-11-17 15:27:30 -05:00
|
|
|
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
|
|
|
|
expect(UserAuthToken.hash_token(unhashed_token)).to eq(user.user_auth_tokens.first.auth_token)
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
2019-11-24 19:49:27 -05:00
|
|
|
|
|
|
|
context "when timezone param is provided" do
|
|
|
|
it "sets the user_option timezone for the user" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword', timezone: "Australia/Melbourne"
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2019-11-24 19:49:27 -05:00
|
|
|
expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
|
|
|
|
end
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
2020-01-09 19:45:56 -05:00
|
|
|
context "when a user has security key-only 2FA login" do
|
|
|
|
let!(:user_security_key) do
|
|
|
|
Fabricate(
|
|
|
|
:user_security_key,
|
|
|
|
user: user,
|
|
|
|
credential_id: valid_security_key_data[:credential_id],
|
|
|
|
public_key: valid_security_key_data[:public_key]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
before do
|
|
|
|
simulate_localhost_webauthn_challenge
|
|
|
|
|
|
|
|
# store challenge in secure session by failing login once
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
context "when the security key params are blank and a random second factor token is provided" do
|
|
|
|
it "shows an error message and denies login" do
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
|
|
|
second_factor_token: '99999999',
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
2020-01-09 19:45:56 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body["failed"]).to eq("FAILED")
|
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
2020-01-15 05:27:12 -05:00
|
|
|
'login.invalid_security_key'
|
2020-01-09 19:45:56 -05:00
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
2021-11-25 02:34:39 -05:00
|
|
|
|
2020-01-09 19:45:56 -05:00
|
|
|
context "when the security key params are invalid" do
|
2020-02-19 14:04:38 -05:00
|
|
|
it "shows an error message and denies login" do
|
2020-01-09 19:45:56 -05:00
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_token: {
|
2020-01-09 19:45:56 -05:00
|
|
|
signature: 'bad_sig',
|
|
|
|
clientData: 'bad_clientData',
|
|
|
|
credentialId: 'bad_credential_id',
|
|
|
|
authenticatorData: 'bad_authenticator_data'
|
|
|
|
},
|
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body["failed"]).to eq("FAILED")
|
|
|
|
expect(response_body['error']).to eq(I18n.t(
|
|
|
|
'webauthn.validation.not_found_error'
|
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
2021-11-25 02:34:39 -05:00
|
|
|
|
2020-01-09 19:45:56 -05:00
|
|
|
context "when the security key params are valid" do
|
|
|
|
it "logs the user in" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_token: valid_security_key_auth_post_data,
|
2020-01-09 19:45:56 -05:00
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-01-09 19:45:56 -05:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(user.user_auth_tokens.count).to eq(1)
|
|
|
|
end
|
|
|
|
end
|
2021-11-25 02:34:39 -05:00
|
|
|
|
2020-01-09 19:45:56 -05:00
|
|
|
context "when the security key is disabled in the background by the user and TOTP is enabled" do
|
|
|
|
before do
|
|
|
|
user_security_key.destroy!
|
|
|
|
Fabricate(:user_second_factor_totp, user: user)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "shows an error message and denies login" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2020-01-15 05:27:12 -05:00
|
|
|
second_factor_token: valid_security_key_auth_post_data,
|
2020-01-09 19:45:56 -05:00
|
|
|
second_factor_method: UserSecondFactor.methods[:security_key]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
2020-05-07 11:04:12 -04:00
|
|
|
response_body = response.parsed_body
|
2020-01-09 19:45:56 -05:00
|
|
|
expect(response_body["failed"]).to eq("FAILED")
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(I18n.t(
|
2020-01-15 05:27:12 -05:00
|
|
|
'login.not_enabled_second_factor_method'
|
2020-01-09 19:45:56 -05:00
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'when user has TOTP-only 2FA login' do
|
2018-06-28 04:12:32 -04:00
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
describe 'when second factor token is missing' do
|
|
|
|
it 'should return the right response' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
2020-01-15 05:27:12 -05:00
|
|
|
password: 'myawesomepassword'
|
2018-05-29 19:11:01 -04:00
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(I18n.t(
|
2020-01-15 05:27:12 -05:00
|
|
|
'login.invalid_second_factor_method'
|
2018-05-29 19:11:01 -04:00
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'when second factor token is invalid' do
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using totp method' do
|
|
|
|
it 'should return the right response' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
|
|
|
second_factor_token: '00000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(I18n.t(
|
2018-06-28 04:12:32 -04:00
|
|
|
'login.invalid_second_factor_code'
|
|
|
|
))
|
|
|
|
end
|
|
|
|
end
|
2021-11-25 02:34:39 -05:00
|
|
|
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using backup code method' do
|
|
|
|
it 'should return the right response' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
|
|
|
second_factor_token: '00000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(I18n.t(
|
2018-06-28 04:12:32 -04:00
|
|
|
'login.invalid_second_factor_code'
|
|
|
|
))
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'when second factor token is valid' do
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using totp method' do
|
|
|
|
it 'should log the user in' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
|
|
|
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-06-28 04:12:32 -04:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(user.user_auth_tokens.count).to eq(1)
|
|
|
|
|
FEATURE: Apply rate limits per user instead of IP for trusted users (#14706)
Currently, Discourse rate limits all incoming requests by the IP address they
originate from regardless of the user making the request. This can be
frustrating if there are multiple users using Discourse simultaneously while
sharing the same IP address (e.g. employees in an office).
This commit implements a new feature to make Discourse apply rate limits by
user id rather than IP address for users at or higher than the configured trust
level (1 is the default).
For example, let's say a Discourse instance is configured to allow 200 requests
per minute per IP address, and we have 10 users at trust level 4 using
Discourse simultaneously from the same IP address. Before this feature, the 10
users could only make a total of 200 requests per minute before they got rate
limited. But with the new feature, each user is allowed to make 200 requests
per minute because the rate limits are applied on user id rather than the IP
address.
The minimum trust level for applying user-id-based rate limits can be
configured by the `skip_per_ip_rate_limit_trust_level` global setting. The
default is 1, but it can be changed by either adding the
`DISCOURSE_SKIP_PER_IP_RATE_LIMIT_TRUST_LEVEL` environment variable with the
desired value to your `app.yml`, or changing the setting's value in the
`discourse.conf` file.
Requests made with API keys are still rate limited by IP address and the
relevant global settings that control API keys rate limits.
Before this commit, Discourse's auth cookie (`_t`) was simply a 32 characters
string that Discourse used to lookup the current user from the database and the
cookie contained no additional information about the user. However, we had to
change the cookie content in this commit so we could identify the user from the
cookie without making a database query before the rate limits logic and avoid
introducing a bottleneck on busy sites.
Besides the 32 characters auth token, the cookie now includes the user id,
trust level and the cookie's generation date, and we encrypt/sign the cookie to
prevent tampering.
Internal ticket number: t54739.
2021-11-17 15:27:30 -05:00
|
|
|
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
|
|
|
|
expect(UserAuthToken.hash_token(unhashed_token))
|
2018-06-28 04:12:32 -04:00
|
|
|
.to eq(user.user_auth_tokens.first.auth_token)
|
|
|
|
end
|
|
|
|
end
|
2021-11-25 02:34:39 -05:00
|
|
|
|
2018-06-28 04:12:32 -04:00
|
|
|
context 'when using backup code method' do
|
|
|
|
it 'should log the user in' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
|
|
|
second_factor_token: 'iAmValidBackupCode',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:backup_codes]
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-06-28 04:12:32 -04:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
expect(user.user_auth_tokens.count).to eq(1)
|
|
|
|
|
FEATURE: Apply rate limits per user instead of IP for trusted users (#14706)
Currently, Discourse rate limits all incoming requests by the IP address they
originate from regardless of the user making the request. This can be
frustrating if there are multiple users using Discourse simultaneously while
sharing the same IP address (e.g. employees in an office).
This commit implements a new feature to make Discourse apply rate limits by
user id rather than IP address for users at or higher than the configured trust
level (1 is the default).
For example, let's say a Discourse instance is configured to allow 200 requests
per minute per IP address, and we have 10 users at trust level 4 using
Discourse simultaneously from the same IP address. Before this feature, the 10
users could only make a total of 200 requests per minute before they got rate
limited. But with the new feature, each user is allowed to make 200 requests
per minute because the rate limits are applied on user id rather than the IP
address.
The minimum trust level for applying user-id-based rate limits can be
configured by the `skip_per_ip_rate_limit_trust_level` global setting. The
default is 1, but it can be changed by either adding the
`DISCOURSE_SKIP_PER_IP_RATE_LIMIT_TRUST_LEVEL` environment variable with the
desired value to your `app.yml`, or changing the setting's value in the
`discourse.conf` file.
Requests made with API keys are still rate limited by IP address and the
relevant global settings that control API keys rate limits.
Before this commit, Discourse's auth cookie (`_t`) was simply a 32 characters
string that Discourse used to lookup the current user from the database and the
cookie contained no additional information about the user. However, we had to
change the cookie content in this commit so we could identify the user from the
cookie without making a database query before the rate limits logic and avoid
introducing a bottleneck on busy sites.
Besides the 32 characters auth token, the cookie now includes the user id,
trust level and the cookie's generation date, and we encrypt/sign the cookie to
prevent tampering.
Internal ticket number: t54739.
2021-11-17 15:27:30 -05:00
|
|
|
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
|
|
|
|
expect(UserAuthToken.hash_token(unhashed_token))
|
2018-06-28 04:12:32 -04:00
|
|
|
.to eq(user.user_auth_tokens.first.auth_token)
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'with a blocked IP' do
|
|
|
|
it "doesn't log in" do
|
|
|
|
ScreenedIpAddress.all.destroy_all
|
|
|
|
get "/"
|
2019-06-10 20:04:26 -04:00
|
|
|
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip)
|
2018-05-29 19:11:01 -04:00
|
|
|
post "/session.json", params: {
|
|
|
|
login: "@" + user.username, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to be_nil
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'strips leading @ symbol' do
|
|
|
|
it 'sets a session id' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: "@" + user.username, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
user.reload
|
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe 'also allow login by email' do
|
|
|
|
it 'sets a session id' do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.email, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'login has leading and trailing space' do
|
|
|
|
let(:username) { " #{user.username} " }
|
|
|
|
let(:email) { " #{user.email} " }
|
|
|
|
|
|
|
|
it "strips spaces from the username" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: username, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
it "strips spaces from the email" do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: email, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe "when the site requires approval of users" do
|
|
|
|
before do
|
|
|
|
SiteSetting.must_approve_users = true
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'with an unapproved user' do
|
|
|
|
before do
|
2019-04-16 14:42:47 -04:00
|
|
|
user.update_columns(approved: false)
|
2018-05-29 19:11:01 -04:00
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.email, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
it "doesn't log in the user" do
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
end
|
|
|
|
|
|
|
|
it "shows the 'not approved' error message" do
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(
|
2018-05-29 19:11:01 -04:00
|
|
|
I18n.t('login.not_approved')
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context "with an unapproved user who is an admin" do
|
|
|
|
it 'sets a session id' do
|
|
|
|
user.admin = true
|
|
|
|
user.save!
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.email, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'when admins are restricted by ip address' do
|
|
|
|
before do
|
2020-07-26 20:23:54 -04:00
|
|
|
SiteSetting.use_admin_ip_allowlist = true
|
2018-05-29 19:11:01 -04:00
|
|
|
ScreenedIpAddress.all.destroy_all
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'is successful for admin at the ip address' do
|
|
|
|
get "/"
|
|
|
|
Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:allow_admin])
|
|
|
|
|
|
|
|
user.admin = true
|
|
|
|
user.save!
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns an error for admin not at the ip address' do
|
|
|
|
Fabricate(:screened_ip_address, ip_address: "111.234.23.11", action_type: ScreenedIpAddress.actions[:allow_admin])
|
|
|
|
user.admin = true
|
|
|
|
user.save!
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).not_to eq(user.id)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'is successful for non-admin not at the ip address' do
|
|
|
|
Fabricate(:screened_ip_address, ip_address: "111.234.23.11", action_type: ScreenedIpAddress.actions[:allow_admin])
|
|
|
|
user.admin = false
|
|
|
|
user.save!
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'when email has not been confirmed' do
|
|
|
|
def post_login
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.email, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
it "doesn't log in the user" do
|
|
|
|
post_login
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
end
|
|
|
|
|
|
|
|
it "shows the 'not activated' error message" do
|
|
|
|
post_login
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(
|
2018-05-29 19:11:01 -04:00
|
|
|
I18n.t 'login.not_activated'
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
context "and the 'must approve users' site setting is enabled" do
|
|
|
|
before { SiteSetting.must_approve_users = true }
|
|
|
|
|
|
|
|
it "shows the 'not approved' error message" do
|
|
|
|
post_login
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2020-05-07 11:04:12 -04:00
|
|
|
expect(response.parsed_body['error']).to eq(
|
2018-05-29 19:11:01 -04:00
|
|
|
I18n.t 'login.not_approved'
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'rate limited' do
|
|
|
|
it 'rate limits login' do
|
|
|
|
SiteSetting.max_logins_per_ip_per_hour = 2
|
|
|
|
RateLimiter.enable
|
|
|
|
RateLimiter.clear_all!
|
2022-03-25 11:44:12 -04:00
|
|
|
EmailToken.confirm(email_token.token)
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
2.times do
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(429)
|
2020-05-07 11:04:12 -04:00
|
|
|
json = response.parsed_body
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(json["error_type"]).to eq("rate_limit")
|
|
|
|
end
|
|
|
|
|
2021-02-02 18:26:28 -05:00
|
|
|
it 'rate limits second factor attempts by IP' do
|
2018-05-29 19:11:01 -04:00
|
|
|
RateLimiter.enable
|
|
|
|
RateLimiter.clear_all!
|
|
|
|
|
2021-02-03 18:03:30 -05:00
|
|
|
6.times do |x|
|
2018-05-29 19:11:01 -04:00
|
|
|
post "/session.json", params: {
|
2021-02-02 18:26:28 -05:00
|
|
|
login: "#{user.username}#{x}",
|
2018-05-29 19:11:01 -04:00
|
|
|
password: 'myawesomepassword',
|
2021-02-03 18:03:30 -05:00
|
|
|
second_factor_token: '000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
2018-05-29 19:11:01 -04:00
|
|
|
}
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).to be_present
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2021-02-03 18:03:30 -05:00
|
|
|
second_factor_token: '000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
2018-05-29 19:11:01 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
expect(response.status).to eq(429)
|
2020-05-07 11:04:12 -04:00
|
|
|
json = response.parsed_body
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(json["error_type"]).to eq("rate_limit")
|
|
|
|
end
|
2021-02-02 18:26:28 -05:00
|
|
|
|
|
|
|
it 'rate limits second factor attempts by login' do
|
|
|
|
RateLimiter.enable
|
|
|
|
RateLimiter.clear_all!
|
2022-03-25 11:44:12 -04:00
|
|
|
EmailToken.confirm(email_token.token)
|
2021-02-02 18:26:28 -05:00
|
|
|
|
2021-02-03 18:03:30 -05:00
|
|
|
6.times do |x|
|
2021-02-02 18:26:28 -05:00
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username,
|
|
|
|
password: 'myawesomepassword',
|
2021-02-03 18:03:30 -05:00
|
|
|
second_factor_token: '000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
2021-02-02 18:26:28 -05:00
|
|
|
}, env: { "REMOTE_ADDR": "1.2.3.#{x}" }
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2021-02-02 18:26:28 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
[user.username + " ", user.username.capitalize, user.username].each_with_index do |username , x|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: username,
|
|
|
|
password: 'myawesomepassword',
|
2021-02-03 18:03:30 -05:00
|
|
|
second_factor_token: '000000',
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp]
|
2021-02-02 18:26:28 -05:00
|
|
|
}, env: { "REMOTE_ADDR": "1.2.4.#{x}" }
|
|
|
|
|
|
|
|
expect(response.status).to eq(429)
|
|
|
|
json = response.parsed_body
|
|
|
|
expect(json["error_type"]).to eq("rate_limit")
|
|
|
|
end
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#destroy' do
|
|
|
|
it 'removes the session variable and the auth token cookies' do
|
|
|
|
user = sign_in(Fabricate(:user))
|
|
|
|
delete "/session/#{user.username}.json"
|
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
expect(response.cookies["_t"]).to be_blank
|
|
|
|
end
|
2020-11-11 10:47:42 -05:00
|
|
|
|
|
|
|
it 'returns the redirect URL in the body for XHR requests' do
|
|
|
|
user = sign_in(Fabricate(:user))
|
|
|
|
delete "/session/#{user.username}.json", xhr: true
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-11-11 10:47:42 -05:00
|
|
|
expect(session[:current_user_id]).to be_blank
|
|
|
|
expect(response.cookies["_t"]).to be_blank
|
|
|
|
|
|
|
|
expect(response.parsed_body["redirect_url"]).to eq("/")
|
|
|
|
end
|
|
|
|
|
2020-12-11 04:44:16 -05:00
|
|
|
it 'redirects to /login when SSO and login_required' do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_url = "https://example.com/sso"
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
2020-11-11 10:47:42 -05:00
|
|
|
|
|
|
|
user = sign_in(Fabricate(:user))
|
|
|
|
delete "/session/#{user.username}.json", xhr: true
|
2020-12-11 04:44:16 -05:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-12-11 04:44:16 -05:00
|
|
|
expect(response.parsed_body["redirect_url"]).to eq("/")
|
2020-11-11 10:47:42 -05:00
|
|
|
|
2020-12-11 04:44:16 -05:00
|
|
|
SiteSetting.login_required = true
|
|
|
|
user = sign_in(Fabricate(:user))
|
|
|
|
delete "/session/#{user.username}.json", xhr: true
|
2020-11-11 10:47:42 -05:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-11-11 10:47:42 -05:00
|
|
|
expect(response.parsed_body["redirect_url"]).to eq("/login")
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'allows plugins to manipulate redirect URL' do
|
|
|
|
callback = -> (data) do
|
|
|
|
data[:redirect_url] = "/myredirect/#{data[:user].username}"
|
|
|
|
end
|
|
|
|
|
|
|
|
DiscourseEvent.on(:before_session_destroy, &callback)
|
|
|
|
|
|
|
|
user = sign_in(Fabricate(:user))
|
|
|
|
delete "/session/#{user.username}.json", xhr: true
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-11-11 10:47:42 -05:00
|
|
|
expect(response.parsed_body["redirect_url"]).to eq("/myredirect/#{user.username}")
|
|
|
|
ensure
|
|
|
|
DiscourseEvent.off(:before_session_destroy, &callback)
|
|
|
|
end
|
2018-05-29 19:11:01 -04:00
|
|
|
end
|
|
|
|
|
2019-04-01 13:18:53 -04:00
|
|
|
describe '#one_time_password' do
|
|
|
|
context 'missing token' do
|
|
|
|
it 'returns the right response' do
|
|
|
|
get "/session/otp"
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'invalid token' do
|
|
|
|
it 'returns the right response' do
|
|
|
|
get "/session/otp/asd1231dasd123"
|
|
|
|
|
|
|
|
expect(response.status).to eq(404)
|
2019-06-12 13:32:13 -04:00
|
|
|
|
|
|
|
post "/session/otp/asd1231dasd123"
|
|
|
|
|
|
|
|
expect(response.status).to eq(404)
|
2019-04-01 13:18:53 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
context 'when token is valid' do
|
2019-06-12 13:32:13 -04:00
|
|
|
it "should display the form for GET" do
|
|
|
|
token = SecureRandom.hex
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
|
2019-06-12 13:32:13 -04:00
|
|
|
|
|
|
|
get "/session/otp/#{token}"
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2019-06-12 13:32:13 -04:00
|
|
|
expect(response.body).to include(
|
|
|
|
I18n.t("user_api_key.otp_confirmation.logging_in_as", username: user.username)
|
|
|
|
)
|
2019-12-03 04:05:53 -05:00
|
|
|
expect(Discourse.redis.get("otp_#{token}")).to eq(user.username)
|
2019-06-12 13:32:13 -04:00
|
|
|
|
|
|
|
expect(session[:current_user_id]).to eq(nil)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "should redirect on GET if already logged in" do
|
|
|
|
sign_in(user)
|
|
|
|
token = SecureRandom.hex
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
|
2019-06-12 13:32:13 -04:00
|
|
|
|
|
|
|
get "/session/otp/#{token}"
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
|
2019-12-03 04:05:53 -05:00
|
|
|
expect(Discourse.redis.get("otp_#{token}")).to eq(nil)
|
2019-06-12 13:32:13 -04:00
|
|
|
expect(session[:current_user_id]).to eq(user.id)
|
|
|
|
end
|
|
|
|
|
2019-04-01 13:18:53 -04:00
|
|
|
it 'should authenticate user and delete token' do
|
|
|
|
user = Fabricate(:user)
|
|
|
|
|
|
|
|
get "/session/current.json"
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
|
|
|
|
token = SecureRandom.hex
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
|
2019-04-01 13:18:53 -04:00
|
|
|
|
2019-06-12 13:32:13 -04:00
|
|
|
post "/session/otp/#{token}"
|
2019-04-01 13:18:53 -04:00
|
|
|
|
|
|
|
expect(response.status).to eq(302)
|
|
|
|
expect(response).to redirect_to("/")
|
2019-12-03 04:05:53 -05:00
|
|
|
expect(Discourse.redis.get("otp_#{token}")).to eq(nil)
|
2019-04-01 13:18:53 -04:00
|
|
|
|
|
|
|
get "/session/current.json"
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2019-04-01 13:18:53 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
describe '#forgot_password' do
|
2021-12-19 20:54:10 -05:00
|
|
|
|
|
|
|
context 'when hide_email_address_taken is set' do
|
|
|
|
before do
|
|
|
|
SiteSetting.hide_email_address_taken = true
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'denies for username' do
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: user.username }
|
|
|
|
|
2022-01-20 03:04:45 -05:00
|
|
|
expect(response.status).to eq(400)
|
2021-12-19 20:54:10 -05:00
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
|
|
|
|
end
|
|
|
|
|
2022-01-26 03:39:58 -05:00
|
|
|
it 'allows for username when staff' do
|
|
|
|
sign_in(Fabricate(:admin))
|
|
|
|
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: user.username }
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2022-01-26 03:39:58 -05:00
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
|
|
|
|
end
|
|
|
|
|
2021-12-19 20:54:10 -05:00
|
|
|
it 'allows for email' do
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: user.email }
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2021-12-19 20:54:10 -05:00
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
it 'raises an error without a username parameter' do
|
|
|
|
post "/session/forgot_password.json"
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
end
|
|
|
|
|
2020-05-07 23:30:16 -04:00
|
|
|
it 'should correctly screen ips' do
|
|
|
|
ScreenedIpAddress.create!(
|
|
|
|
ip_address: '100.0.0.1',
|
|
|
|
action_type: ScreenedIpAddress.actions[:block]
|
|
|
|
)
|
|
|
|
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: 'made_up' },
|
|
|
|
headers: { 'REMOTE_ADDR' => '100.0.0.1' }
|
|
|
|
|
|
|
|
expect(response.parsed_body).to eq({
|
|
|
|
"errors" => [I18n.t("login.reset_not_allowed_from_ip_address")]
|
|
|
|
})
|
|
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'should correctly rate limits' do
|
|
|
|
RateLimiter.enable
|
|
|
|
RateLimiter.clear_all!
|
|
|
|
|
|
|
|
user = Fabricate(:user)
|
|
|
|
|
|
|
|
3.times do
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-05-07 23:30:16 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
expect(response.status).to eq(422)
|
|
|
|
|
|
|
|
3.times do
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: user.username },
|
|
|
|
headers: { 'REMOTE_ADDR' => '10.1.1.1' }
|
|
|
|
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-05-07 23:30:16 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
post "/session/forgot_password.json",
|
|
|
|
params: { login: user.username },
|
|
|
|
headers: { 'REMOTE_ADDR' => '100.1.1.1' }
|
|
|
|
|
|
|
|
# not allowed, max 6 a day
|
|
|
|
expect(response.status).to eq(422)
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
context 'for a non existant username' do
|
|
|
|
it "doesn't generate a new token for a made up username" do
|
|
|
|
expect do
|
|
|
|
post "/session/forgot_password.json", params: { login: 'made_up' }
|
|
|
|
end.not_to change(EmailToken, :count)
|
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'for an existing username' do
|
2019-05-06 23:12:20 -04:00
|
|
|
fab!(:user) { Fabricate(:user) }
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
context 'local login is disabled' do
|
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins = false
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
end
|
|
|
|
it_behaves_like "failed to continue local login"
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'SSO is enabled' do
|
|
|
|
before do
|
2021-02-08 05:04:33 -05:00
|
|
|
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
|
|
|
|
SiteSetting.enable_discourse_connect = true
|
2018-05-29 19:11:01 -04:00
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
it_behaves_like "failed to continue local login"
|
|
|
|
end
|
|
|
|
|
2020-01-16 20:25:31 -05:00
|
|
|
context "local logins are disabled" do
|
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins = false
|
|
|
|
|
|
|
|
post "/session.json", params: {
|
|
|
|
login: user.username, password: 'myawesomepassword'
|
|
|
|
}
|
|
|
|
end
|
|
|
|
it_behaves_like "failed to continue local login"
|
|
|
|
end
|
|
|
|
|
|
|
|
context "local logins via email are disabled" do
|
|
|
|
before do
|
|
|
|
SiteSetting.enable_local_logins_via_email = false
|
|
|
|
end
|
|
|
|
it "does not matter, generates a new token for a made up username" do
|
|
|
|
expect do
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
end.to change(EmailToken, :count)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-05-29 19:11:01 -04:00
|
|
|
it "generates a new token for a made up username" do
|
|
|
|
expect do
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
end.to change(EmailToken, :count)
|
|
|
|
end
|
|
|
|
|
|
|
|
it "enqueues an email" do
|
|
|
|
post "/session/forgot_password.json", params: { login: user.username }
|
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'do nothing to system username' do
|
|
|
|
let(:system) { Discourse.system_user }
|
|
|
|
|
|
|
|
it 'generates no token for system username' do
|
|
|
|
expect do
|
|
|
|
post "/session/forgot_password.json", params: { login: system.username }
|
|
|
|
end.not_to change(EmailToken, :count)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'enqueues no email' do
|
|
|
|
post "/session/forgot_password.json", params: { login: system.username }
|
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context 'for a staged account' do
|
|
|
|
let!(:staged) { Fabricate(:staged) }
|
|
|
|
|
|
|
|
it 'generates no token for staged username' do
|
|
|
|
expect do
|
|
|
|
post "/session/forgot_password.json", params: { login: staged.username }
|
|
|
|
end.not_to change(EmailToken, :count)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'enqueues no email' do
|
|
|
|
post "/session/forgot_password.json", params: { login: staged.username }
|
|
|
|
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#current' do
|
|
|
|
context "when not logged in" do
|
2021-05-20 21:43:47 -04:00
|
|
|
it "returns 404" do
|
2018-05-29 19:11:01 -04:00
|
|
|
get "/session/current.json"
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
context "when logged in" do
|
|
|
|
let!(:user) { sign_in(Fabricate(:user)) }
|
|
|
|
|
|
|
|
it "returns the JSON for the user" do
|
|
|
|
get "/session/current.json"
|
2018-06-07 04:11:09 -04:00
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
2020-05-07 11:04:12 -04:00
|
|
|
json = response.parsed_body
|
2018-05-29 19:11:01 -04:00
|
|
|
expect(json['current_user']).to be_present
|
|
|
|
expect(json['current_user']['id']).to eq(user.id)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
|
|
|
|
describe '#second_factor_auth_show' do
|
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
|
|
|
|
before do
|
|
|
|
sign_in(user)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 404 if there is no challenge for the given nonce' do
|
|
|
|
get "/session/2fa.json", params: { nonce: 'asdasdsadsad' }
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_not_found"))
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 404 if the nonce does not match the challenge nonce' do
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
get "/session/2fa.json", params: { nonce: 'wrongnonce' }
|
|
|
|
expect(response.status).to eq(404)
|
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_not_found"))
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 401 if the challenge nonce has expired' do
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
get "/session/2fa.json", params: { nonce: nonce }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
|
|
|
|
freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
|
|
|
|
get "/session/2fa.json", params: { nonce: nonce }
|
|
|
|
expect(response.status).to eq(401)
|
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_expired"))
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'responds with challenge data' do
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
get "/session/2fa.json", params: { nonce: nonce }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
challenge_data = response.parsed_body
|
|
|
|
expect(challenge_data["totp_enabled"]).to eq(true)
|
|
|
|
expect(challenge_data["backup_enabled"]).to eq(false)
|
|
|
|
expect(challenge_data["security_keys_enabled"]).to eq(false)
|
|
|
|
expect(challenge_data["allowed_methods"]).to contain_exactly(
|
|
|
|
UserSecondFactor.methods[:totp],
|
|
|
|
UserSecondFactor.methods[:security_key],
|
|
|
|
)
|
2022-03-03 22:43:06 -05:00
|
|
|
expect(challenge_data["description"]).to eq("this is description for test action")
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
|
|
|
|
Fabricate(
|
|
|
|
:user_security_key_with_random_credential,
|
|
|
|
user: user,
|
|
|
|
name: 'Enabled YubiKey',
|
|
|
|
enabled: true
|
|
|
|
)
|
|
|
|
Fabricate(:user_second_factor_backup, user: user)
|
|
|
|
post "/session/2fa/test-action", params: { allow_backup_codes: true }
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
get "/session/2fa.json", params: { nonce: nonce }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
challenge_data = response.parsed_body
|
|
|
|
expect(challenge_data["totp_enabled"]).to eq(true)
|
|
|
|
expect(challenge_data["backup_enabled"]).to eq(true)
|
|
|
|
expect(challenge_data["security_keys_enabled"]).to eq(true)
|
|
|
|
expect(challenge_data["allowed_credential_ids"]).to be_present
|
|
|
|
expect(challenge_data["challenge"]).to be_present
|
|
|
|
expect(challenge_data["allowed_methods"]).to contain_exactly(
|
|
|
|
UserSecondFactor.methods[:totp],
|
|
|
|
UserSecondFactor.methods[:security_key],
|
|
|
|
UserSecondFactor.methods[:backup_codes],
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
describe '#second_factor_auth_perform' do
|
|
|
|
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
|
|
|
|
|
|
|
|
before do
|
|
|
|
sign_in(user)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 401 if the challenge nonce has expired' do
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
|
|
|
|
freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
|
|
|
|
token = ROTP::TOTP.new(user_second_factor.data).now
|
|
|
|
post "/session/2fa.json", params: {
|
|
|
|
nonce: nonce,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: token
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(401)
|
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("second_factor_auth.challenge_expired"))
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 403 if the 2FA method is not allowed' do
|
|
|
|
Fabricate(:user_second_factor_backup, user: user)
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
post "/session/2fa.json", params: {
|
|
|
|
nonce: nonce,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:backup_codes],
|
|
|
|
second_factor_token: "iAmValidBackupCode"
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(403)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'returns 403 if the user disables the 2FA method in the middle of the 2FA process' do
|
|
|
|
post "/session/2fa/test-action"
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
token = ROTP::TOTP.new(user_second_factor.data).now
|
|
|
|
user_second_factor.destroy!
|
|
|
|
post "/session/2fa.json", params: {
|
|
|
|
nonce: nonce,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: token
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(403)
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'marks the challenge as successful if the 2fa succeeds' do
|
|
|
|
post "/session/2fa/test-action", params: { redirect_path: "/ggg" }
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
|
|
|
|
token = ROTP::TOTP.new(user_second_factor.data).now
|
|
|
|
post "/session/2fa.json", params: {
|
|
|
|
nonce: nonce,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: token
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
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")
|
|
|
|
|
|
|
|
post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
|
|
|
|
expect(response.status).to eq(200)
|
2022-03-25 11:44:12 -04:00
|
|
|
expect(response.parsed_body['error']).not_to be_present
|
FEATURE: Centralized 2FA page (#15377)
2FA support in Discourse was added and grown gradually over the years: we first
added support for TOTP for logins, then we implemented backup codes, and last
but not least, security keys. 2FA usage was initially limited to logging in,
but it has been expanded and we now require 2FA for risky actions such as
adding a new admin to the site.
As a result of this gradual growth of the 2FA system, technical debt has
accumulated to the point where it has become difficult to require 2FA for more
actions. We now have 5 different 2FA UI implementations and each one has to
support all 3 2FA methods (TOTP, backup codes, and security keys) which makes
it difficult to maintain a consistent UX for these different implementations.
Moreover, there is a lot of repeated logic in the server-side code behind these
5 UI implementations which hinders maintainability even more.
This commit is the first step towards repaying the technical debt: it builds a
system that centralizes as much as possible of the 2FA server-side logic and
UI. The 2 main components of this system are:
1. A dedicated page for 2FA with support for all 3 methods.
2. A reusable server-side class that centralizes the 2FA logic (the
`SecondFactor::AuthManager` class).
From a top-level view, the 2FA flow in this new system looks like this:
1. User initiates an action that requires 2FA;
2. Server is aware that 2FA is required for this action, so it redirects the
user to the 2FA page if the user has a 2FA method, otherwise the action is
performed.
3. User submits the 2FA form on the page;
4. Server validates the 2FA and if it's successful, the action is performed and
the user is redirected to the previous page.
A more technically-detailed explanation/documentation of the new system is
available as a comment at the top of the `lib/second_factor/auth_manager.rb`
file. Please note that the details are not set in stone and will likely change
in the future, so please don't use the system in your plugins yet.
Since this is a new system that needs to be tested, we've decided to migrate
only the 2FA for adding a new admin to the new system at this time (in this
commit). Our plan is to gradually migrate the remaining 2FA implementations to
the new system.
For screenshots of the 2FA page, see PR #15377 on GitHub.
2022-02-17 04:12:59 -05:00
|
|
|
expect(response.parsed_body["result"]).to eq("second_factor_auth_completed")
|
|
|
|
end
|
|
|
|
|
|
|
|
it 'does not mark the challenge as successful if the 2fa fails' do
|
|
|
|
post "/session/2fa/test-action", params: { redirect_path: "/ggg" }
|
|
|
|
nonce = response.parsed_body["second_factor_challenge_nonce"]
|
|
|
|
|
|
|
|
token = ROTP::TOTP.new(user_second_factor.data).now.to_i
|
|
|
|
token += token == 999999 ? -1 : 1
|
|
|
|
post "/session/2fa.json", params: {
|
|
|
|
nonce: nonce,
|
|
|
|
second_factor_method: UserSecondFactor.methods[:totp],
|
|
|
|
second_factor_token: token.to_s
|
|
|
|
}
|
|
|
|
expect(response.status).to eq(400)
|
|
|
|
expect(response.parsed_body["ok"]).to eq(false)
|
|
|
|
expect(response.parsed_body["reason"]).to eq("invalid_second_factor")
|
|
|
|
expect(response.parsed_body["error"]).to eq(I18n.t("login.invalid_second_factor_code"))
|
|
|
|
|
|
|
|
post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
|
|
|
|
expect(response.status).to eq(401)
|
|
|
|
end
|
|
|
|
end
|
2017-04-20 11:17:24 -04:00
|
|
|
end
|