diff --git a/.circleci/config.yml b/.circleci/config.yml
index ab754aee..71477bba 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -52,6 +52,7 @@ jobs:
HYRAX_VALKYRIE: true
RACK_ENV: test
RAILS_ENV: test
+ DATABASE_AUTH: true
SOLR_TEST_URL: http://127.0.0.1:8983/solr/dlp-selfdeposit-test
SPEC_OPTS: >-
--profile 10 --format RspecJunitFormatter --out /tmp/test-results/rspec.xml
diff --git a/Gemfile b/Gemfile
index 3d0084a3..9a968c4b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -11,13 +11,14 @@ gem 'coffee-rails', '~> 4.2'
gem 'dalli'
gem 'devise'
gem 'devise-guests', '~> 0.8'
-gem "devise_saml_authenticatable", git: "https://github.com/apokalipto/devise_saml_authenticatable"
gem 'dotenv-rails'
gem 'hydra-role-management'
gem 'hyrax', git: 'https://github.com/samvera/hyrax', ref: '9c58751'
gem 'jbuilder', '~> 2.5'
gem 'jquery-rails'
gem 'mutex_m', '~> 0.2.0'
+gem 'omniauth-rails_csrf_protection'
+gem 'omniauth-saml'
gem 'pg', '~> 1.3'
gem 'puma'
gem 'rails', '~> 6.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 67c0b0b3..ce09a418 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,11 +1,3 @@
-GIT
- remote: https://github.com/apokalipto/devise_saml_authenticatable
- revision: f8a85c3f3ed069fd40701424e2eef075219ef17f
- specs:
- devise_saml_authenticatable (1.9.1)
- devise (> 2.0.0)
- ruby-saml (~> 1.7)
-
GIT
remote: https://github.com/brentd/xray-rails
revision: f121814718c9907b20058dc9357b80a53afab821
@@ -792,6 +784,16 @@ GEM
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
+ omniauth (2.1.2)
+ hashie (>= 3.4.6)
+ rack (>= 2.2.3)
+ rack-protection
+ omniauth-rails_csrf_protection (1.0.2)
+ actionpack (>= 4.2)
+ omniauth (~> 2.0)
+ omniauth-saml (2.2.1)
+ omniauth (~> 2.1)
+ ruby-saml (~> 1.17)
openseadragon (0.7.0)
rails (> 6.1.0)
orm_adapter (0.5.0)
@@ -1264,7 +1266,6 @@ DEPENDENCIES
debug (>= 1.0.0)
devise
devise-guests (~> 0.8)
- devise_saml_authenticatable!
dotenv-rails
ed25519 (>= 1.2, < 2.0)
factory_bot_rails
@@ -1274,6 +1275,8 @@ DEPENDENCIES
jquery-rails
listen (>= 3.0.5, < 3.2)
mutex_m (~> 0.2.0)
+ omniauth-rails_csrf_protection
+ omniauth-saml
pg (~> 1.3)
pry-doc
pry-rails
diff --git a/app/controllers/omniauth_controller.rb b/app/controllers/omniauth_controller.rb
new file mode 100644
index 00000000..939a21a4
--- /dev/null
+++ b/app/controllers/omniauth_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+class OmniauthController < Devise::SessionsController
+ def new
+ if Rails.env.production?
+ redirect_to user_saml_omniauth_authorize_path
+ else
+ super
+ end
+ end
+end
diff --git a/app/controllers/saml_sessions_controller.rb b/app/controllers/saml_sessions_controller.rb
deleted file mode 100644
index b67626a3..00000000
--- a/app/controllers/saml_sessions_controller.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-class SamlSessionsController < Devise::SamlSessionsController
- before_action :store_redirect_path, only: [:new]
- before_action :check_environment
-
- def new
- super
- end
-
- def create
- authenticate_resource
- handle_authenticated_resource
- rescue StandardError => e
- handle_authentication_error(e)
- end
-
- def after_sign_in_path_for(resource)
- stored_location_for(resource) || root_path
- end
-
- def after_sign_out_path_for(_resource_or_scope)
- root_path
- end
-
- private
-
- def authenticate_resource
- self.resource = warden.authenticate!(auth_options)
- end
-
- def handle_authenticated_resource
- if resource&.persisted?
- sign_in_resource
- else
- handle_invalid_resource
- end
- end
-
- def sign_in_resource
- set_flash_message!(:notice, :signed_in)
- sign_in(resource_name, resource)
- yield resource if block_given?
- respond_with resource, location: after_sign_in_path_for(resource)
- end
-
- def handle_invalid_resource
- error_flash_and_redirect("Unable to create or update user account.")
- end
-
- def handle_authentication_error(error)
- Rails.logger.error("SAML authentication error: #{error.message}")
- error_flash_and_redirect("Unable to create or update user account.")
- end
-
- def error_flash_and_redirect(reason)
- flash[:alert] = I18n.t("devise.failure.saml_invalid", reason:)
- redirect_to root_path
- end
-
- protected
-
- def store_redirect_path
- if params[:redirect_to].present?
- store_location_for(:user, params[:redirect_to])
- elsif request.referer.present? && request.referer != new_saml_user_session_url
- store_location_for(:user, request.referer)
- end
- end
-
- def stored_redirect_path_or_default
- stored_location_for(:user) || root_path
- end
-
- def check_environment
- return if Rails.env.production?
- flash[:alert] = "SAML authentication is only available in production."
- redirect_to new_user_session_path
- end
-end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
new file mode 100644
index 00000000..5389e043
--- /dev/null
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ def saml
+ @user = User.from_omniauth(request.env["omniauth.auth"])
+
+ if @user.persisted?
+ sign_in @user
+ redirect_to request.env["omniauth.origin"] || hyrax.dashboard_path
+ set_flash_message(:notice, :success, kind: "SAML")
+ else
+ redirect_to root_path
+ set_flash_message(:notice, :failure, kind: "SAML", reason: "you aren't authorized to use this application.")
+ end
+ end
+
+ def failure
+ redirect_to root_path
+ end
+end
diff --git a/app/models/auth_config.rb b/app/models/auth_config.rb
new file mode 100644
index 00000000..1f6b1f7f
--- /dev/null
+++ b/app/models/auth_config.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+class AuthConfig
+ # In production, we use Shibboleth for user authentication,
+ # but in development mode, you may want to use local database
+ # authentication instead.
+ def self.use_database_auth?
+ ENV['DATABASE_AUTH'] == 'true'
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 731b77d5..bb08a4bc 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,68 +4,61 @@ class User < ApplicationRecord
include Hydra::User
# Connects this user object to Role-management behaviors.
include Hydra::RoleManagement::UserRoles
- # Connects this user object to Hyrax behaviors.
+
include Hyrax::User
include Hyrax::UserUsageStats
+ class NilSamlUserError < RuntimeError
+ attr_accessor :auth
+
+ def initialize(message = nil, auth = nil)
+ super(message)
+ self.auth = auth
+ end
+ end
+
# Connects this user object to Blacklights Bookmarks.
include Blacklight::User
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
- devise :database_authenticatable, :saml_authenticatable, :registerable,
- :recoverable, :rememberable, :validatable
- # temporary fix for SAML login while we have a hybrid login
- validates :password, presence: true, if: :password_required?
+ devise_modules = [:registerable, :recoverable, :rememberable, :validatable, :omniauthable, omniauth_providers: [:saml]]
+ devise_modules.prepend(:database_authenticatable, :registerable) # if AuthConfig.use_database_auth?
+ devise(*devise_modules)
- def password_required?
- return false if saml_authenticatable?
- super
+ def to_s
+ email
end
- def saml_authenticatable?
- uid.present?
- end
- # Method added by Blacklight; Blacklight uses #to_s on your
- # user class to get a user-displayable login/identifier for
- # the account.
+ def self.from_omniauth(auth)
+ begin
+ user = find_by!(provider: auth.provider, uid: auth.info.net_id)
+ rescue ActiveRecord::RecordNotFound
+ log_omniauth_error(auth)
+ return User.new
+ end
- def to_s
- email
+ assign_user_attributes(user, auth)
+ user.save
+ user
end
- def self.from_saml(auth)
- return unless Rails.env.production?
- user = find_or_initialize_by(email: auth.info.mail)
- set_user_attributes(user, auth)
+ def self.assign_user_attributes(user, auth)
+ user.assign_attributes(
+ display_name: auth.info.display_name,
+ ppid: auth.uid,
+ provider: auth.provider,
+ uid: auth.info.net_id
+ )
- if user.save
- user
- else
- log_saml_error(auth)
- User.new
- end
+ user.email = "#{auth.info.net_id}@emory.edu" unless auth.info.net_id == 'tezprox'
end
- def self.log_saml_error(auth)
- if auth.info.mail.blank?
- Rails.logger.error "Nil user detected: SAML didn't pass an email for #{auth.inspect}"
+ def self.log_omniauth_error(auth)
+ if auth.info.net_id.blank?
+ Rails.logger.error "Nil user detected: SAML didn't pass a net_id for #{auth.inspect}"
else
- Rails.logger.error "Failed to create/update user: #{auth.inspect}"
+ # Log unauthorized logins to error
+ Rails.logger.error "Unauthorized user attempted login: #{auth.inspect}"
end
end
end
-
-private
-
-def set_user_attributes(user, auth)
- password = SecureRandom.hex(16)
- user.assign_attributes(
- display_name: auth.info.displayName,
- department: auth.info.ou,
- title: auth.info.title,
- uid: auth.uid,
- # set a random password temporarily to allow a hybrid login
- password:,
- password_confirmation: password
- )
-end
diff --git a/app/views/_user_util_links.html.erb b/app/views/_user_util_links.html.erb
index 1f6075b5..9750b361 100644
--- a/app/views/_user_util_links.html.erb
+++ b/app/views/_user_util_links.html.erb
@@ -24,7 +24,7 @@
<% end %>
- <%= link_to "Shib", '/users/saml/sign_in', method: :get, class: 'nav-link' %>
+ <%= link_to "Shib", main_app.user_saml_omniauth_authorize_path, method: :post, class: 'nav-link' %>
<% end %>
\ No newline at end of file
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 6265ee4e..a392f4e9 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -95,4 +95,24 @@
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
+
+ # OmniAuth configuration settings
+ config.idp_slo_target_url = ENV['IDP_SLO_TARGET_URL']
+ config.assertion_consumer_service_url = ENV['ASSERTION_CS_URL']
+ config.assertion_consumer_logout_service_url = ENV['ASSERTION_LOGOUT_URL']
+ config.issuer = ENV['ISSUER']
+ config.idp_sso_target_url = ENV['IDP_SSO_TARGET_URL']
+ config.idp_cert = ENV['IDP_CERT']
+ config.certificate = ENV['SP_CERT']
+ config.private_key = ENV['SP_KEY']
+ config.attribute_statements = {
+ net_id: ["urn:oid:0.9.2342.19200300.100.1.1"],
+ display_name: ["urn:oid:1.3.6.1.4.1.5923.1.1.1.2"],
+ last_name: ["urn:oid:2.5.4.4"]
+ }
+ config.uid_attribute = "urn:oid:2.5.4.5"
+ config.security = {
+ want_assertions_encrypted: true, # Makes a 2nd KeyDescriptor, this one says use="encryption"
+ want_assertions_signed: true # Goes on md SPSSODescriptor tag
+ }
end
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index a6b66e1d..08df0ee6 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -46,7 +46,7 @@
# session. If you need permissions, you should implement that in a before filter.
# You can also supply a hash where the value is a boolean determining whether
# or not authentication should be aborted when the value is not present.
- # config.authentication_keys = [:email]
+ config.authentication_keys = [:uid]
# Configure parameters from the request object used for authentication. Each entry
# given should be a request method and it will automatically be passed to the
@@ -272,6 +272,12 @@
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
# config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+ config.omniauth :saml,
+ idp_cert: ENV['IDP_CERT'],
+ certificate: ENV['SP_CERT'],
+ private_key: ENV['SP_KEY'],
+ idp_sso_target_url: 'https://login.emory.edu/idp/profile/SAML2/Redirect/SSO',
+ idp_sso_service_url: ENV['IDP_SSO_TARGET_URL']
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
@@ -308,28 +314,4 @@
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
-
- config.saml_route_helper_prefix = 'saml'
-
- # Configure with your SAML settings (see ruby-saml's README for more information: https://github.com/onelogin/ruby-saml).
- config.saml_configure do |settings|
- base_url = ApplicationUrl.base_url
- settings.assertion_consumer_service_url = "https://#{base_url}/users/saml/auth/"
- settings.sp_entity_id = "https://#{base_url}/users/saml/metadata"
- settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
- settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
- settings.authn_context = ""
- settings.idp_slo_service_url = "https://shib.open.library.emory.edu/Shibboleth.sso/SLO/POST"
- settings.idp_sso_service_url = "https://shib.open.library.emory.edu/Shibboleth.sso/Login"
- # used the use="signing" key below
- settings.idp_cert_fingerprint = ENV['IDP_CERT_FINGERPRINT']
- settings.idp_cert_fingerprint_algorithm = "http://www.w3.org/2000/09/xmldsig#sha1"
- end
-
- config.saml_create_user = true
- config.saml_update_user = true
- config.saml_default_user_key = :email
- config.saml_session_index_key = :session_index
- config.saml_use_subject = true
- config.saml_resource_locator = ->(model, saml_response) { model.from_saml(saml_response) }
end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
new file mode 100644
index 00000000..aef5e327
--- /dev/null
+++ b/config/initializers/omniauth.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Rails.application.config.middleware.use OmniAuth::Builder do
+# if !Rails.env.development? && !Rails.env.test?
+# provider :saml,
+# assertion_consumer_service_url: Rails.application.config.assertion_consumer_service_url,
+# assertion_consumer_logout_service_url: Rails.application.config.assertion_consumer_logout_service_url,
+# issuer: Rails.application.config.issuer,
+# idp_sso_target_url: Rails.application.config.idp_sso_target_url,
+# idp_slo_target_url: Rails.application.config.idp_slo_target_url,
+# idp_cert: Rails.application.config.idp_cert,
+# certificate: Rails.application.config.certificate,
+# private_key: Rails.application.config.private_key,
+# attribute_statements: Rails.application.config.attribute_statements,
+# uid_attribute: Rails.application.config.uid_attribute,
+# security: Rails.application.config.security
+# end
+# end
+
+# # Protect against forgery attacks
+# OmniAuth.config.allowed_request_methods = [:post]
diff --git a/config/routes.rb b/config/routes.rb
index 355c72b0..6dde9bb6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -15,7 +15,18 @@
concerns :searchable
end
- devise_for :users, controllers: { saml_sessions: 'saml_sessions' }
+ devise_for :users, controllers: {
+ omniauth_callbacks: 'users/omniauth_callbacks'
+ }
+
+ # unless AuthConfig.use_database_auth?
+ devise_scope :user do
+ get 'sign_in', to: 'omniauth#new'
+ post 'sign_in', to: 'users/omniauth_callbacks#saml'
+ get 'sign_out', to: 'devise/sessions#destroy'
+ get 'auth/failure', to: 'users/omniauth_callbacks#failure'
+ end
+ # end
mount Hydra::RoleManagement::Engine => '/'
@@ -42,7 +53,6 @@
delete 'clear'
end
end
- # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
# rubocop:disable Rails/FindEach
def latency_text
diff --git a/db/migrate/20241107154801_add_provider_and_ppid_to_users.rb b/db/migrate/20241107154801_add_provider_and_ppid_to_users.rb
new file mode 100644
index 00000000..939975b2
--- /dev/null
+++ b/db/migrate/20241107154801_add_provider_and_ppid_to_users.rb
@@ -0,0 +1,6 @@
+class AddProviderAndPpidToUsers < ActiveRecord::Migration[6.1]
+ def change
+ add_column :users, :provider, :string
+ add_column :users, :ppid, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2658acfc..58d435b2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2024_09_23_144555) do
+ActiveRecord::Schema.define(version: 2024_11_07_154801) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -702,6 +702,8 @@
t.string "zotero_userid"
t.string "preferred_locale"
t.string "uid"
+ t.string "provider"
+ t.string "ppid"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
diff --git a/dotenv.sample b/dotenv.sample
index 380540d0..7927ddb4 100644
--- a/dotenv.sample
+++ b/dotenv.sample
@@ -51,3 +51,4 @@ FITS_SERVLET_VERSION=
FEDORA_VERSION=
OPENEMORY_WORKFLOW_ADMIN_SET_ID=
OPENEMORY_COLLECTION_ID=
+DATABASE_AUTH=true
diff --git a/spec/controllers/omniauth_controller_spec.rb b/spec/controllers/omniauth_controller_spec.rb
new file mode 100644
index 00000000..4b23b6c3
--- /dev/null
+++ b/spec/controllers/omniauth_controller_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe OmniauthController do
+ describe '#new' do
+ before do
+ @request.env['devise.mapping'] = Devise.mappings[:user]
+ @request.env['warden'] = double(Warden::Proxy)
+ allow(@request.env['warden']).to receive(:authenticate?).and_return(false)
+ allow(@request.env['warden']).to receive(:authenticate!).and_return(false)
+ end
+
+ context 'when in production environment' do
+ before do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
+ end
+
+ it 'redirects to shibboleth login' do
+ get :new
+ expect(response).to redirect_to(user_saml_omniauth_authorize_path)
+ end
+ end
+
+ context 'when not in production environment' do
+ before do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
+ end
+
+ it 'renders the default devise login page' do
+ get :new
+ expect(response).to be_successful
+ expect(response).to render_template('devise/sessions/new')
+ end
+ end
+ end
+
+ describe 'routing' do
+ it 'routes GET /sign_in to omniauth#new' do
+ expect(get: '/sign_in').to route_to(
+ controller: 'omniauth',
+ action: 'new'
+ )
+ end
+
+ it 'routes POST /sign_in to users/omniauth_callbacks#saml' do
+ expect(post: '/sign_in').to route_to(
+ controller: 'users/omniauth_callbacks',
+ action: 'saml'
+ )
+ end
+
+ it 'routes GET /sign_out to devise/sessions#destroy' do
+ expect(get: '/sign_out').to route_to(
+ controller: 'devise/sessions',
+ action: 'destroy'
+ )
+ end
+ end
+end
diff --git a/spec/controllers/saml_sessions_controller_spec.rb b/spec/controllers/saml_sessions_controller_spec.rb
deleted file mode 100644
index 85c08745..00000000
--- a/spec/controllers/saml_sessions_controller_spec.rb
+++ /dev/null
@@ -1,117 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe SamlSessionsController, type: :controller do
- include Devise::Test::ControllerHelpers
-
- let(:warden) { double(Warden::Proxy) }
- let(:user) { instance_double(User, persisted?: true) }
-
- before do
- @request.env["devise.mapping"] = Devise.mappings[:user]
- @request.env['warden'] = warden
- allow(warden).to receive(:authenticate?).and_return(false)
- allow(warden).to receive(:set_user)
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
- end
-
- describe "GET #new" do
- before do
- allow_any_instance_of(OneLogin::RubySaml::Authrequest).to receive(:create).and_return("http://idp.example.com/saml/auth")
- end
-
- it "stores the redirect_to parameter" do
- get :new, params: { redirect_to: '/dashboard' }
- expect(session['user_return_to']).to eq('/dashboard')
- end
-
- it "stores the referer if no redirect_to parameter" do
- request.env['HTTP_REFERER'] = '/previous_page'
- get :new
- expect(session['user_return_to']).to eq('/previous_page')
- end
-
- it "doesn't store referer if it matches new_user_session_url" do
- request.env['HTTP_REFERER'] = new_saml_user_session_url
- get :new
- expect(session['user_return_to']).to be_nil
- end
-
- it "initiates SAML authentication" do
- get :new
- expect(response).to redirect_to("http://idp.example.com/saml/auth")
- end
- end
-
- describe "POST #create" do
- context "in production environment" do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
- end
-
- it "sets a success notice and redirects to after_sign_in_path when authentication is successful" do
- allow(warden).to receive(:authenticate!).and_return(user)
- allow(warden).to receive(:user).with(:user).and_return(user)
- allow(controller).to receive(:after_sign_in_path_for).with(user).and_return('/dashboard')
- post :create
- expect(flash[:notice]).to eq(I18n.t("devise.sessions.signed_in"))
- expect(response).to redirect_to('/dashboard')
- end
-
- it "sets an alert and redirects to root_path when authentication fails" do
- allow(warden).to receive(:authenticate!).and_return(nil)
- allow(warden).to receive(:user).with(:user).and_return(nil)
- post :create
- expect(flash[:alert]).to eq(I18n.t("devise.failure.saml_invalid", reason: "Unable to create or update user account."))
- expect(response).to redirect_to(root_path)
- end
- end
-
- context "in non-production environment" do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
- end
-
- it "redirects to sign in path with an alert" do
- post :create
- expect(response).to redirect_to(new_user_session_path)
- expect(flash[:alert]).to eq("SAML authentication is only available in production.")
- end
- end
- end
-
- describe "#after_sign_in_path_for" do
- let(:user) { instance_double(User) }
-
- it "returns stored location if available" do
- allow(controller).to receive(:stored_location_for).with(user).and_return('/stored_path')
- expect(controller.after_sign_in_path_for(user)).to eq('/stored_path')
- end
-
- it "returns root path if no stored location" do
- allow(controller).to receive(:stored_location_for).with(user).and_return(nil)
- expect(controller.after_sign_in_path_for(user)).to eq(root_path)
- end
- end
-
- describe "#check_environment" do
- context "in production" do
- before { allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production")) }
-
- it "allows access" do
- get :new
- expect(response).not_to redirect_to(new_user_session_path)
- end
- end
-
- context "in non-production" do
- before { allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development")) }
-
- it "redirects to sign in path with an alert" do
- get :new
- expect(response).to redirect_to(new_user_session_path)
- expect(flash[:alert]).to eq("SAML authentication is only available in production.")
- end
- end
- end
-end
diff --git a/spec/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/controllers/users/omniauth_callbacks_controller_spec.rb
new file mode 100644
index 00000000..e496a54e
--- /dev/null
+++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe Users::OmniauthCallbacksController do
+ include Rails.application.routes.url_helpers
+
+ def dashboard_path
+ '/dashboard?locale=en'
+ end
+
+ def root_path
+ '/?locale=en'
+ end
+
+ before do
+ @request.env["devise.mapping"] = Devise.mappings[:user]
+ @request.env['warden'] = double(Warden::Proxy)
+ allow(@request.env['warden']).to receive(:authenticate!).and_return(true)
+ allow(@request.env['warden']).to receive(:authenticate?).and_return(false)
+ allow(@request.env['warden']).to receive(:user).and_return(user)
+ allow(@request.env['warden']).to receive(:set_user)
+ allow(controller).to receive(:current_user).and_return(user)
+ end
+
+ describe '#saml' do
+ let(:auth_hash) do
+ OmniAuth::AuthHash.new(
+ provider: 'saml',
+ uid: '12345',
+ info: {
+ net_id: 'test123',
+ display_name: 'Test User',
+ email: 'test123@emory.edu'
+ }
+ )
+ end
+
+ before do
+ @request.env["omniauth.auth"] = auth_hash
+ end
+
+ context 'when user exists and is authorized' do
+ let(:user) { create(:user) }
+
+ before do
+ allow(User).to receive(:from_omniauth).and_return(user)
+ @request.env["omniauth.origin"] = '/custom/path'
+ end
+
+ it 'signs in and redirects the user' do
+ get :saml
+ expect(response).to redirect_to('/custom/path')
+ expect(flash[:notice]).to match(/Successfully authenticated from SAML account./)
+ end
+
+ context 'when omniauth.origin is not present' do
+ before do
+ @request.env["omniauth.origin"] = nil
+ end
+
+ it 'redirects to the dashboard' do
+ get :saml
+ expect(response).to redirect_to(dashboard_path)
+ end
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:unauthorized_user) { User.new }
+ let(:user) { unauthorized_user }
+
+ before do
+ allow(User).to receive(:from_omniauth).and_return(unauthorized_user)
+ end
+
+ it 'redirects to root with error message' do
+ get :saml
+ expect(response).to redirect_to(root_path)
+ expect(flash[:notice]).to eq(
+ 'Could not authenticate you from SAML because "you aren\'t authorized to use this application.".'
+ )
+ end
+ end
+ end
+
+ describe '#failure' do
+ let(:user) { nil }
+
+ it 'redirects to root path' do
+ get :failure
+ expect(response).to redirect_to(root_path)
+ end
+ end
+end
diff --git a/spec/indexers/self_deposit/indexers/file_set_indexer_spec.rb b/spec/indexers/self_deposit/indexers/file_set_indexer_spec.rb
index f92674b3..798e045f 100644
--- a/spec/indexers/self_deposit/indexers/file_set_indexer_spec.rb
+++ b/spec/indexers/self_deposit/indexers/file_set_indexer_spec.rb
@@ -4,7 +4,6 @@
# Shared testing and factories imported from Hyrax v5.0.1
require 'hyrax/specs/shared_specs/indexers'
require 'hyrax/specs/shared_specs/factories/uploaded_files'
-require 'hyrax/specs/shared_specs/factories/users'
RSpec.describe SelfDeposit::Indexers::FileSetIndexer do
shared_context 'with typical preservation_event' do
diff --git a/spec/models/auth_config_spec.rb b/spec/models/auth_config_spec.rb
new file mode 100644
index 00000000..3fff2d45
--- /dev/null
+++ b/spec/models/auth_config_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe AuthConfig do
+ describe '.use_database_auth?' do
+ context 'when DATABASE_AUTH environment variable is set to "true"' do
+ before do
+ allow(ENV).to receive(:[]).with('DATABASE_AUTH').and_return('true')
+ end
+
+ it 'returns true' do
+ expect(described_class.use_database_auth?).to be true
+ end
+ end
+
+ context 'when DATABASE_AUTH environment variable is not "true"' do
+ before do
+ allow(ENV).to receive(:[]).with('DATABASE_AUTH').and_return(nil)
+ end
+
+ it 'returns false' do
+ expect(described_class.use_database_auth?).to be false
+ end
+
+ it 'returns false for any other value' do
+ allow(ENV).to receive(:[]).with('DATABASE_AUTH').and_return('false')
+ expect(described_class.use_database_auth?).to be false
+
+ allow(ENV).to receive(:[]).with('DATABASE_AUTH').and_return('1')
+ expect(described_class.use_database_auth?).to be false
+
+ allow(ENV).to receive(:[]).with('DATABASE_AUTH').and_return('')
+ expect(described_class.use_database_auth?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 0c675341..7da27fd4 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2,115 +2,151 @@
require 'rails_helper'
RSpec.describe User, type: :model do
- describe '.from_saml' do
+ describe '.from_omniauth' do
let(:auth_hash) do
OpenStruct.new(
+ provider: 'saml',
uid: '12345',
info: OpenStruct.new(
- mail: 'user@example.com',
- displayName: 'Test User',
- ou: 'Test Department',
- title: 'Test Title'
+ net_id: 'testuser',
+ display_name: 'Test User'
)
)
end
- context 'when in production environment' do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
- end
+ context 'when user exists' do
+ let!(:existing_user) { create(:emory_saml_user, uid: 'testuser') }
- context 'when user does not exist' do
- it 'creates a new user' do
- expect { User.from_saml(auth_hash) }.to change(User, :count).by(1)
- end
-
- it 'sets the correct attributes' do
- user = User.from_saml(auth_hash)
- puts "User: #{user.inspect}"
- expect(user.email).to eq('user@example.com')
- expect(user.display_name).to eq('Test User')
- expect(user.department).to eq('Test Department')
- expect(user.title).to eq('Test Title')
- expect(user.uid).to eq('12345')
- end
+ it 'updates the user attributes' do
+ user = User.from_omniauth(auth_hash)
+ expect(user.display_name).to eq('Test User')
+ expect(user.ppid).to eq('12345')
+ expect(user.email).to eq('testuser@emory.edu')
end
- context 'when user already exists' do
- let!(:existing_user) { User.create(email: 'user@example.com', password: 'password') }
-
- it 'does not create a new user' do
- expect { User.from_saml(auth_hash) }.not_to change(User, :count)
- end
-
- it 'updates the existing user' do
- user = User.from_saml(auth_hash)
- expect(user.id).to eq(existing_user.id)
- expect(user.display_name).to eq('Test User')
- expect(user.department).to eq('Test Department')
- expect(user.title).to eq('Test Title')
- expect(user.uid).to eq('12345')
- end
+ it 'does not create a new user' do
+ expect { User.from_omniauth(auth_hash) }.not_to change(User, :count)
end
+ end
- context 'when SAML response is invalid' do
- let(:invalid_auth_hash) do
- OpenStruct.new(
- uid: '12345',
- info: OpenStruct.new(
- mail: nil,
- displayName: 'Test User',
- ou: 'Test Department',
- title: 'Test Title'
- )
+ context 'when net_id is tezprox' do
+ let(:tezprox_auth) do
+ OpenStruct.new(
+ provider: 'saml',
+ uid: '12345',
+ info: OpenStruct.new(
+ net_id: 'tezprox',
+ display_name: 'Test User'
)
- end
-
- it 'logs an error' do
- expect(Rails.logger).to receive(:error).with(/Nil user detected/)
- User.from_saml(invalid_auth_hash)
- end
-
- it 'returns a new, unsaved user' do
- user = User.from_saml(invalid_auth_hash)
- expect(user).to be_a(User)
- expect(user).not_to be_persisted
- end
+ )
+ end
+
+ let!(:existing_user) { create(:emory_saml_user, :tezprox) }
+
+ it 'updates other attributes' do
+ user = User.from_omniauth(tezprox_auth)
+ expect(user.display_name).to eq('Test User')
+ expect(user.ppid).to eq('12345')
+ end
+
+ it 'does not set the email' do
+ original_email = existing_user.email
+ user = User.from_omniauth(tezprox_auth)
+ expect(user.email).to eq(original_email)
end
end
- context 'when not in production environment' do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
+ context 'when SAML response is invalid' do
+ let(:invalid_auth_hash) do
+ OpenStruct.new(
+ provider: 'saml',
+ uid: '12345',
+ info: OpenStruct.new(
+ net_id: nil,
+ display_name: 'Test User'
+ )
+ )
+ end
+
+ it 'logs an error' do
+ expect(Rails.logger).to receive(:error).with(/Nil user detected/)
+ User.from_omniauth(invalid_auth_hash)
end
- it 'does not create or update a user' do
- expect(User.from_saml(auth_hash)).to be_nil
+ it 'returns a new unsaved user' do
+ user = User.from_omniauth(invalid_auth_hash)
+ expect(user).to be_a(User)
+ expect(user).not_to be_persisted
end
end
end
- describe '.log_saml_error' do
+ describe '.log_omniauth_error' do
+ let(:auth_hash) do
+ OpenStruct.new(
+ provider: 'saml',
+ info: OpenStruct.new(
+ net_id: nil,
+ display_name: 'Test User'
+ )
+ )
+ end
+
+ it 'logs an error when net_id is missing' do
+ expect(Rails.logger).to receive(:error).with(/Nil user detected: SAML didn't pass a net_id/)
+ User.log_omniauth_error(auth_hash)
+ end
+
+ it 'logs an unauthorized login attempt when net_id is present' do
+ auth_hash.info.net_id = 'testuser'
+ expect(Rails.logger).to receive(:error).with(/Unauthorized user attempted login/)
+ User.log_omniauth_error(auth_hash)
+ end
+ end
+
+ describe '.assign_user_attributes' do
+ let(:user) { create(:emory_saml_user) }
let(:auth_hash) do
OpenStruct.new(
+ provider: 'saml',
+ uid: '12345',
info: OpenStruct.new(
- mail: nil,
- displayName: 'Test User',
- ou: 'Test Department',
- title: 'Test Title'
+ net_id: 'testuser',
+ display_name: 'Test User'
)
)
end
- it 'logs an error when email is missing' do
- expect(Rails.logger).to receive(:error).with(/Nil user detected: SAML didn't pass an email/)
- User.log_saml_error(auth_hash)
+ it 'assigns the correct attributes' do
+ User.assign_user_attributes(user, auth_hash)
+ expect(user.display_name).to eq('Test User')
+ expect(user.ppid).to eq('12345')
+ expect(user.email).to eq('testuser@emory.edu')
end
- it 'logs an error when user creation or update fails' do
- auth_hash.info.mail = 'user@example.com'
- expect(Rails.logger).to receive(:error).with(/Failed to create\/update user/)
- User.log_saml_error(auth_hash)
+ context 'when net_id is tezprox' do
+ let(:tezprox_auth) do
+ OpenStruct.new(
+ provider: 'saml',
+ uid: '12345',
+ info: OpenStruct.new(
+ net_id: 'tezprox',
+ display_name: 'Test User'
+ )
+ )
+ end
+
+ it 'does not update email' do
+ original_email = user.email
+ User.assign_user_attributes(user, tezprox_auth)
+ expect(user.email).to eq(original_email)
+ end
+
+ it 'updates other attributes' do
+ User.assign_user_attributes(user, tezprox_auth)
+ expect(user.display_name).to eq('Test User')
+ expect(user.ppid).to eq('12345')
+ end
end
end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 0a49136e..ff0cb1d5 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -10,10 +10,8 @@
# Add additional requires below this line. Rails is not loaded until this point!
require 'devise'
-require 'devise_saml_authenticatable'
require 'hyrax/specs/shared_specs/factories/strategies/json_strategy'
require 'hyrax/specs/shared_specs/factories/strategies/valkyrie_resource'
-require 'hyrax/specs/shared_specs/factories/users'
require 'hyrax/specs/capybara'
require 'rspec/active_model/mocks'
@@ -72,6 +70,8 @@ def database_exists?
# config.use_active_record = false
config.infer_spec_type_from_file_location!
+ config.include FactoryBot::Syntax::Methods
+
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
diff --git a/spec/support/factories/emory_saml_user.rb b/spec/support/factories/emory_saml_user.rb
new file mode 100644
index 00000000..4e17da3a
--- /dev/null
+++ b/spec/support/factories/emory_saml_user.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+FactoryBot.define do
+ factory :emory_saml_user, class: 'User' do
+ sequence(:email) { |n| "samluser#{n}@emory.edu" }
+ sequence(:uid) { |n| "samluser#{n}" }
+ provider { 'saml' }
+
+ after(:build) do |user|
+ user.password = 'testing123'
+ user.password_confirmation = 'testing123'
+ end
+
+ trait :with_display_name do
+ sequence(:display_name) { |n| "Test User #{n}" }
+ end
+
+ trait :with_ppid do
+ sequence(:ppid) { |n| "PPID#{n}" }
+ end
+
+ trait :tezprox do
+ uid { 'tezprox' }
+ provider { 'saml' }
+ display_name { 'Tezprox User' }
+ end
+ end
+end
diff --git a/spec/support/factories/users.rb b/spec/support/factories/users.rb
new file mode 100644
index 00000000..746a39d3
--- /dev/null
+++ b/spec/support/factories/users.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+FactoryBot.define do
+ factory :user do
+ sequence(:email) { |n| "user#{n}@example.com" }
+ password { 'password' }
+
+ transient do
+ groups { [] }
+ end
+
+ after(:build) do |user, evaluator|
+ current_user = user
+ User.group_service.add(user: current_user, groups: evaluator.groups)
+ end
+
+ after(:create) do |user, _evaluator|
+ user.update(uid: user.email) if user.uid.blank?
+ end
+
+ trait :with_uid do
+ sequence(:uid) { |n| "user#{n}" }
+ end
+
+ trait :saml do
+ provider { 'saml' }
+ sequence(:uid) { |n| "samluser#{n}" }
+ sequence(:ppid) { |n| "PPID#{n}" }
+ end
+
+ factory :admin do
+ groups { ['admin'] }
+ end
+
+ factory :user_with_mail do
+ after(:create) do |user|
+ (1..10).each do |number|
+ file = MockFile.new(number.to_s, "Single File #{number}")
+ User.batch_user.send_message(user, 'File 1 could not be updated. You do not have sufficient privileges to edit it.', file.to_s, false)
+ User.batch_user.send_message(user, 'File 1 has been saved', file.to_s, false)
+ end
+
+ files = []
+ (1..50).each do |number|
+ files << MockFile.new(number.to_s, "File #{number}")
+ end
+ User.batch_user.send_message(user, 'These files could not be updated. You do not have sufficient privileges to edit them.', 'Batch upload permission denied', false)
+ User.batch_user.send_message(user, 'These files have been saved', 'Batch upload complete', false)
+ end
+ end
+ end
+
+ trait :guest do
+ guest { true }
+ end
+end
+
+class MockFile
+ attr_accessor :to_s, :id
+ def initialize(id, string)
+ self.id = id
+ self.to_s = string
+ end
+end