Skip to content

Commit

Permalink
fixes #2417 - SSO abstractioning
Browse files Browse the repository at this point in the history
Apache and Signo SSO reworked to a new general SSO concept.

You can use SSO service that comes with Katello for loggin in. It's
based on OpenID protocol with slightly customized provider.
  • Loading branch information
ares authored and ohadlevy committed Apr 22, 2013
1 parent 8bfaec2 commit 4e7ea9b
Show file tree
Hide file tree
Showing 19 changed files with 362 additions and 17 deletions.
24 changes: 15 additions & 9 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ def welcome
not_found
end

def api_request?
request.format.json? or request.format.yaml?
end

protected

# Authorize the user for the requested action
Expand All @@ -53,6 +57,9 @@ def require_ssl
redirect_to :protocol => 'https' and return if request.protocol != 'https' and not request.ssl?
end

def available_sso
@available_sso ||= SSO.get_available(self)
end

# Force a user to login if authentication is enabled
# Sets User.current to the logged in user, or to admin if logins are not used
Expand All @@ -62,11 +69,14 @@ def require_login
if SETTINGS[:login]
# authentication is enabled

# If REMOTE_USER is provided by the web server then
# authenticate the user without using password.
if remote_user_provided?
user = User.unscoped.find_by_login(@remote_user)
logger.warn("Failed REMOTE_USER authentication from #{request.remote_ip}") unless user
if available_sso.present?
if available_sso.authenticated?
user = User.unscoped.find_by_login(available_sso.user)
User.logout_path = available_sso.logout_path if available_sso.support_logout?
elsif available_sso.support_login?
available_sso.authenticate!
return
end
# Else, fall back to the standard authentication mechanism,
# only if it's an API request.
elsif api_request?
Expand Down Expand Up @@ -120,10 +130,6 @@ def not_found(exception = nil)
true
end

def api_request?
request.format.json? or request.format.yaml?
end

# this method sets the Current user to be the Admin
# its required for actions which are not authenticated by default
# such as unattended notifications coming from an OS, or fact and reports creations
Expand Down
15 changes: 9 additions & 6 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,14 @@ def destroy
def login
session[:user] = User.current = nil
if request.post?
user = User.try_to_login(params[:login]['login'].downcase, params[:login]['password'])
user = User.try_to_login(params[:login]['login'].downcase, params[:login]['password'])
if user.nil?
#failed to authenticate, and/or to generate the account on the fly
error _("Incorrect username or password")
redirect_to login_users_path
else
#valid user
session[:user] = user.id
uri = session[:original_uri]
session[:original_uri] = nil
redirect_to (uri || hosts_path)
login_user(user)
end
end
end
Expand All @@ -118,7 +115,6 @@ def logout
end

private

def authorize(ctrl = params[:controller], action = params[:action])
# Editing self is true when the user is granted access to just their own account details

Expand All @@ -144,4 +140,11 @@ def update_hostgroups_owners(hostgroup_ids)
subhostgroups.each { |subhs| subhs.users << @user }
end

def login_user(user)
session[:user] = user.id
uri = session[:original_uri]
session[:original_uri] = nil
redirect_to (uri || hosts_path)
end

end
4 changes: 4 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ def gravatar_url(email, default_image)
"#{request.protocol}//secure.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?d=mm&s=30"
end

def sign_out_url
User.logout_path + URI.escape(logout_users_url)
end

private
def edit_inline(object, property, options={})
name = "#{type}[#{property}]"
Expand Down
2 changes: 1 addition & 1 deletion app/views/home/_user_dropdown.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<li class="dropdown">
<%= user_header %>
<ul class="dropdown-menu pull-right">
<li><%= link_to(_("Sign Out"), logout_users_path) %></li>
<li><%= link_to(_("Sign Out"), sign_out_url) %></li>
<li><%= link_to(_("My account"), edit_user_path(User.current) )%></li>
<% if SETTINGS[:locations_enabled] %>
<%= render 'home/location_dropdown' %>
Expand Down
3 changes: 3 additions & 0 deletions bundler.d/openid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
group :openid do
gem 'rack-openid'
end
6 changes: 6 additions & 0 deletions config/initializers/rack_openid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
begin
require 'rack/openid'
Rails.configuration.middleware.use Rack::OpenID
rescue LoadError
nil
end
5 changes: 4 additions & 1 deletion lib/foreman/default_settings/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def load(reset=false)

Setting.transaction do
domain = Facter.domain
fqdn = Facter.fqdn
[
set('administrator', "The default administrator email address", "root@#{domain}"),
set('foreman_url', "The hostname where your Foreman instance is reachable", "foreman.#{domain}"),
Expand Down Expand Up @@ -85,7 +86,9 @@ def load(reset=false)
set('require_ssl_puppetmasters', 'Client SSL certificates are used to identify Smart Proxies accessing fact/report importers and ENC output over HTTPS (:require_ssl should also be enabled)', true),
set('trusted_puppetmaster_hosts', 'Hosts that will be trusted in addition to Smart Proxies for access to fact/report importers and ENC output', []),
set('ssl_client_dn_env', 'Environment variable containing the subject DN from a client SSL certificate', 'SSL_CLIENT_S_DN'),
set('ssl_client_verify_env', 'Environment variable containing the verification status of a client SSL certificate', 'SSL_CLIENT_VERIFY')
set('ssl_client_verify_env', 'Environment variable containing the verification status of a client SSL certificate', 'SSL_CLIENT_VERIFY'),
set('signo_sso', 'Use Signo SSO for login', false),
set('signo_url', 'Signo SSO url', "https://#{fqdn}/signo")
].compact.each { |s| create s.update(:category => "Auth")}
end
true
Expand Down
14 changes: 14 additions & 0 deletions lib/foreman/thread_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ def self.as login
ensure
self.current = old_user
end

# returns a logout path for the user, useful for single sign on support
#
# it's being set when user logs into a foreman and it's meant to be an url of SSO system
# logout page, it's appended by return url so it should contain a parameter at the end
# e.g. "https://localhost/signo?return_url="
def self.logout_path
Thread.current[:logout_path] || ''
end

# sets a logout path to be used for a current user when logging out
def self.logout_path=(path)
Thread.current[:logout_path] = path
end
end
end
end
Expand Down
13 changes: 13 additions & 0 deletions lib/sso.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module SSO
METHODS = [Apache, Signo]

def self.get_available(controller)
all_methods = all.map { |method| method.new(controller) }
all_methods.select(&:available?).first
end

def self.all
METHODS
end

end
16 changes: 16 additions & 0 deletions lib/sso/apache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module SSO
class Apache < Base
CAS_USERNAME = 'REMOTE_USER'
def available?
return false unless Setting['authorize_login_delegation']
return false if controller.api_request? and not Setting['authorize_login_delegation_api']
true
end

# If REMOTE_USER is provided by the web server then
# authenticate the user without using password.
def authenticated?
(self.user = request.env[CAS_USERNAME]).present?
end
end
end
27 changes: 27 additions & 0 deletions lib/sso/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module SSO
class Base
attr_reader :controller
attr_accessor :user
delegate :request, :to => :controller

def initialize(controller)
@controller = controller
end

def support_login?
false
end

def support_logout?
false
end

def authenticated?
raise NotImplemented, 'authenticated? not implemented for this authentication method'
end

def authenticate!
raise NotImplemented, 'authenticate! not implemented for this authentication method'
end
end
end
56 changes: 56 additions & 0 deletions lib/sso/signo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module SSO
class Signo < Base
attr_reader :env, :headers
delegate :env, :to => :request
delegate :headers, :to => :controller

def available?
Setting['signo_sso'] && defined?(Rack::OpenID)
end

def support_login?
true
end

def support_logout?
true
end

def logout_path
"#{Setting['signo_url']}/logout?return_url="
end

def authenticated?
if (response = env[Rack::OpenID::RESPONSE])
parse_open_id(response)
else
false
end
end

def authenticate!
if (username = request.cookies['username'])
# we already have cookie
identifier = "#{Setting['signo_url']}/user/#{username}"
headers['WWW-Authenticate'] = Rack::OpenID.build_header({ :identifier => identifier })
controller.render :text => '', :status => 401
else
# we have no cookie yet so we plain redirect to OpenID provider to login
controller.redirect_to "#{Setting['signo_url']}?return_url=#{URI.escape(request.url)}"
end
end

private

def parse_open_id(response)
case response.status
when :success
self.user = response.identity_url.split('/').last
return true
else
Rails.logger.debug response.respond_to?(:message) ? response.message : "OpenID authentication failed: #{response.status}"
return false
end
end
end
end
10 changes: 10 additions & 0 deletions test/fixtures/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,13 @@ attribute33:
category: General
default: "true"
description: "Should Foreman use gravatar to display user icons"
attribute34:
name: signo_sso
category: Auth
default: "false"
description: "Use SSO"
attribute35:
name: signo_url
category: Auth
default: "https://localhost:3002"
description: "Where Signo runs"
23 changes: 23 additions & 0 deletions test/functional/dashboard_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,27 @@ def user_with_viewer_rights_should_succeed_in_viewing_the_dashboard
get :index
assert_response :success
end

test "should redirect unknown users to signo when SSO allowed" do
configure_sso
@controller.env = @controller.request.env
get :index
assert_response :redirect
assert @response.redirect_url.include?(Setting['signo_url'])
end

test "OpenID request should be made for known users to Signo when SSO allowed" do
configure_sso
request.cookies[:username] = 'admin'
@controller.env = @controller.request.env
get :index
assert_response 401
identifier = @response.headers.try(:[],"WWW-Authenticate")
assert_equal "OpenID identifier=\"#{Setting['signo_url']}/user/admin\"", identifier
end

def configure_sso
Setting['signo_sso'] = true
Setting["authorize_login_delegation"] = false
end
end
2 changes: 2 additions & 0 deletions test/functional/hosts_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ def setup_multiple_environments

test "if only authorize_login_delegation is set, REMOTE_USER should be
ignored for API requests" do
Setting[:signo_sso] = false
Setting[:authorize_login_delegation] = true
Setting[:authorize_login_delegation_api] = false
set_remote_user_to users(:admin)
Expand All @@ -496,6 +497,7 @@ def setup_multiple_environments

test "if both authorize_login_delegation{,_api} are unset,
REMOTE_USER should ignored in all cases" do
Setting[:signo_sso] = false
Setting[:authorize_login_delegation] = false
Setting[:authorize_login_delegation_api] = false
set_remote_user_to users(:admin)
Expand Down
26 changes: 26 additions & 0 deletions test/unit/sso.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require 'test_helper'

class DummyMethod < SSO::Base
def initialize(*args)
end
end

class DummyTrueMethod < DummyMethod
def available?
true
end
end

class DummyFalseMethod < DummyMethod
def available?
false
end
end

class SSOTest < ActiveSupport::TestCase
def test_get_available_should_find_first_available_method
stub(SSO).all { [ DummyFalseMethod, DummyTrueMethod, DummyFalseMethod ] }
available = SSO.get_available(Object.new)
assert_present available
end
end
Loading

0 comments on commit 4e7ea9b

Please sign in to comment.