discourse/spec/requests/session_controller_spec.rb
Martin Brennan 355d51afde
FEATURE: Allow using invites when DiscourseConnect SSO is enabled (#12419)
This PR allows invitations to be used when the DiscourseConnect SSO is enabled for a site (`enable_discourse_connect`) and local logins are disabled. Previously invites could not be accepted with SSO enabled simply because we did not have the code paths to handle that logic.

The invitation methods that are supported include:

* Inviting people to groups via email address
* Inviting people to topics via email address
* Using invitation links generated by the Invite Users UI in the /my/invited/pending route

The flow works like this:

1. User visits an invite URL
2. The normal invitation validations (redemptions/expiry) happen at that point
3. We store the invite key in a secure session
4. The user clicks "Accept Invitation and Continue" (see below)
5. The user is redirected to /session/sso then to the SSO provider URL then back to /session/sso_login
6. We retrieve the invite based on the invite key in secure session. We revalidate the invitation. We show an error to the user if it is not valid. An additional check here for invites with an email specified is to check the SSO email matches the invite email
7. If the invite is OK we create the user via the normal SSO methods
8. We redeem the invite and activate the user. We clear the invite key in secure session.
9. If the invite had a topic we redirect the user there, otherwise we redirect to /

Note that we decided for SSO-based invites the `must_approve_users` site setting is ignored, because the invite is a form of pre-approval, and because regular non-staff users cannot send out email invites or generally invite to the forum in this case.

Also deletes some group invite checks as per https://github.com/discourse/discourse/pull/12353
2021-03-19 10:20:10 +10:00

2210 lines
76 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
require 'rotp'
RSpec.describe SessionController do
let(:email_token) { Fabricate(:email_token) }
let(:user) { email_token.user }
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
shared_examples 'failed to continue local login' do
it 'should return the right response' do
expect(response).not_to be_successful
expect(response.status).to eq(403)
end
end
describe '#email_login_info' do
before do
SiteSetting.enable_local_logins_via_email = true
end
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"
expect(response.status).to eq(403)
user.update(admin: true)
get "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
end
end
context "when SSO enabled" do
before do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
end
it "only works for admins" do
get "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(403)
user.update(admin: true)
get "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
end
end
context 'missing token' do
it 'returns the right response' do
get "/session/email-login"
expect(response.status).to eq(404)
end
end
context 'valid token' do
it 'returns information' do
get "/session/email-login/#{email_token.token}.json"
expect(response.parsed_body["can_login"]).to eq(true)
expect(response.parsed_body["second_factor_required"]).to eq(nil)
# 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"
expect(response.status).to eq(403)
end
it 'fails when local logins is disabled' do
SiteSetting.enable_local_logins = false
get "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(403)
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"
response_body_parsed = response.parsed_body
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)
end
end
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"
response_body_parsed = response.parsed_body
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])
secure_session = SecureSession.new(session["secure_session_id"])
expect(response_body_parsed["challenge"]).to eq(Webauthn.challenge(user, secure_session))
expect(Webauthn.rp_id(user, secure_session)).to eq(Discourse.current_hostname)
end
end
end
end
describe '#email_login' do
before do
SiteSetting.enable_local_logins_via_email = true
end
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"
expect(response.status).to eq(403)
user.update(admin: true)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(user.id)
end
end
context 'missing token' do
it 'returns the right response' do
post "/session/email-login"
expect(response.status).to eq(404)
end
end
context 'invalid token' do
it 'returns the right response' do
post "/session/email-login/adasdad.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
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)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t('email_login.invalid_token')
)
end
end
end
context 'valid token' do
it 'returns success' do
post "/session/email-login/#{email_token.token}.json"
expect(response.parsed_body["success"]).to eq("OK")
expect(session[:current_user_id]).to eq(user.id)
end
it 'fails when local logins via email is disabled' do
SiteSetting.enable_local_logins_via_email = false
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(403)
expect(session[:current_user_id]).to eq(nil)
end
it 'fails when local logins is disabled' do
SiteSetting.enable_local_logins = false
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(403)
expect(session[:current_user_id]).to eq(nil)
end
it "doesn't log in the user when not approved" do
SiteSetting.must_approve_users = true
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(I18n.t("login.not_approved"))
expect(session[:current_user_id]).to eq(nil)
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]
)
SiteSetting.use_admin_ip_allowlist = true
user.update!(admin: true)
end
it 'returns the right response' do
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)
)
expect(session[:current_user_id]).to eq(nil)
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)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.not_allowed_from_ip_address", username: user.username)
)
expect(session[:current_user_id]).to eq(nil)
end
end
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)
expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
end
end
it "fails when user is suspended" do
user.update!(
suspended_till: 2.days.from_now,
suspended_at: Time.zone.now
)
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.suspended", date: I18n.l(user.suspended_till, format: :date_only)
))
expect(session[:current_user_id]).to eq(nil)
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) }
describe 'errors on incorrect 2-factor' do
context 'when using totp method' do
it 'does not log in with incorrect two factor' do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "0000",
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.invalid_second_factor_code")
)
expect(session[:current_user_id]).to eq(nil)
end
end
context 'when using backup code method' do
it 'does not log in with incorrect backup code' do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "0000",
second_factor_method: UserSecondFactor.methods[:backup_codes]
}
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.invalid_second_factor_code")
)
expect(session[:current_user_id]).to eq(nil)
end
end
end
describe 'allows successful 2-factor' do
context 'when using totp method' do
it 'logs in correctly' do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.parsed_body["success"]).to eq("OK")
expect(session[:current_user_id]).to eq(user.id)
end
end
context 'when using backup code method' do
it 'logs in correctly' do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "iAmValidBackupCode",
second_factor_method: UserSecondFactor.methods[:backup_codes]
}
expect(response.parsed_body["success"]).to eq("OK")
expect(session[:current_user_id]).to eq(user.id)
end
end
end
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)
expect(response.parsed_body["error"]).to eq(
I18n.t("login.invalid_second_factor_code")
)
expect(session[:current_user_id]).to eq(nil)
end
end
end
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)
response_body = response.parsed_body
expect(response_body['error']).to eq(I18n.t(
'login.not_enabled_second_factor_method'
))
end
end
context "when the security key params are invalid" do
it "shows an error message and denies login" do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: {
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)
response_body = response.parsed_body
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',
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
end
end
end
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)
response_body = response.parsed_body
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)
response_body = response.parsed_body
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
end
end
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
post '/draft.json', params: {}
expect(response.headers['Discourse-Logged-Out']).to eq("1")
end
end
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)
expect(response.parsed_body["error_type"]).to eq("invalid_access")
expect(session[:current_user_id]).to be_blank
end
it "works in developmenet mode" do
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
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
describe '#sso_login' do
before do
@sso_url = "http://example.com/discourse_sso"
@sso_secret = "shjkfdhsfkjh"
SiteSetting.discourse_connect_url = @sso_url
SiteSetting.enable_discourse_connect = true
SiteSetting.discourse_connect_secret = @sso_secret
Fabricate(:admin)
end
let(:headers) { { host: Discourse.current_hostname } }
def get_sso(return_path)
nonce = SecureRandom.hex
dso = DiscourseSingleSignOn.new(secure_session: read_secure_session)
dso.nonce = nonce
dso.register_nonce(return_path)
sso = SingleSignOn.new
sso.nonce = nonce
sso.sso_secret = @sso_secret
sso
end
it 'does not create superflous auth tokens when already logged in' do
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
it 'will never redirect back to /session/sso path' do
sso = get_sso("/session/sso?bla=1")
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('/')
sso = get_sso("http://#{Discourse.current_hostname}/session/sso?bla=1")
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('/')
end
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'
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)
expect(response.body).to include(I18n.t('discourse_connect.blank_id_error'))
end
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)
expect(response.body).to include(I18n.t("discourse_connect.email_error", email: ERB::Util.html_escape("test@test.com")))
end
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
it 'can take over an account' do
user = Fabricate(:user, email: 'bill@bill.com')
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')
# we are updating the email ... ensure auto group membership works
sign_out
SiteSetting.email_editable = false
SiteSetting.auth_overrides_email = true
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)
end
def sso_for_ip_specs
sso = get_sso('/a/')
sso.external_id = '666'
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 "/"
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:block])
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
DiscourseSingleSignOn.parse(sso.payload, secure_session: read_secure_session).lookup_or_create_user(request.remote_ip)
sso = sso_for_ip_specs
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:block])
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/')
sso.external_id = '666'
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/')
sso.external_id = '666'
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
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
it 'redirects to a non-relative url' do
sso = get_sso("#{Discourse.base_url}/b/")
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('/b/')
end
it 'redirects to random url if it is allowed' do
SiteSetting.discourse_connect_allows_all_return_paths = true
sso = get_sso('https://gusundtrout.com')
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('https://gusundtrout.com')
end
it 'redirects to root if the host of the return_path is different' do
sso = get_sso('//eviltrout.com')
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('/')
end
it 'redirects to root if the host of the return_path is different' do
sso = get_sso('http://eviltrout.com')
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('/')
end
it 'allows you to create an account' do
group = Fabricate(:group, name: :bob, automatic_membership_email_domains: 'bob.com')
sso = get_sso('/a/')
sso.external_id = '666'
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
expect(group.users.where(id: logged_on_user.id).count).to eq(1)
# 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
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
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/')
sso.external_id = '666'
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/')
sso.external_id = '666'
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
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
context "when sso provider is enabled" do
before do
SiteSetting.enable_discourse_connect_provider = true
SiteSetting.discourse_connect_provider_secrets = [
"*|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
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
describe 'local attribute override from SSO payload' do
before do
SiteSetting.email_editable = false
SiteSetting.auth_overrides_email = true
SiteSetting.auth_overrides_username = true
SiteSetting.auth_overrides_name = true
@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 } }
describe 'can act as an SSO provider' do
before do
stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return(
status: 200,
body: lambda { |request| file_from_fixtures("logo.png") }
)
SiteSetting.enable_discourse_connect_provider = true
SiteSetting.enable_discourse_connect = false
SiteSetting.enable_local_logins = true
SiteSetting.discourse_connect_provider_secrets = [
"*|secret,forAll",
"*.rainbow|wrongSecretForOverRainbow",
"www.random.site|secretForRandomSite",
"somewhere.over.rainbow|secretForOverRainbow",
].join("\n")
@sso = SingleSignOnProvider.new
@sso.nonce = "mynonce"
@sso.return_sso_url = "http://somewhere.over.rainbow/sso"
@user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true)
group = Fabricate(:group)
group.add(@user)
@user.create_user_avatar!
UserAvatar.import_url_for_user(logo_fixture, @user)
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false)
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true)
@user.reload
@user.user_avatar.reload
@user.user_profile.reload
EmailToken.update_all(confirmed: true)
end
it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response).to redirect_to("/login")
post "/session.json",
params: { login: @user.username, password: "myfrogs123ADMIN" }, xhr: true, headers: headers
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]
sso2 = SingleSignOnProvider.parse(payload)
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 "it fails to log in if secret is wrong" do
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForRandomSite"))
expect(response.status).to eq(422)
end
it "fails with a nice error message if secret is blank" do
SiteSetting.discourse_connect_provider_secrets = ""
sso = SingleSignOnProvider.new
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)
expect(response.body).to eq(I18n.t("discourse_connect.missing_secret"))
end
it "returns a 422 if no return_sso_url" do
SiteSetting.discourse_connect_provider_secrets = "abcdefghij"
sso = SingleSignOnProvider.new
get "/session/sso_provider?sso=asdf&sig=abcdefghij"
expect(response.status).to eq(422)
end
it "successfully redirects user to return_sso_url when the user is logged in" do
sign_in(@user)
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/)
payload = location.split("?")[1]
sso2 = SingleSignOnProvider.parse(payload)
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"
setup_s3
SiteSetting.s3_cdn_url = "http://cdn.com"
stub_request(:any, /s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/).to_return(status: 200, body: "", headers: { referer: "fgdfds" })
@user.create_user_avatar!
upload = Fabricate(:upload, url: "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/something")
Fabricate(:optimized_image,
sha1: SecureRandom.hex << "A" * 8,
upload: upload,
width: 98,
height: 98,
url: "//s3-upload-bucket.s3.amazonaws.com/something/else"
)
@user.update_columns(uploaded_avatar_id: upload.id)
upload1 = Fabricate(:upload_s3)
upload2 = Fabricate(:upload_s3)
@user.user_profile.update!(
profile_background_upload: upload1,
card_background_upload: upload2
)
@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') }
)
get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
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]
sso2 = SingleSignOnProvider.parse(payload)
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("#{SiteSetting.s3_cdn_url}/original")
expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url)
expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url)
end
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
end
end
describe '#create' do
let(:user) { Fabricate(:user) }
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
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
post "/session.json", params: {
login: user.username, password: 'myawesomepassword'
}
end
it_behaves_like "failed to continue local login"
end
context 'local login via email is disabled' do
before do
SiteSetting.enable_local_logins_via_email = false
end
it 'doesnt matter, logs in correctly' do
post "/session.json", params: {
login: user.username, password: 'myawesomepassword'
}
expect(response.status).to eq(200)
end
end
context 'when email is confirmed' do
before do
token = user.email_tokens.find_by(email: user.email)
EmailToken.confirm(token.token)
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'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(
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))
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(
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'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(I18n.t('login.suspended_with_reason',
date: I18n.l(user.suspended_till, format: :date_only),
reason: Rack::Utils.escape_html(user.suspend_reason)
))
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'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(I18n.t('login.not_activated'))
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
expect(response.status).to eq(200)
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)
expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token)
end
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)
expect(user.reload.user_option.timezone).to eq("Australia/Melbourne")
end
end
end
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',
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = response.parsed_body
expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t(
'login.invalid_security_key'
))
end
end
context "when the security key params are invalid" do
it "shows an error message and denies login" do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: {
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)
response_body = response.parsed_body
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.json", params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
end
end
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',
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = response.parsed_body
expect(response_body["failed"]).to eq("FAILED")
expect(response.parsed_body['error']).to eq(I18n.t(
'login.not_enabled_second_factor_method'
))
end
end
end
context 'when user has TOTP-only 2FA login' do
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
describe 'when second factor token is missing' do
it 'should return the right response' do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(I18n.t(
'login.invalid_second_factor_method'
))
end
end
describe 'when second factor token is invalid' do
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)
expect(response.parsed_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
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)
expect(response.parsed_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
end
describe 'when second factor token is valid' do
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)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
expect(UserAuthToken.hash_token(cookies[:_t]))
.to eq(user.user_auth_tokens.first.auth_token)
end
end
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)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
expect(UserAuthToken.hash_token(cookies[:_t]))
.to eq(user.user_auth_tokens.first.auth_token)
end
end
end
end
describe 'with a blocked IP' do
it "doesn't log in" do
ScreenedIpAddress.all.destroy_all
get "/"
_screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip)
post "/session.json", params: {
login: "@" + user.username, password: 'myawesomepassword'
}
expect(response.status).to eq(200)
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'
}
expect(response.status).to eq(200)
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'
}
expect(response.status).to eq(200)
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'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).not_to be_present
end
it "strips spaces from the email" do
post "/session.json", params: {
login: email, password: 'myawesomepassword'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).not_to be_present
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
user.update_columns(approved: false)
post "/session.json", params: {
login: user.email, password: 'myawesomepassword'
}
end
it "doesn't log in the user" do
expect(response.status).to eq(200)
expect(session[:current_user_id]).to be_blank
end
it "shows the 'not approved' error message" do
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(
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'
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(user.id)
end
end
end
context 'when admins are restricted by ip address' do
before do
SiteSetting.use_admin_ip_allowlist = true
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'
}
expect(response.status).to eq(200)
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'
}
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to be_present
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'
}
expect(response.status).to eq(200)
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
expect(response.status).to eq(200)
expect(session[:current_user_id]).to be_blank
end
it "shows the 'not activated' error message" do
post_login
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(
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
expect(response.status).to eq(200)
expect(response.parsed_body['error']).to eq(
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!
2.times do
post "/session.json", params: {
login: user.username, password: 'myawesomepassword'
}
expect(response.status).to eq(200)
end
post "/session.json", params: {
login: user.username, password: 'myawesomepassword'
}
expect(response.status).to eq(429)
json = response.parsed_body
expect(json["error_type"]).to eq("rate_limit")
end
it 'rate limits second factor attempts by IP' do
RateLimiter.enable
RateLimiter.clear_all!
6.times do |x|
post "/session.json", params: {
login: "#{user.username}#{x}",
password: 'myawesomepassword',
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
end
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(429)
json = response.parsed_body
expect(json["error_type"]).to eq("rate_limit")
end
it 'rate limits second factor attempts by login' do
RateLimiter.enable
RateLimiter.clear_all!
6.times do |x|
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp]
}, env: { "REMOTE_ADDR": "1.2.3.#{x}" }
expect(response.status).to eq(200)
end
[user.username + " ", user.username.capitalize, user.username].each_with_index do |username , x|
post "/session.json", params: {
login: username,
password: 'myawesomepassword',
second_factor_token: '000000',
second_factor_method: UserSecondFactor.methods[:totp]
}, 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
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
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)
expect(session[:current_user_id]).to be_blank
expect(response.cookies["_t"]).to be_blank
expect(response.parsed_body["redirect_url"]).to eq("/")
end
it 'redirects to /login when SSO and login_required' do
SiteSetting.discourse_connect_url = "https://example.com/sso"
SiteSetting.enable_discourse_connect = true
user = sign_in(Fabricate(:user))
delete "/session/#{user.username}.json", xhr: true
expect(response.status).to eq(200)
expect(response.parsed_body["redirect_url"]).to eq("/")
SiteSetting.login_required = true
user = sign_in(Fabricate(:user))
delete "/session/#{user.username}.json", xhr: true
expect(response.status).to eq(200)
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)
expect(response.parsed_body["redirect_url"]).to eq("/myredirect/#{user.username}")
ensure
DiscourseEvent.off(:before_session_destroy, &callback)
end
end
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)
post "/session/otp/asd1231dasd123"
expect(response.status).to eq(404)
end
context 'when token is valid' do
it "should display the form for GET" do
token = SecureRandom.hex
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
get "/session/otp/#{token}"
expect(response.status).to eq(200)
expect(response.body).to include(
I18n.t("user_api_key.otp_confirmation.logging_in_as", username: user.username)
)
expect(Discourse.redis.get("otp_#{token}")).to eq(user.username)
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
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
get "/session/otp/#{token}"
expect(response.status).to eq(302)
expect(Discourse.redis.get("otp_#{token}")).to eq(nil)
expect(session[:current_user_id]).to eq(user.id)
end
it 'should authenticate user and delete token' do
user = Fabricate(:user)
get "/session/current.json"
expect(response.status).to eq(404)
token = SecureRandom.hex
Discourse.redis.setex "otp_#{token}", 10.minutes, user.username
post "/session/otp/#{token}"
expect(response.status).to eq(302)
expect(response).to redirect_to("/")
expect(Discourse.redis.get("otp_#{token}")).to eq(nil)
get "/session/current.json"
expect(response.status).to eq(200)
end
end
end
end
describe '#forgot_password' do
it 'raises an error without a username parameter' do
post "/session/forgot_password.json"
expect(response.status).to eq(400)
end
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)
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)
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
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
fab!(:user) { Fabricate(:user) }
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
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.enable_discourse_connect = true
post "/session.json", params: {
login: user.username, password: 'myawesomepassword'
}
end
it_behaves_like "failed to continue local login"
end
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
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
it "retuns 404" do
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"
expect(response.status).to eq(200)
json = response.parsed_body
expect(json['current_user']).to be_present
expect(json['current_user']['id']).to eq(user.id)
end
end
end
end