Cloud Pak for Business Automation

Cloud Pak for Business Automation

Come for answers. Stay for best practices. All we’re missing is you.

 View Only

IBM Cloud Paks and Integrated Windows Authentication (SPNEGO)

By Jens Engelke posted Wed November 10, 2021 08:39 AM

  

A short description on how to Single-Sign-On to IBM Cloud Pak applications from Microsoft Windows based browsers.

A big THANK YOU goes to @PIETER VOET who went through automating and testing all of this. Bravo!

It’s common for end-users to have Microsoft Windows workstations, where the users have their account registered in an LDAP based repository. In Microsoft Windows environments often Microsoft Active Directory is used for this user-repository. Initial login to the Windows workstations is then authenticated using Active Directory. Once logged on, Active Directory maintains a security token that can be used to authenticate the user at other logins, for example a secured web-application that is accessed using an internet browser. The sequence of events that occur if the user accesses a secured web-application and the security token is used for login to that application is known as Simple and Protected GSSAPI Negotiation Mechanism or SPNEGO. Effectively, the user is required to provide credentials once, at Workstation logon, and the security token can then be used for other logons without user interaction. This is called Single Sign-On (SSO).

In order to let a server accept SPNEGO for automatic login, two ingredients are required: an account ( or entry ) in Active Directory representing the server (referred to as Service Principal Name, SPN), and a file containing pre-defined credentials of this account, with which this account can verify the authentication of other accounts, such as a user.

Authentication in Cloud Pak for Business Automation 21.0.2

Support for SPNEGO is something that software needs to implement. Authentication components in the IBM Cloud Pak suite do not natively implement SPNEGO, but it’s still possible to leverage SPNEGO for authentication by adding a component that supports SPNEGO to the authentication chain. In Cloud Pak for Business Automation User Management Service (UMS) provides single sign-on (SSO), directory queries (SCIM), and Teams capabilities to Cloud Pak services such as Workflow, Content and Decisions. The authentication component in IBM Cloud Paks is called Identity and Access Management (IAM). UMS uses IAM for authentication. The IAM component can be configured to delegate authentication to a third-party Identity Provider (IDP) using the SAML protocol. If the IDP supports SPNEGO, then Cloud Pak services can use Microsoft Windows credentials to authenticate users.

Microsoft Active Directory can be configured to act as an IDP, and hence also is an IDP that supports SPNEGO. Because I don’t have admin access to an Active Directory instance, another IDP is used in this article : Keycloak. Keycloak is an open-source single sign on solution for web apps and RESTful web services. You can also use Keycloak as an integration platform to hook it into existing LDAP and Active Directory servers.

This way, the authentication chain for Cloud Pak based services that want SSO looks like :

   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.

Get the script from the appendix.

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}

That's it.

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)
2 comments
78 views

Permalink

Comments

Fri March 08, 2024 01:51 AM

@Tilo S

I am sorry, apparently there is no email notification for comments (or I missed it). Sorry.

Back in 2021, when this was published, the goal was to configure SPNEGO authentication for browser users by delegating to an external IdP that supports SPNEGO. This external IdP should be available via OIDC or SAML. If it is keycloak or AD FS or IBM Security Verify or anything else doesn't really matter.

Key characteristic is

  • SPNEGO for browser users
  • OIDC / SAML between IdP and the cloud pak
  • no availability of the Kerberos token to any of the cloud pak components, that is, no chance for delegation scenarios.

Wed March 22, 2023 05:17 PM

Moin Moin @Jens Engelke 

so "Microsoft Active Directory can be configured to act as an IDP" is that via AD FS? or is there a simpler way (without 3rd party tools like keycloak)? 

Thanks, Tilo