Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ gem 'daemons'
gem 'bcrypt', '~> 3.1'
gem 'get_process_mem'
gem 'rack-cors', '~> 1.0.2', require: 'rack/cors'
gem 'jwt', '~> 2.1.0'
gem 'jwt', '~> 2.2.1'
gem 'graphql', '~> 1.8.0'
gem 'graphql-batch'

Expand Down
10 changes: 8 additions & 2 deletions app/controllers/concerns/foreman/controller/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,18 @@ def sso_authentication

def set_current_user(user)
User.current = user

# API access resets the whole session and marks the session as initialized from API
# such sessions aren't checked for CSRF
# UI access resets only session ID
if api_request?
reset_session
# When authenticating using SSO::OpenidConnect, upon successful authentication, we refresh the
# :expires_at and :sso_method values in the session (in OpenidConnect#update_session).
# Hence when we reset_session for SSO::OpenidConnect here, we do not reset the expires_at for session.
if session[:sso_method] == "SSO::OpenidConnect"
backup_session_content([:sso_method, :expires_at]) { reset_session }
else
reset_session
end
session[:user] = user.id
session[:api_authenticated_session] = true
set_activity_time
Comment thread
tbrisker marked this conversation as resolved.
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/concerns/foreman/controller/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ def session_expiry

# Backs up some state from a user's session around a supplied block, which
# will usually expire or reset the session in some way
def backup_session_content
save_items = session.to_hash.slice('organization_id', 'location_id', 'original_uri', 'sso_method').symbolize_keys
def backup_session_content(keys = [:organization_id, :location_id, :original_uri, :sso_method])
save_items = session.to_hash.slice(*keys.map(&:to_s)).symbolize_keys
yield if block_given?
session.update(save_items)
end
Expand All @@ -25,7 +25,11 @@ def update_activity_time
set_activity_time
end

# In case of SSO::OpenidConnect Foreman will use :expiry_at from the token. This is
# set when the current user is set (in Authentication#set_current_user method)
# For other SSO types like basic_auth we use expiry at from the Settings
def set_activity_time
return if session[:sso_method] == "SSO::OpenidConnect"
session[:expires_at] = Setting[:idle_timeout].minutes.from_now.to_i
end

Expand Down
4 changes: 2 additions & 2 deletions app/models/setting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ class Setting < ApplicationRecord
TYPES = %w{integer boolean hash array string}
FROZEN_ATTRS = %w{name category full_name}
NONZERO_ATTRS = %w{puppet_interval idle_timeout entries_per_page max_trend outofsync_interval}
BLANK_ATTRS = %w{ host_owner trusted_hosts login_delegation_logout_url authorize_login_delegation_auth_source_user_autocreate root_pass default_location default_organization websockets_ssl_key websockets_ssl_cert oauth_consumer_key oauth_consumer_secret login_text
smtp_address smtp_domain smtp_user_name smtp_password smtp_openssl_verify_mode smtp_authentication sendmail_arguments sendmail_location http_proxy http_proxy_except_list default_locale default_timezone ssl_certificate ssl_ca_file ssl_priv_key default_pxe_item_global default_pxe_item_local }
Comment thread
rabajaj0509 marked this conversation as resolved.
BLANK_ATTRS = %w{ host_owner trusted_hosts login_delegation_logout_url authorize_login_delegation_auth_source_user_autocreate root_pass default_location default_organization websockets_ssl_key websockets_ssl_cert oauth_consumer_key oauth_consumer_secret login_text oidc_audience oidc_issuer oidc_algorithm
smtp_address smtp_domain smtp_user_name smtp_password smtp_openssl_verify_mode smtp_authentication sendmail_arguments sendmail_location http_proxy http_proxy_except_list default_locale default_timezone ssl_certificate ssl_ca_file ssl_priv_key default_pxe_item_global default_pxe_item_local oidc_jwks_url }
ARRAY_HOSTNAMES = %w{trusted_hosts}
URI_ATTRS = %w{foreman_url unattended_url}
URI_BLANK_ATTRS = %w{login_delegation_logout_url}
Expand Down
4 changes: 4 additions & 0 deletions app/models/setting/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def self.default_settings
self.set('idle_timeout', N_("Log out idle users after a certain number of minutes"), 60, N_('Idle timeout')),
self.set('bcrypt_cost', N_("Cost value of bcrypt password hash function for internal auth-sources (4-30). Higher value is safer but verification is slower particularly for stateless API calls and UI logins. Password change needed to take effect."), 4, N_('BCrypt password cost')),
self.set('bmc_credentials_accessible', N_("Permits access to BMC interface passwords through ENC YAML output and in templates"), true, N_('BMC credentials access')),
self.set('oidc_jwks_url', N_("OpenID Connect JSON Web Key Set(JWKS) URL. Typically https://keycloak.example.com/auth/realms/<realm name>/protocol/openid-connect/certs when using Keycloak as an IDP"), nil, N_('OIDC JWKs URL')),
self.set('oidc_audience', N_("Name of the OpenID Connect Audience that is being used for Authentication. In case of Keycloak this is the Client ID."), nil, N_('OIDC Audience')),
self.set('oidc_issuer', N_("The iss (issuer) claim identifies the principal that issued the JWT, which exists at a `/.well-known/openid-configuration` in case of most of the IDP's."), nil, N_('OIDC Issuer')),
Comment thread
rabajaj0509 marked this conversation as resolved.
self.set('oidc_algorithm', N_("The algorithm used to encode the JWT in the IDP."), nil, N_('OIDC Algorithm')),
]
end

Expand Down
6 changes: 3 additions & 3 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,10 @@ def self.find_or_create_external_user(attrs, auth_source_name)
user.usergroups = new_usergroups.uniq
end

return true
return user
# not existing user and creating is disabled by settings
elsif auth_source_name.nil?
return false
return nil
# not existing user and auth source is set, we'll create the user and auth source if needed
else
User.as_anonymous_admin do
Expand All @@ -315,7 +315,7 @@ def self.find_or_create_external_user(attrs, auth_source_name)
end
user.post_successful_login
end
return true
return user
end
end

Expand Down
10 changes: 5 additions & 5 deletions app/services/jwt_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ def to_s
token
end

# This method does not verify if the token signature is valid
def decoded_payload
@decoded_payload ||= JWT.decode(token, nil, false).first
end

private

def secret
Expand All @@ -43,9 +48,4 @@ def secret
def user_id
@user_id ||= decoded_payload['user_id']
end

# This method does not verify if the token signature is valid
def decoded_payload
@decoded_payload ||= JWT.decode(token, nil, false).first
end
end
6 changes: 6 additions & 0 deletions app/services/oidc_jwt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class OidcJwt < JwtToken
def decode
return if token.blank?
OidcJwtValidate.new(token).decoded_payload
end
end
50 changes: 50 additions & 0 deletions app/services/oidc_jwt_validate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
class OidcJwtValidate
attr_reader :decoded_token
delegate :logger, to: :Rails

def initialize(jwt_token)
@jwt_token = jwt_token
end

def decoded_payload
# OpenSSL#set_key method does not support ruby version < 2.4.0, apparently the JWT gem uses
# OpenSSL#set_key method for all ruby version. We must remove this condition once new version
# of the JWT(2.2.2) is released.
unless OpenSSL::PKey::RSA.new.respond_to?(:set_key)
Foreman::Logging.logger('app').error "SSO feature is not available for Ruby < 2.4.0"
return nil
end
JWT.decode(@jwt_token, nil, true,
{ aud: Setting['oidc_audience'],
verify_aud: true,
iss: Setting['oidc_issuer'],
verify_iss: true,
algorithms: [Setting['oidc_algorithm']],
Comment thread
rabajaj0509 marked this conversation as resolved.
jwks: jwks_loader }
).first
rescue JWT::DecodeError => e
Foreman::Logging.exception('Failed to decode JWT', e)
nil
end

private

def jwks_loader(options = {})
response = RestClient::Request.execute(
:url => Setting['oidc_jwks_url'],
:method => :get,
:verify_ssl => true
)
json_response = JSON.parse(response)
if json_response.is_a?(Hash)
jwks_keys = json_response['keys']
{ keys: jwks_keys.map(&:symbolize_keys) }
else
Foreman::Logging.logger('app').error "Invalid JWKS response."
{}
end
rescue RestClient::Exception, SocketError, JSON::ParserError => e
Foreman::Logging.exception('Failed to load the JWKS', e)
{}
end
end
2 changes: 1 addition & 1 deletion app/services/sso.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module SSO
METHODS = [Apache, Basic, Jwt, Oauth]
METHODS = [Apache, Basic, Jwt, Oauth, OpenidConnect]

def self.get_available(controller)
all_methods = all.map { |method| method.new(controller) }
Expand Down
6 changes: 5 additions & 1 deletion app/services/sso/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class Jwt < Base
attr_reader :current_user

def available?
controller.api_request? && bearer_token_set?
controller.api_request? && bearer_token_set? && no_issuer?
end

def authenticate!
Expand Down Expand Up @@ -35,5 +35,9 @@ def jwt_token_from_request
def bearer_token_set?
request.authorization.present? && request.authorization.start_with?('Bearer')
end

def no_issuer?
Comment thread
ekohl marked this conversation as resolved.
!jwt_token.decoded_payload.key?('iss')
end
end
end
58 changes: 58 additions & 0 deletions app/services/sso/openid_connect.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
module SSO
class OpenidConnect < Base
delegate :session, :to => :controller
attr_reader :current_user

def available?
controller.api_request? && bearer_token_set? && valid_issuer?
end

def authenticate!
payload = jwt_token.decode
return nil if payload.nil?
user = find_or_create_user_from_jwt(payload)
@current_user = user
update_session(payload)
user&.login
end

def authenticated?
self.user = User.current.presence || authenticate!
end

private

def jwt_token
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still like to avoid this naming (https://en.wikipedia.org/wiki/RAS_syndrome) but I won't block on it.

Copy link
Copy Markdown
Member

@tbrisker tbrisker Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand but we're already using that in multiple places and it seems pretty common in general (e.g. https://www.google.com/search?q=%22jwt+token%22).

@jwt_token ||= jwt_token_from_request
end

def jwt_token_from_request
token = request.authorization.split(' ')[1]
OidcJwt.new(token)
end

def bearer_token_set?
request.authorization.present? && request.authorization.start_with?('Bearer')
end

def valid_issuer?
payload = jwt_token.decoded_payload
payload.key?('iss') && (payload['iss'] == Setting['oidc_issuer'])
end

def update_session(payload)
session[:sso_method] = self.class.to_s
session[:expires_at] = payload['exp']
end

def find_or_create_user_from_jwt(payload)
User.find_or_create_external_user(
{ login: payload['preferred_username'],
Comment thread
timogoebel marked this conversation as resolved.
mail: payload['email'],
firstname: payload['given_name'],
lastname: payload['family_name']},
Setting['authorize_login_delegation_auth_source_user_autocreate']
)
end
end
end
25 changes: 25 additions & 0 deletions test/fixtures/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,28 @@ attribute87:
category: Setting::General
default: '2abbbe02-4ace-4269-9e20-2753f3206cc2'
description: 'Foreman UUID'
attribute88:
name: oidc_jwks_url
category: Setting::Auth
default: 'https://keycloak.example.com/auth/realms/foreman/protocol/openid-connect/certs'
description: 'OpenID Connect JSON Web Key Set(JWKS) URL. Typically https://keycloak.example.com/auth/realms/<realm name>/protocol/openid-connect/certs if you are using Keycloak as an IDP'
attribute89:
name: oidc_audience
category: Setting::Auth
default: 'rest-client'
description: 'Name of the OpenID Connect Audience that is being used for Authentication. For example in case of Keycloak this is the Client ID.'
attribute90:
name: oidc_issuer
category: Setting::Auth
default: 127.0.0.1
description: "The iss (issuer) claim identifies the principal that issued the JWT, which exists at a `/.well-known/openid-configuration` in case of most of the IDP's."
attribute91:
name: oidc_algorithm
category: Setting::Auth
default: 'RS512'
description: 'The algorithm used to encode the JWT in the IDP.'
attribute92:
name: authorize_login_delegation_auth_source_user_autocreate
category: Setting::Auth
default: 'External'
description: 'Name of the external auth source where unknown externally authentication users (see authorize_login_delegation) should be created (keep unset to prevent the autocreation)'
52 changes: 29 additions & 23 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -687,29 +687,36 @@ def setup_user(operation, type = 'users', search = nil)
end

context "find_or_create_external_user" do
not_existing_user_login = 'not_existing_user'
not_existing_auth_source = 'new_external_source'

context "internal or not existing AuthSource" do
test 'existing user' do
assert_difference('User.count', 0) do
assert User.find_or_create_external_user({:login => users(:one).login}, nil)
login = users(:one).login
assert_equal User.find_or_create_external_user({:login => login}, nil),
User.find_by_login(login)
end
end

test 'not existing user without auth source specified' do
assert_difference('User.count', 0) do
refute User.find_or_create_external_user({:login => 'not_existing_user'}, nil)
user = User.find_or_create_external_user({:login => not_existing_user_login}, nil)
assert user.nil?
end
end

test 'not existing user with non existing auth source' do
assert_difference('User.count', 1) do
assert_difference('AuthSource.count', 1) do
assert User.find_or_create_external_user({:login => 'not_existing_user'},
'new_external_source')
user = User.find_or_create_external_user({:login => not_existing_user_login},
not_existing_auth_source)
assert_equal user, User.find_by_login(not_existing_user_login)

new_source = AuthSourceExternal.find_by_name(not_existing_auth_source)
assert_equal new_source.name, user.auth_source.name
end
end
created_user = User.find_by_login('not_existing_user')
new_source = AuthSourceExternal.find_by_name('new_external_source')
assert_equal new_source.name, created_user.auth_source.name
end
end

Expand All @@ -721,23 +728,24 @@ def setup_user(operation, type = 'users', search = nil)
test "not existing" do
assert_difference('User.count', 1) do
assert_difference('AuthSource.count', 0) do
assert User.find_or_create_external_user({:login => 'not_existing_user'},
@apache_source.name)
assert_equal User.find_or_create_external_user(
{:login => not_existing_user_login}, @apache_source.name),
User.find_by_login(not_existing_user_login)
end
end
end

test "not existing with attributes" do
assert User.find_or_create_external_user({:login => 'not_existing_user',
:mail => 'foobar@example.com',
:firstname => 'Foo',
:lastname => 'Bar'},
@apache_source.name)
created_user = User.find_by_login('not_existing_user')
assert_equal @apache_source.name, created_user.auth_source.name
created_user = User.find_or_create_external_user(
{:login => not_existing_user_login,
:mail => 'foobar@example.com',
:firstname => 'Foo',
:lastname => 'Bar'}, @apache_source.name)
assert_equal not_existing_user_login, created_user.login
assert_equal @apache_source.name, created_user.auth_source.name
assert_equal 'foobar@example.com', created_user.mail
assert_equal 'Foo', created_user.firstname
assert_equal 'Bar', created_user.lastname
assert_equal 'Foo', created_user.firstname
assert_equal 'Bar', created_user.lastname
end

context 'with external user groups' do
Expand All @@ -749,11 +757,9 @@ def setup_user(operation, type = 'users', search = nil)

test "existing user groups that are assigned" do
@external.update(:usergroup => @usergroup, :name => @usergroup.name)
assert User.find_or_create_external_user({:login => "not_existing_user",
:groups => [@external.name,
"notexistentexternal"]},
@apache_source.name)
created_user = User.find_by_login("not_existing_user")
created_user = User.find_or_create_external_user(
{:login => not_existing_user_login, :groups => [@external.name, 'notexistentexternal']},
@apache_source.name)
assert_equal [@usergroup], created_user.usergroups
end
end
Expand Down
Loading