From 13dd57a5a6539a3b0fba3b50296b2bb44425f53c Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Tue, 1 Apr 2025 17:57:14 -0400 Subject: [PATCH 1/4] Add support for authenticated directory searches --- Dockerfile | 4 + README.md | 29 ++++++ api/authentication.py | 158 ++++++++++++++++++++++++------- docs/src/install-guide/docker.md | 18 ++++ 4 files changed, 177 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index d11739f9..69495522 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,10 @@ EXPOSE 8443 # env variables ENV LOCAL_USER true ENV LDAP_AUTH false +# LDAP service account binding configuration (disabled by default) +ENV LDAP_USE_SERVICE_ACCOUNT false +ENV LDAP_SERVICE_ACCOUNT_DN "" +ENV LDAP_SERVICE_ACCOUNT_PASSWORD "" # start the application CMD uwsgi --ini uwsgi.ini --processes $(grep -c 'cpu[0-9]' /proc/stat) diff --git a/README.md b/README.md index ef4deaab..cae91e80 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,35 @@ will be shown (when run in via the CLI). and will be used to determine the generalizability of the model. This data set reflects the prevalence of disease being classified (eg. not balanced). +## Authentication + +This application supports authentication via LDAP with two different binding methods: + +### Direct Binding (Default) + +The application attempts to bind directly with the user's credentials. This is the simplest +configuration but may not work with LDAP servers that require authenticated connections +for searching the directory. + +Required environment variables: +- `LDAP_SERVER`: LDAP server address +- `LDAP_BASE_DN`: Base DN for LDAP searches +- `LDAP_AUTH_SECRET`: Secret key for JWT token signing + +Optional: +- `LDAP_REQUIRED_GROUP`: Group membership required for authentication + +### Service Account Binding + +The application first binds with a service account before authenticating the user. This is +required for LDAP servers that do not allow anonymous queries or require all connections +to be authenticated. + +Enable this mode by setting the following additional environment variables: +- `LDAP_USE_SERVICE_ACCOUNT`: Set to "true" to use service account binding +- `LDAP_SERVICE_ACCOUNT_DN`: Service account distinguished name +- `LDAP_SERVICE_ACCOUNT_PASSWORD`: Service account password + ## Command Line Interface To run the program simply execute the following command: diff --git a/api/authentication.py b/api/authentication.py index 7e6cc125..ca7d6966 100644 --- a/api/authentication.py +++ b/api/authentication.py @@ -1,5 +1,9 @@ """ Methods to handle authentication. + +This module provides LDAP authentication with support for both: +- Direct binding with user credentials +- Service account binding before user authentication (secure LDAP configurations) """ import json @@ -7,52 +11,142 @@ from datetime import datetime, timedelta, timezone import jwt -from ldap3 import Server, Connection, SUBTREE +from ldap3 import Server, Connection, SUBTREE, SIMPLE from flask import jsonify, request, abort def ldap_login(): """ Authenticate a user using LDAP. + + Supports two authentication methods: + 1. Direct bind: Attempts to bind directly using the provided user credentials + 2. Service account bind: First binds with service account, then authenticates the user + (controlled by LDAP_USE_SERVICE_ACCOUNT environment variable) + + Required environment variables: + - LDAP_SERVER: LDAP server address + - LDAP_BASE_DN: Base DN for LDAP searches + - LDAP_AUTH_SECRET: Secret key for JWT token signing + + Optional environment variables: + - LDAP_REQUIRED_GROUP: Group membership required for authentication + - LDAP_USE_SERVICE_ACCOUNT: Set to "true" to use service account binding + - LDAP_SERVICE_ACCOUNT_DN: Service account distinguished name (when using service account) + - LDAP_SERVICE_ACCOUNT_PASSWORD: Service account password (when using service account) """ payload = json.loads(request.data) - - connection = Connection( - Server(os.getenv('LDAP_SERVER')), - user=payload['username'], - password=payload['password'] - ) - - if not connection.bind(): - return abort(401) - else: + username = payload['username'] + password = payload['password'] + + # Determine username for search (remove domain if present) + search_username = username.split('@')[0] + + # Set up the LDAP server connection + server = Server(os.getenv('LDAP_SERVER')) + + # Check if we need to use service account binding first + use_service_account = os.getenv('LDAP_USE_SERVICE_ACCOUNT', '').lower() == 'true' + + if use_service_account: + # Bind with service account first + service_account_dn = os.getenv('LDAP_SERVICE_ACCOUNT_DN') + service_account_password = os.getenv('LDAP_SERVICE_ACCOUNT_PASSWORD') + + if not service_account_dn or not service_account_password: + # Missing service account configuration + return abort(500) + + # Bind with service account + connection = Connection( + server, + user=service_account_dn, + password=service_account_password, + authentication=SIMPLE + ) + + if not connection.bind(): + # Service account bind failed + return abort(500) + + # Search for the user connection.search( - search_base=os.getenv('LDAP_BASE_DN'), - search_filter='(sAMAccountName=' + payload['username'].split('@')[0] + ')', - search_scope = SUBTREE, - attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + search_base=os.getenv('LDAP_BASE_DN'), + search_filter=f'(sAMAccountName={search_username})', + search_scope=SUBTREE, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf', 'distinguishedName'] ) - - if os.getenv('LDAP_REQUIRED_GROUP') and not any(os.getenv('LDAP_REQUIRED_GROUP') in item for item in connection.entries[0]['memberOf']): + + if not connection.entries: + # User not found + connection.unbind() return abort(401) - - token = jwt.encode( - { - 'iss': 'milo-ml', - 'aud': 'milo-ml', - 'sub': payload['username'], - 'iat': datetime.utcnow(), - 'exp': datetime.now(tz=timezone.utc) + timedelta(days=1), - 'uid': str(connection.entries[0]['objectGUID']).strip('{}'), - 'name': str(connection.entries[0]['givenName']) + ' ' + str(connection.entries[0]['sn']), - 'email': str(connection.entries[0]['mail']) - }, - os.getenv('LDAP_AUTH_SECRET'), - algorithm='HS256' + + # Get the user's DN + user_dn = str(connection.entries[0]['distinguishedName']) + + # Unbind service account connection + connection.unbind() + + # Try to bind with the user's credentials + connection = Connection( + server, + user=user_dn, + password=password, + authentication=SIMPLE ) + + if not connection.bind(): + # User authentication failed + return abort(401) + + # Re-search to get all user attributes + connection.search( + search_base=os.getenv('LDAP_BASE_DN'), + search_filter=f'(distinguishedName={user_dn})', + search_scope=SUBTREE, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + ) + else: + # Original direct bind method + connection = Connection( + server, + user=username, + password=password + ) + + if not connection.bind(): + return abort(401) + else: + connection.search( + search_base=os.getenv('LDAP_BASE_DN'), + search_filter=f'(sAMAccountName={search_username})', + search_scope=SUBTREE, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + ) + # Group membership verification + if os.getenv('LDAP_REQUIRED_GROUP') and not any(os.getenv('LDAP_REQUIRED_GROUP') in item for item in connection.entries[0]['memberOf']): connection.unbind() - return jsonify({'token': token}) + return abort(401) + + token = jwt.encode( + { + 'iss': 'milo-ml', + 'aud': 'milo-ml', + 'sub': username, + 'iat': datetime.utcnow(), + 'exp': datetime.now(tz=timezone.utc) + timedelta(days=1), + 'uid': str(connection.entries[0]['objectGUID']).strip('{}'), + 'name': str(connection.entries[0]['givenName']) + ' ' + str(connection.entries[0]['sn']), + 'email': str(connection.entries[0]['mail']) + }, + os.getenv('LDAP_AUTH_SECRET'), + algorithm='HS256' + ) + + connection.unbind() + return jsonify({'token': token}) def ldap_verify(token): """ diff --git a/docs/src/install-guide/docker.md b/docs/src/install-guide/docker.md index b9cea126..a25bc5ca 100644 --- a/docs/src/install-guide/docker.md +++ b/docs/src/install-guide/docker.md @@ -119,6 +119,24 @@ the secret for that token. `LDAP_REQUIRED_GROUP`: Ensure the user is a member of the group provided. Only checked if a group is defined otherwise no group checking is performed. +### LDAP Service Account Binding + +MILO-ML supports LDAP servers that require authenticated connections for directory searches (no anonymous binds). In this configuration, +MILO-ML first binds with a service account, then searches for the user, and finally authenticates with the user's credentials. + +To enable service account binding, set the following environment variables: + +`LDAP_USE_SERVICE_ACCOUNT`: Set to `true` to enable service account binding (default is `false`) + +`LDAP_SERVICE_ACCOUNT_DN`: The distinguished name (DN) of the service account used for initial binding + +`LDAP_SERVICE_ACCOUNT_PASSWORD`: The password for the service account + +::: tip +Service account binding is recommended for secure LDAP configurations where the directory server requires authenticated connections +for searching the directory. +::: + `BROKER_URL`: URL to the RabbitMQ broker (do not use when using the all-in-one image). ### Configuring Docker Resources From 4236e24a26674037c6be2c4f7badded78ab624f6 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Tue, 1 Apr 2025 18:05:07 -0400 Subject: [PATCH 2/4] Potential fix for code scanning alert no. 17: LDAP query built from user-controlled sources Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- api/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/authentication.py b/api/authentication.py index ca7d6966..4084ccba 100644 --- a/api/authentication.py +++ b/api/authentication.py @@ -12,6 +12,7 @@ import jwt from ldap3 import Server, Connection, SUBTREE, SIMPLE +from ldap3.utils.conv import escape_filter_chars from flask import jsonify, request, abort def ldap_login(): @@ -40,7 +41,7 @@ def ldap_login(): password = payload['password'] # Determine username for search (remove domain if present) - search_username = username.split('@')[0] + search_username = escape_filter_chars(username.split('@')[0]) # Set up the LDAP server connection server = Server(os.getenv('LDAP_SERVER')) From 3ee9a1f642a95844eb9ff30bbc823681fb46a904 Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Wed, 23 Apr 2025 14:59:54 -0400 Subject: [PATCH 3/4] Add new variables --- Dockerfile | 5 +++++ README.md | 3 +++ api/authentication.py | 38 +++++++++++++++++++++++--------- docs/src/install-guide/docker.md | 6 +++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 69495522..e06c1aab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,11 @@ ENV LDAP_USE_SERVICE_ACCOUNT false ENV LDAP_SERVICE_ACCOUNT_DN "" ENV LDAP_SERVICE_ACCOUNT_PASSWORD "" +# LDAP search configuration defaults +ENV LDAP_LOGIN_ATTRIBUTE "sAMAccountName" +ENV LDAP_SEARCH_SCOPE "SUBTREE" +ENV LDAP_SEARCH_FILTER "" + # start the application CMD uwsgi --ini uwsgi.ini --processes $(grep -c 'cpu[0-9]' /proc/stat) diff --git a/README.md b/README.md index cae91e80..f01cc7eb 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ Required environment variables: Optional: - `LDAP_REQUIRED_GROUP`: Group membership required for authentication +- `LDAP_LOGIN_ATTRIBUTE`: The LDAP attribute to use for user lookup (default `sAMAccountName`) +- `LDAP_SEARCH_SCOPE`: The LDAP search scope – one of `BASE`, `LEVEL`, or `SUBTREE` (default `SUBTREE`) +- `LDAP_SEARCH_FILTER`: A custom LDAP search filter; if set, it overrides the default attribute filter and group check ### Service Account Binding diff --git a/api/authentication.py b/api/authentication.py index 4084ccba..1407396a 100644 --- a/api/authentication.py +++ b/api/authentication.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone import jwt -from ldap3 import Server, Connection, SUBTREE, SIMPLE +from ldap3 import Server, Connection, SUBTREE, LEVEL, BASE, SIMPLE from ldap3.utils.conv import escape_filter_chars from flask import jsonify, request, abort @@ -39,10 +39,25 @@ def ldap_login(): payload = json.loads(request.data) username = payload['username'] password = payload['password'] + # optional override of login attribute and search scope + login_attr = os.getenv('LDAP_LOGIN_ATTRIBUTE', 'sAMAccountName') + scope_name = os.getenv('LDAP_SEARCH_SCOPE', 'SUBTREE').upper() + scope_map = {'BASE': BASE, 'LEVEL': LEVEL, 'SUBTREE': SUBTREE} + search_scope = scope_map.get(scope_name, SUBTREE) # Determine username for search (remove domain if present) search_username = escape_filter_chars(username.split('@')[0]) - + # optional custom filter: can include placeholders {login_attr}, {username}, {group} + custom_filter = os.getenv('LDAP_SEARCH_FILTER') + if custom_filter: + user_search_filter = custom_filter.format( + login_attr=login_attr, + username=search_username, + group=os.getenv('LDAP_REQUIRED_GROUP','') + ) + else: + user_search_filter = f'({login_attr}={search_username})' + # Set up the LDAP server connection server = Server(os.getenv('LDAP_SERVER')) @@ -73,8 +88,8 @@ def ldap_login(): # Search for the user connection.search( search_base=os.getenv('LDAP_BASE_DN'), - search_filter=f'(sAMAccountName={search_username})', - search_scope=SUBTREE, + search_filter=user_search_filter, + search_scope=search_scope, attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf', 'distinguishedName'] ) @@ -105,7 +120,7 @@ def ldap_login(): connection.search( search_base=os.getenv('LDAP_BASE_DN'), search_filter=f'(distinguishedName={user_dn})', - search_scope=SUBTREE, + search_scope=search_scope, attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] ) else: @@ -121,15 +136,16 @@ def ldap_login(): else: connection.search( search_base=os.getenv('LDAP_BASE_DN'), - search_filter=f'(sAMAccountName={search_username})', - search_scope=SUBTREE, + search_filter=user_search_filter, + search_scope=search_scope, attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] ) - # Group membership verification - if os.getenv('LDAP_REQUIRED_GROUP') and not any(os.getenv('LDAP_REQUIRED_GROUP') in item for item in connection.entries[0]['memberOf']): - connection.unbind() - return abort(401) + # Group membership verification (skip if using custom filter) + if not custom_filter and os.getenv('LDAP_REQUIRED_GROUP'): + if not any(os.getenv('LDAP_REQUIRED_GROUP') in item for item in connection.entries[0]['memberOf']): + connection.unbind() + return abort(401) token = jwt.encode( { diff --git a/docs/src/install-guide/docker.md b/docs/src/install-guide/docker.md index a25bc5ca..739a57cc 100644 --- a/docs/src/install-guide/docker.md +++ b/docs/src/install-guide/docker.md @@ -119,6 +119,12 @@ the secret for that token. `LDAP_REQUIRED_GROUP`: Ensure the user is a member of the group provided. Only checked if a group is defined otherwise no group checking is performed. +`LDAP_LOGIN_ATTRIBUTE`: The LDAP attribute to use for user lookup (default `sAMAccountName`). + +`LDAP_SEARCH_SCOPE`: The LDAP search scope – one of `BASE`, `LEVEL`, or `SUBTREE` (default `SUBTREE`). + +`LDAP_SEARCH_FILTER`: A custom LDAP search filter; if set, it overrides the default attribute filter and group check. + ### LDAP Service Account Binding MILO-ML supports LDAP servers that require authenticated connections for directory searches (no anonymous binds). In this configuration, From cb29a611a4fc092fd0ef0b3fe5607f619e17356a Mon Sep 17 00:00:00 2001 From: Samer Albahra Date: Fri, 16 May 2025 22:10:35 -0400 Subject: [PATCH 4/4] Fix LDAP search base handling --- README.md | 2 +- api/authentication.py | 8 +++++--- docs/src/install-guide/docker.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f01cc7eb..a6ad1ed6 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ for searching the directory. Required environment variables: - `LDAP_SERVER`: LDAP server address -- `LDAP_BASE_DN`: Base DN for LDAP searches +- `LDAP_BASE_DN`: Base DN for LDAP searches. This should reference the part of the directory tree where user accounts are stored (e.g. `DC=example,DC=com`). Providing a group-specific OU will prevent user objects from being located. - `LDAP_AUTH_SECRET`: Secret key for JWT token signing Optional: diff --git a/api/authentication.py b/api/authentication.py index 1407396a..6a8d18f7 100644 --- a/api/authentication.py +++ b/api/authentication.py @@ -117,10 +117,12 @@ def ldap_login(): return abort(401) # Re-search to get all user attributes + # Retrieve user attributes using a BASE scope on the exact DN to avoid + # failures when LDAP_BASE_DN does not contain the user's full path connection.search( - search_base=os.getenv('LDAP_BASE_DN'), - search_filter=f'(distinguishedName={user_dn})', - search_scope=search_scope, + search_base=user_dn, + search_filter='(objectClass=*)', + search_scope=BASE, attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] ) else: diff --git a/docs/src/install-guide/docker.md b/docs/src/install-guide/docker.md index 739a57cc..db9bc5f2 100644 --- a/docs/src/install-guide/docker.md +++ b/docs/src/install-guide/docker.md @@ -112,7 +112,7 @@ available for configuration in such an environment: `LDAP_SERVER`: The complete path to the LDAP server including the protocol (`ldap` or `ldaps`) and port number. -`LDAP_BASE_DN`: Defines the base distinguished name used to search for users. +`LDAP_BASE_DN`: Defines the base distinguished name used to search for users. This should be the DN where user objects reside (for example `DC=example,DC=com`). Using a group OU will prevent users from being found. `LDAP_AUTH_SECRET`: After successfully authenticating using LDAP, sessions are authenticated using a signed JWT token and this defines the secret for that token.