From 23e3a68592cdd9cdb84528b6d05c492fbfe49ab2 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Wed, 30 May 2018 02:11:01 +0300 Subject: [PATCH] REFACTOR: session controller specs to requests --- spec/controllers/session_controller_spec.rb | 1101 ------------------ spec/requests/session_controller_spec.rb | 1103 +++++++++++++++++++ 2 files changed, 1103 insertions(+), 1101 deletions(-) delete mode 100644 spec/controllers/session_controller_spec.rb diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb deleted file mode 100644 index 476bdb1e162..00000000000 --- a/spec/controllers/session_controller_spec.rb +++ /dev/null @@ -1,1101 +0,0 @@ -require 'rails_helper' - -describe SessionController do - shared_examples 'failed to continue local login' do - it 'should return the right response' do - expect(response).not_to be_success - expect(response.status.to_i).to eq 500 - 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 :become, params: { session_id: user.username }, format: :json - - expect(response.status).to eq(403) - expect(JSON.parse(response.body)["error_type"]).to eq("invalid_access") - expect(session[:current_user_id]).to be_blank - end - - it "works in developmenet mode" do - Rails.env.stubs(:development?).returns(true) - get :become, params: { session_id: user.username }, format: :json - expect(response).to be_redirect - expect(session[:current_user_id]).to eq(user.id) - end - end - - let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" } - - describe '#sso_login' do - before do - @sso_url = "http://somesite.com/discourse_sso" - @sso_secret = "shjkfdhsfkjh" - - request.host = Discourse.current_hostname - - SiteSetting.sso_url = @sso_url - SiteSetting.enable_sso = true - SiteSetting.sso_secret = @sso_secret - - # We have 2 options, either fabricate an admin or don't - # send welcome messages - Fabricate(:admin) - # skip for now - # SiteSetting.send_welcome_message = false - end - - def get_sso(return_path) - nonce = SecureRandom.hex - dso = DiscourseSingleSignOn.new - dso.nonce = nonce - dso.register_nonce(return_path) - - sso = SingleSignOn.new - sso.nonce = nonce - sso.sso_secret = @sso_secret - sso - end - - it 'can take over an account' do - sso = get_sso("/") - user = Fabricate(:user) - sso.email = user.email - sso.external_id = 'abc' - sso.username = 'sam' - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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') - end - - def sso_for_ip_specs - sso = get_sso('/a/') - sso.external_id = '666' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - sso - end - - it 'respects IP restrictions on create' do - screened_ip = Fabricate(:screened_ip_address) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(screened_ip.ip_address) - - sso = sso_for_ip_specs - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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 - sso = sso_for_ip_specs - _user = DiscourseSingleSignOn.parse(sso.payload).lookup_or_create_user(request.remote_ip) - - sso = sso_for_ip_specs - screened_ip = Fabricate(:screened_ip_address) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(screened_ip.ip_address) - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - 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' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - - ScreenedEmail.block('bob@bob.com') - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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' # the number of the beast - 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 :sso_login, params: Rack::Utils.parse_query(sso.payload) - - logged_on_user = Discourse.current_user_provider.new(request.env).current_user - expect(logged_on_user.admin).to eq(true) - end - - it 'redirects to a non-relative url' do - sso = get_sso("#{Discourse.base_url}/b/") - sso.external_id = '666' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - expect(response).to redirect_to('/b/') - end - - it 'redirects to random url if it is allowed' do - SiteSetting.sso_allows_all_return_paths = true - - sso = get_sso('https://gusundtrout.com') - sso.external_id = '666' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - expect(response).to redirect_to('https://gusundtrout.com') - end - - it 'redirects to root if the host of the return_path is different' do - sso = get_sso('//eviltrout.com') - sso.external_id = '666' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - 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' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - expect(response).to redirect_to('/') - end - - it 'allows you to create an account' do - sso = get_sso('/a/') - sso.external_id = '666' # the number of the beast - 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 :sso_login, params: Rack::Utils.parse_query(sso.payload) - 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 - - # 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 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' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - sso.require_activation = true - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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 - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) - sso = get_sso('/a/') - sso.external_id = '666' # the number of the beast - sso.email = 'bob@bob.com' - sso.name = 'Sam Saffron' - sso.username = 'sam' - sso.require_activation = true - - get :sso_login, params: Rack::Utils.parse_query(sso.payload) - 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 :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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 :sso_login, params: Rack::Utils.parse_query(sso.payload) - - 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 :sso_login, params: Rack::Utils.parse_query(sso.payload) - expect(response.code).to eq('419') - end - - describe 'can act as an SSO provider' do - before do - stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( - status: 200, - body: lambda { |request| file_from_fixtures("logo.png") } - ) - - SiteSetting.enable_sso_provider = true - SiteSetting.enable_sso = false - SiteSetting.enable_local_logins = true - SiteSetting.sso_secret = "topsecret" - - @sso = SingleSignOn.new - @sso.nonce = "mynonce" - @sso.sso_secret = SiteSetting.sso_secret - @sso.return_sso_url = "http://somewhere.over.rainbow/sso" - - @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) - group = Fabricate(:group) - group.add(@user) - - @user.create_user_avatar! - UserAvatar.import_url_for_user(logo_fixture, @user) - UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) - UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) - - @user.reload - @user.user_avatar.reload - @user.user_profile.reload - EmailToken.update_all(confirmed: true) - end - - it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do - get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) - expect(response).to redirect_to("/login") - - post :create, - params: { login: @user.username, password: "myfrogs123ADMIN" }, - format: :json, - xhr: true - - location = response.cookies["sso_destination_url"] - # javascript code will handle redirection of user to return_sso_url - expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) - - payload = location.split("?")[1] - sso2 = SingleSignOn.parse(payload, "topsecret") - - expect(sso2.email).to eq(@user.email) - expect(sso2.name).to eq(@user.name) - expect(sso2.username).to eq(@user.username) - expect(sso2.external_id).to eq(@user.id.to_s) - expect(sso2.admin).to eq(true) - expect(sso2.moderator).to eq(false) - expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) - - expect(sso2.avatar_url.blank?).to_not eq(true) - expect(sso2.profile_background_url.blank?).to_not eq(true) - expect(sso2.card_background_url.blank?).to_not eq(true) - - expect(sso2.avatar_url).to start_with(Discourse.base_url) - expect(sso2.profile_background_url).to start_with(Discourse.base_url) - expect(sso2.card_background_url).to start_with(Discourse.base_url) - end - - it "successfully redirects user to return_sso_url when the user is logged in" do - log_in_user(@user) - - get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) - - location = response.header["Location"] - expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) - - payload = location.split("?")[1] - sso2 = SingleSignOn.parse(payload, "topsecret") - - expect(sso2.email).to eq(@user.email) - expect(sso2.name).to eq(@user.name) - expect(sso2.username).to eq(@user.username) - expect(sso2.external_id).to eq(@user.id.to_s) - expect(sso2.admin).to eq(true) - expect(sso2.moderator).to eq(false) - expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) - - expect(sso2.avatar_url.blank?).to_not eq(true) - expect(sso2.profile_background_url.blank?).to_not eq(true) - expect(sso2.card_background_url.blank?).to_not eq(true) - - expect(sso2.avatar_url).to start_with(Discourse.base_url) - expect(sso2.profile_background_url).to start_with(Discourse.base_url) - expect(sso2.card_background_url).to start_with(Discourse.base_url) - end - - it 'handles non local content correctly' do - SiteSetting.avatar_sizes = "100|49" - SiteSetting.enable_s3_uploads = true - SiteSetting.s3_access_key_id = "XXX" - SiteSetting.s3_secret_access_key = "XXX" - SiteSetting.s3_upload_bucket = "test" - SiteSetting.s3_cdn_url = "http://cdn.com" - - stub_request(:any, /test.s3.amazonaws.com/).to_return(status: 200, body: "", headers: {}) - - @user.create_user_avatar! - upload = Fabricate(:upload, url: "//test.s3.amazonaws.com/something") - - Fabricate(:optimized_image, - sha1: SecureRandom.hex << "A" * 8, - upload: upload, - width: 98, - height: 98, - url: "//test.s3.amazonaws.com/something/else" - ) - - @user.update_columns(uploaded_avatar_id: upload.id) - @user.user_profile.update_columns( - profile_background: "//test.s3.amazonaws.com/something", - card_background: "//test.s3.amazonaws.com/something" - ) - - @user.reload - @user.user_avatar.reload - @user.user_profile.reload - - log_in_user(@user) - - stub_request(:get, "http://cdn.com/something/else").to_return( - body: lambda { |request| File.new(Rails.root + 'spec/fixtures/images/logo.png') } - ) - - get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) - - location = response.header["Location"] - # javascript code will handle redirection of user to return_sso_url - expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) - - payload = location.split("?")[1] - sso2 = SingleSignOn.parse(payload, "topsecret") - - 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) - expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url) - expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url) - end - end - - describe 'local attribute override from SSO payload' do - before do - SiteSetting.email_editable = false - SiteSetting.sso_overrides_email = true - SiteSetting.sso_overrides_username = true - SiteSetting.sso_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 :sso_login, params: Rack::Utils.parse_query(@sso.payload) - @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 :sso_login, params: Rack::Utils.parse_query(@sso.payload) - - 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 :sso_login, params: Rack::Utils.parse_query(@sso.payload) - - logged_on_user = Discourse.current_user_provider.new(request.env).current_user - expect(logged_on_user.username).to eq(@user.username) - expect(logged_on_user.name).to eq(@user.name) - expect(logged_on_user.email).to eq(@user.email) - end - - end - end - - describe '#sso_provider' do - before do - stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( - status: 200, - body: lambda { |request| file_from_fixtures("logo.png") } - ) - - SiteSetting.enable_sso_provider = true - SiteSetting.enable_sso = false - SiteSetting.enable_local_logins = true - SiteSetting.sso_secret = "topsecret" - - @sso = SingleSignOn.new - @sso.nonce = "mynonce" - @sso.sso_secret = SiteSetting.sso_secret - @sso.return_sso_url = "http://somewhere.over.rainbow/sso" - - @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) - @user.create_user_avatar! - UserAvatar.import_url_for_user(logo_fixture, @user) - UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) - UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) - - @user.reload - @user.user_avatar.reload - @user.user_profile.reload - EmailToken.update_all(confirmed: true) - end - - it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do - get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) - expect(response).to redirect_to("/login") - - post :create, - params: { login: @user.username, password: "myfrogs123ADMIN" }, - format: :json, - xhr: true - - location = response.cookies["sso_destination_url"] - # javascript code will handle redirection of user to return_sso_url - expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) - - payload = location.split("?")[1] - sso2 = SingleSignOn.parse(payload, "topsecret") - - expect(sso2.email).to eq(@user.email) - expect(sso2.name).to eq(@user.name) - expect(sso2.username).to eq(@user.username) - expect(sso2.external_id).to eq(@user.id.to_s) - expect(sso2.admin).to eq(true) - expect(sso2.moderator).to eq(false) - expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) - - expect(sso2.avatar_url.blank?).to_not eq(true) - expect(sso2.profile_background_url.blank?).to_not eq(true) - expect(sso2.card_background_url.blank?).to_not eq(true) - - expect(sso2.avatar_url).to start_with(Discourse.base_url) - expect(sso2.profile_background_url).to start_with(Discourse.base_url) - expect(sso2.card_background_url).to start_with(Discourse.base_url) - end - - it "successfully redirects user to return_sso_url when the user is logged in" do - log_in_user(@user) - - get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) - - location = response.header["Location"] - expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) - - payload = location.split("?")[1] - sso2 = SingleSignOn.parse(payload, "topsecret") - - 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.avatar_url.blank?).to_not eq(true) - expect(sso2.profile_background_url.blank?).to_not eq(true) - expect(sso2.card_background_url.blank?).to_not eq(true) - - expect(sso2.avatar_url).to start_with(Discourse.base_url) - expect(sso2.profile_background_url).to start_with(Discourse.base_url) - expect(sso2.card_background_url).to start_with(Discourse.base_url) - end - end - - describe '#create' do - - let(:user) { Fabricate(:user) } - - context 'local login is disabled' do - before do - SiteSetting.enable_local_logins = false - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - end - it_behaves_like "failed to continue local login" - end - - context 'SSO is enabled' do - before do - SiteSetting.sso_url = "https://www.example.com/sso" - SiteSetting.enable_sso = true - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - end - it_behaves_like "failed to continue local login" - end - - context 'when email is confirmed' do - before do - token = user.email_tokens.find_by(email: user.email) - EmailToken.confirm(token.token) - end - - it "raises an error when the login isn't present" do - expect do - post :create, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - describe 'invalid password' do - it "should return an error with an invalid password" do - post :create, params: { - login: user.username, password: 'sssss' - }, format: :json - - expect(::JSON.parse(response.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 :create, params: { - login: user.username, password: ('s' * (User.max_password_length + 1)) - }, format: :json - - expect(::JSON.parse(response.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, "banned") - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(JSON.parse(response.body)['error']).to eq(I18n.t('login.suspended_with_reason', - date: I18n.l(user.suspended_till, format: :date_only), - reason: Rack::Utils.escape_html(user.suspend_reason) - )) - end - end - - describe 'deactivated user' do - it 'should return an error' do - User.any_instance.stubs(:active).returns(false) - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(JSON.parse(response.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 :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - end - - expect(events.map { |event| event[:event_name] }).to include( - :user_logged_in, :user_first_logged_in - ) - - user.reload - - expect(session[:current_user_id]).to eq(user.id) - expect(user.user_auth_tokens.count).to eq(1) - expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token) - end - end - - context 'when user has 2-factor logins' do - let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) } - - describe 'when second factor token is missing' do - it 'should return the right response' do - post :create, params: { - login: user.username, - password: 'myawesomepassword', - }, format: :json - - expect(JSON.parse(response.body)['error']).to eq(I18n.t( - 'login.invalid_second_factor_code' - )) - end - end - - describe 'when second factor token is invalid' do - it 'should return the right response' do - post :create, params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: '00000000' - }, format: :json - - expect(JSON.parse(response.body)['error']).to eq(I18n.t( - 'login.invalid_second_factor_code' - )) - end - end - - describe 'when second factor token is valid' do - it 'should log the user in' do - post :create, params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now - }, format: :json - - user.reload - - expect(session[:current_user_id]).to eq(user.id) - expect(user.user_auth_tokens.count).to eq(1) - - expect(UserAuthToken.hash_token(cookies[:_t])) - .to eq(user.user_auth_tokens.first.auth_token) - end - end - end - - describe 'with a blocked IP' do - before do - screened_ip = Fabricate(:screened_ip_address) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(screened_ip.ip_address) - post :create, params: { - login: "@" + user.username, password: 'myawesomepassword' - }, format: :json - - user.reload - end - - it "doesn't log in" do - expect(session[:current_user_id]).to be_nil - end - end - - describe 'strips leading @ symbol' do - before do - post :create, params: { - login: "@" + user.username, password: 'myawesomepassword' - }, format: :json - - user.reload - end - - it 'sets a session id' do - expect(session[:current_user_id]).to eq(user.id) - end - end - - describe 'also allow login by email' do - before do - post :create, params: { - login: user.email, password: 'myawesomepassword' - }, format: :json - end - - it 'sets a session id' do - expect(session[:current_user_id]).to eq(user.id) - end - end - - context 'login has leading and trailing space' do - let(:username) { " #{user.username} " } - let(:email) { " #{user.email} " } - - it "strips spaces from the username" do - post :create, params: { - login: username, password: 'myawesomepassword' - }, format: :json - - expect(::JSON.parse(response.body)['error']).not_to be_present - end - - it "strips spaces from the email" do - post :create, params: { - login: email, password: 'myawesomepassword' - }, format: :json - - expect(::JSON.parse(response.body)['error']).not_to be_present - end - end - - describe "when the site requires approval of users" do - before do - SiteSetting.expects(:must_approve_users?).returns(true) - end - - context 'with an unapproved user' do - before do - post :create, params: { - login: user.email, password: 'myawesomepassword' - }, format: :json - end - - it "doesn't log in the user" do - expect(session[:current_user_id]).to be_blank - end - - it "shows the 'not approved' error message" do - expect(JSON.parse(response.body)['error']).to eq( - I18n.t('login.not_approved') - ) - end - end - - context "with an unapproved user who is an admin" do - before do - User.any_instance.stubs(:admin?).returns(true) - - post :create, params: { - login: user.email, password: 'myawesomepassword' - }, format: :json - end - - it 'sets a session id' do - expect(session[:current_user_id]).to eq(user.id) - end - end - end - - context 'when admins are restricted by ip address' do - let(:permitted_ip_address) { '111.234.23.11' } - before do - Fabricate(:screened_ip_address, ip_address: permitted_ip_address, action_type: ScreenedIpAddress.actions[:allow_admin]) - SiteSetting.use_admin_ip_whitelist = true - end - - it 'is successful for admin at the ip address' do - User.any_instance.stubs(:admin?).returns(true) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(permitted_ip_address) - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(session[:current_user_id]).to eq(user.id) - end - - it 'returns an error for admin not at the ip address' do - User.any_instance.stubs(:admin?).returns(true) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns("111.234.23.12") - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(JSON.parse(response.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 - User.any_instance.stubs(:admin?).returns(false) - ActionDispatch::Request.any_instance.stubs(:remote_ip).returns("111.234.23.12") - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(session[:current_user_id]).to eq(user.id) - end - end - end - - context 'when email has not been confirmed' do - def post_login - post :create, params: { - login: user.email, password: 'myawesomepassword' - }, format: :json - end - - it "doesn't log in the user" do - post_login - expect(session[:current_user_id]).to be_blank - end - - it "shows the 'not activated' error message" do - post_login - expect(JSON.parse(response.body)['error']).to eq( - I18n.t 'login.not_activated' - ) - end - - context "and the 'must approve users' site setting is enabled" do - before { SiteSetting.expects(:must_approve_users?).returns(true) } - - it "shows the 'not approved' error message" do - post_login - expect(JSON.parse(response.body)['error']).to eq( - I18n.t 'login.not_approved' - ) - end - end - end - - context 'rate limited' do - it 'rate limits login' do - SiteSetting.max_logins_per_ip_per_hour = 2 - RateLimiter.enable - RateLimiter.clear_all! - - 2.times do - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(response).to be_success - end - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - - expect(response.status).to eq(429) - json = JSON.parse(response.body) - expect(json["error_type"]).to eq("rate_limit") - end - - it 'rate limits second factor attempts' do - RateLimiter.enable - RateLimiter.clear_all! - - 3.times do - post :create, params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: '000000' - }, format: :json - - expect(response).to be_success - end - - post :create, params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: '000000' - }, format: :json - - expect(response.status).to eq(429) - json = JSON.parse(response.body) - expect(json["error_type"]).to eq("rate_limit") - end - end - end - - describe '.destroy' do - before do - @user = log_in - delete :destroy, params: { id: @user.username }, format: :json - end - - it 'removes the session variable' do - expect(session[:current_user_id]).to be_blank - end - - it 'removes the auth token cookie' do - expect(response.cookies["_t"]).to be_blank - end - end - - describe '.forgot_password' do - - it 'raises an error without a username parameter' do - expect do - post :forgot_password, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - context 'for a non existant username' do - it "doesn't generate a new token for a made up username" do - expect do - post :forgot_password, params: { login: 'made_up' }, format: :json - end.not_to change(EmailToken, :count) - end - - it "doesn't enqueue an email" do - Jobs.expects(:enqueue).with(:user_mail, anything).never - post :forgot_password, params: { login: 'made_up' }, format: :json - end - end - - context 'for an existing username' do - let(:user) { Fabricate(:user) } - - context 'local login is disabled' do - before do - SiteSetting.enable_local_logins = false - post :forgot_password, params: { login: user.username }, format: :json - end - it_behaves_like "failed to continue local login" - end - - context 'SSO is enabled' do - before do - SiteSetting.sso_url = "https://www.example.com/sso" - SiteSetting.enable_sso = true - - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - end - it_behaves_like "failed to continue local login" - end - - it "generates a new token for a made up username" do - expect do - post :forgot_password, params: { login: user.username }, format: :json - end.to change(EmailToken, :count) - end - - it "enqueues an email" do - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :forgot_password, user_id: user.id)) - post :forgot_password, params: { login: user.username }, format: :json - end - end - - context 'do nothing to system username' do - let(:system) { Discourse.system_user } - - it 'generates no token for system username' do - expect do - post :forgot_password, params: { login: system.username }, format: :json - end.not_to change(EmailToken, :count) - end - - it 'enqueues no email' do - Jobs.expects(:enqueue).never - post :forgot_password, params: { login: system.username }, format: :json - end - end - - context 'for a staged account' do - let!(:staged) { Fabricate(:staged) } - - it 'generates no token for staged username' do - expect do - post :forgot_password, params: { login: staged.username }, format: :json - end.not_to change(EmailToken, :count) - end - - it 'enqueues no email' do - Jobs.expects(:enqueue).never - post :forgot_password, params: { login: staged.username }, format: :json - end - end - end - - describe '#current' do - context "when not logged in" do - it "retuns 404" do - get :current, format: :json - expect(response).not_to be_success - end - end - - context "when logged in" do - let!(:user) { log_in } - - it "returns the JSON for the user" do - get :current, format: :json - expect(response).to be_success - json = ::JSON.parse(response.body) - expect(json['current_user']).to be_present - expect(json['current_user']['id']).to eq(user.id) - end - end - end -end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index b315609fb77..e2da38a415a 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -3,6 +3,14 @@ require 'rails_helper' RSpec.describe SessionController do let(:email_token) { Fabricate(:email_token) } let(:user) { email_token.user } + let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" } + + shared_examples 'failed to continue local login' do + it 'should return the right response' do + expect(response).not_to be_success + expect(response.status).to eq(500) + end + end describe '#email_login' do before do @@ -195,4 +203,1099 @@ RSpec.describe SessionController do 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(JSON.parse(response.body)["error_type"]).to eq("invalid_access") + expect(session[:current_user_id]).to be_blank + end + + it "works in developmenet mode" do + Rails.env.stubs(:development?).returns(true) + get "/session/#{user.username}/become.json" + expect(response).to be_redirect + expect(session[:current_user_id]).to eq(user.id) + end + end + + + describe '#sso_login' do + before do + @sso_url = "http://somesite.com/discourse_sso" + @sso_secret = "shjkfdhsfkjh" + + SiteSetting.sso_url = @sso_url + SiteSetting.enable_sso = true + SiteSetting.sso_secret = @sso_secret + + # We have 2 options, either fabricate an admin or don't + # send welcome messages + Fabricate(:admin) + # skip for now + # SiteSetting.send_welcome_message = false + end + + let(:headers) { { host: Discourse.current_hostname } } + + def get_sso(return_path) + nonce = SecureRandom.hex + dso = DiscourseSingleSignOn.new + dso.nonce = nonce + dso.register_nonce(return_path) + + sso = SingleSignOn.new + sso.nonce = nonce + sso.sso_secret = @sso_secret + sso + end + + it 'can take over an account' do + sso = get_sso("/") + user = Fabricate(:user) + 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') + end + + def sso_for_ip_specs + sso = get_sso('/a/') + sso.external_id = '666' # the number of the beast + sso.email = 'bob@bob.com' + sso.name = 'Sam Saffron' + sso.username = 'sam' + sso + end + + it 'respects IP restrictions on create' do + ScreenedIpAddress.all.destroy_all + get "/" + screened_ip = Fabricate(:screened_ip_address, ip_address: request.remote_ip, action_type: ScreenedIpAddress.actions[:block]) + + sso = sso_for_ip_specs + get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers + + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + expect(logged_on_user).to eq(nil) + end + + it 'respects IP restrictions on login' do + ScreenedIpAddress.all.destroy_all + get "/" + sso = sso_for_ip_specs + DiscourseSingleSignOn.parse(sso.payload).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' # the number of the beast + 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' # the number of the beast + 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 'redirects to a non-relative url' do + sso = get_sso("#{Discourse.base_url}/b/") + sso.external_id = '666' # the number of the beast + 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.sso_allows_all_return_paths = true + + sso = get_sso('https://gusundtrout.com') + sso.external_id = '666' # the number of the beast + sso.email = 'bob@bob.com' + sso.name = 'Sam Saffron' + sso.username = 'sam' + + get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers + expect(response).to redirect_to('https://gusundtrout.com') + end + + it 'redirects to root if the host of the return_path is different' do + sso = get_sso('//eviltrout.com') + sso.external_id = '666' # the number of the beast + 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' # the number of the beast + sso.email = 'bob@bob.com' + sso.name = 'Sam Saffron' + sso.username = 'sam' + + get "/session/sso_login", params: Rack::Utils.parse_query(sso.payload), headers: headers + expect(response).to redirect_to('/') + end + + it 'allows you to create an account' do + sso = get_sso('/a/') + sso.external_id = '666' # the number of the beast + 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 + + # 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 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' # the number of the beast + 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 + SiteSetting.queue_jobs = true + sso = get_sso('/a/') + sso.external_id = '666' # the number of the beast + 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 + + describe 'can act as an SSO provider' do + before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + + SiteSetting.enable_sso_provider = true + SiteSetting.enable_sso = false + SiteSetting.enable_local_logins = true + SiteSetting.sso_secret = "topsecret" + + @sso = SingleSignOn.new + @sso.nonce = "mynonce" + @sso.sso_secret = SiteSetting.sso_secret + @sso.return_sso_url = "http://somewhere.over.rainbow/sso" + + @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) + group = Fabricate(:group) + group.add(@user) + + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload + EmailToken.update_all(confirmed: true) + end + + it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do + get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload), headers: headers + + expect(response).to redirect_to("/login") + + post "/session.json", + params: { login: @user.username, password: "myfrogs123ADMIN" }, xhr: true + location = response.cookies["sso_destination_url"] + # javascript code will handle redirection of user to return_sso_url + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + expect(sso2.email).to eq(@user.email) + expect(sso2.name).to eq(@user.name) + expect(sso2.username).to eq(@user.username) + expect(sso2.external_id).to eq(@user.id.to_s) + expect(sso2.admin).to eq(true) + expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + + it "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), headers: headers + + location = response.header["Location"] + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + expect(sso2.email).to eq(@user.email) + expect(sso2.name).to eq(@user.name) + expect(sso2.username).to eq(@user.username) + expect(sso2.external_id).to eq(@user.id.to_s) + expect(sso2.admin).to eq(true) + expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + + it 'handles non local content correctly' do + SiteSetting.avatar_sizes = "100|49" + SiteSetting.enable_s3_uploads = true + SiteSetting.s3_access_key_id = "XXX" + SiteSetting.s3_secret_access_key = "XXX" + SiteSetting.s3_upload_bucket = "test" + SiteSetting.s3_cdn_url = "http://cdn.com" + + stub_request(:any, /test.s3.amazonaws.com/).to_return(status: 200, body: "", headers: {}) + + @user.create_user_avatar! + upload = Fabricate(:upload, url: "//test.s3.amazonaws.com/something") + + Fabricate(:optimized_image, + sha1: SecureRandom.hex << "A" * 8, + upload: upload, + width: 98, + height: 98, + url: "//test.s3.amazonaws.com/something/else" + ) + + @user.update_columns(uploaded_avatar_id: upload.id) + @user.user_profile.update_columns( + profile_background: "//test.s3.amazonaws.com/something", + card_background: "//test.s3.amazonaws.com/something" + ) + + @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), headers: headers + + location = response.header["Location"] + # javascript code will handle redirection of user to return_sso_url + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + 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) + expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url) + expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url) + end + end + + describe 'local attribute override from SSO payload' do + before do + SiteSetting.email_editable = false + SiteSetting.sso_overrides_email = true + SiteSetting.sso_overrides_username = true + SiteSetting.sso_overrides_name = true + + @user = Fabricate(:user) + + @sso = get_sso('/hello/world') + @sso.external_id = '997' + + @reversed_username = @user.username.reverse + @sso.username = @reversed_username + @sso.email = "#{@reversed_username}@garbage.org" + @reversed_name = @user.name.reverse + @sso.name = @reversed_name + + @suggested_username = UserNameSuggester.suggest(@sso.username || @sso.name || @sso.email) + @suggested_name = User.suggest_name(@sso.name || @sso.username || @sso.email) + @user.create_single_sign_on_record(external_id: '997', last_payload: '') + end + + it 'stores the external attributes' do + get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers + @user.single_sign_on_record.reload + expect(@user.single_sign_on_record.external_username).to eq(@sso.username) + expect(@user.single_sign_on_record.external_email).to eq(@sso.email) + expect(@user.single_sign_on_record.external_name).to eq(@sso.name) + end + + it 'overrides attributes' do + get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers + + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + expect(logged_on_user.username).to eq(@suggested_username) + expect(logged_on_user.email).to eq("#{@reversed_username}@garbage.org") + expect(logged_on_user.name).to eq(@sso.name) + end + + it 'does not change matching attributes for an existing account' do + @sso.username = @user.username + @sso.name = @user.name + @sso.email = @user.email + + get "/session/sso_login", params: Rack::Utils.parse_query(@sso.payload), headers: headers + + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + expect(logged_on_user.username).to eq(@user.username) + expect(logged_on_user.name).to eq(@user.name) + expect(logged_on_user.email).to eq(@user.email) + end + end + end + + describe '#sso_provider' do + before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + + SiteSetting.enable_sso_provider = true + SiteSetting.enable_sso = false + SiteSetting.enable_local_logins = true + SiteSetting.sso_secret = "topsecret" + + @sso = SingleSignOn.new + @sso.nonce = "mynonce" + @sso.sso_secret = SiteSetting.sso_secret + @sso.return_sso_url = "http://somewhere.over.rainbow/sso" + + @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload + EmailToken.update_all(confirmed: true) + end + + it "successfully logs in and redirects user to return_sso_url when the user is not logged in" do + get "/session/sso_provider", params: Rack::Utils.parse_query(@sso.payload) + expect(response).to redirect_to("/login") + + post "/session.json", + params: { login: @user.username, password: "myfrogs123ADMIN" }, + xhr: true + + location = response.cookies["sso_destination_url"] + # javascript code will handle redirection of user to return_sso_url + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + expect(sso2.email).to eq(@user.email) + expect(sso2.name).to eq(@user.name) + expect(sso2.username).to eq(@user.username) + expect(sso2.external_id).to eq(@user.id.to_s) + expect(sso2.admin).to eq(true) + expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + + it "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) + + location = response.header["Location"] + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + 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.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + end + + describe '#create' do + let(:user) { Fabricate(:user) } + + context 'local login is disabled' do + before do + SiteSetting.enable_local_logins = false + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + end + it_behaves_like "failed to continue local login" + end + + context 'SSO is enabled' do + before do + SiteSetting.sso_url = "https://www.example.com/sso" + SiteSetting.enable_sso = true + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + end + it_behaves_like "failed to continue local login" + end + + context 'when email is confirmed' do + before do + token = user.email_tokens.find_by(email: user.email) + EmailToken.confirm(token.token) + end + + it "raises an error when the login isn't present" do + post "/session.json" + expect(response.status).to eq(400) + end + + describe 'invalid password' do + it "should return an error with an invalid password" do + post "/session.json", params: { + login: user.username, password: 'sssss' + } + + expect(response).to be_success + expect(::JSON.parse(response.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).to be_success + expect(::JSON.parse(response.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, "banned") + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + + expect(response).to be_success + expect(JSON.parse(response.body)['error']).to eq(I18n.t('login.suspended_with_reason', + date: I18n.l(user.suspended_till, format: :date_only), + reason: Rack::Utils.escape_html(user.suspend_reason) + )) + end + end + + describe 'deactivated user' do + it 'should return an error' do + user.active = false + user.save! + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + + expect(response).to be_success + expect(JSON.parse(response.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).to be_success + expect(events.map { |event| event[:event_name] }).to contain_exactly( + :user_logged_in, :user_first_logged_in + ) + + user.reload + + expect(session[:current_user_id]).to eq(user.id) + expect(user.user_auth_tokens.count).to eq(1) + expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token) + end + end + + context 'when user has 2-factor logins' do + let!(:user_second_factor) { Fabricate(:user_second_factor, 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).to be_success + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) + end + end + + describe 'when second factor token is invalid' do + it 'should return the right response' do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '00000000' + } + + expect(response).to be_success + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) + end + end + + describe 'when second factor token is valid' 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 + } + expect(response).to be_success + user.reload + + expect(session[:current_user_id]).to eq(user.id) + expect(user.user_auth_tokens.count).to eq(1) + + expect(UserAuthToken.hash_token(cookies[:_t])) + .to eq(user.user_auth_tokens.first.auth_token) + end + end + end + + 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).to be_success + 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).to be_success + 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).to be_success + expect(session[:current_user_id]).to eq(user.id) + end + end + + context 'login has leading and trailing space' do + let(:username) { " #{user.username} " } + let(:email) { " #{user.email} " } + + it "strips spaces from the username" do + post "/session.json", params: { + login: username, password: 'myawesomepassword' + } + expect(response).to be_success + expect(::JSON.parse(response.body)['error']).not_to be_present + end + + it "strips spaces from the email" do + post "/session.json", params: { + login: email, password: 'myawesomepassword' + } + expect(response).to be_success + expect(::JSON.parse(response.body)['error']).not_to be_present + end + end + + describe "when the site requires approval of users" do + before do + SiteSetting.must_approve_users = true + end + + context 'with an unapproved user' do + before do + post "/session.json", params: { + login: user.email, password: 'myawesomepassword' + } + end + + it "doesn't log in the user" do + expect(response).to be_success + expect(session[:current_user_id]).to be_blank + end + + it "shows the 'not approved' error message" do + expect(response).to be_success + expect(JSON.parse(response.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).to be_success + expect(session[:current_user_id]).to eq(user.id) + end + end + end + + context 'when admins are restricted by ip address' do + let(:permitted_ip_address) { '111.234.23.11' } + before do + SiteSetting.use_admin_ip_whitelist = 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).to be_success + 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).to be_success + expect(JSON.parse(response.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).to be_success + 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).to be_success + expect(session[:current_user_id]).to be_blank + end + + it "shows the 'not activated' error message" do + post_login + expect(response).to be_success + expect(JSON.parse(response.body)['error']).to eq( + I18n.t 'login.not_activated' + ) + end + + context "and the 'must approve users' site setting is enabled" do + before { SiteSetting.must_approve_users = true } + + it "shows the 'not approved' error message" do + post_login + expect(response).to be_success + expect(JSON.parse(response.body)['error']).to eq( + I18n.t 'login.not_approved' + ) + end + end + end + + context 'rate limited' do + it 'rate limits login' do + SiteSetting.max_logins_per_ip_per_hour = 2 + RateLimiter.enable + RateLimiter.clear_all! + + 2.times do + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + + expect(response).to be_success + end + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + + expect(response.status).to eq(429) + json = JSON.parse(response.body) + expect(json["error_type"]).to eq("rate_limit") + end + + it 'rate limits second factor attempts' do + RateLimiter.enable + RateLimiter.clear_all! + + 3.times do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '000000' + } + + expect(response).to be_success + end + + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '000000' + } + + expect(response.status).to eq(429) + json = JSON.parse(response.body) + expect(json["error_type"]).to eq("rate_limit") + 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 + end + + describe '#forgot_password' do + it 'raises an error without a username parameter' do + post "/session/forgot_password.json" + expect(response.status).to eq(400) + end + + context 'for a non existant username' do + it "doesn't generate a new token for a made up username" do + SiteSetting.queue_jobs = true + 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 + let(:user) { Fabricate(:user) } + + context 'local login is disabled' do + before do + SiteSetting.enable_local_logins = false + post "/session/forgot_password.json", params: { login: user.username } + end + it_behaves_like "failed to continue local login" + end + + context 'SSO is enabled' do + before do + SiteSetting.sso_url = "https://www.example.com/sso" + SiteSetting.enable_sso = true + + post "/session.json", params: { + login: user.username, password: 'myawesomepassword' + } + end + it_behaves_like "failed to continue local login" + 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 + SiteSetting.queue_jobs = true + post "/session/forgot_password.json", params: { login: user.username } + expect(Jobs::CriticalUserEmail.jobs.size).to eq(1) + end + end + + context 'do nothing to system username' do + let(:system) { Discourse.system_user } + + it 'generates no token for system username' do + expect do + post "/session/forgot_password.json", params: { login: system.username } + end.not_to change(EmailToken, :count) + end + + it 'enqueues no email' do + SiteSetting.queue_jobs = true + 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 + SiteSetting.queue_jobs = true + post "/session/forgot_password.json", params: { login: staged.username } + expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) + end + end + end + + describe '#current' do + context "when not logged in" do + it "retuns 404" do + get "/session/current.json" + expect(response.status).to eq(404) + end + end + + context "when logged in" do + let!(:user) { sign_in(Fabricate(:user)) } + + it "returns the JSON for the user" do + get "/session/current.json" + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json['current_user']).to be_present + expect(json['current_user']['id']).to eq(user.id) + end + end + end end