September 10, 2023

Keycloak, User Federation from LDAP and Lesson Learned

LDAP

We are going to use OpenLDAP and setup in previous blog OpenLDAP Image and Custom LDIF with User and Group.

$ podman run -d --name openldap \
    -e LDAP_ROOT=dc=magnuskkarlsson,dc=se \
    -e LDAP_ADMIN_USERNAME=admin \
    -e LDAP_ADMIN_PASSWORD=changeit \
    -e LDAP_CONFIG_ADMIN_ENABLED=true \
    -e LDAP_ALLOW_ANON_BINDING=false \
    -e LDAP_CUSTOM_LDIF_DIR=/ldifs \
    -p 1389:1389 \
    -p 1636:1636 \
    -v ./ldifs:/ldifs:Z \
    docker.io/bitnami/openldap:2.6

Test it is working.

$ ldapsearch -H ldap://localhost:1389 -D cn=admin,dc=magnuskkarlsson,dc=se -w changeit -b dc=magnuskkarlsson,dc=se -s sub

Keycloak Installation

Here we will use the supported Keycloak version - RH SSO.

$ unzip rh-sso-7.6.0-server-dist.zip
$ mv rh-sso-7.6 rh-sso-7.6.0
$ cd rh-sso-7.6.0/bin/

$ ./add-user-keycloak.sh --user admin --password admin

Configure LDAP logging in RH SSO

$ vim rh-sso-7.6.0/standalone/configuration/standalone.xml 
...
    <profile>
        <subsystem xmlns="urn:jboss:domain:logging:8.0">
...
            </logger>
            <logger category="org.keycloak.storage.ldap">
                <level name="TRACE"/>
            </logger>            
            <root-logger>
...

Start

$ export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk

$ ./standalone.sh -Djboss.socket.binding.port-offset=100

Kecloak Configuration User Federation with NO Import

We will start federate openldap user and roles and not import them and test what happens, to understand how user federation works.

Add User federation

Edit mode: READ_ONLY

Import Users: OFF

No Synchronization

Add Mappers:

  • password: user-attribute-ldap-mapper; password; userPassword
  • role-ldap-mapper: role-ldap-mapper;

We can verify that user is not imported, by clicking on User and View all users

Now test federation, by logging in to user portal: http://localhost:8180/auth/realms/demo/account/

john
bitnami1

kate
bitnami1

And observe server log, that user is fetch by RDN LDAP attribute: uid

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 2 ms

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 1 ms

LdapOperation: search
 baseDn: ou=Groups,dc=magnuskkarlsson,dc=se
 filter: (&(member=cn=john,ou=People,dc=magnuskkarlsson,dc=se)(objectclass=groupOfNames))
 searchScope: 1
 returningAttrs: [cn]
 resultSize: 2
took: 1 ms
bitnami1

Kecloak Configuration User Federation with Import

Now Import Users: ON

Then login (user fetced by RDN LDAP attribute: uid), close private window and login again. Now is the user fetched by UUID LDAP attribute: entryUUID

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 2 ms

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 3 ms

LdapOperation: search
 baseDn: ou=Groups,dc=magnuskkarlsson,dc=se
 filter: (&(member=cn=john,ou=People,dc=magnuskkarlsson,dc=se)(objectclass=groupOfNames))
 searchScope: 1
 returningAttrs: [cn]
 resultSize: 2
took: 1 ms

...

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 4 ms

When User is imported you can search and find User in Web Console and you can see in server.log that User is updated from LDAP.

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 2 ms

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 1 ms

Side effects

This online dependency has also consequence, if LDAP server is offline than User cannot log in.

But if the LDAP server comes back online, User can login again.

15:04:09,715 WARN  [org.keycloak.services] (default task-44) KC-SERVICES0013: Failed authentication: org.keycloak.models.ModelException: LDAP Query failed
...
Caused by: org.keycloak.models.ModelException: Querying of LDAP failed org.keycloak.storage.ldap.idm.query.internal.LDAPQuery@54e2e27
...
Caused by: org.keycloak.models.ModelException: Could not query server using DN [ou=People,dc=magnuskkarlsson,dc=se] and filter [(&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))]
...
Caused by: javax.naming.CommunicationException: localhost:1389 [Root exception is java.net.ConnectException: Connection refused (Connection refused)]
...
Caused by: java.net.ConnectException: Connection refused (Connection refused)
...
15:04:09,755 WARN  [org.keycloak.events] (default task-44) type=LOGIN_ERROR, realmId=demo, clientId=account-console, userId=null, ipAddress=127.0.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8180/auth/realms/demo/account/#/, code_id=618ac086-9b7f-4bd1-b8a2-0498ab2e7390, username=john, authSessionParentId=618ac086-9b7f-4bd1-b8a2-0498ab2e7390, authSessionTabId=XLmqDMVrjkc

Or if LDAP server is slow (We have set Connection and Read timeout, this is important, since default values are infinite), the User cannot login.

15:10:14,894 WARN  [org.keycloak.services] (default task-44) KC-SERVICES0013: Failed authentication: org.keycloak.models.ModelException: LDAP Query failed
...
Caused by: org.keycloak.models.ModelException: Querying of LDAP failed org.keycloak.storage.ldap.idm.query.internal.LDAPQuery@c684144
...
Caused by: org.keycloak.models.ModelException: Could not query server using DN [ou=People,dc=magnuskkarlsson,dc=se] and filter [(&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))]
...
Caused by: javax.naming.CommunicationException: localhost:1389 [Root exception is java.net.SocketTimeoutException: connect timed out]
...
Caused by: java.net.SocketTimeoutException: connect timed out
...
15:10:14,907 WARN  [org.keycloak.events] (default task-44) type=LOGIN_ERROR, realmId=demo, clientId=account-console, userId=null, ipAddress=127.0.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8180/auth/realms/demo/account/#/, code_id=f68f708a-82de-4c5b-957f-9c7084374922, username=john, authSessionParentId=f68f708a-82de-4c5b-957f-9c7084374922, authSessionTabId=RyZud1gnRGs

Another side effect of not periodically import all User, is that User, that have never logged in, will not be visible in RH SSO.

And another side effect, is that User in RH SSO will never be pruned, i.e. if user is deleted in LDAP, that User will never be removed from RH SSO.

And this online dependency, can not be circumvented, by disabling user federation.

Kecloak Configuration User Federation and Different Edit Modes

WRITABLE means data will be synced back to LDAP on demand.

UNSYNCED means user data will be imported, but not synced back to LDAP.

READ_ONLY is a read-only LDAP store. "You cannot change the username, email, first name, last name, and other mapped attributes. Red Hat Single Sign-On shows an error anytime a user attempts to update these fields. Password updates are not supported."

September 8, 2023

OpenLDAP Image and Custom LDIF with User and Group

Reference: https://hub.docker.com/r/bitnami/openldap

LDAP_PORT_NUMBER: The port OpenLDAP is listening for requests. Priviledged port is supported (e.g. 1389). Default: 1389 (non privileged port).

LDAP_ROOT: LDAP baseDN (or suffix) of the LDAP tree. Default: dc=example,dc=org

LDAP_ADMIN_USERNAME: LDAP database admin user. Default: admin

LDAP_ADMIN_PASSWORD: LDAP database admin password. Default: adminpassword

LDAP_CONFIG_ADMIN_ENABLED: Whether to create a configuration admin user. Default: no.

LDAP_USERS: Comma separated list of LDAP users to create in the default LDAP tree. Default: user01,user02

LDAP_PASSWORDS: Comma separated list of passwords to use for LDAP users. Default: bitnami1,bitnami2

LDAP_USER_DC: DC for the users' organizational unit. Default: users

LDAP_GROUP: Group used to group created users. Default: readers

LDAP_ALLOW_ANON_BINDING: Allow anonymous bindings to the LDAP server. Default: yes.

LDAP_PASSWORD_HASH: Hash to be used in generation of user passwords. Must be one of {SSHA}, {SHA}, {SMD5}, {MD5}, {CRYPT}, and {CLEARTEXT}. Default: {SSHA}.

LDAP_CUSTOM_LDIF_DIR: Location of a directory that contains LDIF files that should be used to bootstrap the database. Only files ending in .ldif will be used. Default LDAP tree based on the LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP will be skipped when LDAP_CUSTOM_LDIF_DIR is used. When using this it will override the usage of LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP. You should set LDAP_ROOT to your base to make sure the olcSuffix configured on the database matches the contents imported from the LDIF files. Default: /ldifs

LDAP_PASSWORD_HASH: Hash to be used in generation of user passwords. Must be one of {SSHA}, {SHA}, {SMD5}, {MD5}, {CRYPT}, and {CLEARTEXT}. Default: {SSHA}.

Create a new directory ldif with custom LDIF for Users and Groups

dn: dc=magnuskkarlsson,dc=se
objectClass: dcObject
objectClass: organization
dc: magnuskkarlsson
o: Magnus K Karlsson

dn: ou=People,dc=magnuskkarlsson,dc=se
objectClass: organizationalUnit
ou: People

dn: ou=Groups,dc=magnuskkarlsson,dc=se
objectClass: organizationalUnit
ou: Groups

## Users

dn: cn=john,ou=People,dc=magnuskkarlsson,dc=se
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: john
userPassword:: Yml0bmFtaTE=
cn: John
sn: Doe
mail: john.doe@domain.com
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/john

dn: cn=kate,ou=People,dc=magnuskkarlsson,dc=se
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: kate
userPassword:: Yml0bmFtaTE=
cn: Kate
sn: Doe
mail: kate.doe@domain.com
uidNumber: 1001
gidNumber: 1001
homeDirectory: /home/kate

## Groups

dn: cn=USER,ou=Groups,dc=magnuskkarlsson,dc=se
cn: USER
objectClass: groupOfNames
member: cn=john,ou=People,dc=magnuskkarlsson,dc=se
member: cn=kate,ou=People,dc=magnuskkarlsson,dc=se

dn: cn=ADMIN,ou=Groups,dc=magnuskkarlsson,dc=se
cn: ADMIN
objectClass: groupOfNames
member: cn=john,ou=People,dc=magnuskkarlsson,dc=se
$ podman run -d --name openldap \
    -e LDAP_ROOT=dc=magnuskkarlsson,dc=se \
    -e LDAP_ADMIN_USERNAME=admin \
    -e LDAP_ADMIN_PASSWORD=changeit \
    -e LDAP_CONFIG_ADMIN_ENABLED=true \
    -e LDAP_ALLOW_ANON_BINDING=false \
    -e LDAP_CUSTOM_LDIF_DIR=/ldifs \
    -p 1389:1389 \
    -p 1636:1636 \
    -v ./ldifs:/ldifs:Z \
    docker.io/bitnami/openldap:2.6
$ podman logs --follow openldap
...
 13:30:55.23 INFO  ==> Loading custom LDIF files...
 13:30:55.23 WARN  ==> Ignoring LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP environment variables...
 13:30:56.35 INFO  ==> ** LDAP setup finished! **

And later to stop

$ podman stop openldap; podman rm openldap

Now verify ldap and it's entries. First install ldap client

$ sudo dnf install openldap-clients
$ ldapsearch -h
...
  -H URI     LDAP Uniform Resource Identifier(s)
  -D binddn  bind DN
  -x         Simple authentication  
  -w passwd  bind password (for simple authentication)
  -b basedn  base dn for search  
  -s scope   one of base, one, sub or children (search scope)
...

$ ldapsearch -H ldap://localhost:1389 -D cn=admin,dc=magnuskkarlsson,dc=se -w changeit -b dc=magnuskkarlsson,dc=se -s sub

GUI Administration Tools. Apache Directory Studio Eclipse-based LDAP tools

https://magnus-k-karlsson.blogspot.com/2015/02/understanding-ldap-and-ldap.html