Skip to content

Commit

Permalink
Fixes #25809 - JWT auth for external users
Browse files Browse the repository at this point in the history
  • Loading branch information
rabajaj0509 committed Oct 7, 2019
1 parent 6dd32db commit f0ae0d6
Show file tree
Hide file tree
Showing 18 changed files with 381 additions and 45 deletions.
2 changes: 1 addition & 1 deletion Gemfile
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
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
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/concerns/foreman/controller/session.rb
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
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 }
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
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. For example if you are using Keycloak this url(https://<your keycloak server>/auth/realms/<realm name>/protocol/openid-connect/certs) would be found as `jwk_uri` at https://<your keycloak server>/auth/realms/<realm name>/.well-known/openid-configuration."), nil, N_('OIDC JWKs URL')),
self.set('oidc_audience', N_("Name of the OpenID Connect Audience that is being used for Authentication. For example 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')),
self.set('oidc_algorithm', N_("The algorithm with which JWT was encoded in the IDP."), nil, N_('OIDC Algorithm')),
]
end

Expand Down
6 changes: 3 additions & 3 deletions app/models/user.rb
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
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
@@ -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
@@ -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']],
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']
@cached_keys = nil if options[:invalidate] # need to reload the keys
@cached_keys ||= { keys: jwks_keys.map(&:symbolize_keys) }
else
raise JSON::ParserError.new('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
@@ -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
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?
!jwt_token.decoded_payload.key?('iss')
end
end
end
58 changes: 58 additions & 0 deletions app/services/sso/openid_connect.rb
@@ -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
@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'],
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
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: 127.0.0.1
description: 'OpenID Connect JSON Web Key Set(JWKS) URL. For example if you are using Keycloak this url(https://<your keycloak server>/auth/realms/<realm name>/protocol/openid-connect/certs) would be found as `jwk_uri` at https://<your keycloak server>/auth/realms/<realm name>/.well-known/openid-configuration.'
attribute89:
name: oidc_audience
category: Setting::Auth
default: 'rest-client'
description: 'Name of the OpenID Connect Audience that is being used for Authentication. For exmaple 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 IDPs.'
attribute91:
name: oidc_algorithm
category: Setting::Auth
default: 'RS512'
description: 'The alogirithm with which JWT was encoded 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
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

0 comments on commit f0ae0d6

Please sign in to comment.