Skip to content

Instantly share code, notes, and snippets.

@yumenohikari
Last active August 23, 2024 07:28
Show Gist options
  • Save yumenohikari/8440144023cf33ab3ef0d68084a1b42f to your computer and use it in GitHub Desktop.
Save yumenohikari/8440144023cf33ab3ef0d68084a1b42f to your computer and use it in GitHub Desktop.
Active Directory LDAP auth for Home Assistant

Active Directory LDAP auth for Home Assistant

This script allows users to log in to Home Assistant using their sAMAccountName or userPrincipalName identifiers without any special requirements for the ldapsearch or curl utilities. Instead, it requires the ldap3 Python module, but there are ways to install that locally so it can even be used in supervised / Home Assistant OS installs.

Editing for use in your installation

Obviously most of the configuration values in the script need to be edited to work in your environment.

  • SERVER - the DNS name of your AD domain, or the name or IP of a specific domain controller.
  • HELPERDN - the DN (distinguishedName attribute) of the service account you're using to search LDAP for the desired user.
  • HELPERPASS - the password for that service account.
  • TIMEOUT - LDAP search timeout in seconds.
  • FILTER - LDAP search filter to find the desired user. To match by SAM name or UPN and a group membership, just edit the memberOf line to include the DN of the group you want to use to control access.
  • BASEDN - the DN of the top-most container to search. To search the entire domain, use just the "DC" sections at the end of your domain's DNs, e.g. DC=ad,DC=example,DC=com. As written, the script searches recursively.

Authentication configuration

In a Home Assistant Core installation, you can install the Python module using pip or your package manager, then put the script in any directory where Home Assistant can reach it. Then add a section to configuration.yaml:

homeassistant:
    auth_providers:
        - type: command_line
          command: /usr/local/bin/ldap-auth-ad.py
          meta: true
        - type: homeassistant

Note that homeassistant must be explicitly specified as an authentication method, or you won't have access to locally-created users.

Installing in a Docker-based installation

Because Python modules can be installed in and loaded from the current path, it's possible to make this work in Docker containers as well, by hiding it in the /config directory.

First, make a directory to contain everything, and copy the configured script into the host directory that's mounted as /config:

me@host:~ $ sudo mkdir /usr/share/hassio/homeassistant/ldap-auth
me@host:~ $ sudo cp ldap-auth-ad.py /usr/share/hassio/homeassistant/ldap-auth

Next, open a shell in the Home Assistant core container, and change to the directory we just created:

me@host:~ $ sudo docker exec -it homeassistant bash
bash-5.0# cd /config/ldap-auth

Install the module:

bash-5.0# pip install -t . ldap3

And insert the configuration section (note the modified path):

homeassistant:
    auth_providers:
        - type: command_line
          command: /config/ldap-auth/ldap-auth-ad.py
          meta: true
        - type: homeassistant

Finally, restart the entire application (Configuration > Server Controls > Server Management > Restart) to reload the config. (It may be possible to reload without doing this, but I'm not entirely clear on when configuration.yaml is read.)

You should now be able to log in as any user that's a member of the group you picked above. Home Assistant will create a new user in the local database the first time a user logs in.

Credits

This whole thing is hacked out of a more generic LDAP script by Rechner Fox. I mostly tweaked the filters and added the username search.

#!/usr/bin/env python
# ldap-auth-ad.py - authenticate Home Assistant against AD via LDAP
# Based on Rechner Fox's ldap-auth.py
# Original found at https://gist.github.com/rechner/57c123d243b8adb83ccb1dc94c80847f
import os
import sys
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_bytes, escape_filter_chars
# Quick and dirty print to stderr
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ad.example.com"
# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=LDAP Helper,OU=Service Accounts,OU=Accounts,DC=ad,DC=example,DC=com"
HELPERPASS = "sEcUrEpAsSwOrD"
TIMEOUT = 3
BASEDN = "DC=ad,DC=example,DC=com"
FILTER = """
(&
(objectClass=person)
(|
(sAMAccountName={})
(userPrincipalName={})
)
(memberOf=CN=Home Assistant,OU=Security Groups,OU=Accounts,DC=ad,DC=example,DC=com)
)"""
ATTRS = ""
## End config section
if 'username' not in os.environ or 'password' not in os.environ:
eprint("Need username and password environment variables!")
exit(1)
safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)
server = Server(SERVER, get_info=ALL)
try:
conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True)
except Exception as e:
eprint("initial bind failed: {}".format(e))
exit(1)
search = conn.search(BASEDN, FILTER, attributes='displayName')
if len(conn.entries) > 0: # search is True on success regardless of result size
eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
user_dn = conn.entries[0].entry_dn
user_displayName = conn.entries[0].displayName
else:
eprint("search for username {} yielded empty result".format(os.environ['username']))
exit(1)
try:
conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
eprint("bind as {} failed: {}".format(os.environ['username'], e))
exit(1)
print("name = {}".format(user_displayName))
eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)
@Zen3515
Copy link

Zen3515 commented Jul 5, 2023

You could install ldap3 using only one .py file too.
Borrowed from A reddit comment

So you have to modify the import statement to this

import pip

def install(package):
  if hasattr(pip, 'main'):
    pip.main(['install', package])
  else:
    pip._internal.main(['install', package])

try:
  from ldap3 import Server, Connection, ALL
  from ldap3.utils.conv import escape_bytes, escape_filter_chars
except:
  install('ldap3')
  from ldap3 import Server, Connection, ALL
  from ldap3.utils.conv import escape_bytes, escape_filter_chars

@charlespick
Copy link

charlespick commented Nov 12, 2023

I am getting a [homeassistant.auth.providers.command_line] User 'charlespick' failed to authenticate, command exited with code 127 in my homeassistant.log but when I run export (credentials); python3 ldap.py I get

search success: username charlespick, result [DN: CN=Charles Pickering,CN=Users,DC=makerad,DC=makerland,DC=xyz - STATUS: Read - READ TIME: 2023-11-12T00:55:55.017441
    displayName: Charles Pickering
]
name = Charles Pickering
charlespick authenticated successfully

HA config:

homeassistant:
       auth_providers:
       - type: homeassistant
       - type: command_line
         command: /home/hass/.homeassistant/ldap.py
         meta: true

What gives? And where is 127 coming from?

Update, I needed to update the shebang at the top of the script for my computer 🙃

@HerrTim
Copy link

HerrTim commented Nov 17, 2023

Thanks for the script!
I'm new to HA and I'm struggeling with one problem, which is not caused by the script. I'll get a "Permission denied" when I try to login.

2023-11-17 15:40:28.964 ERROR (MainThread) [homeassistant.auth.providers.command_line] Error while authenticating 'dummy': [Errno 13] Permission denied: '/config/scripts/ldap-auth-ad.py'

Update: ... Problem solved: chmod 755
But another problem occured:
image

@panteLx
Copy link

panteLx commented Dec 15, 2023

For everyone who is getting the error [homeassistant.auth.providers.command_line] User 'xx' failed to authenticate, command exited with code 127 like me, you have to use the following configuration:

auth_providers:
    - type: command_line
      name: 'LDAP'
      command: '/usr/local/bin/python3'
      args: ['/config/ldap-auth.py']
      meta: true

Otherwise it wont work (for me) because you cant run the python command within a container (on HASS OS).

For further instructions check out my repo: https://github.com/panteLx/HASS-LDAP-Auth

@panteLx
Copy link

panteLx commented Dec 15, 2023

Thanks for the script! I'm new to HA and I'm struggeling with one problem, which is not caused by the script. I'll get a "Permission denied" when I try to login.

2023-11-17 15:40:28.964 ERROR (MainThread) [homeassistant.auth.providers.command_line] Error while authenticating 'dummy': [Errno 13] Permission denied: '/config/scripts/ldap-auth-ad.py'

Update: ... Problem solved: chmod 755 But another problem occured: image

Do you have meta: true enabled? Had the same issue without the true flag

@darootler
Copy link

darootler commented Jan 20, 2024

Hi guys, i am not able to get this working. On the command line everything works fine:

image

But i am getting just an exit code 1 if i try to log in:

2024-01-20 21:09:42.747 ERROR (MainThread) [homeassistant.auth.providers.command_line] User 'lea' failed to authenticate, command exited with code 1

Thats my HA config:

homeassistant:
  auth_providers:
    - type: command_line
      name: 'LDAP'
      command: /config/python_scripts/ldap-auth-ad.py
      meta: true
  - type: homeassistant

Any ideas how to debug this further?

Regards
Richard

@panteLx
Copy link

panteLx commented Jan 20, 2024

Check out my repo or comment above. The python script won’t work like that.

@darootler
Copy link

darootler commented Jan 20, 2024

Check out my repo or comment above. The python script won’t work like that.

Thx, but i already tried that with the same outcome.

I also modified your script as this not working with Home Assistant OS, and i also added LDAPs support. Just in case someone is interested.

Regards
Richard

@panteLx
Copy link

panteLx commented Jan 20, 2024

Try to run it within your HASS Docker container. Do you get any errors ?

@darootler
Copy link

darootler commented Jan 20, 2024

Thank you very much, this helped me out. It's working now also from within the container, missed a "import pip".

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

Regards
Richard

@hanneshier
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

@darootler
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

There is a Add-on called Run On Startup.d, i am running this script with the Add-on for the homeassistant container:

#! /bin/bash
/usr/local/bin/python3 /config/python_scripts/install_ldap3.py
#!/usr/bin/env python3

import importlib.util
from subprocess import DEVNULL, STDOUT, check_call

ldap3_spec = importlib.util.find_spec("ldap3")
ldap3_found = ldap3_spec is not None

if not ldap3_found:
    check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT)

Additionally i am running the following automation to trigger the script on HA start:

alias: Installation Module
description: ""
trigger:
  - platform: homeassistant
    event: start
condition: []
action:
  - service: hassio.addon_restart
    metadata: {}
    data:
      addon: 1f3d020e_run_on_startup_addon
mode: single

Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades.

Regards
Richard

@cimco1990
Copy link

cimco1990 commented Mar 8, 2024

Hello, thanks for your work.

I have a HA OS, so no docker and have not been able to get this working. Maybe one of you can help me out. Here is what i did so far:

  1. on CLI i did the pip install ldap3 comand
  2. I added this section to the configuration.yaml
homeassistant:
    auth_providers:
        - type: homeassistant
        - type: command_line
          name: "LDAP"
          command: /config/python_scripts/ldap-auth-ad.py
          meta: true
  1. I put the script inside the /config Folder:
    image
  2. Edit permissions to 755
  3. Edited the Settings
`# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "xxx.local"

# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=homeassistant,OU=Users,DC=xxx,DC=local"
HELPERPASS = "xxx"

TIMEOUT = 3
BASEDN = "OU=Users,DC=xxx,DC=local"
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (memberOf=CN=HomeAssistantUsers,OU=Users,DC=xxx,DC=local)
    )"""
ATTRS = ""

  1. When trying to login in the logs:
    2024-03-07 18:04:44.934 ERROR (MainThread) [homeassistant.auth.providers.command_line] User 'xxx' failed to authenticate, command exited with code 1 2024-03-07 18:04:44.962 WARNING (MainThread) [homeassistant.components.http.ban] Login attempt or request with invalid authentication from xxx (xxx). Requested URL: '/auth/login_flow/9352d69b1b315b2d974f4c0d3470371f'. (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36)`

Can you help figuring out what i missed or did wrong? Thank you very much.

@WoutervanZijl
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

There is a Add-on called Run On Startup.d, i am running this script with the Add-on for the homeassistant container:

#! /bin/bash
/usr/local/bin/python3 /config/python_scripts/install_ldap3.py
#!/usr/bin/env python3

import importlib.util
from subprocess import DEVNULL, STDOUT, check_call

ldap3_spec = importlib.util.find_spec("ldap3")
ldap3_found = ldap3_spec is not None

if not ldap3_found:
    check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT)

Additionally i am running the following automation to trigger the script on HA start:

alias: Installation Module
description: ""
trigger:
  - platform: homeassistant
    event: start
condition: []
action:
  - service: hassio.addon_restart
    metadata: {}
    data:
      addon: 1f3d020e_run_on_startup_addon
mode: single

Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades.

Regards Richard

Hello Richard, is it possible you could share youre .py ? I am trying the same and also with ldaps and samba and i keep getting code 1 and if i am correct it just an output when connecting fails to samba active directory.

Great work for explanation how you manage to get it ti work

@darootler
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

There is a Add-on called Run On Startup.d, i am running this script with the Add-on for the homeassistant container:

#! /bin/bash
/usr/local/bin/python3 /config/python_scripts/install_ldap3.py
#!/usr/bin/env python3

import importlib.util
from subprocess import DEVNULL, STDOUT, check_call

ldap3_spec = importlib.util.find_spec("ldap3")
ldap3_found = ldap3_spec is not None

if not ldap3_found:
    check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT)

Additionally i am running the following automation to trigger the script on HA start:

alias: Installation Module
description: ""
trigger:
  - platform: homeassistant
    event: start
condition: []
action:
  - service: hassio.addon_restart
    metadata: {}
    data:
      addon: 1f3d020e_run_on_startup_addon
mode: single

Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades.
Regards Richard

Hello Richard, is it possible you could share youre .py ? I am trying the same and also with ldaps and samba and i keep getting code 1 and if i am correct it just an output when connecting fails to samba active directory.

Great work for explanation how you manage to get it ti work

Hi @WoutervanZijl

Here is my working ldap_auth python script:

#!/usr/bin/env python3

import os
import pip
import ssl
import sys
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_bytes, escape_filter_chars

# Quick and dirty print to stderr
def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ldaps://myldaphost.mydomain.corp:636"

# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp"
HELPERPASS = "PASSS"

TIMEOUT = 3
BASEDN = "DC=mydomain,DC=corp"
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp))
    )"""
ATTRS = ""

## End config section

if 'username' not in os.environ or 'password' not in os.environ:
    eprint("Need username and password environment variables!")
    #logger.error("Need username and password environment variables!")
    exit(1)

safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)

server = Server(SERVER, get_info=ALL)
try:
    conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True)
except Exception as e:
    eprint("initial bind failed: {}".format(e))
    exit(1)

search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof'])
if len(conn.entries) > 0: # search is True on success regardless of result size
    eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
    user_dn = conn.entries[0].entry_dn
    user_displayName = conn.entries[0].displayName
    user_memberof = conn.entries[0].memberof
else:
    eprint("search for username {} yielded empty result".format(os.environ['username']))
    exit(1)

try:
    conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
    eprint("bind as {} failed: {}".format(os.environ['username'], e))
    exit(1)

if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)

Regards

@WoutervanZijl
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

There is a Add-on called Run On Startup.d, i am running this script with the Add-on for the homeassistant container:

#! /bin/bash
/usr/local/bin/python3 /config/python_scripts/install_ldap3.py
#!/usr/bin/env python3

import importlib.util
from subprocess import DEVNULL, STDOUT, check_call

ldap3_spec = importlib.util.find_spec("ldap3")
ldap3_found = ldap3_spec is not None

if not ldap3_found:
    check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT)

Additionally i am running the following automation to trigger the script on HA start:

alias: Installation Module
description: ""
trigger:
  - platform: homeassistant
    event: start
condition: []
action:
  - service: hassio.addon_restart
    metadata: {}
    data:
      addon: 1f3d020e_run_on_startup_addon
mode: single

Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades.
Regards Richard

Hello Richard, is it possible you could share youre .py ? I am trying the same and also with ldaps and samba and i keep getting code 1 and if i am correct it just an output when connecting fails to samba active directory.
Great work for explanation how you manage to get it ti work

Hi @WoutervanZijl

Here is my working ldap_auth python script:

#!/usr/bin/env python3

import os
import pip
import ssl
import sys
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_bytes, escape_filter_chars

# Quick and dirty print to stderr
def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ldaps://myldaphost.mydomain.corp:636"

# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp"
HELPERPASS = "PASSS"

TIMEOUT = 3
BASEDN = "DC=mydomain,DC=corp"
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp))
    )"""
ATTRS = ""

## End config section

if 'username' not in os.environ or 'password' not in os.environ:
    eprint("Need username and password environment variables!")
    #logger.error("Need username and password environment variables!")
    exit(1)

safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)

server = Server(SERVER, get_info=ALL)
try:
    conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True)
except Exception as e:
    eprint("initial bind failed: {}".format(e))
    exit(1)

search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof'])
if len(conn.entries) > 0: # search is True on success regardless of result size
    eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
    user_dn = conn.entries[0].entry_dn
    user_displayName = conn.entries[0].displayName
    user_memberof = conn.entries[0].memberof
else:
    eprint("search for username {} yielded empty result".format(os.environ['username']))
    exit(1)

try:
    conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
    eprint("bind as {} failed: {}".format(os.environ['username'], e))
    exit(1)

if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)

Regards

Thanks! python i new for me. Now i connect to my samba with nextcloud jellyfin and truenas but how to add a ignore ssl check? is that possible. Maybe you know?

@darootler
Copy link

Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“.

How did you manage to overcome the missing pip installation after the HA update?

There is a Add-on called Run On Startup.d, i am running this script with the Add-on for the homeassistant container:

#! /bin/bash
/usr/local/bin/python3 /config/python_scripts/install_ldap3.py
#!/usr/bin/env python3

import importlib.util
from subprocess import DEVNULL, STDOUT, check_call

ldap3_spec = importlib.util.find_spec("ldap3")
ldap3_found = ldap3_spec is not None

if not ldap3_found:
    check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT)

Additionally i am running the following automation to trigger the script on HA start:

alias: Installation Module
description: ""
trigger:
  - platform: homeassistant
    event: start
condition: []
action:
  - service: hassio.addon_restart
    metadata: {}
    data:
      addon: 1f3d020e_run_on_startup_addon
mode: single

Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades.
Regards Richard

Hello Richard, is it possible you could share youre .py ? I am trying the same and also with ldaps and samba and i keep getting code 1 and if i am correct it just an output when connecting fails to samba active directory.
Great work for explanation how you manage to get it ti work

Hi @WoutervanZijl
Here is my working ldap_auth python script:

#!/usr/bin/env python3

import os
import pip
import ssl
import sys
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_bytes, escape_filter_chars

# Quick and dirty print to stderr
def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ldaps://myldaphost.mydomain.corp:636"

# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp"
HELPERPASS = "PASSS"

TIMEOUT = 3
BASEDN = "DC=mydomain,DC=corp"
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp))
    )"""
ATTRS = ""

## End config section

if 'username' not in os.environ or 'password' not in os.environ:
    eprint("Need username and password environment variables!")
    #logger.error("Need username and password environment variables!")
    exit(1)

safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)

server = Server(SERVER, get_info=ALL)
try:
    conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True)
except Exception as e:
    eprint("initial bind failed: {}".format(e))
    exit(1)

search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof'])
if len(conn.entries) > 0: # search is True on success regardless of result size
    eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
    user_dn = conn.entries[0].entry_dn
    user_displayName = conn.entries[0].displayName
    user_memberof = conn.entries[0].memberof
else:
    eprint("search for username {} yielded empty result".format(os.environ['username']))
    exit(1)

try:
    conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
    eprint("bind as {} failed: {}".format(os.environ['username'], e))
    exit(1)

if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)

Regards

Thanks! python i new for me. Now i connect to my samba with nextcloud jellyfin and truenas but how to add a ignore ssl check? is that possible. Maybe you know?

Haven't tested but you can try this:

#!/usr/bin/env python3

import os
import pip
import ssl
import sys
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_bytes, escape_filter_chars

# Quick and dirty print to stderr
def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ldaps://myldaphost.mydomain.corp:636"

# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp"
HELPERPASS = "PASSS"
tls_configuration = Tls(validate=ssl.CERT_NONE)

TIMEOUT = 3
BASEDN = "DC=mydomain,DC=corp"
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp))
    )"""
ATTRS = ""

## End config section

if 'username' not in os.environ or 'password' not in os.environ:
    eprint("Need username and password environment variables!")
    #logger.error("Need username and password environment variables!")
    exit(1)

safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)

server = Server(SERVER, get_info=ALL)
try:
    conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True, tls=tls_configuration)
except Exception as e:
    eprint("initial bind failed: {}".format(e))
    exit(1)

search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof'])
if len(conn.entries) > 0: # search is True on success regardless of result size
    eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
    user_dn = conn.entries[0].entry_dn
    user_displayName = conn.entries[0].displayName
    user_memberof = conn.entries[0].memberof
else:
    eprint("search for username {} yielded empty result".format(os.environ['username']))
    exit(1)

try:
    conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
    eprint("bind as {} failed: {}".format(os.environ['username'], e))
    exit(1)

if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)

Basically you need to tell the connector to ignore SSL certificates. I also run samba on my qnap, but i use certificates issued from my internal CA, so they are basically valid and trusted on my devices.

Regards

@WoutervanZijl
Copy link

WoutervanZijl commented Jul 17, 2024 via email

@WoutervanZijl
Copy link

Thanks! I tried but still recieving and exit code of 1. I am not sure what or where the error occurred and I am reading how to ad somekind of error log with names instead of exit code but not finding anything yet. I am using caddy and it is not possible to get my samba ssl authenticated with basic modules. I want to use ssl but that would be my next step to fix that issue. Met vriendelijke groet, Wouter van Zijl Op 17 jul 2024 om 10:27 heeft darootler @.***> het volgende geschreven:  @darootler commented on this gist.

________________________________ Also got a workaround if a HA Core update takes place (pip installation gone). And i adopted the script to use two ldap groups, one sets the HA user to group „system-users“ and the other to the group „system-admin“. How did you manage to overcome the missing pip installation after the HA update? There is a Add-on called Run On Startup.dhttps://community.home-assistant.io/t/run-on-startup-d/271008, i am running this script with the Add-on for the homeassistant container: #! /bin/bash /usr/local/bin/python3 /config/python_scripts/install_ldap3.py #!/usr/bin/env python3 import importlib.util from subprocess import DEVNULL, STDOUT, check_call ldap3_spec = importlib.util.find_spec("ldap3") ldap3_found = ldap3_spec is not None if not ldap3_found: check_call(['pip', 'install', 'ldap3', '--break-system-packages'], stdout=DEVNULL, stderr=STDOUT) Additionally i am running the following automation to trigger the script on HA start: alias: Installation Module description: "" trigger: - platform: homeassistant event: start condition: [] action: - service: hassio.addon_restart metadata: {} data: addon: 1f3d020e_run_on_startup_addon mode: single Not the prettiest setup but it works and keeps the LDAP integration working even on HA upgrades. Regards Richard Hello Richard, is it possible you could share youre .py ? I am trying the same and also with ldaps and samba and i keep getting code 1 and if i am correct it just an output when connecting fails to samba active directory. Great work for explanation how you manage to get it ti work Hi @WoutervanZijlhttps://github.com/WoutervanZijl Here is my working ldap_auth python script: #!/usr/bin/env python3 import os import pip import ssl import sys from ldap3 import Server, Connection, ALL, Tls from ldap3.utils.conv import escape_bytes, escape_filter_chars # Quick and dirty print to stderr def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) # XXX: Update these with settings apropriate to your environment: # (mine below are based on Active Directory and a security group) SERVER = "ldaps://myldaphost.mydomain.corp:636" # We need to search by SAM/UPN to find the DN, so we use a helper account # This account should be unprivileged and blocked from interactive logon HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp" HELPERPASS = "PASSS" TIMEOUT = 3 BASEDN = "DC=mydomain,DC=corp" FILTER = """ (& (objectClass=person) (| (sAMAccountName={}) (userPrincipalName={}) ) (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)) )""" ATTRS = "" ## End config section if 'username' not in os.environ or 'password' not in os.environ: eprint("Need username and password environment variables!") #logger.error("Need username and password environment variables!") exit(1) safe_username = escape_filter_chars(os.environ['username']) FILTER = FILTER.format(safe_username, safe_username) server = Server(SERVER, get_info=ALL) try: conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True) except Exception as e: eprint("initial bind failed: {}".format(e)) exit(1) search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof']) if len(conn.entries) > 0: # search is True on success regardless of result size eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries)) user_dn = conn.entries[0].entry_dn user_displayName = conn.entries[0].displayName user_memberof = conn.entries[0].memberof else: eprint("search for username {} yielded empty result".format(os.environ['username'])) exit(1) try: conn.rebind(user=user_dn, password=os.environ['password']) except Exception as e: eprint("bind as {} failed: {}".format(os.environ['username'], e)) exit(1) if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep) if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep) if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep) eprint("{} authenticated successfully".format(os.environ['username'])) exit(0) Regards Thanks! python i new for me. Now i connect to my samba with nextcloud jellyfin and truenas but how to add a ignore ssl check? is that possible. Maybe you know? Haven't tested but you can try this: #!/usr/bin/env python3 import os import pip import ssl import sys from ldap3 import Server, Connection, ALL, Tls from ldap3.utils.conv import escape_bytes, escape_filter_chars # Quick and dirty print to stderr def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) # XXX: Update these with settings apropriate to your environment: # (mine below are based on Active Directory and a security group) SERVER = "ldaps://myldaphost.mydomain.corp:636" # We need to search by SAM/UPN to find the DN, so we use a helper account # This account should be unprivileged and blocked from interactive logon HELPERDN = "CN=ldapreader,OU=myou,DC=mydomain,DC=corp" HELPERPASS = "PASSS" tls_configuration = Tls(validate=ssl.CERT_NONE) TIMEOUT = 3 BASEDN = "DC=mydomain,DC=corp" FILTER = """ (& (objectClass=person) (| (sAMAccountName={}) (userPrincipalName={}) ) (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)) )""" ATTRS = "" ## End config section if 'username' not in os.environ or 'password' not in os.environ: eprint("Need username and password environment variables!") #logger.error("Need username and password environment variables!") exit(1) safe_username = escape_filter_chars(os.environ['username']) FILTER = FILTER.format(safe_username, safe_username) server = Server(SERVER, get_info=ALL) try: conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True, tls=tls_configuration) except Exception as e: eprint("initial bind failed: {}".format(e)) exit(1) search = conn.search(BASEDN, FILTER, attributes=['displayName','memberof']) if len(conn.entries) > 0: # search is True on success regardless of result size eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries)) user_dn = conn.entries[0].entry_dn user_displayName = conn.entries[0].displayName user_memberof = conn.entries[0].memberof else: eprint("search for username {} yielded empty result".format(os.environ['username'])) exit(1) try: conn.rebind(user=user_dn, password=os.environ['password']) except Exception as e: eprint("bind as {} failed: {}".format(os.environ['username'], e)) exit(1) if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep) if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep) if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof: print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep) eprint("{} authenticated successfully".format(os.environ['username'])) exit(0) Basically you need to tell the connector to ignore SSL certificates. I also run samba on my qnap, but i use certificates issued from my internal CA, so they are basically valid and trusted on my devices. Regards — Reply to this email directly, view it on GitHubhttps://gist.github.com/yumenohikari/8440144023cf33ab3ef0d68084a1b42f#gistcomment-5124072 or unsubscribehttps://github.com/notifications/unsubscribe-auth/AR235RDUFXGK2YISCWFFETDZMYTG5BFKMF2HI4TJMJ2XIZLTSKBKK5TBNR2WLJDUOJ2WLJDOMFWWLO3UNBZGKYLEL5YGC4TUNFRWS4DBNZ2F6YLDORUXM2LUPGBKK5TBNR2WLJDHNFZXJJDOMFWWLK3UNBZGKYLEL52HS4DFVRZXKYTKMVRXIX3UPFYGLK2HNFZXIQ3PNVWWK3TUUZ2G64DJMNZZDAVEOR4XAZNEM5UXG5FFOZQWY5LFVEYTAOBTGYZTQNZWU52HE2LHM5SXFJTDOJSWC5DF. You are receiving this email because you were mentioned. Triage notifications on the go with GitHub Mobile for iOShttps://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Androidhttps://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

Can you maybe help me with the following error i keep getting when running the script in the terminal.
I know the binding works because when using the script with only the connection it works however i can not get it to work because it keeps exiting with code 1. When running in the terminal it keeps saying # Check if required environment variables are set
if "username" not in os.environ or "password" not in os.environ:
"Need username and password environment variables!"

is there something i need to do for these users. do i need to create them first? i having a hard time to debug this.

@darootler
Copy link

darootler commented Jul 19, 2024

The script assumes that the system environment variables "username" and "password" are set. Home Assistant does this during the authentication process. If you want to test the script manually then you have to set the system variables like this:

export username=mytestuser
export password=mytestpassword

For sure you also need to set the following in the homeassistant section in configuration.yaml:

homeassistant:
  auth_providers:
    - type: command_line
      name: 'LDAP'
      command: '/usr/local/bin/python3'
      args: ['/config/python_scripts/ldap_auth.py']
      meta: true

"meta: true" is the option to have Home Assistant setting up the environment variables during authentication, if i remember correctly.

Regards
Richard

@k1llerk3ks
Copy link

Thanks for that amazing Tutorial!

In the end it was pretty easy to implement. However, i had the issue of my username just being [], because conn.entries[0] did not contain a displayName. conn.entries[0].entry_dn did exist though which equals for me cn=myusername,dc=domain,dc=com -

so i chose the hacky way and just did
user_displayName = conn.entries[0].entry_dn.split(",",1)[0].split("cn=")[1]

Besides that: I know HA is currently step by step implementing Admin vs User Accounts - is there any possibility to automatically just login with User permissions and not right away as admin?

@darootler
Copy link

darootler commented Aug 22, 2024

if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

There is a mapping between the AD groups and the HA groups. So there is no default, but if you restrict the HA users to AD groups and map it, then you can achive what you want.

Regards

@charlespick
Copy link

@darootler sorry to jump into this conversation months later but does that work for setting users to users and administrator roles now? How does the custom group for doorlock users work?

Also I was trying to reorganize my AD the other day and my new standard group naming format has a space in it. Maybe not a smart design choice. But when I replace "hassusers" in the ldap.py with "Home Assistant Users" which is the new group it breaks. Is there a way I need to escape the space character or something?

@darootler
Copy link

@darootler sorry to jump into this conversation months later but does that work for setting users to users and administrator roles now? How does the custom group for doorlock users work?

Also I was trying to reorganize my AD the other day and my new standard group naming format has a space in it. Maybe not a smart design choice. But when I replace "hassusers" in the ldap.py with "Home Assistant Users" which is the new group it breaks. Is there a way I need to escape the space character or something?

Hi!

Let me explain the py code snippets a bit:

  • This part defines all members of the AD groups who are able to login with AD creds. Only those members are allowed:
FILTER = """
    (&
        (objectClass=person)
        (|
            (sAMAccountName={})
            (userPrincipalName={})
        )
        (|(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp)(memberOf=CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp))
    )"""
ATTRS = ""
  • This part maps the AD groups to the existing HA groups:
if "CN=homeassistant_admins,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-admin",sep=os.linesep)

if "CN=homeassistant_doorlockusers,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = custom-doorlock-users",sep=os.linesep)

if "CN=homeassistant_users,OU=myou,DC=mydomain,DC=corp" in user_memberof:
    print("name = {}".format(user_displayName),"group = system-users",sep=os.linesep)

You can view all available HA groups in the file "/homeassistant/.storage/auth". As per default there are two HA groups, "system-admin" and "system-users".

The custom groups aren't really implemented to work with the HA GUI. I created the group by myself using this documentation --> https://developers.home-assistant.io/blog/2019/03/11/user-permissions?_highlight=group#what-about-custom-groups

I hope that helps.

Regards
Richard

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment