diff --git a/Dockerfile b/Dockerfile index d11739f9..e06c1aab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,15 @@ 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 "" + +# 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 ef4deaab..a6ad1ed6 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,38 @@ 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. 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: +- `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 + +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..6a8d18f7 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,161 @@ from datetime import datetime, timedelta, timezone import jwt -from ldap3 import Server, Connection, SUBTREE +from ldap3 import Server, Connection, SUBTREE, LEVEL, BASE, SIMPLE +from ldap3.utils.conv import escape_filter_chars 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) + 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')) + + # 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=user_search_filter, + search_scope=search_scope, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf', 'distinguishedName'] + ) + + if not connection.entries: + # User not found + connection.unbind() + return abort(401) + + # 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 + # 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='(sAMAccountName=' + payload['username'].split('@')[0] + ')', - search_scope = SUBTREE, - attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + search_base=user_dn, + search_filter='(objectClass=*)', + search_scope=BASE, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + ) + else: + # Original direct bind method + connection = Connection( + server, + user=username, + password=password ) - 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.bind(): return abort(401) + else: + connection.search( + search_base=os.getenv('LDAP_BASE_DN'), + search_filter=user_search_filter, + search_scope=search_scope, + attributes=['objectGUID', 'givenName', 'sn', 'mail', 'memberOf'] + ) - 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' - ) + # 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) - connection.unbind() - return jsonify({'token': token}) + 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..db9bc5f2 100644 --- a/docs/src/install-guide/docker.md +++ b/docs/src/install-guide/docker.md @@ -112,13 +112,37 @@ 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. `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, +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