mirror of
https://github.com/discourse/discourse.git
synced 2024-11-27 16:53:43 +08:00
9174716737
This method is a huge footgun in production, since it calls
the Redis KEYS command. From the Redis documentation at
https://redis.io/commands/keys/:
> Warning: consider KEYS as a command that should only be used in
production environments with extreme care. It may ruin performance when
it is executed against large databases. This command is intended for
debugging and special operations, such as changing your keyspace layout.
Don't use KEYS in your regular application code.
Since we were only using `delete_prefixed` in specs (now that we
removed the usage in production in 24ec06ff85
)
we can remove this and instead rely on `use_redis_snapshotting` on the
particular tests that need this kind of clearing functionality.
2999 lines
107 KiB
Ruby
2999 lines
107 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(Webauthn.challenge(user, secure_session))
|
|
expect(Webauthn.rp_id(user, secure_session)).to eq(Discourse.current_hostname)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#email_login" do
|
|
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 "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 "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("login.invalid_security_key"))
|
|
end
|
|
end
|
|
|
|
context "when the security key params are invalid" do
|
|
it "shows an error message and denies login" do
|
|
post "/session.json",
|
|
params: {
|
|
login: user.username,
|
|
password: "myawesomepassword",
|
|
second_factor_token: {
|
|
signature: "bad_sig",
|
|
clientData: "bad_clientData",
|
|
credentialId: "bad_credential_id",
|
|
authenticatorData: "bad_authenticator_data",
|
|
},
|
|
second_factor_method: UserSecondFactor.methods[:security_key],
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
expect(session[:current_user_id]).to eq(nil)
|
|
response_body = response.parsed_body
|
|
expect(response_body["failed"]).to eq("FAILED")
|
|
expect(response_body["error"]).to eq(I18n.t("webauthn.validation.not_found_error"))
|
|
end
|
|
end
|
|
|
|
context "when the security key params are valid" do
|
|
it "logs the user in" do
|
|
post "/session.json",
|
|
params: {
|
|
login: user.username,
|
|
password: "myawesomepassword",
|
|
second_factor_token: valid_security_key_auth_post_data,
|
|
second_factor_method: UserSecondFactor.methods[:security_key],
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
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 "#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
|