service <-> UMS <-> Zen <-> IAM <-> Keycloak <-> Active Directory
Steps to configure Cloud Paks for SPNEGO SSO
The steps to configure SPNEGO Single Sign-On for Cloud Pak services are :
Note
|
Although many of the required steps are usually done using a GUI, my preference is to convert to command-line for automation purposes. |
Register an account in Active Directory
Although most documentation mention the use of the Windows-based ktpass.exe and setspn.exe programs to create Active Directory server entries and keytab files, it is possible to just use a Java Runtime Environment (JRE) to do this. In addition with the python-ldap Python module, I created a Python script that configures the Active Directory server entry and the Kerberos keytab file using the ktab command that is part of the IBM Java Runtime Environment. Not every Java runtime includes the ktab command, but the IBM JRE does.
Note
|
The script is as-is. It can be extended with all kinds of functionality like reading in password from the commandline, but for educational purposes the script is kept simple. Also, the script is Linux based, and hence contains Unix-style paths.Feel free to modify for use in a Windows environment. |
Prequisites for the script are :
-
a container in Active Directory that holds the server entry
-
credentials (username and password) for an Active Directory account that has write access to this container to create LDAP Person entries
-
a server where Keycloak is installed
-
a JAVA runtime environment on the computer where the script will run
-
a Python runtime with the python-ldap module installed on the computer where the script will run
In the main section of the script, you have to set a couple of variables before running the script. The script will configure a kerberos configuration file that sets up the Kerberos domain for the command that will create and validate the keytab entries. Therefore the JRE needs to be writeable for the script. For convenience I recommend to create a copy of the JRE using the same useraccount that will run the script. On Unix based systems that’ll be :
$ cp -r <PATH_TO_YOUR_JRE_DIRECTORY> /tmp
Next, using an editor of choice, set the variables in the main section of the script :
ldapserver['host' ] = 'ads-server.yourdomain.something'
|
# hostname of your Active Directory (AD) server
|
ldapserver['port' ] = 636
|
# modifying AD LDAP entries requires secure LDAP
|
ldapserver['sso_ou' ] = 'OU=SSO,OU=mysuborg,OU=myorg,DC=yourdomain,DC=something'
|
# a container in AD where the server entries are created
|
ldapserver['write_dn' ] = 'adswriteuser'
|
# a user in AD that can add entries to the container
|
ldapserver['write_pw' ] = 'adswriteuser_password'
|
# password of the user that has write access to the container
|
servers['cp-console' ] = 'idpserver.yourdomain.something'
|
# the hostname of the Keycloak server
|
sso_server_entry_password = 'Act1veDir@ctory_passw0rd'
|
# a password for the SPNEGO server entry that adheres to AD password rules
|
Make sure the JAVA_HOME environment variable is set to the directory where the JRE is. This can be the copy of the JRE if that step was necessary.
os.environ[`'JAVA_HOME'`] = `'/tmp/<YOUR_JRE_COPY_DIRECTORY>'`
Save the modifications to the script and run it :
$ python configure_spnego.py
If the script was executed succesfully, a server entry was created in the specified Active Directory container, and a Kerberos keytab file was created in /tmp. To verify the keytab file, it can be used to authenticate to Active Directory. First list it’s contents :
$ export JAVA_HOME=/tmp/<YOUR_JRE_COPY_DIRECTORY>
$ export PATH=$JAVA_HOME/bin:$PATH
$ klist -k -t /tmp/<HOSTNAME_OF_KEYCLOAK_SERVER>.keytab
Key table: /tmp/idpserver.yourdomain.something.keytab
Number of entries: 1
[1] principal: HTTP/idpserver.yourdomain.something@YOURDOMAIN.SOMETHING
KVNO:1
Time stamp: Tuesday, October 5, 2021 11:09:19 AM
next, get credentials using this keytab file :
$ kinit -k -t /tmp/idpserver.yourdomain.something.keytab HTTP/idpserver.yourdomain.something@YOURDOMAIN.SOMETHING
Done!
New ticket is stored in cache file /tmp/krb5cc_pieter
We now have a server entry in Active Directory and a Kerberos keytab file containing its credentials.
Install and configure a Keycloak server
The Keycloak website is http://keycloak.org . On the Downloads page there are several options to install a Keycloak including a container image to deploy on a containerized environment such as Docker or OpenShift.
Note
|
this definition of container is different from an LDAP container mentioned so far. |
For this article we will use a tar-ball image to install it on a Linux based server. The image includes a copy of WildFly, the opensource JEE application server. Download the image, and extract its contents so a directory. Here /opt is used :
$ cd /opt
$ tar -xvzf <DOWNLOAD_DIR>/keycloak-15.0.2.tar.gz
Important
|
If your server has firewalling software enabled, make sure it gets configured to allow for Keycloak http and https. |
Get the IP address of the public network interface. E.g. if your interface is eth0, you can issue :
$ ifconfig eth0 | awk '/inet / { print $2}'
192.168.178.132
Go to the directory wo where Keycloak was extracted, set the admin user credentials and start the Keycloak server :
$ cd /opt/keycloak-15.0.2
$ bin/add-user-keycloak.sh -u admin -p admin
$ bin/standalone.sh -b 192.168.178.132 & # IPaddress of public interface
Copy the generated keytab file from the first step to a directory in the Keycloak profile
$ mkdir krb5
$ cp /tmp/idpserver.yourdomain.something.keytab krb5
Configure a Keycloak realm.
$ bin/kcadm.sh config credentials --server http://192.168.178.132:8080/auth --realm master --user admin
$ bin/kcadm.sh create realms -s realm=my-keycloak-realm -s enabled=true -o
Configure Keycloak to use Active Directory as the user repository, including SPNEGO :
bin/kcadm.sh create components -r my-keycloak-realm -s name="Active Directory" -s providerId=ldap -s providerType=org.keycloak.storage.UserStorageProvider -s 'config.vendor=["Active Directory"]' -s 'config.connectionUrl=["ldap://ads-server.yourdomain.something"]' -s 'config.usersDn=["DC=yourdomain,DC=something"]' -s 'config.bindDn=["CN=binduser,OU=Service_Accounts,OU=myproject,DC=ont,DC=yourdomain,DC=somthing"]' -s 'config.bindCredential=["bindpassw0rd"]' -s 'config.usernameLDAPAttribute=["cn"]' -s 'config.rdnLDAPAttribute=["cn"]' -s 'config.uuidLDAPAttribute=["objectGUID"]' -s 'config.userObjectClasses=["person, organizationalPerson, user"]' -s 'config.searchScope=["2"]' -s 'config.allowKerberosAuthentication=["true"]' -s 'config.serverPrincipal=["HTTP/idpserver.yourdomain.something@YOURDOMAIN.SOMETHING"]' -s 'config.keyTab=["/opt/keycloak-15.0.2/krb5/idpserver.yourdomain.something.keytab"]' -s 'config.kerberosRealm=["YOURDOMAIN.SOMETHING"]' -s 'config.debug=["false"]' -s 'config.useKerberosForPasswordAuthentication=["false"]'
To prepare for SAML configuration, export the Keycloak SAML metadata
$ curl -k https://192.168.178.132:8443/auth/realms/my-keycloak-realm/protocol/saml/descriptor -o /tmp/spMetadata.xml
Enable Cloud Pak User Management Service for inter-component Single Sign-On
For IBM Cloud Paks version 21.0.2 or below, UMS needs to be enabled when the Cloud Pak gets deployed, in case you are not installing a pattern that automatically selects UMS for you.
The custom resource that is used to deploy the Cloud Pak has a `spec.shared_configuration.sc_optional_components element. To enable UMS, add ums to this element.
spec:
shared_configuration:
sc_optional_components: "decisionCenter,ums"
Including UMS is done at deploytime and components using UMS are registered with UMS. I do not know how to enable and configure UMS if the Cloud Pak components are already deployed. See your Cloud Pak component documentation on how to define the custom resource that deploys the component.
Configure IAM for SSO
Now we have the IDP that is able to use SPNEGO tokens for authentication, the last step is to configure the Cloudpak Identity and Access Management service to delegate authentication to the Keycloak IDP server. The Cloud Pak documentation already describes how to enable SSO for IAM, but for completeness it is included in this section. Since the cloudctl commandline utility is required, instructions to get it are here.
Logon to the CloudPak IAM service :
$ cloudctl login -a https://cp-console.apps.yourcluster.yourdomain.something -u yourusername
Enable SAML for the IAM service :
$ cloudctl iam saml-enable
Export the IAM SAML metadata for exchange with the IDP
$ cloudctl iam saml-export-metadata --file /tmp/iam-saml-metadata.xml
Export the IAM SAML metadata for the IDP :
$ cloudctl iam saml-export-metadata --file /tmp/iam-saml-metadata.xml
Import the IDP SAML metedata that was exported in step 2 :
$ cloudctl iam saml-upload-metadata --file /tmp/keycloak-saml-metadata.xml
On the Keycloak IDP server, import the IAM SAML metadata :
TOKEN_URL="http://192.168.178.132:8080/auth/realms/master/protocol/openid-connect/token"
#AUTH="Authorization: bearer $(curl -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password ${TOKEN_URL} | jq -r '.access_token')"
AUTH="Authorization: bearer $(curl -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password ${TOKEN_URL} | sed -n 's|.*"access_token":"\([^"]*\)".*|\1|p')"
CONVERTER_URL='http://192.168.178.132:8080/auth/admin/realms/my-keycloak-realm/client-description-converter'
SAML_XML="@iam_saml.xml"
CLIENT_JSON=$(curl -X POST -H "${AUTH}" -H 'content-type: application/json' ${CONVERTER_URL} --data-binary ${SAML_XML=})
CLIENTS_URL='http://192.168.178.132:8080/auth/admin/realms/my-keycloak-realm/clients'
curl -X POST -H "${AUTH}" -H 'content-type: application/json' ${CLIENTS_URL} -d ${CLIENT_JSON}
Appendix
#!/usr/bin/python
__author__ = 'Pieter Voet'
import os
import re
import sys
import ldap
import socket
import ldap.modlist as modlist
from shutil import copyfile
#
# Classes
#
class ActiveDirectory():
def __init__(self, ldap_url, bind_dn, bind_pw):
self.ldap_url = ldap_url
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, 0)
ldap.set_option(ldap.OPT_REFERRALS, 0)
self.ldap_connection = ldap.initialize(ldap_url)
self.ldap_connection.bind_s(bind_dn, bind_pw)
self.domain = re.split(r'ldap://|:', ldap_url)[1].split('.', 1)[1]
self.base_dn = 'DC=%s' % re.sub('\.(.*?)', ',DC=\g<1>', self.domain)
def toBytes(self, attrs):
for key,value in attrs.items():
if isinstance(value, list):
for index,item in enumerate(value):
value[index] = item.encode('utf-8')
else:
attrs[key] = value.encode('utf-8') if isinstance(value, str) else value
return attrs
def add_user(self, uid, sso_ou):
shortname = uid['name'].split('.')[0]
attrs = {}
attrs['objectClass'] = ['top', 'user', 'person', 'organizationalPerson']
attrs['cn'] = shortname
attrs['name'] = shortname
attrs['displayName'] = shortname
attrs['sAMAccountName'] = str(shortname[0:20]) # sAMAccountName has 20 char limit
attrs['userPrincipalName'] = '%s@%s' % (shortname, self.domain.upper())
attrs['userAccountControl'] = '512' # 512 will set user account to enabled
try:
if str(uid["no_expire_password"]):
attrs['userAccountControl'] = '66048' # 66048 will set user password never expires and normal account
except:
pass
try:
if sys.version_info[0] > 2:
attrs['unicodePwd'] = ('\"%s\"' % str(uid['password'])).encode('utf-16-le')
else:
unicode_pass = unicode('\"%s\"' % str(uid['password']), 'iso-8859-1')
attrs['unicodePwd'] = unicode_pass.encode('utf-16-le')
except:
print('Creating user failed.', 'Required password missing')
try:
attrs['servicePrincipalName'] = str(uid['servicePrincipalName'][0])
# if SPN starts with 'HTTP', then this is an SpNego server account and should not expire
if attrs['servicePrincipalName'][:5] == 'HTTP/':
attrs['accountExpires'] = '0' # 0 will set user account never expires
attrs['userAccountControl'] = '65536' # 65536 will set user password never expires
attrs['userPrincipalName'] = attrs['servicePrincipalName'] + "@"+ self.domain.upper()
#attrs['userPrincipalName'] = augName + "@"+ self.domain.upper()
except: pass
attrs = self.toBytes(attrs) if sys.version_info[0] > 2 else attrs
ldif = modlist.addModlist(attrs)
aug_dn = "CN=%s,%s" % (shortname, sso_ou)
try:
self.ldap_connection.add_s(aug_dn, ldif)
if 'servicePrincipalName' in uid:
for spn in uid['servicePrincipalName'][1:]:
addSPN = [(ldap.MOD_ADD, 'servicePrincipalName', spn.encode('utf-8') if sys.version_info[0] > 2 else str(spn))]
self.ldap_connection.modify_s(aug_dn, addSPN)
except ldap.LDAPError as errorMessage:
if not isinstance(errorMessage, ldap.ALREADY_EXISTS):
print('creating %s failed! error = %s' % (aug_dn, errorMessage))
def delete(self, dn):
self.ldap_connection.delete(dn)
#
# Functions
#
def create_keytab_file(keytab_file, spns, password, adsdomain, overwrite=False):
global changed
try: java_home = os.environ['JAVA_HOME']
except : raise Exception('JAVA_HOME not set.')
if os.path.exists(keytab_file) and not overwrite:
return
KRB5CONF_FILE = '%s/jre/lib/security/krb5.conf' % java_home
saved = os.path.exists(KRB5CONF_FILE)
if saved:
copyfile(KRB5CONF_FILE, '%s.save' % KRB5CONF_FILE)
file = open(KRB5CONF_FILE, 'w')
file.write('[libdefaults]\n default_realm = %s\n default_tkt_enctypes = rc4-hmac' % adsdomain.upper())
file.close()
try : os.remove(keytab_file)
except : pass
for principal in spns:
cmd = '%s/jre/bin/ktab -a %s %s -k %s' % (java_home, principal, password, keytab_file)
rc = os.system(cmd)
if rc:
raise Exception("Creating keytab file failed with RC = %s !" % rc)
changed = True
os.remove(KRB5CONF_FILE)
if saved: os.rename('%s.save' % KRB5CONF_FILE, KRB5CONF_FILE)
def create_server_entry(ldapserver, entry, password, domain, adsdomain, spns = [], overwrite=False):
#
# checkmode/overwrite logic may be a bit flaky...
#
global changed
uid = {'name': entry, 'password': password }
uid['servicePrincipalName'] = spns
ldap_url = 'ldaps://%s:%s' % (ldapserver['host'], ldapserver['port'])
ads = ActiveDirectory(ldap_url, ldapserver['write_dn'], ldapserver['write_pw'])
if overwrite:
ads.delete('CN=%s,%s' % (uid['name'].split('.')[0], ldapserver['sso_ou']))
try:
ads.add_user(uid, ldapserver['sso_ou'])
changed = True
except Exception, e:
print(e)
# Assuming it's ours..
pass
def configure_spnego(ldapserver, sso_server_entry_password, extra_spns=None, overwrite=False):
adsdomain = domain = '.'.join(ldapserver['host'].split('.')[1:])
server = servers['cp-console']
spns = ['HTTP/%s' % servers['cp-console']]
if extra_spns:
for entry in extra_spns.split(','):
spns.append('HTTP/%s' % entry)
keytab_file = '/tmp/%s.keytab' % server
create_keytab_file('%s' % keytab_file, spns, sso_server_entry_password, adsdomain, overwrite)
create_server_entry(ldapserver, server, sso_server_entry_password, domain, adsdomain, spns, overwrite=overwrite)
#
# Main
#
changed = False
servers = {}
ldapserver = {}
ldapserver['host'] = 'ads-server.yourdomain.something'
ldapserver['port'] = 636
ldapserver['write_dn'] = 'adswriteuser'
ldapserver['write_pw'] = 'adswriteuser_password'
ldapserver['sso_ou'] = 'OU=SSO,OU=mysuborg,OU=myorg,DC=yourdomain,DC=something'
servers['cp-console'] = 'idpserver.yourdomain.something'
sso_server_entry_password = 'Act1veDir@ctory_passw0rd'
extra_spns = None
overwrite = True
# To be able to write to '<JRE-DIR>/lib/security/krb5.conf' we need write access.
# Recursively copy the JRE in case of read-only.
#
# cp -r /opt/IBM/ibm-java-x86_64-70 /tmp
# Set JAVA_HOME to a writeable JRE
os.environ['JAVA_HOME'] = '/tmp/ibm-java-x86_64-70'
configure_spnego(ldapserver, sso_server_entry_password, extra_spns=extra_spns, overwrite=overwrite)