Search code examples
phpldapkeycloakopenldap

Migrating users from Keycloak to LDAP with passwords intact


We currently have a Keycloak realm where we want to export all existing users into LDAP (with their passwords intact), so we can add login support for other locations that don't support OpenID Connect or SAML, but do support LDAP.

Given that password credentials are stored in the Keycloak MySQL user database using the pbkdf2-sha256 algorithm, I had to use OpenLDAP 2.4.47 with the contrib modules to support this on the LDAP side as well (I also believe I've enabled this module in the configuration).

I'm having trouble transferring the existing has (from Keycloak) to LDAP using the format described here: https://github.com/hamano/openldap-pbkdf2

Forgive me for using PHP, but it's the programming language I'm most comfortable with at this time.

I've tried using the HASH_ITERATIONS, SALT, and VALUE fields, along with the base64url_encode custom function provided in the comments under base64_encode in the PHP manual, to make an "Adapted Base64" string, and provide the built string as the userPassword field when creating an LDAP user. The user is successfully created in LDAP, but I'm unable to authenticate against LDAP using the credentials for the user I've just created.

/*
This example migrates a single user, to be adapted to a loop later, if it works.

NOTE: KC_* constants are defined in an external config.php file, such as:
KC_LDAP_BASEDN = "ou=users,dc=sso,dc=example,dc=com"
KC_REALM_ID = "KeycloakUserRealm"
(not actual code from config.php, but you get the point)
*/

$ldc = ldap_connect(KC_LDAP_SERVER);
ldap_set_option($ldc, LDAP_OPT_PROTOCOL_VERSION, KC_LDAP_PROTOCOL); // should be 3 in config.php
$ldb = ldap_bind($ldc, KC_LDAP_BINDDN, KC_LDAP_BINDPW);

$usertest = 'username_of_user_to_be_migrated';

// fetch user info
$u_sel = $db->query("SELECT * FROM `USER_ENTITY` WHERE `USERNAME`='".$db->real_escape_string($usertest)."' AND `REALM_ID`='".$db->real_escape_string(KC
_REALM_ID)."'");

$uinfo = $u_sel->fetch_assoc();

// fetch credential of user, type="password"
$c_sel = $db->query("SELECT * FROM `CREDENTIAL` WHERE `USER_ID`='".$db->real_escape_string($uinfo['ID'])."' AND `TYPE`='password'");

$cred = $c_sel->fetch_assoc();

$uprop = array();
$uprop['objectClass'] = array('top', 'person', 'organizationalPerson', 'inetOrgPerson');
$uprop['uid'] = $uinfo['USERNAME'];
$uprop['mail'] = $uinfo['EMAIL'];
$uprop['cn'] = $uinfo['FIRST_NAME'];
$uprop['sn'] = $uinfo['LAST_NAME'];

// also base64_decode() VALUE, since this seems to be already enocoded in base64, before re-encoding it with base64url_encode()
$uprop['userPassword'] = '{'.strtoupper($cred['ALGORITHM']).'}'.$cred['HASH_ITERATIONS'].'$'.base64url_encode($cred['SALT']).'$'.base64url_encode(base6
4_decode($cred['VALUE']));

ldap_add($ldc, 'uid='.$uprop['uid'].','.KC_LDAP_BASEDN, $uprop);

Once this is done, I run a second script to attempt connecting with this user:

$ldc = ldap_connect(KC_LDAP_SERVER);
ldap_set_option($ldc, LDAP_OPT_PROTOCOL_VERSION, KC_LDAP_PROTOCOL); // should be 3 in config.php

$userdn = 'uid=username_of_user_to_be_migrated,'.KC_LDAP_BASEDN;
$userpw = 'asdASD123';

$ldb = ldap_bind($ldc, $userdn, $userpw);

When doing the second script above, I just get:

PHP Warning:  ldap_bind(): Unable to bind to server: Invalid credentials in /home/user/projectdir/test_ldap_user.php on line 22

Example entry in the CREDENTIAL database looks something like this:

             ID: 5718a65c-1927-4ac7-87ce-aec0c7dda296
         DEVICE: NULL
HASH_ITERATIONS: 27500
           SALT: � �??�Pz�e��X,
           TYPE: password
          VALUE: DdCJAJvuhidAC2by7TZY8I0E8HF4V6FXrPa4nSXduvSzbb+xHW3D4QiiiPpvuzL2bdk6k0kNQKS/477k5kiLzA==
        USER_ID: b06ce13f-4e8e-474e-b5ee-5d664d6f9575
   CREATED_DATE: 1561051801144
        COUNTER: 0
         DIGITS: 0
         PERIOD: 0
      ALGORITHM: pbkdf2-sha256

The output of the userPassword is usually something like this:

{PBKDF2-SHA256}27500$FrcRlj8SGJJQet1l9LNYLA$DdCJAJvuhidAC2by7TZY8I0E8HF4V6FXrPa4nSXduvSzbb-xHW3D4QiiiPpvuzL2bdk6k0kNQKS_477k5kiLzA

Is there anything I might be missing from this code to ensure the password hash is properly migrated over?

Or is there already a migration script/solution that tackles Keycloak->LDAP that I haven't found yet?

Thank you in advance for any help or nudge in the right direction.


Solution

  • Quick update: I managed to find a solution together with one of my excellent colleagues, and I wanted to post the solution here, for future reference if anyone else get stuck on this.

    We found out that the key length used for hashing in Keycloak was 64 bytes, no matter which algorithm was used. This also meant that the byte sequence of the hash is repeated for those algorithms that have a default key length that is shorter than 64 bytes (to make it exactly 64 bytes long). In PBKDF2, the default key length for SHA-1 is 20 bytes, it's 32 bytes for SHA-256, and 64 bytes for SHA-512. This allowed me to make the following PHP function that returns the exact same type of string returned by slappasswd:

    // Convert password credentials from Keycloak database to LDAP format (specific to PBKDF2 module)
    // Parameter $cred is array from Keycloak database fields for 'CREDENTIAL' table
    function password_keycloak_to_ldap($cred) {
            switch (strtolower($cred['ALGORITHM'])) {
                    case 'pbkdf2':
                    case 'pbkdf2-sha1':
                            $keybytes = 20;
                            break;
                    case 'pbkdf2-sha256':
                            $keybytes = 32;
                            break;
                    default:
                            $keybytes = 64;
                            break;
            }
            $out = '{'.strtoupper($cred['ALGORITHM']).'}'.$cred['HASH_ITERATIONS'].'$'.ab64_encode($cred['SALT']).'$';
            $oldhash = base64_decode($cred['VALUE']);
            $newhash = substr($oldhash, 0, $keybytes);
            $out .= ab64_encode($newhash);
            return $out;
    }
    

    Also, I set up the additional helper functions (copied from elsewhere):

    // ab64_* functions adapted from Python's Passlib v1.7.1
    function ab64_encode($data){
            return rtrim(strtr(base64_encode($data), '+', '.'), '=');
    }
    
    function ab64_decode($data){
            return base64_decode(strtr($data,'.', '+').str_repeat('=', 3-(3+strlen($data))%4));
    }
    

    Finally, the usage in the script I posted in the question then replaces the userPassword line with the following:

    $uprop['userPassword'] = password_keycloak_to_ldap($cred);
    

    I hope this helps anyone else with migrating users from Keycloak to LDAP, and also preserving user passwords.