discourse/spec/requests/session_controller_spec.rb
Penar Musaraj e3e73a3091
DEV: Add routes and controller actions for passkeys (2/3) (#23587)
This is part 2 (of 3) for passkeys support.

This adds a hidden site setting plus routes and controller actions.

1. registering passkeys

Passkeys are registered in a two-step process. First, `create_passkey`
returns details for the browser to create a passkey. This includes
- a challenge
- the relying party ID and Origin
- the user's secure identifier
- the supported algorithms
- the user's existing passkeys (if any)

Then the browser creates a key with this information, and submits it to
the server via `register_passkey`.

2. authenticating passkeys

A similar process happens here as well. First, a challenge is created
and sent to the browser. Then the browser makes a public key credential
and submits it to the server via `passkey_auth_perform`.

3. renaming/deleting passkeys

These routes allow changing the name of a key and deleting it.

4. checking if session is trusted for sensitive actions

Since a passkey is a password replacement, we want to make sure to confirm the user's identity before allowing adding/deleting passkeys. The u/trusted-session GET route returns success if user has confirmed their session (and failed if user hasn't). In the frontend (in the next PR), we're using these routes to show the password confirmation screen. 

The `/u/confirm-session` route allows the user to confirm their session with a password. The latter route's functionality already existed in core, under the 2FA flow, but it has been abstracted into its own here so it can be used independently.


Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
2023-10-11 14:36:54 -04:00

3185 lines
113 KiB
Ruby

# frozen_string_literal: true
require "rotp"
RSpec.describe SessionController do
let(:user) { Fabricate(:user) }
let(:email_token) { Fabricate(:email_token, user: user) }
fab!(:admin) { Fabricate(:admin) }
let(:admin_email_token) { Fabricate(:email_token, user: admin) }
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
let(:email_token) do
Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login])
end
before { SiteSetting.enable_local_logins_via_email = true }
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)
expect(response.parsed_body["error"]).not_to be_present
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)
expect(response.parsed_body["error"]).not_to be_present
end
end
context "with missing token" do
it "returns the right response" do
get "/session/email-login"
expect(response.status).to eq(404)
end
end
context "with 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 "when 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 "when 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(
DiscourseWebauthn.challenge(user, secure_session),
)
expect(DiscourseWebauthn.rp_id).to eq("localhost")
end
end
end
end
describe "#email_login" do
let(:email_token) do
Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login])
end
before { SiteSetting.enable_local_logins_via_email = true }
context "when in staff writes only mode" do
use_redis_snapshotting
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
it "allows admins to login" do
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
it "does not allow other users to login" do
post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(503)
expect(session[:current_user_id]).to eq(nil)
end
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(response.parsed_body["error"]).not_to be_present
expect(session[:current_user_id]).to eq(user.id)
end
end
context "with missing token" do
it "returns the right response" do
post "/session/email-login"
expect(response.status).to eq(404)
end
end
context "with 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", base_url: Discourse.base_url),
)
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", base_url: Discourse.base_url),
)
end
end
end
context "with 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(response.parsed_body["error"]).not_to be_present
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 "when 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 "when 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)
expect(response.parsed_body["error"]).not_to be_present
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
end
end
end
context "when 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
describe "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 "/drafts.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 development 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 = DiscourseConnect.new(secure_session: read_secure_session)
dso.nonce = nonce
dso.register_nonce(return_path)
sso = DiscourseConnectBase.new
sso.nonce = nonce
sso.sso_secret = @sso_secret
sso
end
context "when in staff writes only mode" do
use_redis_snapshotting
before { Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY) }
it "allows staff to login" do
sso = get_sso("/a/")
sso.external_id = "666"
sso.email = "bob@bob.com"
sso.name = "Bob Bobson"
sso.username = "bob"
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).not_to eq(nil)
end
it 'doesn\'t allow non-staff to login' do
sso = get_sso("/a/")
sso.external_id = "666"
sso.email = "bob@bob.com"
sso.name = "Bob Bobson"
sso.username = "bob"
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
end
it "does not create superfluous 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"
logger =
track_log_messages do
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
end
expect(logger.warnings.length).to eq(0)
expect(logger.errors.length).to eq(0)
expect(logger.fatals.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"
logger =
track_log_messages do
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
end
expect(logger.warnings.length).to eq(0)
expect(logger.errors.length).to eq(0)
expect(logger.fatals.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
DiscourseConnect.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_allowed_redirect_domains = "gusundtrout.com|foobar.com"
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 "allows wildcard character to redirect to any domain" do
SiteSetting.discourse_connect_allowed_redirect_domains = "*|foo.com"
sso = get_sso("https://foobar.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://foobar.com")
end
it "does not allow wildcard character in domains" do
SiteSetting.discourse_connect_allowed_redirect_domains = "*.foobar.com|foobar.com"
sso = get_sso("https://sub.foobar.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("//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 "creates a user but ignores auto_approve_email_domains site setting when must_approve_users site setting is not enabled" do
SiteSetting.auto_approve_email_domains = "discourse.com"
sso = get_sso("/a/")
sso.external_id = "666"
sso.email = "sam@discourse.com"
sso.name = "Sam Saffron"
sso.username = "sam"
events =
DiscourseEvent.track_events do
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
expect(response).to redirect_to("/a/")
end
expect(events.map { |event| event[:event_name] }).to include(
:user_logged_in,
:user_first_logged_in,
)
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
# 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("sam@discourse.com")
expect(logged_on_user.name).to eq("Sam Saffron")
expect(logged_on_user.username).to eq("sam")
expect(logged_on_user.approved).to eq(false)
expect(logged_on_user.active).to eq(true)
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")
end
context "when must_approve_users site setting has been enabled" do
before { SiteSetting.must_approve_users = true }
it "creates a user but does not approve when user's email domain does not match a domain in auto_approve_email_domains site settings" do
SiteSetting.auto_approve_email_domains = "discourse.com"
sso = get_sso("/a/")
sso.external_id = "666"
sso.email = "sam@discourse.org"
sso.name = "Sam Saffron"
sso.username = "sam"
expect do
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
expect(response.status).to eq(403)
expect(response.parsed_body).to include(I18n.t("discourse_connect.account_not_approved"))
end.to change { User.count }.by(1)
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
expect(logged_on_user).to eq(nil)
user = User.last
expect(user.admin).to eq(false)
expect(user.email).to eq("sam@discourse.org")
expect(user.name).to eq("Sam Saffron")
expect(user.username).to eq("sam")
expect(user.approved).to eq(false)
expect(user.active).to eq(true)
expect(user.single_sign_on_record.external_id).to eq("666")
expect(user.single_sign_on_record.external_username).to eq("sam")
end
it "creates and approves a user when user's email domain matches a domain in auto_approve_email_domains site settings" do
SiteSetting.auto_approve_email_domains = "discourse.com"
sso = get_sso("/a/")
sso.external_id = "666"
sso.email = "sam@discourse.com"
sso.name = "Sam Saffron"
sso.username = "sam"
events =
DiscourseEvent.track_events do
get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers
expect(response).to redirect_to("/a/")
end
expect(events.map { |event| event[:event_name] }).to include(
:user_logged_in,
:user_first_logged_in,
)
logged_on_user = Discourse.current_user_provider.new(request.env).current_user
# 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("sam@discourse.com")
expect(logged_on_user.name).to eq("Sam Saffron")
expect(logged_on_user.username).to eq("sam")
expect(logged_on_user.approved).to eq(true)
expect(logged_on_user.active).to eq(true)
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")
end
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 "creates the user account and redeems the invite but does not approve the user if must_approve_users is enabled" do
SiteSetting.must_approve_users = true
login_with_sso_and_invite
expect(response.status).to eq(403)
expect(response.parsed_body).to include(I18n.t("discourse_connect.account_not_approved"))
expect(invite.reload.redeemed?).to eq(true)
user = User.find_by_email("bob@bob.com")
expect(user.active).to eq(true)
expect(user.approved).to eq(false)
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 = %w[
*|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
context "when in readonly mode" do
use_redis_snapshotting
before { Discourse.enable_readonly_mode }
it "disallows requests" do
get "/session/sso_login"
expect(response.status).to eq(503)
end
end
end
describe "#sso_provider" do
let(:headers) { { host: Discourse.current_hostname } }
let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" }
fab!(:user) { Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) }
before do
stub_request(:any, %r{#{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 = %w[
*|secret,forAll
*.rainbow|wrongSecretForOverRainbow
www.random.site|secretForRandomSite
somewhere.over.rainbow|oldSecretForOverRainbow
somewhere.over.rainbow|secretForOverRainbow
somewhere.over.rainbow|newSecretForOverRainbow
].join("\n")
@sso = DiscourseConnectProvider.new
@sso.nonce = "mynonce"
@sso.return_sso_url = "http://somewhere.over.rainbow/sso"
@user = user
group = Fabricate(:group)
group.add(@user)
@user.create_user_avatar!
UserAvatar.import_url_for_user(logo_fixture, @user)
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false)
UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true)
@user.reload
@user.user_avatar.reload
@user.user_profile.reload
EmailToken.update_all(confirmed: true)
end
describe "can act as an SSO provider" do
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(%r{^http://somewhere.over.rainbow/sso})
payload = location.split("?")[1]
sso2 = DiscourseConnectProvider.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)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end
it "correctly logs in for secondary domain secrets" do
sign_in @user
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("newSecretForOverRainbow"))
expect(response.status).to eq(302)
redirect_uri = URI.parse(response.location)
expect(redirect_uri.host).to eq("somewhere.over.rainbow")
redirect_query = CGI.parse(redirect_uri.query)
expected_sig =
DiscourseConnectBase.sign(redirect_query["sso"][0], "newSecretForOverRainbow")
expect(redirect_query["sig"][0]).to eq(expected_sig)
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("oldSecretForOverRainbow"))
expect(response.status).to eq(302)
redirect_uri = URI.parse(response.location)
expect(redirect_uri.host).to eq("somewhere.over.rainbow")
redirect_query = CGI.parse(redirect_uri.query)
expected_sig =
DiscourseConnectBase.sign(redirect_query["sso"][0], "oldSecretForOverRainbow")
expect(redirect_query["sig"][0]).to eq(expected_sig)
end
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 = DiscourseConnectProvider.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"
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(%r{^http://somewhere.over.rainbow/sso})
payload = location.split("?")[1]
sso2 = DiscourseConnectProvider.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)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end
it "fails with a nice error message if `prompt` parameter has an invalid value" do
@sso.prompt = "xyzpdq"
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response.status).to eq(400)
expect(response.body).to eq(
I18n.t("discourse_connect.invalid_parameter_value", param: "prompt"),
)
end
it "redirects browser to return_sso_url with auth failure when prompt=none is requested and the user is not logged in" do
@sso.prompt = "none"
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
location = response.header["Location"]
expect(location).to match(%r{^http://somewhere.over.rainbow/sso})
payload = location.split("?")[1]
sso2 = DiscourseConnectProvider.parse(payload)
expect(sso2.failed).to eq(true)
expect(sso2.email).to eq(nil)
expect(sso2.name).to eq(nil)
expect(sso2.username).to eq(nil)
expect(sso2.external_id).to eq(nil)
expect(sso2.admin).to eq(nil)
expect(sso2.moderator).to eq(nil)
expect(sso2.groups).to eq(nil)
expect(sso2.avatar_url).to eq(nil)
expect(sso2.profile_background_url).to eq(nil)
expect(sso2.card_background_url).to eq(nil)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
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(%r{^http://somewhere.over.rainbow/sso})
payload = location.split("?")[1]
sso2 = DiscourseConnectProvider.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)
expect(sso2.confirmed_2fa).to eq(nil)
expect(sso2.no_2fa_methods).to eq(nil)
end
it "successfully logs out and redirects user to return_sso_url when the user is logged in" do
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(%r{^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(%r{^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
describe "can act as a 2FA provider" do
fab!(:user_totp) { Fabricate(:user_second_factor_totp, user: user) }
before { @sso.require_2fa = true }
it "requires the user to confirm 2FA before they are redirected to the SSO return URL" do
sign_in(user)
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
uri = URI(response.location)
expect(uri.hostname).to eq(Discourse.current_hostname)
expect(uri.path).to eq("/session/2fa")
nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
expect(nonce).to be_present
# attempt no. 1 to bypass 2fa
get "/session/sso_provider", params: { second_factor_nonce: nonce }
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed"),
)
# attempt no. 2 to bypass 2fa
get "/session/sso_provider",
params: { second_factor_nonce: nonce }.merge(
Rack::Utils.parse_query(@sso.payload("secretForOverRainbow")),
)
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed"),
)
# confirm 2fa
post "/session/2fa.json",
params: {
nonce: nonce,
second_factor_token: ROTP::TOTP.new(user_totp.data).now,
second_factor_method: UserSecondFactor.methods[:totp],
}
expect(response.status).to eq(200)
expect(response.parsed_body["ok"]).to eq(true)
expect(response.parsed_body["callback_method"]).to eq("GET")
expect(response.parsed_body["callback_path"]).to eq("/session/sso_provider")
expect(response.parsed_body["redirect_url"]).to be_blank
get "/session/sso_provider", params: { second_factor_nonce: nonce }
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq("OK")
redirect_url = response.parsed_body["redirect_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(true)
expect(sso.no_2fa_methods).to eq(nil)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
it "doesn't accept backup codes" do
backup_codes = user.generate_backup_codes
sign_in(user)
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
uri = URI(response.location)
expect(uri.hostname).to eq(Discourse.current_hostname)
expect(uri.path).to eq("/session/2fa")
nonce = uri.query.match(/\Anonce=([A-Za-z0-9]{32})\Z/)[1]
expect(nonce).to be_present
post "/session/2fa.json",
params: {
nonce: nonce,
second_factor_token: backup_codes.sample,
second_factor_method: UserSecondFactor.methods[:backup_codes],
}
expect(response.status).to eq(403)
get "/session/sso_provider", params: { second_factor_nonce: nonce }
expect(response.status).to eq(401)
expect(response.parsed_body["error"]).to eq(
I18n.t("second_factor_auth.challenge_not_completed"),
)
end
context "when the user has no 2fa methods" do
before do
user_totp.destroy!
user.reload
end
it "redirects the user back to the SSO return url and indicates in the payload that they do not have 2fa methods" do
sign_in(user)
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response.status).to eq(302)
redirect_url = response.location
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(nil)
expect(sso.no_2fa_methods).to eq(true)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
end
context "when there is no logged in user" do
it "redirects the user to login first" do
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
expect(response.status).to eq(302)
expect(response.location).to eq("http://#{Discourse.current_hostname}/login")
end
it "doesn't make the user confirm 2fa twice if they've just logged in and confirmed 2fa while doing so" do
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
post "/session.json",
params: {
login: user.username,
password: "myfrogs123ADMIN",
second_factor_token: ROTP::TOTP.new(user_totp.data).now,
second_factor_method: UserSecondFactor.methods[:totp],
},
xhr: true,
headers: headers
expect(response.status).to eq(204)
# the frontend will take care of actually redirecting the user
redirect_url = response.cookies["sso_destination_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(true)
expect(sso.no_2fa_methods).to eq(nil)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
it "doesn't indicate the user has confirmed 2fa after they've logged in if they have no 2fa methods" do
user_totp.destroy!
user.reload
get "/session/sso_provider",
params: Rack::Utils.parse_query(@sso.payload("secretForOverRainbow"))
post "/session.json",
params: {
login: user.username,
password: "myfrogs123ADMIN",
},
xhr: true,
headers: headers
redirect_url = response.cookies["sso_destination_url"]
expect(redirect_url).to start_with("http://somewhere.over.rainbow/sso?sso=")
sso = DiscourseConnectProvider.parse(URI(redirect_url).query)
expect(sso.confirmed_2fa).to eq(nil)
expect(sso.no_2fa_methods).to eq(true)
expect(sso.username).to eq(user.username)
expect(sso.email).to eq(user.email)
end
end
end
end
describe "#create" do
context "when read only mode" do
use_redis_snapshotting
before do
Discourse.enable_readonly_mode
EmailToken.confirm(email_token.token)
EmailToken.confirm(admin_email_token.token)
end
it "prevents login by regular users" do
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
expect(response.status).not_to eq(200)
end
it "prevents login by admins" do
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
expect(response.status).not_to eq(200)
end
end
context "when in staff writes only mode" do
use_redis_snapshotting
before do
Discourse.enable_readonly_mode(Discourse::STAFF_WRITES_ONLY_MODE_KEY)
EmailToken.confirm(email_token.token)
EmailToken.confirm(admin_email_token.token)
end
it "allows admin login" do
post "/session.json", params: { login: admin.username, password: "myawesomepassword" }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
end
it "prevents login by regular users" do
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
expect(response.status).not_to eq(200)
end
end
context "when 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 "when 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 "when local login via email is disabled" do
before do
SiteSetting.enable_local_logins_via_email = false
EmailToken.confirm(email_token.token)
end
it "doesnt matter, logs in correctly" do
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
end
end
context "when email is confirmed" do
before { EmailToken.confirm(email_token.token) }
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" }
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),
)
expect(response.status).to eq(200)
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)
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(response.parsed_body["error"]).not_to be_present
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)
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
expect(UserAuthToken.hash_token(unhashed_token)).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(response.parsed_body["error"]).not_to be_present
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("webauthn.validation.malformed_public_key_credential_error"),
)
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)
expect(response.parsed_body["error"]).not_to be_present
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)
expect(response.parsed_body["error"]).not_to be_present
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
expect(UserAuthToken.hash_token(unhashed_token)).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)
expect(response.parsed_body["error"]).not_to be_present
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
unhashed_token = decrypt_auth_cookie(cookies[:_t])[:token]
expect(UserAuthToken.hash_token(unhashed_token)).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)
expect(response.parsed_body["error"]).to be_present
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)
expect(response.parsed_body["error"]).not_to be_present
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(response.parsed_body["error"]).not_to be_present
expect(session[:current_user_id]).to eq(user.id)
end
end
context "when 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 { SiteSetting.must_approve_users = true }
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(response.parsed_body["error"]).to be_present
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(response.parsed_body["error"]).not_to be_present
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(response.parsed_body["error"]).not_to be_present
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(response.parsed_body["error"]).not_to be_present
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(response.parsed_body["error"]).to be_present
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 "when 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 "when rate limited" do
before { RateLimiter.enable }
use_redis_snapshotting
it "rate limits login" do
SiteSetting.max_logins_per_ip_per_hour = 2
EmailToken.confirm(email_token.token)
2.times do
post "/session.json", params: { login: user.username, password: "myawesomepassword" }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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
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)
expect(response.parsed_body["error"]).to be_present
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
EmailToken.confirm(email_token.token)
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)
expect(response.parsed_body["error"]).not_to be_present
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(response.parsed_body["error"]).not_to be_present
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["error"]).not_to be_present
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["error"]).not_to be_present
expect(response.parsed_body["redirect_url"]).to eq("/login")
end
it "allows plugins to manipulate redirect URL" do
callback = ->(data) { data[:redirect_url] = "/myredirect/#{data[:user].username}" }
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["error"]).not_to be_present
expect(response.parsed_body["redirect_url"]).to eq("/myredirect/#{user.username}")
ensure
DiscourseEvent.off(:before_session_destroy, &callback)
end
it "includes ip and user agent in the before_session_destroy event params" do
callback_params = {}
callback = ->(data) { callback_params = data }
DiscourseEvent.on(:before_session_destroy, &callback)
user = sign_in(Fabricate(:user))
delete "/session/#{user.username}.json",
xhr: true,
headers: {
HTTP_USER_AGENT: "AwesomeBrowser",
}
expect(callback_params[:user_agent]).to eq("AwesomeBrowser")
expect(callback_params[:client_ip]).to eq("127.0.0.1")
ensure
DiscourseEvent.off(:before_session_destroy, &callback)
end
end
describe "#one_time_password" do
context "with missing token" do
it "returns the right response" do
get "/session/otp"
expect(response.status).to eq(404)
end
end
context "with 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.parsed_body["error"]).not_to be_present
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)
expect(response.parsed_body["error"]).not_to be_present
end
end
end
end
describe "#forgot_password" do
context "when hide_email_address_taken is set" do
before { SiteSetting.hide_email_address_taken = true }
it "denies for username" do
post "/session/forgot_password.json", params: { login: user.username }
expect(response.status).to eq(400)
expect(Jobs::CriticalUserEmail.jobs.size).to eq(0)
end
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)
expect(response.parsed_body["error"]).not_to be_present
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
end
it "allows for email" do
post "/session/forgot_password.json", params: { login: user.email }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
expect(Jobs::CriticalUserEmail.jobs.size).to eq(1)
end
end
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
describe "rate limiting" do
before { RateLimiter.enable }
use_redis_snapshotting
it "should correctly rate limits" do
user = Fabricate(:user)
3.times do
post "/session/forgot_password.json", params: { login: user.username }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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)
expect(response.parsed_body["error"]).not_to be_present
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
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 "when 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 "when 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 "when 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 "when local logins via email are disabled" do
before { SiteSetting.enable_local_logins_via_email = false }
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 "when doing 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 "returns 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)
expect(response.parsed_body["error"]).not_to be_present
json = response.parsed_body
expect(json["current_user"]).to be_present
expect(json["current_user"]["id"]).to eq(user.id)
end
end
end
describe "#second_factor_auth_show" do
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
before { sign_in(user) }
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", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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],
)
expect(challenge_data["description"]).to eq("this is description for test action")
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 }, xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"]
get "/session/2fa.json", params: { nonce: nonce }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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 { sign_in(user) }
it "returns 401 if the challenge nonce has expired" do
post "/session/2fa/test-action", xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"]
freeze_time (SecondFactor::AuthManager::MAX_CHALLENGE_AGE + 1.minute).from_now
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", xhr: true
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", xhr: true
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_url: "/ggg" }, xhr: true
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)
expect(response.parsed_body["error"]).not_to be_present
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_url"]).to eq("/ggg")
post "/session/2fa/test-action", params: { second_factor_nonce: nonce }
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
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_url: "/ggg" }, xhr: true
nonce = response.parsed_body["second_factor_challenge_nonce"]
token = ROTP::TOTP.new(user_second_factor.data).now.to_i
token += token == 999_999 ? -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
describe "#passkey_challenge" do
it "returns a challenge for an anonymous user" do
get "/session/passkey/challenge.json"
expect(response.status).to eq(200)
expect(response.parsed_body["challenge"]).not_to eq(nil)
end
it "returns a challenge for an authenticated user" do
sign_in(user)
get "/session/passkey/challenge.json"
expect(response.status).to eq(200)
expect(response.parsed_body["challenge"]).not_to eq(nil)
end
it "reset challenge on subsequent calls" do
get "/session/passkey/challenge.json"
expect(response.status).to eq(200)
challenge1 = response.parsed_body["challenge"]
get "/session/passkey/challenge.json"
expect(response.parsed_body["challenge"]).not_to eq(challenge1)
end
it "fails if local logins are not allowed" do
SiteSetting.enable_local_logins = false
get "/session/passkey/challenge.json"
expect(response.status).to eq(403)
end
end
describe "#passkey_login" do
it "returns 404 if feature is not enabled" do
SiteSetting.experimental_passkeys = false
post "/session/passkey/auth.json"
expect(response.status).to eq(404)
end
context "when experimental_passkeys is enabled" do
before { SiteSetting.experimental_passkeys = true }
it "fails if public key param is missing" do
post "/session/passkey/auth.json"
expect(response.status).to eq(400)
json = response.parsed_body
expect(json["errors"][0]).to include("param is missing")
expect(json["errors"][0]).to include("publicKeyCredential")
end
it "fails on malformed credentials" do
post "/session/passkey/auth.json", params: { publicKeyCredential: "someboringstring" }
expect(response.status).to eq(401)
json = response.parsed_body
expect(json["errors"][0]).to eq(
I18n.t("webauthn.validation.malformed_public_key_credential_error"),
)
end
it "fails on invalid credentials" do
post "/session/passkey/auth.json",
params: {
# creds are well-formed but security key is not registered
publicKeyCredential: {
signature:
"MEYCIQDYtbfkTGHOfizXHBHltn5KOq1eC3EM6Uq4peZ0L+3wMwIhAMgzm88qOOZ7SPYh5M6zvKMjVsUAne7n9RKdN/4Bb6z8",
clientData:
"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWmpJMk16UmxNMlV3TkRSaFl6QmhNemczTURjMlpUaGhaR1l5T1dGaU5qSXpNamMxWmpCaU9EVmxNVFUzTURaaVpEaGpNVEUwTVdJeU1qRXkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2x9",
authenticatorData: "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAA==",
credentialId: "humAArAAAiZZuwFE/F9Gi4BAVTsRL/FowuzQsYTPKIk=",
},
}
expect(response.status).to eq(401)
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.not_found_error"))
end
context "when user has a valid registered passkey" do
let!(:passkey) do
Fabricate(
:user_security_key,
credential_id: valid_passkey_data[:credential_id],
public_key: valid_passkey_data[:public_key],
user: user,
factor_type: UserSecurityKey.factor_types[:first_factor],
last_used: nil,
name: "A key",
)
end
it "fails if local logins are not allowed" do
SiteSetting.enable_local_logins = false
post "/session/passkey/auth.json",
params: {
publicKeyCredential: valid_passkey_auth_data,
}
expect(response.status).to eq(403)
end
it "fails when the key is registered to another user" do
simulate_localhost_passkey_challenge
user.activate
user.create_or_fetch_secure_identifier
post "/session/passkey/auth.json",
params: {
publicKeyCredential:
valid_passkey_auth_data.merge(
{ userHandle: Base64.strict_encode64(SecureRandom.hex(20)) },
),
}
expect(response.status).to eq(401)
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.ownership_error"))
expect(session[:current_user_id]).to eq(nil)
end
it "logs the user in" do
simulate_localhost_passkey_challenge
user.activate
user.create_or_fetch_secure_identifier
post "/session/passkey/auth.json",
params: {
publicKeyCredential:
valid_passkey_auth_data.merge(
{ userHandle: Base64.strict_encode64(user.secure_identifier) },
),
}
expect(response.status).to eq(200)
expect(response.parsed_body["error"]).not_to be_present
expect(session[:current_user_id]).to eq(user.id)
end
end
end
end
describe "#scopes" do
context "when not a valid api request" do
it "returns 404" do
get "/session/scopes.json"
expect(response.status).to eq(404)
end
end
context "when a valid api request" do
let(:admin) { Fabricate(:admin) }
let(:scope) do
ApiKeyScope.new(resource: "topics", action: "read", allowed_parameters: { topic_id: "3" })
end
let(:api_key) { Fabricate(:api_key, user: admin, api_key_scopes: [scope]) }
it "returns the scopes of the api key" do
get "/session/scopes.json",
headers: {
"Api-Key": api_key.key,
"Api-Username": admin.username,
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["scopes"].size).to eq(1)
expect(json["scopes"].first["resource"]).to eq("topics")
expect(json["scopes"].first["action"]).to eq("read")
expect(json["scopes"].first["allowed_parameters"]).to eq({ topic_id: "3" }.as_json)
end
end
end
end