Technical recipes for frequently and infrequently recurring problems
Goal: Authenticate against shibboleth in production, but against a database in (local) development environment.
spec/models/user_spec.rb:
describe 'omniauthable user' do
it "has a uid field" do
expect(user.uid).not_to be_empty
end
it "can have a provider" do
expect(described_class.new.respond_to?(:provider)).to eq true
end
end
Run on command line:
$ rails g migration AddOmniauthToUsers provider:string uid:string:index
$ rake db:migrate
spec/factories/users.rb
:
sequence :uid do |n|
"#{FFaker::Internet.user_name}#{n}"
end
Edit config/initializers/devise.rb
and change the value of config.authentication_keys
to uid
(or whatever is appropriate for this particular shibboleth integration.)
config.authentication_keys = [:uid]
Run your test suite and fix any tests that broke. You might need to use find_by_user_key
instead of find_by(:email), for example.
Add an model called AuthConfig we can use to configure which authentication method we want to use. This lets us continue to use database authentication in development. Add this to app/models/auth_config.rb
:
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?
!Rails.env.production? && ENV['DATABASE_AUTH'] == 'true'
end
end
We can’t assume that all user accounts will exist on the system before they log in, so authenticating against shibboleth has to allow for the creation of a new User account at login time.
Add your tests first, to /spec/models/user_spec.rb
context "shibboleth integration" do
let(:auth_hash) do
OmniAuth::AuthHash.new(
provider: 'shibboleth',
uid: "janeq",
info: {
display_name: "Jane Quest",
uid: 'janeq',
mail: 'janeq@example.com'
}
)
end
let(:user) { described_class.from_omniauth(auth_hash) }
before do
described_class.delete_all
end
context "shibboleth" do
it "has a shibboleth provided name" do
expect(user.display_name).to eq auth_hash.info.display_name
end
it "has a shibboleth provided uid which is not nil" do
expect(user.uid).to eq auth_hash.info.uid
expect(user.uid).not_to eq nil
end
it "has a shibboleth provided email which is not nil" do
expect(user.email).to eq auth_hash.info.mail
expect(user.email).not_to eq nil
end
end
end
from_omniauth
method to app/models/user.rb
:
# When a user authenticates via shibboleth, find their User object or make
# a new one. Populate it with data we get from shibboleth.
# @param [OmniAuth::AuthHash] auth
def self.from_omniauth(auth)
Rails.logger.debug "auth = #{auth.inspect}"
# Uncomment the debugger above to capture what a shib auth object looks like for testing
user = where(provider: auth.provider, uid: auth.info.uid).first_or_create
user.display_name = auth.info.display_name
user.uid = auth.info.uid
user.email = auth.info.mail
user.save
user
end
Now your spec/models/user_spec.rb
test should pass.
omniauth-shibboleth
to your Gemfile
and run bundle install
:
gem 'omniauth-shibboleth', '~> 1.3'
app/models/user.rb
with the ones below.
# Include devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
# remove :database_authenticatable in production, remove :validatable to integrate with Shibboleth
devise_modules = [:omniauthable, :rememberable, :trackable, omniauth_providers: [:shibboleth], authentication_keys: [:uid]]
devise_modules.prepend(:database_authenticatable) if AuthConfig.use_database_auth?
devise(*devise_modules)
undefined method destroy_user_session_path
. That’s because you haven’t added omniauth routes yet.config/routes.rb
and replace the line devise_for :users
with:
devise_for :users, controllers: { omniauth_callbacks: "omniauth_callbacks" }
# Disable these routes if you are using Devise's
# database_authenticatable in your development environment.
unless AuthConfig.use_database_auth?
devise_scope :user do
get 'sign_in', to: 'omniauth#new', as: :new_user_session
post 'sign_in', to: 'omniauth_callbacks#shibboleth', as: :new_session
get 'sign_out', to: 'devise/sessions#destroy', as: :destroy_user_session
end
end
app/controllers/omniauth_controller.rb
:
class OmniauthController < Devise::SessionsController
def new
# Rails.logger.debug "SessionsController#new: request.referer = #{request.referer}"
if Rails.env.production?
redirect_to user_shibboleth_omniauth_authorize_path
else
super
end
end
end
app/controllers/omniauth_callbacks_controller.rb
:
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def shibboleth
Rails.logger.debug "OmniauthCallbacksController#shibboleth: request.env['omniauth.auth']: #{request.env['omniauth.auth']}"
# had to create the `from_omniauth(auth_hash)` class method on our User model
@user = User.from_omniauth(request.env["omniauth.auth"])
set_flash_message :notice, :success, kind: "Shibboleth"
sign_in_and_redirect @user
end
end
config/initializers/devise.rb
:
config.omniauth :shibboleth,
uid_field: 'uid',
info_fields: { display_name: 'displayName', uid: 'uid', mail: 'mail' },
callback_url: '/users/auth/shibboleth/callback',
strategy_class: OmniAuth::Strategies::Shibboleth
app/views/devise/sessions/new.html.erb
:
<h2>Log in</h2>
<%= render "devise/shared/links" %>
<% if AuthConfig.use_database_auth? %>
<%= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="form-inputs">
<%= f.input :uid, required: true, autofocus: true %>
<%= f.input :password, required: true %>
<%= f.input :remember_me, as: :boolean if devise_mapping.rememberable? %>
</div>
<div class="form-actions">
<%= f.button :submit, "Log in" %>
</div>
<% end %>
<% end %>
Now in production we’re expecting that all users will be managed with shibboleth,
and so the User
model no longer has a password
method. This is going to cause
anything that creates a systems user to fail. Let’s fix that.
spec/models/user_spec.rb
:
context "in a world without passwords" do
before do
described_class.delete_all
end
it "system users are created without error" do
allow(AuthConfig).to receive(:use_database_auth?).and_return(false)
u = ::User.find_or_create_system_user("batch_user")
expect(u).to be_instance_of(::User)
end
end
app/models/user.rb
, outside the class
block:
# Override a Hyrax class that expects to create system users with passwords.
# Since in production we're using shibboleth, and this removes the password
# methods from the User model, we need to override it.
module Hyrax::User
module ClassMethods
def find_or_create_system_user(user_key)
u = ::User.find_or_create_by(uid: user_key)
u.display_name = user_key
u.email = "#{user_key}@example.com"
u.password = ('a'..'z').to_a.shuffle(random: Random.new).join if AuthConfig.use_database_auth?
u.save
u
end
end
end
config/initializers/hyrax.rb
and change the user_key for the batch user and the audit user to something that isn’t an email address (e.g., “batchuser” and “audituser”).You should now be able to deploy this application to a systems with Shibboleth SP configured and have it work as expected. Note that this document assumes the systems to which you’ll be deploying is set up in the DCE Shibboleth SP pattern.