LDAP Lookups For OpenVPN

My coworker was having some issues with the ldap3 Package behaving oddly. This came about because OpenVPN Access Server 2.8.0 changed from python-ldap to ldap3.

The idea being, have OpenVPN authenticate with Okta and assign VPN Profiles matching your Active Directory (AD) Group.

The Problem

He was trying to query the Okta LDAP endpoint for a list of Groups that a User belongs to.

The initial query tested only gave back Users without information about what Groups they are members of. We figured out that setting optional attributes would add the Group membership details with attributes='memberOf'. This wasn’t ideal because he wasn’t sure we would have access to modify the global configuration parameters as in the example.

The second query took an exorbitant amount of time to execute. This was probably caused by searching with attributes='*' to get back all possible attributes associated with a User. It might be the Module needing to do subsequent searches for each object returned.

Solution

Once I installed Apache Directory, I could more easily view the query results. After some clicking around, I drilled down to create a Search.

DIT
└── Root DSE (2)
  └── dc=business,dc=okta,dc=com
     └── ou=groups
         └── cn=vpn1

I right-clicked on cn=vpn1 and selected New -> New Search…. This is where I learned more about the Search Base and Filter fields.

  • Search Base: cn=vpn1,ou=groups,dc=business,dc=okta,dc=com
  • Filter: (objectClass=*)

Looking in the Attribute Description column of cn=vpn1, I noticed that objectClass was one of the listed attributes. Then I thought, maybe this thing can create the search for me if I can click on the line with peoples’s names? Right-clicking on the line with:

Attribute Description Value
uniqueMember uid=me@business.com,ou=users,dc=business,dc=okta,dc=com’

I was able to select New Search… and it had the fields populated for me.

  • Search Base: cn=vpn1,ou=groups,dc=business,dc=okta,dc=com
  • Filter: (uniqueMember=uid=me@business.com,ou=users,dc=business,dc=okta,dc=com)

This was close enough, but when I tested out the query with ldap3, I got an object back that was essentially a string that we would need to parse.

connection.search(
    search_base='cn=vpn1,ou=groups,dc=business,dc=okta,dc=com',
    search_filter='(uniqueMember=uid=me@business.com)',
)
# Search results are stored in `connection.entries` as a list

vpn_groups = set(entry.entry_dn for entry in connection.entries)

return vpn_groups
# {'cn=vpn1,ou=groups,dc=business,dc=okta,dc=com'}

That’s when I remembered he was testing some stuff with attributes. In the Search properties, I noticed there was a Returning Attributes field. I looked that up and it’s supposed to give you back the attributes you want from the returned objects. So I tried adding cn as an attribute and got back a list that didn’t need parsing.

connection.search(
    search_base='cn=vpn1,ou=groups,dc=business,dc=okta,dc=com',
    search_filter='(uniqueMember=uid=me@business.com)',
    attributes=['cn'],
)
# Search results are stored in `connection.entries` as a list

vpn_groups = set(entry.cn.value for entry in connection.entries)

return vpn_groups
# {'vpn1'}

Nice! But is there a way for me to get all of the Groups? Turns out, if you remove the cn=vpn1 from the Search Base, you query all of the Groups. With this, we get a list of all Groups where I am a member.

connection.search(
    search_base='ou=groups,dc=business,dc=okta,dc=com',
    search_filter='(uniqueMember=uid=me@business.com)',
    attributes=['cn'],
)
# Search results are stored in `connection.entries` as a list

all_groups = set(entry.cn.value for entry in connection.entries)

return all_groups
# {'all', 'california', 'devops', 'testvpn', 'productionvpn', 'vpn1'}

Even better! Now let’s try and filter this back down to only Groups with the word vpn. My coworker gave me the last piece of the Filter to glob match the names. Doing (&(cn=*vpn*)(uniqueMember=uid=me@business.com)) means we want results where the Group name has vpn in it AND I am a member. So the final piece looks about:

"""Get Group names that a User belongs to using Okta LDAP.

MIT License

Copyright (c) 2020 Nate Tangsurat

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import ldap3

HOST = 'ldaps://business.ldap.okta.com'
USER = 'uid=secret_read_only@business.com,dc=business,dc=okta,dc=com'
PASSWORD = 'SUPA_SECRET_PARADISE_IN_VAULT'


def main():
    """Test LDAP query."""
    vpn_groups = set()

    connection = ldap3.Connection(HOST, user=USER, password=PASSWORD, auto_bind=True)

    search_base = 'ou=groups,dc=business,dc=okta,dc=com'
    search_filter = '(&(cn=*vpn*)(uniqueMember=uid={user}@business.com))'.format(user='me')

    query_ok = connection.search(
        search_base=search_base,
        search_filter=search_filter,
        attributes=['cn'],
    )
    # Search results are stored in `connection.entries` as a list

    if query_ok:
        vpn_groups = set(entry.cn.value for entry in connection.entries)
    else:
        print('Query did not return results.')

    return vpn_groups


if __name__ == '__main__':
    main()