ldap3 in paperless-ngx
In this post, I will explain how I integrated LDAP authentication into Paperless-ngx using the ldap3
library. By leveraging LDAP, we can centrally manage user authentication, ensuring secure and streamlined access to Paperless-ngx.
Custom LDAP Authentication Backend
The first step is creating a custom authentication backend. This backend uses ldap3
to authenticate users against the LDAP directory. Below is the complete implementation also transfering groups from LDAP.
Key Features of the Backend
- Centralized Authentication: User credentials are verified against the LDAP server.
- Dynamic User Management: Users are automatically created or updated in the local Django database upon successful authentication.
- Dynamic Group Management: Groups are automatically generated from those that the user belongs to.
- Admin: When a user is in the admin group, he also gets a staff member status.
- Logging: Authentication attempts are logged for debugging and auditing purposes.
The backend including LDAP groups
import logging
from django.contrib.auth.models import User, Group
from django.contrib.auth.backends import BaseBackend
from ldap3 import Server, Connection, ALL, SUBTREE
# LDAP Server Configuration
LDAP_SERVER = "ldap://example.com:3268"
LDAP_AUTH_SEARCH_BASE = "ou=it,ou=departments,dc=example,dc=com"
LDAP_AUTH_CONNECTION_USERNAME = "cn=ldapadmin,ou=users,dc=example,dc=com"
LDAP_AUTH_CONNECTION_PASSWORD = "pass of ldapadmin"
logger = logging.getLogger(__name__)
class LDAPBackend(BaseBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
server = Server(LDAP_SERVER, get_info=ALL)
conn = Connection(server, user=LDAP_AUTH_CONNECTION_USERNAME, password=LDAP_AUTH_CONNECTION_PASSWORD, auto_bind=True)
# Define the LDAP search filter to find the user by sAMAccountName
search_filter = f"(sAMAccountName={username})"
try:
# Search for the user in LDAP
conn.search(
search_base=LDAP_AUTH_SEARCH_BASE,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=['sAMAccountName', 'mail', 'givenName', 'sn', 'memberOf']
)
if conn.entries:
# If the user exists, attempt to bind with user's credentials to authenticate
user_dn = conn.entries[0].entry_dn
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
if user_conn.bind():
# Extract user details from LDAP entry
ldap_entry = conn.entries[0]
first_name = ldap_entry.givenName.value if hasattr(ldap_entry, 'givenName') else ""
last_name = ldap_entry.sn.value if hasattr(ldap_entry, 'sn') else ""
email = ldap_entry.mail.value if hasattr(ldap_entry, 'mail') else ""
groups = ldap_entry.memberOf.values if hasattr(ldap_entry, 'memberOf') else []
# Get or create the Django user
user, created = User.objects.get_or_create(username=username)
if created:
user.set_unusable_password()
user.first_name = first_name
user.last_name = last_name
user.email = email
# Create groups and assign the user to them
for group_dn in groups:
group_name = group_dn.split(',')[0][3:] # Extract group name from DN (e.g., "CN=admin" -> "admin")
group, _ = Group.objects.get_or_create(name=group_name) # Ensure the group exists
if not user.groups.filter(name=group_name).exists():
user.groups.add(group)
# Check if the user is part of the 'admin' group to set is_staff
if group_name.lower() == 'admin':
user.is_staff = True
user.save()
logger.info(f"User {username} authenticated and {'created' if created else 'updated'} in Django. Assigned to groups {', '.join([g.split(',')[0][3:] for g in groups])}.")
return user
except Exception as e:
logger.warning(f"LDAP authentication failed for user {username}: {e}")
return None
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
Backend without groups
example with just user creation without any groups
import logging
from django.contrib.auth.models import User
from django.contrib.auth.backends import BaseBackend
from ldap3 import Server, Connection, ALL, SUBTREE
# LDAP Server Configuration
LDAP_SERVER = "ldap://example.com:3268"
LDAP_AUTH_SEARCH_BASE = "ou=it,ou=departments,dc=example,dc=com"
LDAP_AUTH_CONNECTION_USERNAME = "cn=ldapadmin,ou=users,dc=example,dc=com"
LDAP_AUTH_CONNECTION_PASSWORD = "pass of ldapadmin"
logger = logging.getLogger(__name__)
class LDAPBackend(BaseBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
server = Server(LDAP_SERVER, get_info=ALL)
conn = Connection(server, user=LDAP_AUTH_CONNECTION_USERNAME, password=LDAP_AUTH_CONNECTION_PASSWORD, auto_bind=True)
# Define the LDAP search filter to find the user by sAMAccountName
search_filter = f"(sAMAccountName={username})"
try:
# Search for the user in LDAP
conn.search(
search_base=LDAP_AUTH_SEARCH_BASE,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=['sAMAccountName', 'mail', 'givenName', 'sn']
)
if conn.entries:
# If the user exists, attempt to bind with user's credentials to authenticate
user_dn = conn.entries[0].entry_dn
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
if user_conn.bind():
# Extract user details from LDAP entry
ldap_entry = conn.entries[0]
first_name = ldap_entry.givenName.value if hasattr(ldap_entry, 'givenName') else ""
last_name = ldap_entry.sn.value if hasattr(ldap_entry, 'sn') else ""
email = ldap_entry.mail.value if hasattr(ldap_entry, 'mail') else ""
# Get or create the Django user
user, created = User.objects.get_or_create(username=username)
if created:
user.set_unusable_password()
# Update user's first name, last name, and email
user.first_name = first_name
user.last_name = last_name
user.email = email
# Explicitly set is_staff to False
user.is_staff = False
user.save()
logger.info(f"User {username} authenticated and {'created' if created else 'updated'} in Django with name '{first_name} {last_name}' and email '{email}'.")
return user
except Exception as e:
logger.warning(f"LDAP authentication failed for user {username}: {e}")
return None
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
Adding LDAP Support to Paperless-ngx
To integrate this backend into Paperless-ngx, I created a custom Docker image and updated the necessary configuration files.
Custom Docker Image
I extended the official Paperless-ngx Docker image to include the ldap3
library and the custom authentication backend. Here's the Dockerfile
:
# Start from the official paperless-ngx image
FROM ghcr.io/paperless-ngx/paperless-ngx:latest
# Install LDAP library
RUN pip install ldap3
# Copy the custom LDAP backend code
COPY ldap_auth_backend.py /usr/src/paperless/src/paperless/ldap_auth_backend.py
# Update settings.py to include the LDAP backend using a sed command
RUN sed -i "/^AUTHENTICATION_BACKENDS = /a 'paperless.ldap_auth_backend.LDAPBackend'," /usr/src/paperless/src/paperless/settings.py
Build and Run the Docker Image
Use the following commands to build and run the custom Docker image:
# Build the Docker image
docker build -t paperless-ngx-ldap .
# Run the Docker container
docker run -d \
--name paperless-ngx-ldap \
-e PAPERLESS_DBHOST=your_db_host \
-e PAPERLESS_DBPASS=your_db_password \
-e PAPERLESS_TIMEZONE=your_timezone \
-p 8000:8000 \
paperless-ngx-ldap
Docker compose example with existing postgresql
docker-compose.yml example
This is an example docker compose file
services:
broker:
image: docker.io/library/redis:7
restart: unless-stopped
volumes:
- redisdata:/data
webserver:
build: .
restart: unless-stopped
depends_on:
- broker
- gotenberg
- tika
ports:
- "8000:8000"
volumes:
- /home/user/docker-compose/paperless-ngx/data/:/usr/src/paperless/data
- /home/user/docker-compose/paperless-ngx/media/:/usr/src/paperless/media
- /home/user/docker-compose/paperless-ngx/export:/usr/src/paperless/export
- /home/user/docker-compose/paperless-ngx/consume/:/usr/src/paperless/consume
env_file: docker-compose.env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
PAPERLESS_LDAP_PIP_INSTALL: "true"
gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: ghcr.io/paperless-ngx/tika:latest
restart: unless-stopped
volumes:
data:
media:
redisdata:
environment example
PAPERLESS_TIME_ZONE=Europe/Ljubljana
PAPERLESS_OCR_LANGUAGE=eng+slv+deu
PAPERLESS_SECRET_KEY=OO5345345349urerexuRj1nkKoUmKzJJcq2vBik4Cwre0luVS9iapnLP5
PAPERLESS_OCR_LANGUAGES=eng slv deu
PAPERLESS_DBENGINE=postgresql
PAPERLESS_DBHOST=192.168.1.2
PAPERLESS_DBPORT=5432
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: strongpass
PAPERLESS_URL=https://paperless.example.com
PAPERLESS_CSRF_TRUSTED_ORIGINS=https://paperless.example.com,https://example.com
PAPERLESS_ALLOWED_HOSTS=paperless.paperless.example.com,192.168.1.4,example.com
PAPERLESS_CORS_ALLOWED_HOSTS=https://paperless.example.com,https://example.com # can be set using PAPERLESS_URL
Final Thoughts
By integrating LDAP authentication into Paperless-ngx, I was able to streamline user management and centralize authentication using our existing LDAP infrastructure. The flexibility of Paperless-ngx and the power of ldap3
made this integration straightforward and effective. I hope this guide helps others looking to implement similar functionality. There are also others who managed to create usefull integrations on (didn't work for me though): https://github.com/paperless-ngx/paperless-ngx/discussions/3228.