from pathlib import Path
from typing import Literal
from pydantic import RedisDsn, ValidationError, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from nginx_ldap_auth.validators import validate_ldap_search_filter
[docs]class Settings(BaseSettings):
"""
Settings for the nginx_ldap_auth service.
"""
# ==================
# Logging
# ==================
#: FastAPI debug mode
debug: bool = False
#: Default log level. Choose from any of the standard Python log levels.
loglevel: Literal["NOTSET", "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"] = "INFO"
#: What format should we log in? Valid values are ``json`` and ``text``
log_type: Literal["json", "text"] = "text"
# ==================
# HTTP
# ==================
#: Use this as the title for the login form, to give a hint to the
#: user as to what they're logging into
auth_realm: str = "Restricted"
#: Whether to run the web server without TLS
insecure: bool = False
# ==================
# Session
# ==================
#: The name of the cookie to set when a user authenticates
cookie_name: str = "nginxauth"
#: The domain to use for our session cookie, if any.
cookie_domain: str | None = None
#: The secret key to use for session cookies
secret_key: str
#: The maximum age of a session cookie in seconds
session_max_age: int = 0
#: Reset the session lifetime to :py:attr:`session_max_age` every time the
#: user accesses the protected site
use_rolling_session: bool = False
#: Session type: either ``redis`` or ``memory``
session_backend: Literal["redis", "memory"] = "memory"
#: If using the Redis session backend, the DSN on which to connect to Redis.
#:
#: A fully specified Redis DSN looks like this::
#:
#: redis://[username][:password]@host:port/db
#:
#: * The username is only necessary if you are using role-based access
#: controls on your Redis server. Otherwise the password is sufficient if you
#: have a server password for your Redis server.
#: * If you don't specify a database, ``0`` is used.
#: * If you don't specify a password, no password is used.
#: * If you don't specify a port, ``6379`` is used.
redis_url: RedisDsn | None = None
#: If using the Redis session backend, the prefix to use for session keys
redis_prefix: str = "nginx_ldap_auth."
# ==================
# LDAP
# ==================
#: The URI via which to connect to LDAP
ldap_uri: str
#: The DN as which to bind to LDAP
ldap_binddn: str
#: The password to use when binding to LDAP when doing our searches
ldap_password: str
#: Whether to use TLS when connecting to LDAP
ldap_starttls: bool = True
#: Whether to validate the LDAP certificate
ldap_validate_cert: bool = True
#: The path to the CA certificate to use when validating the LDAP certificate
ldap_ca_cert_name: str | None = None
#: The path to the CA certificate directory to use when validating the LDAP
#: certificate
ldap_ca_cert_dir: Path | None = None
#: Whether to disable LDAP referrals
ldap_disable_referrals: bool = False
#: The base DN under which to perform searches
ldap_basedn: str
#: The base DN to append to the user's username when binding. This is only
#: important for Active Directory, where we need to use the value of
#: ``userPrincipalName`` (typically the user's email address) as the
#: username intead of the dn which would be built as
#: ``sAMAccountName=user,{LDAP_BASEDN}``. Include the ``@`` at the begining
#: of the string. If this is set, the binddn will be
#: ``{username}{ldap_user_basedn}``
ldap_user_basedn: str | None = None
#: The LDAP attribute to use as the username when searching for a user
ldap_username_attribute: str = "uid"
#: The LDAP attribute to use as the full name when getting search results
ldap_full_name_attribute: str = "cn"
#: The LDAP search filter to use when searching for a user. This should
#: be a valid LDAP search filter. The search will be a SUBTREE search
#: with the base DN of :py:attr:`ldap_basedn`.
#:
#: You may use these replacement fields in the filter:
#:
#: - ``{username_attribute}``: the value of
#: :py:class:`Settings.ldap_username_attribute`
#: - ``{username_full_name_attribute}``: the value of
#: :py:class:`Settings.ldap_full_name_attribute`
#:
#: The ``{username}`` placeholder must be present in the filter, as it is
#: used in the search filter as the placeholder for the username supplied by
#: the user from the login form.
ldap_get_user_filter: str = "{username_attribute}={username}"
#: The LDAP search filter to use to determine whether a user is authorized. This
#: should a valid LDAP search filter. If this is ``None``, all users who can
#: successfully authenticate will be authorized. If this is not ``None``,
#: the search with this filter must return at least one result for the user
#: to be authorized.
#:
#: You may use these replacement fields in the filter:
#:
#: - ``{username_attribute}``: the value of
#: :py:attr:`ldap_username_attribute`
#: - ``{username_full_name_attribute}``: the value of
#: :py:attr:`ldap_full_name_attribute`
#:
#: The ``{username}`` placeholder must be present in the filter, as it is
#: used in the search filter as the placeholder for the username supplied by
#: the user from the login form.
ldap_authorization_filter: str | None = None
#: Whether to allow the ``X-Authorization-Filter`` header to override
#: :py:attr:`ldap_authorization_filter`. When set to ``True`` (the default),
#: the header value takes precedence over the environment variable setting.
#:
#: .. warning::
#:
#: Setting this to ``True`` without properly configuring NGINX to control
#: the ``X-Authorization-Filter`` header is a **security risk**. Malicious
#: clients could send a permissive filter (e.g., ``(objectClass=*)``) to
#: bypass group-based authorization restrictions.
#:
#: For secure deployments, set this to ``False`` and use only the
#: :envvar:`LDAP_AUTHORIZATION_FILTER` environment variable, or ensure your
#: NGINX configuration explicitly sets or clears the header using
#: ``proxy_set_header`` before forwarding requests.
#:
#: .. note::
#:
#: The default is ``True`` for backwards compatibility. Future versions
#: may change the default to ``False`` for improved security.
allow_authorization_filter_header: bool = True
#: Number of seconds to wait for an LDAP connection to be established
ldap_timeout: int = 15
#: Min number of LDAP connections to keep in the pool
ldap_min_pool_size: int = 1
#: Max number of LDAP connections to keep in the pool
ldap_max_pool_size: int = 30
#: Recycle LDAP connections after this many seconds
ldap_pool_connection_lifetime_seconds: int = 20
# ==================
# Duo
# ==================
#: Whether to enable Duo MFA
duo_enabled: bool = False
#: Duo integration host
duo_host: str | None = None
#: Duo integration ikey
duo_ikey: str | None = None
#: Duo integration skey
duo_skey: str | None = None
# ==================
# Sentry
# ==================
#: The sentry DSN to use for error reporting. If this is ``None``, no
#: error reporting will be done.
sentry_url: str | None = None
model_config = SettingsConfigDict()
[docs] @model_validator(mode="after") #: type: ignore
def redis_url_required_if_session_type_is_redis(self):
"""
If we've configured the session backend to be ``redis``,
:py:attr:`redis_url` is required.
Raises:
ValidationError: ``redis_url`` is required if ``session_backend`` is
``redis``
"""
if self.session_backend == "redis" and not self.redis_url:
msg = "redis_url is required if session_backend is redis"
raise ValidationError(msg)
return self
[docs] @model_validator(mode="after") #: type: ignore
def duo_settings_required_if_enabled(self):
"""
If we've enabled Duo MFA, :py:attr:`duo_host`, :py:attr:`duo_ikey`,
and :py:attr:`duo_skey` are required.
Raises:
ValidationError: Duo settings are required if ``duo_enabled`` is
``True``
"""
if self.duo_enabled:
if not all([self.duo_host, self.duo_ikey, self.duo_skey]):
msg = (
"duo_host, duo_ikey, and duo_skey are required if duo_enabled "
"is True"
)
raise ValidationError(msg)
return self
[docs] @model_validator(mode="after") #: type: ignore
def ensure_get_user_filter_is_a_valid_ldap_filter(self):
"""
Ensure that the get user filter is a valid LDAP filter.
Raises:
ValueError: The get user filter is not a valid LDAP filter
ValueError: The get user filter does not use the {username} placeholder
"""
validate_ldap_search_filter(
self.ldap_get_user_filter,
ldap_username_attribute=self.ldap_username_attribute,
ldap_full_name_attribute=self.ldap_full_name_attribute,
)
return self
[docs] @model_validator(mode="after") #: type: ignore
def ensure_ca_cert_cert(self):
"""
Ensure that the CA certificate path is valid.
- If ldap_ca_cert_name is set, ldap_ca_cert_dir must be set
- If ldap_ca_cert_dir is set, ldap_ca_cert_name must be set
- ldap_ca_cert_dir must exist and be a directory
- ldap_ca_cert_name must exist in ldap_ca_cert_dir and be a file
Raises:
ValueError: ldap_ca_cert_dir does not exist
ValueError: ldap_ca_cert_dir is not a directory
ValueError: ldap_ca_cert_name does not exist in ldap_ca_cert_dir
ValueError: ldap_ca_cert_name is not a file
"""
if self.ldap_ca_cert_name and not self.ldap_ca_cert_dir:
msg = "ldap_ca_cert_dir is required if ldap_ca_cert_name is set"
raise ValueError(msg)
if not self.ldap_ca_cert_name and self.ldap_ca_cert_dir:
msg = "ldap_ca_cert_name is required if ldap_ca_cert_dir is set"
raise ValueError(msg)
if not self.ldap_ca_cert_name and not self.ldap_ca_cert_dir:
return self
cert_dir = self.ldap_ca_cert_dir
cert_name = self.ldap_ca_cert_name
if cert_dir is None or cert_name is None:
msg = "ldap_ca_cert_dir and ldap_ca_cert_name must both be set"
raise ValueError(msg)
if not cert_dir.exists():
msg = "ldap_ca_cert_dir does not exist"
raise ValueError(msg)
if not cert_dir.is_dir():
msg = "ldap_ca_cert_dir is not a directory"
raise ValueError(msg)
cert_full_path = cert_dir / cert_name
if not cert_full_path.exists():
msg = "ldap_ca_cert_name does not exist in ldap_ca_cert_dir"
raise ValueError(msg)
if not cert_full_path.is_file():
msg = "ldap_ca_cert_name is not a file"
raise ValueError(msg)
return self