Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
175 changes: 144 additions & 31 deletions api/authentication.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,171 @@
"""
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
import os
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):
"""
Expand Down
26 changes: 25 additions & 1 deletion docs/src/install-guide/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down