diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70720ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,251 @@ +# AGENTS.md - Guidance for AI Coding Agents + +This document provides essential information for AI agents working in this codebase. + +## Project Overview + +- **Language:** Python 3.11+ +- **Framework:** FastAPI +- **Package Manager:** uv (with pip/docker alternatives) +- **Purpose:** LDAP authentication service for nginx using `ngx_http_auth_request_module` + +## Upstream Divergence Guidance + +- Keep Kerberos/LDAP changes narrowly scoped to the Kerberos/LDAP code paths. +- Isolate edits to Kerberos/LDAP-specific files and logic; avoid unnecessary changes elsewhere. +- Regularly check the upstream main branch to stay aligned and keep merges frictionless. + +## Build, Lint, and Test Commands + +### Testing + +```bash +# Install test dependencies first +uv sync --group test + +# Run all tests +pytest test/ + +# Run a single test file +pytest test/test_auth_flow.py + +# Run a single test function +pytest test/test_auth_flow.py::test_login_success + +# Run tests matching a pattern +pytest -k "test_login" + +# Run with verbose output +pytest -v test/test_header_auth.py +``` + +### Linting and Formatting + +```bash +# Lint with Ruff +ruff check . + +# Auto-fix linting issues +ruff check --fix . + +# Format code +ruff format . + +# Type checking +mypy nginx_ldap_auth/ +``` + +### Build Commands + +```bash +make build # Build Docker image +make docs # Generate documentation +make dev # Start development environment (docker compose) +make dist # Build source distribution +``` + +## Project Structure + +``` +nginx_ldap_auth/ +├── __init__.py # Version and metadata +├── main.py # CLI entry point +├── settings.py # Pydantic settings configuration +├── ldap.py # LDAP connection pool management +├── logging.py # Structlog configuration +├── types.py # Type aliases +├── exc.py # Custom exceptions +├── cli/ # CLI commands (Click) +│ ├── cli.py +│ └── server.py +└── app/ # FastAPI application + ├── main.py # App, routes, lifespan + ├── models.py # User model, UserManager + ├── forms.py # LoginForm + ├── middleware.py # Session and exception middleware + ├── header_auth.py # Kerberos/SPNEGO authorization endpoint + ├── header_auth_cache.py # Authorization caching with LRU cleanup + └── templates/ # Jinja2 templates + +test/ +├── conftest.py # Fixtures (mock_user_manager, client, etc.) +└── test_*.py # Test modules +``` + +## Code Style Guidelines + +### Packaging and Environment + +- Use `uv` for package management as defined in `pyproject.toml`. +- Use `uv sync --dev` to create the virtual environment and install dev deps. +- Activate the virtual environment before running Python commands: `source .venv/bin/activate`. + +### Formatting (enforced by Ruff) + +- **Line length:** 88 characters +- **Indentation:** 4 spaces +- **Quotes:** Double quotes for strings + +### Import Order + +1. Standard library imports +2. Third-party imports +3. Local imports (relative within package) + +```python +import logging +from typing import Annotated, ClassVar + +from fastapi import Depends, HTTPException +from pydantic import BaseModel + +from ..logging import get_logger +from .models import User +``` + +### Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Variables/functions | snake_case | `check_required_headers`, `session_max_age` | +| Classes | PascalCase | `UserManager`, `LoginForm`, `SessionMiddleware` | +| Constants | UPPER_SNAKE_CASE | `DUO_AUTH_PATHS`, `COOKIE_NAME_HEADER` | +| Private members | Leading underscore | `_logger`, `_pool` | +| Type aliases | PascalCase | `LDAPValue`, `LDAPObject` | + +### Type Hints + +- Use type hints on all function signatures (Python 3.10+ typing). +- Prefer built-in collection types (`list`, `dict`, `tuple`) over `List`, `Dict`. +- Be specific for collection types (e.g., `dict[str, LDAPValue]`). +- Use `Annotated` with `Depends()` for FastAPI dependency injection. +- Use `TypeAlias` for custom type definitions in `nginx_ldap_auth/types.py`. +- Use `Literal` for constrained string values. +- Use `ClassVar` for class-level attributes. + +### Structure and Complexity + +- Keep functions/methods under ~60 lines; split if longer. +- Use `@dataclass`, Pydantic models, or `TypedDict` instead of plain dicts. + - User-facing data models: Pydantic. + - Internal-only data models: `@dataclass` or `TypedDict`. + +### Async Patterns + +- Use async/await for LDAP operations and FastAPI endpoints +- Use `asynccontextmanager` for resource lifecycle management +- Use connection pooling with async context managers + +### Error Handling + +- Define custom exceptions in `exc.py`. +- Use specific exception types, not bare `except:`. +- Allow unexpected `Exception` types to propagate rather than catching all. +- Log exceptions with `logger.exception()` for stack traces. +- Use `HTTPException` for HTTP errors. +- Return `False` for authentication failures rather than raising. + +### Logging + +- Use structlog for structured logging +- Event names are dot-separated: `auth.login.success`, `ldap.authenticate.error` +- Never log passwords or sensitive data + +```python +from ..logging import get_logger +logger = get_logger() +logger.info("auth.login.success", username=username, realm=realm) +``` + +### Documentation + +- Use reStructuredText format for docstrings. +- Include `:param:`, `:returns:`, `:raises:` sections. +- For Napoleon-style docstrings, include `Args`, `Keyword Arguments`, `Raises`, + and `Returns` sections, with a blank line after `Returns`. +- Document class attributes inline using `#:` comments above each attribute. + +```python +def authenticate(username: str, password: str) -> bool: + """Authenticate a user against LDAP. + + :param username: The user's login name + :param password: The user's password + :returns: True if authentication succeeded + :raises LDAPError: If connection to LDAP fails + """ +``` + +## Testing + +- Tests are in `test/` directory using pytest. +- Use `pytest-asyncio` for async tests (`@pytest.mark.asyncio`). +- Use `pytest-mock` for mocking (via `mocker` fixture). +- Common fixtures in `conftest.py`: `client`, `mock_user_manager`, `mock_settings`. + +### Test Categories and Markers + +- **Unit tests**: `@pytest.mark.unit` +- **Integration tests**: `@pytest.mark.integration` +- **CLI tests**: `@pytest.mark.cli` +- **Model tests**: `@pytest.mark.model` +- **Service tests**: `@pytest.mark.service` + +### Naming and Organization + +- Test files: `test_.py` under `test/`. +- Test classes: `Test` when grouping is helpful. +- Test functions: `test_`. + +### Fixtures + +- Define local fixtures in the test module when they are single-use. +- Define shared fixtures in `test/conftest.py`. + +### Mocking Guidance + +- Mock external dependencies only (LDAP servers, Redis, HTTP APIs, etc.). +- Avoid mocking internal code paths; test real logic when possible. + +### Integration Tests + +- Use environment variables for real backends/configuration. +- Skip integration tests if required environment variables are missing. +- Warn users before running destructive integration tests. + +### Running Tests + +- Install test deps: `uv sync --group test`. +- Run all tests: `pytest test/`. +- Run a single test file: `pytest test/test_auth_flow.py`. +- Run a single test: `pytest test/test_auth_flow.py::test_login_success`. +- Run with markers: `pytest -m "unit"` (or `integration`, `cli`, etc.). + +## Key Dependencies + +- **FastAPI** - Web framework +- **bonsai** - Async LDAP client +- **pydantic-settings** - Configuration from environment +- **structlog** - Structured logging +- **starsessions** - Session management (memory or Redis) +- **uvicorn** - ASGI server diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..8294167 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,186 @@ +# LDAP Helper Deployment Guide (Podman Quadlet) + +This guide defines deployable runtime contract for `nginx-ldap-auth-service` in SPNEGO header-auth flow. + +## Deployment Shape + +- Same host as auth gateway +- Same Podman network as auth gateway +- Different container IPs +- Gateway reaches helper by service DNS/container name on Podman network +- Helper listens on `0.0.0.0:8888` + +## Security Baseline + +- LDAPS certificate validation enabled by default (`LDAP_VALIDATE_CERT=true`) +- AD CA certificate mounted at runtime (do not bake customer CA into image) +- Effective authorization filter is required +- Missing effective authorization filter fails closed (`500`) +- Header-auth trusted header is `X-Ldap-User` +- Default identity model for SPNEGO flow: + - NGINX sends full UPN (for example `alice@SAMDOM.EXAMPLE.COM`) + - LDAP user lookup filter uses `userPrincipalName` + +## Environment Variables + +Required: + +| Variable | Example | Purpose | +| --- | --- | --- | +| `LDAP_URI` | `ldaps://ad01.corp.example.com:636` | LDAP/AD endpoint | +| `LDAP_BINDDN` | `CN=ldap-auth-svc,CN=Users,DC=corp,DC=example,DC=com` | Service bind DN | +| `LDAP_PASSWORD` | `` | Service bind password | +| `LDAP_BASEDN` | `DC=corp,DC=example,DC=com` | Base DN for searches | +| `SECRET_KEY` | `` | Session signing key | +| `CSRF_SECRET_KEY` | `` | CSRF signing key | +| `LDAP_AUTHORIZATION_FILTER` | `(&(userPrincipalName={username})(memberOf=CN=Developers,CN=Users,DC=corp,DC=example,DC=com))` | Baseline authorization policy | +| `LDAP_GET_USER_FILTER` | `(userPrincipalName={username})` | User lookup filter for full UPN flow | + +Recommended: + +| Variable | Example | Purpose | +| --- | --- | --- | +| `HEADER_AUTH_ENABLED` | `true` | Enable `/check-header` flow | +| `LDAP_TRUSTED_USER_HEADER` | `X-Ldap-User` | Trusted upstream user header | +| `ALLOW_AUTHORIZATION_FILTER_HEADER` | `true` | Allow per-route filter override from gateway | +| `LDAP_VALIDATE_CERT` | `true` | Validate LDAPS certificate | +| `LDAP_CA_CERT_DIR` | `/etc/ldap/ca` | Directory holding mounted CA file | +| `LDAP_CA_CERT_NAME` | `customer-ad-ca.pem` | CA file name inside mounted directory | +| `HOST` | `0.0.0.0` | Bind interface | +| `PORT` | `8888` | Service port | +| `LOG_LEVEL` | `INFO` | Log level | +| `LOG_TYPE` | `text` | Log output format | +| `HEADER_AUTH_CACHE_TTL` | `300` | Header-auth cache TTL in seconds | + +Optional Redis cache: + +| Variable | Example | Purpose | +| --- | --- | --- | +| `SESSION_BACKEND` | `redis` | Redis-backed cache/session backend | +| `REDIS_URL` | `redis://redis:6379/0` | Redis DSN | + +## Required Mounts + +- Env file (for secrets and runtime config), for example `/etc/ldap-auth/ldap-auth.env` +- AD CA file mount, for example: + - host path: `/etc/pki/customer-ad-ca.pem` + - container path: `/etc/ldap/ca/customer-ad-ca.pem:ro` + +## Podman Quadlet Examples + +Recommended (Redis-enabled) deployment: + +`/etc/containers/systemd/redis-ldap-auth.container` + +```ini +[Unit] +Description=Redis for LDAP auth cache + +[Container] +Image=docker.io/library/redis:7-alpine +ContainerName=redis-ldap-auth +Network=authnet.network +PublishPort=127.0.0.1:6379:6379 + +[Service] +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +`/etc/containers/systemd/ldap-auth.container` + +```ini +[Unit] +Description=LDAP auth helper +After=redis-ldap-auth.service +Wants=redis-ldap-auth.service + +[Container] +Image=ghcr.io/your-org/nginx-ldap-auth-service: +ContainerName=ldap-auth +Network=authnet.network +EnvironmentFile=/etc/ldap-auth/ldap-auth.env +Volume=/etc/pki/customer-ad-ca.pem:/etc/ldap/ca/customer-ad-ca.pem:ro +PublishPort=127.0.0.1:8888:8888 +HealthCmd=/usr/bin/curl -fsS http://127.0.0.1:8888/status || exit 1 +HealthInterval=30s +HealthTimeout=5s +HealthRetries=3 +HealthStartPeriod=20s + +[Service] +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Example network quadlet: + +`/etc/containers/systemd/authnet.network` + +```ini +[Network] +NetworkName=authnet +``` + +Apply and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now authnet-network.service +sudo systemctl enable --now redis-ldap-auth.service +sudo systemctl enable --now ldap-auth.service +``` + +## Health and Smoke Checks + +Liveness endpoint: + +```bash +curl -fsS http://127.0.0.1:8888/status +``` + +LDAP reachability endpoint: + +```bash +curl -fsS http://127.0.0.1:8888/status/ldap +``` + +Header-auth checks: + +```bash +# 401: missing trusted user header +curl -i http://127.0.0.1:8888/check-header + +# 200/403 depending on group membership +curl -i -H 'X-Ldap-User: alice@CORP.EXAMPLE.COM' \ + -H 'X-Authorization-Filter: (&(userPrincipalName={username})(memberOf=CN=Developers,CN=Users,DC=corp,DC=example,DC=com))' \ + http://127.0.0.1:8888/check-header +``` + +## Redis Degraded Mode + +- Service works without Redis (in-memory fallback) +- Expect higher LDAP load and less cache protection when Redis is unavailable +- Recommended production profile keeps Redis enabled + +## Gateway Header Security Contract + +Gateway configuration must prevent header spoofing from clients. + +- Do not pass client request headers through to helper +- Explicitly set trusted identity header (`X-Ldap-User`) in gateway +- Explicitly set or clear `X-Authorization-Filter` per location + +Recommended pattern in gateway subrequest location: + +```nginx +proxy_pass_request_headers off; +proxy_pass_request_body off; +proxy_set_header Content-Length ""; +proxy_set_header X-Ldap-User $remote_user; +proxy_set_header X-Authorization-Filter "(&(userPrincipalName={username})(memberOf=CN=Developers,CN=Users,DC=corp,DC=example,DC=com))"; +``` diff --git a/README.md b/README.md index 01831f1..97f69c7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ It works in conjunction with nginx's [ngx_http_auth_request_module](http://nginx - **Duo MFA Support**: Optional Duo Multi-Factor Authentication workflow. - **Flexible Session Backends**: Support for in-memory or Redis-based sessions for high availability. - **Authorization Filters**: Restrict access based on LDAP search filters (e.g., group membership). +- **Kerberos/SPNEGO Support**: Stateless header-based authorization for Single Sign-On environments where Nginx handles Kerberos authentication. - **Docker Ready**: Easily deployable as a sidecar container. - **Monitoring Endpoints**: Built-in `/status` and `/status/ldap` health checks. @@ -87,9 +88,17 @@ The service can be configured via environment variables, command-line arguments, - `SESSION_BACKEND`: `memory` (default) or `redis`. - `LDAP_AUTHORIZATION_FILTER`: LDAP filter to restrict access. - `COOKIE_NAME`: Name of the session cookie (default: `nginxauth`). +- `HEADER_AUTH_ENABLED`: Enable `/check-header` endpoint for Kerberos/SPNEGO (default: `True`). +- `HEADER_AUTH_CACHE_TTL`: Cache TTL for header-based auth results in seconds (default: `300`). For a full list of configuration options, see the [Configuration Documentation](https://nginx-ldap-auth-service.readthedocs.io/en/latest/configuration.html). +## Deployment (Podman Quadlet) + +For deployable runtime contract, security baseline, required environment variables, +mounts, health checks, and customer-oriented Podman quadlet examples, see +[`DEPLOYMENT.md`](DEPLOYMENT.md). + ## Nginx Integration To use the service with Nginx, configure your `location` blocks to use `auth_request`: @@ -131,6 +140,36 @@ location /check-auth { For detailed Nginx configuration examples, including caching and Duo MFA headers, see the [Nginx Configuration Guide](https://nginx-ldap-auth-service.readthedocs.io/en/latest/nginx.html). +## Kerberos/SPNEGO Authentication + +For environments using Kerberos authentication (common in enterprise Active Directory setups), the service provides a stateless `/check-header` endpoint. Nginx handles Kerberos/SPNEGO authentication and passes the authenticated username via a trusted header for LDAP group authorization: + +```nginx +location / { + auth_gss on; + auth_request /check-header-auth; + error_page 403 = @forbidden; + # ... your application config ... +} + +location /check-header-auth { + internal; + proxy_pass http://nginx-ldap-auth-service:8888/check-header; + proxy_pass_request_headers off; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Ldap-User $remote_user; + proxy_set_header X-Authorization-Filter "(&(sAMAccountName={username})(memberOf=cn=mygroup,ou=Groups,dc=example,dc=com))"; +} +``` + +This approach provides: +- **Single Sign-On (SSO)**: Users authenticate automatically via Kerberos tickets +- **Stateless Authorization**: No session cookies required +- **High Performance**: Authorization results are cached (default: 5 minutes) + +See the [Kerberos/SPNEGO Configuration Guide](https://nginx-ldap-auth-service.readthedocs.io/en/latest/nginx.html#kerberos-spnego-authentication) for complete setup instructions. + ## Documentation The full documentation is available at [https://nginx-ldap-auth-service.readthedocs.io](https://nginx-ldap-auth-service.readthedocs.io). diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 242951e..850ed4c 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -581,8 +581,8 @@ These settings configure the LDAP server to use for authentication. .. envvar:: LDAP_AUTHORIZATION_FILTER The LDAP search filter to use when determining if a user is authorized to login. - for authorizations. Defaults to no filter, meaning all users are authorized if - they exist in LDAP. See :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` for more details. + This setting is required. See :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` + for more details. The filter will within the base DN given by :envvar:`LDAP_BASEDN` and with scope of ``SUBTREE``. @@ -590,8 +590,7 @@ These settings configure the LDAP server to use for authentication. .. envvar:: ALLOW_AUTHORIZATION_FILTER_HEADER Whether to allow the ``X-Authorization-Filter`` HTTP header to override - :envvar:`LDAP_AUTHORIZATION_FILTER`. Defaults to ``True`` for backwards - compatibility. + :envvar:`LDAP_AUTHORIZATION_FILTER`. .. warning:: @@ -605,11 +604,6 @@ These settings configure the LDAP server to use for authentication. 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. - See :py:attr:`nginx_ldap_auth.settings.Settings.allow_authorization_filter_header` for more details. @@ -632,3 +626,53 @@ These settings configure the LDAP server to use for authentication. The maximum number of seconds to keep a connection in the LDAP connection pool. Defaults to ``20``. + + +.. _header_auth_config: + +Header-Based Authentication (Kerberos/SPNEGO) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These settings configure the stateless header-based authorization endpoint +(``/check-header``) for use with Kerberos/SPNEGO authentication. See +:ref:`kerberos_spnego` for NGINX configuration examples. + +.. envvar:: HEADER_AUTH_ENABLED + + Set to ``True`` to enable the ``/check-header`` endpoint for header-based + authorization. Defaults to ``True``. + + When enabled, the ``/check-header`` endpoint accepts a username from a trusted + header (set by NGINX after Kerberos authentication) and performs LDAP group + authorization without requiring a session. + +.. envvar:: LDAP_TRUSTED_USER_HEADER + + The name of the HTTP header containing the authenticated username from + Kerberos/SPNEGO. This header should be set by NGINX from ``$remote_user`` + after successful Kerberos authentication. + + Defaults to ``X-Ldap-User``. + + .. important:: + + This header is trusted implicitly. Your NGINX configuration must + ensure that clients cannot spoof this header. Use + ``proxy_pass_request_headers off`` and explicitly set only the headers + you need. + +.. envvar:: HEADER_AUTH_CACHE_TTL + + The time-to-live (in seconds) for cached authorization results. The cache + stores whether a user is authorized for a specific LDAP filter to reduce + load on the LDAP server. + + Defaults to ``300`` (5 minutes). + + Set to ``0`` to disable caching entirely (not recommended for production). + + .. note:: + + Changes to LDAP group membership will not take effect until the cache + entry expires. For environments requiring immediate membership updates, + consider a shorter TTL or deploy an admin endpoint to clear the cache. diff --git a/doc/source/index.rst b/doc/source/index.rst index 53e651f..39b5ecd 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -56,6 +56,9 @@ User authorization - Users can be authorized to access resources based on an LDAP search filter you supply. +- Supports Kerberos/SPNEGO authentication for Single Sign-On (SSO) environments. + NGINX handles Kerberos authentication while this service performs LDAP group + authorization. See :ref:`kerberos_spnego` for configuration details. Other features -------------- diff --git a/doc/source/nginx.rst b/doc/source/nginx.rst index 7982274..44348b2 100644 --- a/doc/source/nginx.rst +++ b/doc/source/nginx.rst @@ -4,7 +4,15 @@ Configuring nginx ================= This page describes how to configure nginx to use ``nginx-ldap-auth-service`` to -password protect your site using LDAP. +protect your site using LDAP. + +There are two authentication modes supported: + +1. **Form-based authentication** (default) - Users are presented with a login form + and authenticate with username/password against LDAP. +2. **Kerberos/SPNEGO authentication** - NGINX handles Kerberos authentication and + passes the authenticated username to the service for LDAP group authorization. + See :ref:`kerberos_spnego` for details. ngx_http_auth_request_module ---------------------------- @@ -148,3 +156,185 @@ Things to note: } } + +.. _kerberos_spnego: + +Kerberos/SPNEGO Authentication +------------------------------ + +If your environment uses Kerberos authentication (common in enterprise environments +with Active Directory), you can configure NGINX to handle SPNEGO authentication +while using ``nginx-ldap-auth-service`` for LDAP group-based authorization. + +This approach provides: + +- **Single Sign-On (SSO)**: Users authenticate automatically via their Kerberos tickets +- **Stateless authorization**: No session cookies required for the authorization check +- **Group-based access control**: Authorize users based on LDAP group membership +- **High performance**: Authorization results are cached to reduce LDAP load + +Prerequisites +~~~~~~~~~~~~~ + +1. NGINX compiled with the ``ngx_http_auth_spnego_module`` or similar Kerberos module +2. A valid Kerberos keytab file for your web service +3. Users with valid Kerberos tickets (e.g., domain-joined workstations) + +How it works +~~~~~~~~~~~~ + +1. User requests a protected resource +2. NGINX negotiates Kerberos authentication via SPNEGO +3. On successful authentication, NGINX sets ``$remote_user`` to the authenticated principal +4. NGINX calls ``/check-header`` on ``nginx-ldap-auth-service``, passing the username +5. The service checks LDAP group membership and returns 200 (authorized) or 403 (forbidden) +6. NGINX grants or denies access based on the response + +NGINX Configuration +~~~~~~~~~~~~~~~~~~~ + +Below is a complete example configuration for Kerberos/SPNEGO authentication with +LDAP group authorization. + +.. important:: + + **Security**: The ``X-Ldap-User`` header must be set by NGINX, not passed through + from the client. The configuration below uses ``proxy_pass_request_headers off`` + to prevent header spoofing. + +.. code-block:: nginx + + user nginx; + worker_processes auto; + + error_log /dev/stderr info; + pid /tmp/nginx.pid; + + events { + worker_connections 1024; + } + + http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 443 ssl; + http2 on; + + ssl_certificate /certs/localhost.crt; + ssl_certificate_key /certs/localhost.key; + + # Kerberos authentication settings + auth_gss on; + auth_gss_keytab /etc/krb5.keytab; + auth_gss_realm EXAMPLE.COM; + auth_gss_service_name HTTP; + + location / { + # Require Kerberos authentication + auth_gss on; + + # Use auth_request to check LDAP group membership + auth_request /check-header-auth; + + # Pass the authenticated user to the backend application + auth_request_set $auth_user $upstream_http_x_auth_user; + proxy_set_header X-Authenticated-User $auth_user; + + root /usr/share/nginx/html; + index index.html index.htm; + + # Return 403 if authorization fails (user not in required group) + error_page 403 = @forbidden; + } + + location @forbidden { + return 403 "Access denied: You are not authorized to access this resource."; + } + + location /check-header-auth { + internal; + proxy_pass https://nginx_ldap_auth_service:8888/check-header; + + # IMPORTANT: Do not pass client headers to prevent spoofing + proxy_pass_request_headers off; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # Pass the Kerberos-authenticated username + # $remote_user is set by auth_gss after successful authentication + proxy_set_header X-Ldap-User $remote_user; + + # Optional: Specify LDAP authorization filter + # Users must be members of the "web-users" group + proxy_set_header X-Authorization-Filter "(&(sAMAccountName={username})(memberOf=cn=web-users,ou=Groups,dc=example,dc=com))"; + + # Prevent caching in NGINX (the service has its own cache) + proxy_no_cache 1; + proxy_cache_bypass 1; + } + } + } + +Configuration Notes +~~~~~~~~~~~~~~~~~~~ + +X-Ldap-User Header + + The header containing the authenticated username. By default, this is + ``X-Ldap-User``, but you can change it with the :envvar:`LDAP_TRUSTED_USER_HEADER` + environment variable. + + .. code-block:: nginx + + # Extract just the username from user@REALM format + map $remote_user $kerberos_user { + ~^(?[^@]+)@ $user; + default $remote_user; + } + + location /check-header-auth { + # ... + proxy_set_header X-Ldap-User $kerberos_user; + } + +X-Authorization-Filter Header + + The LDAP filter used to determine authorization. If not specified, the service + uses the :envvar:`LDAP_AUTHORIZATION_FILTER` environment variable. + + .. code-block:: nginx + + # Different filters for different locations + location /admin { + auth_request /check-header-auth-admin; + # ... + } + + location /check-header-auth-admin { + internal; + proxy_pass https://nginx_ldap_auth_service:8888/check-header; + proxy_set_header X-Ldap-User $remote_user; + proxy_set_header X-Authorization-Filter "(&(sAMAccountName={username})(memberOf=cn=admins,ou=Groups,dc=example,dc=com))"; + # ... + } + +Response Codes + + The ``/check-header`` endpoint returns: + + - **200 OK**: User is authorized (also sets ``X-Auth-User`` response header) + - **401 Unauthorized**: Missing username header (Kerberos auth failed upstream) + - **403 Forbidden**: User exists but is not in the required LDAP group + - **500 Internal Server Error**: LDAP connection error + +Caching Behavior + + The service includes a built-in authorization cache (default TTL: 5 minutes) + to reduce LDAP load. You can configure this with: + + - :envvar:`HEADER_AUTH_CACHE_TTL`: Cache duration in seconds (0 to disable) + + The cache is keyed by username + authorization filter hash, so different + filters result in separate cache entries. diff --git a/nginx_ldap_auth/app/forms.py b/nginx_ldap_auth/app/forms.py index ca38a47..d928078 100644 --- a/nginx_ldap_auth/app/forms.py +++ b/nginx_ldap_auth/app/forms.py @@ -45,12 +45,12 @@ async def is_valid(self) -> bool: * the user must exist in LDAP meaning that the user must be in the results of the ldap search named by :py:attr:`nginx_ldap_auth.settings.Settings.ldap_get_user_filter` - * If the ``X-Authorization-Filter`` header (when + * The user must match an effective LDAP authorization filter. + * If ``X-Authorization-Filter`` is present and :py:attr:`nginx_ldap_auth.settings.Settings.allow_authorization_filter_header` - is ``True``) or - :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` - is not ``None``, the user must be in the results of that LDAP search. - The optional header will override the setting when allowed. + is ``True``, header value is used. + * Otherwise, the service uses + :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter`. * the bind to LDAP must be successful If all those tests pass, return ``True``. Otherwise, return ``False``. @@ -71,11 +71,22 @@ async def is_valid(self) -> bool: user = cast("User", user) # Ensure that the user is authorized to access this service if settings.allow_authorization_filter_header: - ldap_authorization_filter: str | None = self.request.headers.get( + ldap_authorization_filter = self.request.headers.get( "x-authorization-filter", settings.ldap_authorization_filter ) else: ldap_authorization_filter = settings.ldap_authorization_filter + if not ldap_authorization_filter or not ldap_authorization_filter.strip(): + self.errors.append("Service authorization is not configured.") + _logger.error( + "auth.failed.missing_authorization_filter", + username=self.username, + target=self.service, + allow_authorization_filter_header=settings.allow_authorization_filter_header, + header_present="x-authorization-filter" in self.request.headers, + settings_filter_present=bool(settings.ldap_authorization_filter), + ) + return False if not await User.objects.is_authorized( cast("str", self.username), ldap_authorization_filter ): diff --git a/nginx_ldap_auth/app/header_auth.py b/nginx_ldap_auth/app/header_auth.py new file mode 100644 index 0000000..7799388 --- /dev/null +++ b/nginx_ldap_auth/app/header_auth.py @@ -0,0 +1,121 @@ +""" +Header-based authorization endpoint for Kerberos/SPNEGO authentication. + +This module provides a stateless authorization endpoint that accepts user +identity from a trusted header (set by NGINX after Kerberos authentication) +and checks LDAP group membership for authorization. +""" + +from typing import Any + +from bonsai import LDAPError +from fastapi import APIRouter, Request, Response, status + +from nginx_ldap_auth.settings import Settings + +from ..logging import get_logger +from .header_auth_cache import ( + authorization_lock, + get_cached_authorization, + set_cached_authorization, +) +from .models import User + +router = APIRouter(tags=["header-auth"]) + +settings = Settings() + + +@router.get("/check-header") +async def check_header_auth( # noqa: PLR0911 + request: Request, response: Response +) -> dict[str, Any]: + """ + Stateless authorization check for header-based authentication. + + This endpoint is designed for use with Kerberos/SPNEGO authentication + where NGINX handles authentication and passes the username via a + trusted header (default: ``X-Ldap-User``). + + **Response Codes:** + + - ``200 OK``: User is authorized + - ``401 Unauthorized``: Missing username header + - ``403 Forbidden``: User is not authorized (not in required group) + - ``500 Internal Server Error``: LDAP error + """ + _logger = get_logger(request) + response.headers["Cache-Control"] = "no-cache" + + # Get username from trusted header + header_name = settings.ldap_trusted_user_header + username = request.headers.get(header_name.lower()) + is_authorized = False + + if not username: + _logger.warning("header_auth.check.missing_header", header=header_name) + response.status_code = status.HTTP_401_UNAUTHORIZED + return {} + + # Get authorization filter from header or settings + if settings.allow_authorization_filter_header: + ldap_authorization_filter = request.headers.get( + "x-authorization-filter", settings.ldap_authorization_filter + ) + else: + ldap_authorization_filter = settings.ldap_authorization_filter + + if not ldap_authorization_filter or not ldap_authorization_filter.strip(): + _logger.error( + "header_auth.check.missing_authorization_filter", + username=username, + allow_authorization_filter_header=settings.allow_authorization_filter_header, + header_present="x-authorization-filter" in request.headers, + settings_filter_present=bool(settings.ldap_authorization_filter), + ) + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return {} + + # Use distributed lock to prevent thundering herd on LDAP + async with authorization_lock(username, ldap_authorization_filter): + # Check cache (inside lock to prevent duplicate LDAP queries) + cached_result = await get_cached_authorization( + username, ldap_authorization_filter + ) + + if cached_result is not None: + if cached_result: + _logger.info("header_auth.check.success.cached", username=username) + response.headers["X-Auth-User"] = username + return {} + _logger.info("header_auth.check.forbidden.cached", username=username) + response.status_code = status.HTTP_403_FORBIDDEN + return {} + + # Cache miss - query LDAP + try: + is_authorized = await User.objects.is_authorized( + username, ldap_authorization_filter + ) + except LDAPError: + _logger.exception("header_auth.check.ldap_error", username=username) + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return {} + + # Cache the result + await set_cached_authorization( + username, ldap_authorization_filter, authorized=is_authorized + ) + + # Return result (outside lock) + if is_authorized: + _logger.info("header_auth.check.success", username=username) + response.headers["X-Auth-User"] = username + return {} + _logger.info( + "header_auth.check.forbidden", + username=username, + filter=ldap_authorization_filter, + ) + response.status_code = status.HTTP_403_FORBIDDEN + return {} diff --git a/nginx_ldap_auth/app/header_auth_cache.py b/nginx_ldap_auth/app/header_auth_cache.py new file mode 100644 index 0000000..a55aba2 --- /dev/null +++ b/nginx_ldap_auth/app/header_auth_cache.py @@ -0,0 +1,366 @@ +""" +Authorization cache for stateless header-based authentication. + +Caches LDAP authorization results to reduce load on the LDAP server. +Uses the same backend as the session store (memory or Redis). + +Includes per-key locking to prevent thundering herd / DOS on LDAP: +- In-memory backend: uses asyncio.Lock (per-process) +- Redis backend: uses distributed SETNX lock (across all workers) +""" + +import asyncio +import hashlib +import time +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from nginx_ldap_auth.logging import logger +from nginx_ldap_auth.settings import Settings + +settings = Settings() +_USE_REDIS_BACKEND = ( + settings.session_backend == "redis" and settings.redis_url is not None +) + +# In-memory cache: key -> (authorized, expires_at) +_cache: dict[str, tuple[bool, float]] = {} + +# Per-key locks for in-memory backend with LRU tracking +# Stores: key -> (lock, last_access_time) +_key_locks: dict[str, tuple[asyncio.Lock, float]] = {} +_key_locks_lock = asyncio.Lock() + +# Maximum number of locks to keep in memory (prevents unbounded growth) +_MAX_LOCKS = 10_000 + +# Distributed lock TTL (seconds) - should be longer than typical LDAP query time +_REDIS_LOCK_TTL = 10 + + +def _make_cache_key(username: str, authorization_filter: str | None) -> str: + """ + Generate cache key from username and filter hash. + + :param username: Username used for the cache key. + :param authorization_filter: LDAP authorization filter to hash, if present. + :returns: Cache key for the username and filter combination. + """ + if authorization_filter is None: + filter_hash = "none" + else: + filter_hash = hashlib.sha256(authorization_filter.encode()).hexdigest()[:16] + return f"header_auth:{username}:{filter_hash}" + + +def _get_redis_connection(): + """ + Get Redis connection from the session store. + + Note: This accesses the private ``_connection`` attribute of RedisStore + to reuse the existing Redis connection rather than creating a separate one. + This coupling is intentional to avoid managing a second Redis connection, + but may need updating if starsessions changes its internal implementation. + + :returns: Redis connection instance if available, otherwise ``None``. + """ + if not _USE_REDIS_BACKEND: + return None + + from starsessions.stores.redis import RedisStore + + from nginx_ldap_auth.app.main import store + + if not isinstance(store, RedisStore): + logger.error( + "cache.redis.backend_mismatch", + backend=type(store).__name__, + ) + return None + + connection = store._connection + if connection is None: + logger.error("cache.redis.connection_missing") + return connection + + +def ensure_redis_connection() -> None: + """ + Ensure Redis connection is available when configured. + + :raises RuntimeError: Redis backend configured but connection unavailable. + """ + if not _USE_REDIS_BACKEND: + return + if _get_redis_connection() is None: + msg = "Redis backend configured but connection unavailable" + logger.error("cache.redis.connection_unavailable") + raise RuntimeError(msg) + + +# --- Public API --- + + +@asynccontextmanager +async def authorization_lock( + username: str, authorization_filter: str | None +) -> AsyncGenerator[None, None]: + """ + Acquire a lock for the given username/filter combination. + + For in-memory backend: uses asyncio.Lock (protects within process) + For Redis backend: uses distributed SETNX lock (protects across workers) + + Usage: + async with authorization_lock(username, filter): + # Only one request at a time (per worker or globally) reaches here + result = await check_ldap(...) + """ + key = _make_cache_key(username, authorization_filter) + + if _USE_REDIS_BACKEND: + async with _redis_lock(key): + yield + else: + async with await _get_memory_lock(key): + yield + + +async def get_cached_authorization( + username: str, authorization_filter: str | None +) -> bool | None: + """ + Get cached authorization result. + + Returns True/False if cached, None if not in cache. + """ + key = _make_cache_key(username, authorization_filter) + + if _USE_REDIS_BACKEND: + return await _redis_get(key) + return _memory_get(key) + + +async def set_cached_authorization( + username: str, + authorization_filter: str | None, + *, + authorized: bool, +) -> None: + """Cache an authorization result.""" + if settings.header_auth_cache_ttl <= 0: + return # Caching disabled + + key = _make_cache_key(username, authorization_filter) + + if _USE_REDIS_BACKEND: + await _redis_set(key, authorized, settings.header_auth_cache_ttl) + else: + _memory_set(key, authorized, settings.header_auth_cache_ttl) + + +# --- In-memory implementation --- + + +async def _get_memory_lock(key: str) -> asyncio.Lock: + """ + Get or create a lock for a specific cache key. + + Implements LRU-based cleanup: when the number of locks exceeds _MAX_LOCKS, + the oldest unused locks are pruned to prevent unbounded memory growth. + """ + async with _key_locks_lock: + current_time = time.time() + + if key in _key_locks: + # Update access time and return existing lock + lock, _ = _key_locks[key] + _key_locks[key] = (lock, current_time) + return lock + + # Create new lock + lock = asyncio.Lock() + _key_locks[key] = (lock, current_time) + + # Prune oldest locks if we exceed the limit + if len(_key_locks) > _MAX_LOCKS: + _prune_oldest_locks() + + return lock + + +def _prune_oldest_locks() -> None: + """ + Remove the oldest 10% of locks based on last access time. + + This is called under _key_locks_lock, so no additional locking needed. + Only prunes locks that are not currently held (locked). + """ + # Calculate how many to remove (10% of max, minimum 1) + num_to_remove = max(1, _MAX_LOCKS // 10) + + # Sort by last access time (oldest first), filter out currently held locks + candidates = [ + (k, access_time) + for k, (lock, access_time) in _key_locks.items() + if not lock.locked() + ] + candidates.sort(key=lambda x: x[1]) + + # Remove oldest candidates + removed = 0 + for key, _ in candidates: + if removed >= num_to_remove: + break + if key in _key_locks and not _key_locks[key][0].locked(): + # Re-check lock state in case it was acquired after the snapshot. + del _key_locks[key] + removed += 1 + + if removed > 0: + logger.debug("cache.locks.pruned", count=removed, remaining=len(_key_locks)) + + +def _memory_get(key: str) -> bool | None: + entry = _cache.get(key) + if entry is None: + logger.debug("cache.miss", key=key, backend="memory") + return None + + authorized, expires_at = entry + if time.time() > expires_at: + del _cache[key] + logger.debug("cache.expired", key=key, backend="memory") + return None + + logger.debug("cache.hit", key=key, authorized=authorized, backend="memory") + return authorized + + +def _memory_set(key: str, authorized: bool, ttl: int) -> None: # noqa: FBT001 + _cache[key] = (authorized, time.time() + ttl) + logger.debug("cache.set", key=key, authorized=authorized, ttl=ttl, backend="memory") + + +# --- Redis implementation --- + + +async def _redis_get(key: str) -> bool | None: + """ + Get cached authorization result from Redis. + + :param key: Cache key to retrieve. + :returns: Cached authorization result or ``None``. + """ + redis = _get_redis_connection() + if redis is None: + logger.error("cache.redis.unavailable", action="get", key=key) + return _memory_get(key) + + full_key = f"{settings.redis_prefix}{key}" + value = await redis.get(full_key) + + if value is None: + logger.debug("cache.miss", key=full_key, backend="redis") + return None + + authorized = value in {b"1", "1"} + logger.debug("cache.hit", key=full_key, authorized=authorized, backend="redis") + return authorized + + +async def _redis_set(key: str, authorized: bool, ttl: int) -> None: # noqa: FBT001 + """ + Set cached authorization result in Redis. + + :param key: Cache key to set. + :param authorized: Authorization result to cache. + :param ttl: Cache time-to-live in seconds. + """ + redis = _get_redis_connection() + if redis is None: + logger.error("cache.redis.unavailable", action="set", key=key) + _memory_set(key, authorized, ttl) + return + + full_key = f"{settings.redis_prefix}{key}" + value = "1" if authorized else "0" + await redis.setex(full_key, ttl, value) + logger.debug( + "cache.set", key=full_key, authorized=authorized, ttl=ttl, backend="redis" + ) + + +@asynccontextmanager +async def _redis_lock(key: str) -> AsyncGenerator[None, None]: + """ + Distributed lock using Redis SETNX pattern. + + Acquires a lock that works across all workers/processes. + If lock cannot be acquired, waits and retries with exponential backoff. + + :param key: Cache key to lock. + """ + redis = _get_redis_connection() + if redis is None: + logger.error("cache.redis.unavailable", action="lock", key=key) + # Fallback to memory lock if Redis unavailable + async with await _get_memory_lock(key): + yield + return + + lock_key = f"{settings.redis_prefix}lock:{key}" + acquired = False + retry_delay = 0.01 # Start with 10ms + max_delay = 0.5 # Max 500ms between retries + max_wait = _REDIS_LOCK_TTL # Don't wait longer than lock TTL + + start_time = time.time() + + try: + while not acquired: + # Try to acquire lock with NX (only set if not exists) and EX (expiry) + acquired = await redis.set(lock_key, "1", nx=True, ex=_REDIS_LOCK_TTL) + + if acquired: + logger.debug("cache.lock.acquired", key=lock_key) + break + + # Check if we've waited too long + if time.time() - start_time > max_wait: + # Give up waiting and proceed without lock + # + # Trade-off: Availability over consistency + # - PRO: Prevents indefinite blocking during Redis issues + # - PRO: Service stays available under high contention + # - CON: May cause duplicate LDAP queries (thundering herd) + # - SAFETY: Fail-safe - more restrictive auth result wins + # via cache expiration + logger.warning("cache.lock.timeout", key=lock_key) + break + + # Wait with exponential backoff + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, max_delay) + + yield + + finally: + if acquired: + await redis.delete(lock_key) + logger.debug("cache.lock.released", key=lock_key) + + +# --- Test helpers --- + + +def reset_cache() -> None: + """Reset cache state (for testing).""" + global _cache, _key_locks # noqa: PLW0603 + _cache = {} + _key_locks = {} + + +def get_lock_count() -> int: + """Return the current number of locks (for monitoring/testing).""" + return len(_key_locks) diff --git a/nginx_ldap_auth/app/main.py b/nginx_ldap_auth/app/main.py index b65d011..e94eca6 100644 --- a/nginx_ldap_auth/app/main.py +++ b/nginx_ldap_auth/app/main.py @@ -28,6 +28,7 @@ from ..logging import get_logger from .forms import LoginForm +from .header_auth import router as header_auth_router from .middleware import ExceptionLoggingMiddleware, SessionMiddleware from .models import User @@ -46,6 +47,7 @@ "/auth/duo", "/status", "/check", + "/check-header", } # -------------------------------------- @@ -122,6 +124,10 @@ async def lifespan(app: FastAPI): # noqa: ARG001 """ # Startup: Create the LDAP connection pool await User.objects.create_pool() + if settings.header_auth_enabled: + from .header_auth_cache import ensure_redis_connection + + ensure_redis_connection() yield # Shutdown: Close the LDAP connection pool await User.objects.cleanup() @@ -146,6 +152,10 @@ async def lifespan(app: FastAPI): # noqa: ARG001 # middleware. app.add_middleware(ExceptionLoggingMiddleware) +# Register header-based auth router (for Kerberos/SPNEGO support) +if settings.header_auth_enabled: + app.include_router(header_auth_router) + get_logger().info( "session.setup.complete", @@ -598,13 +608,12 @@ async def check_auth(request: Request, response: Response) -> dict[str, Any]: The user is authorized if the cookie exists, the session the cookie refers to exists, and the ``username`` key in the settings is set. Additionally, - the user must still exist in LDAP, and if - the ``X-Authorization-Filter`` header (when + the user must still exist in LDAP and match an effective authorization + filter. If ``X-Authorization-Filter`` is present and :py:attr:`nginx_ldap_auth.settings.Settings.allow_authorization_filter_header` - is ``True``) or + is ``True``, the header value is used. Otherwise, :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` is - not ``None``, the user must also match the filter. - The optional header will override the setting when allowed. + used. Side Effects: If the user is not authorized, the session is destroyed, and the user is @@ -642,7 +651,7 @@ async def check_auth(request: Request, response: Response) -> dict[str, Any]: response.status_code = status.HTTP_401_UNAUTHORIZED return {} if settings.allow_authorization_filter_header: - ldap_authorization_filter: str | None = request.headers.get( + ldap_authorization_filter = request.headers.get( "x-authorization-filter", settings.ldap_authorization_filter ) if ldap_authorization_filter: @@ -659,6 +668,16 @@ async def check_auth(request: Request, response: Response) -> dict[str, Any]: raise else: ldap_authorization_filter = settings.ldap_authorization_filter + if not ldap_authorization_filter or not ldap_authorization_filter.strip(): + _logger.error( + "auth.check.missing_authorization_filter", + username=request.session["username"], + allow_authorization_filter_header=settings.allow_authorization_filter_header, + header_present="x-authorization-filter" in request.headers, + settings_filter_present=bool(settings.ldap_authorization_filter), + ) + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return {} if not await User.objects.is_authorized( request.session["username"], ldap_authorization_filter ): diff --git a/nginx_ldap_auth/app/models.py b/nginx_ldap_auth/app/models.py index 7be61d8..89c456a 100644 --- a/nginx_ldap_auth/app/models.py +++ b/nginx_ldap_auth/app/models.py @@ -143,17 +143,16 @@ async def exists(self, username: str) -> bool: return await self.get(username) is not None async def is_authorized( - self, username: str, ldap_authorization_filter: str | None + self, username: str, ldap_authorization_filter: str ) -> bool: """ Test whether the user is authorized to log in. This is done by performing an LDAP search using the filter specified in a header or :py:class:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter`. - If the value is ``None``, the user is considered authorized. Args: username: the username to check - ldap_authorization_filter: LDAP authorization filter (optional) + ldap_authorization_filter: LDAP authorization filter Raises: LDAPError: if an error occurred while communicating with the LDAP server @@ -173,8 +172,9 @@ async def is_authorized( username=username, ldap_authorization_filter=ldap_authorization_filter, ) - if ldap_authorization_filter is None: - return True + if not ldap_authorization_filter or not ldap_authorization_filter.strip(): + msg = "ldap_authorization_filter must not be empty" + raise ValueError(msg) try: async with pool.spawn() as conn: results = await conn.search( diff --git a/nginx_ldap_auth/settings.py b/nginx_ldap_auth/settings.py index 723e9c0..d9a0d72 100644 --- a/nginx_ldap_auth/settings.py +++ b/nginx_ldap_auth/settings.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Literal -from pydantic import RedisDsn, ValidationError, model_validator +from pydantic import RedisDsn, ValidationError, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from nginx_ldap_auth.validators import validate_ldap_search_filter @@ -117,11 +117,9 @@ class Settings(BaseSettings): #: 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. + #: The LDAP search filter to use to determine whether a user is authorized. + #: This must be a valid LDAP search filter. 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: #: @@ -133,9 +131,9 @@ class Settings(BaseSettings): #: 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 + ldap_authorization_filter: str #: Whether to allow the ``X-Authorization-Filter`` header to override - #: :py:attr:`ldap_authorization_filter`. When set to ``True`` (the default), + #: :py:attr:`ldap_authorization_filter`. When set to ``True``, #: the header value takes precedence over the environment variable setting. #: #: .. warning:: @@ -150,10 +148,6 @@ class Settings(BaseSettings): #: 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 @@ -176,6 +170,23 @@ class Settings(BaseSettings): #: Duo integration skey duo_skey: str | None = None + # ================== + # Header-Based Auth (Kerberos/SPNEGO) + # ================== + + #: Enable the /check-header endpoint for stateless header-based authorization. + #: When enabled, this endpoint trusts the username from a header set by NGINX + #: after Kerberos/SPNEGO authentication and performs LDAP group authorization. + header_auth_enabled: bool = True + #: The header name containing the trusted username from Kerberos/SPNEGO + #: authentication. This header is set by NGINX after successful Kerberos + #: authentication and contains the value of $remote_user. + ldap_trusted_user_header: str = "X-Ldap-User" + #: TTL for authorization cache entries in seconds. Set to 0 to disable + #: caching. The cache stores the result of LDAP group membership checks + #: to reduce load on the LDAP server. + header_auth_cache_ttl: int = 300 + # ================== # Sentry # ================== @@ -185,6 +196,15 @@ class Settings(BaseSettings): model_config = SettingsConfigDict() + @field_validator("header_auth_cache_ttl") + @classmethod + def validate_cache_ttl(cls, v: int) -> int: + """Validate that header_auth_cache_ttl is non-negative.""" + if v < 0: + msg = "header_auth_cache_ttl must be >= 0" + raise ValueError(msg) + return v + @model_validator(mode="after") #: type: ignore def redis_url_required_if_session_type_is_redis(self): """ @@ -221,6 +241,23 @@ def duo_settings_required_if_enabled(self): raise ValidationError(msg) return self + @model_validator(mode="after") #: type: ignore + def ensure_authorization_filter_is_set(self): + """ + Ensure that the authorization filter is configured. + + Raises: + ValueError: The authorization filter is missing or empty + + """ + if ( + not self.ldap_authorization_filter + or not self.ldap_authorization_filter.strip() + ): + msg = "ldap_authorization_filter is required" + raise ValueError(msg) + return self + @model_validator(mode="after") #: type: ignore def ensure_authorization_filter_header_is_a_valid_ldap_filter(self): """ @@ -231,7 +268,7 @@ def ensure_authorization_filter_header_is_a_valid_ldap_filter(self): ValueError: The authorization filter does not use the {username} placeholder """ - if self.allow_authorization_filter_header and self.ldap_authorization_filter: + if self.ldap_authorization_filter: validate_ldap_search_filter( self.ldap_authorization_filter, ldap_username_attribute=self.ldap_username_attribute, diff --git a/nginx_ldap_auth/validators.py b/nginx_ldap_auth/validators.py index 180fc96..2e6229a 100644 --- a/nginx_ldap_auth/validators.py +++ b/nginx_ldap_auth/validators.py @@ -11,6 +11,7 @@ def _parse_ldap_search_filter( Raises: ValueError: The LDAP search filter is not a valid LDAP filter + """ try: # Filters can have placeholders for various values, so we need to diff --git a/pyproject.toml b/pyproject.toml index d5650c9..00f75ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,6 +225,7 @@ LDAP_URI = "ldap://localhost" LDAP_BINDDN = "cn=admin" LDAP_PASSWORD = "password" LDAP_BASEDN = "dc=example,dc=com" +LDAP_AUTHORIZATION_FILTER = "({username_attribute}={username})" SECRET_KEY = "secret" CSRF_SECRET_KEY = "csrf-secret" COOKIE_NAME = "nginxauth" diff --git a/test/conftest.py b/test/conftest.py index 39c3e8d..b091c42 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -90,11 +90,16 @@ def mock_settings(mocker): # Patch main.py settings mocker.patch.object(app_settings, "cookie_name", "nginxauth") mocker.patch.object(app_settings, "session_backend", "memory") - mocker.patch.object(app_settings, "ldap_authorization_filter", None) + mocker.patch.object( + app_settings, + "ldap_authorization_filter", + "({username_attribute}={username})", + ) mocker.patch.object(app_settings, "auth_realm", "Restricted") mocker.patch.object(app_settings, "allow_authorization_filter_header", value=True) - # Patch forms.py settings to use the same object + # Patch forms.py and header_auth.py settings to use the same object mocker.patch("nginx_ldap_auth.app.forms.settings", app_settings) + mocker.patch("nginx_ldap_auth.app.header_auth.settings", app_settings) return app_settings diff --git a/test/test_check_middleware.py b/test/test_check_middleware.py index 424360a..e88b1b9 100644 --- a/test/test_check_middleware.py +++ b/test/test_check_middleware.py @@ -83,7 +83,9 @@ def test_check_with_authorization_filter_header( response = client.get( "/check", headers={ - "x-authorization-filter": "(&(group=admin)({username_attribute}={username}))" + "x-authorization-filter": ( + "(&(group=admin)({username_attribute}={username}))" + ) }, cookies={"nginxauth": cookie}, ) @@ -228,8 +230,8 @@ def test_check_invalid_authorization_filter_header(client, mock_user_manager): cookie = login_response.cookies.get("nginxauth") # 2. Call /check with an invalid filter header - # This should raise a ValueError in the app, which FastAPI - # will catch and return as a 500 Internal Server Error by default + # This should raise a ValueError in the app, which FastAPI + # will catch and return as a 500 Internal Server Error by default # if not explicitly handled. try: response = client.get( diff --git a/test/test_header_auth.py b/test/test_header_auth.py new file mode 100644 index 0000000..ca596e9 --- /dev/null +++ b/test/test_header_auth.py @@ -0,0 +1,366 @@ +""" +Tests for the header-based authorization endpoint (/check-header). + +This endpoint is designed for Kerberos/SPNEGO authentication where NGINX +handles authentication and passes the username via a trusted header. +""" + +import pytest +from bonsai import LDAPError + + +@pytest.fixture(autouse=True) +def reset_auth_cache(): + """Reset the authorization cache before and after each test.""" + from nginx_ldap_auth.app.header_auth_cache import reset_cache + + reset_cache() + yield + reset_cache() + + +class TestCheckHeaderEndpoint: + """Tests for the /check-header endpoint.""" + + def test_check_header_success(self, client, mock_user_manager): + """Test successful authorization with valid header and authorized user.""" + mock_user_manager.is_authorized.return_value = True + + response = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": ( + "(&(uid={username})(memberOf=cn=admins,dc=example,dc=com))" + ), + }, + ) + + assert response.status_code == 200 + assert response.headers.get("x-auth-user") == "testuser" + assert response.headers.get("cache-control") == "no-cache" + + def test_check_header_missing_header(self, client, mock_user_manager): # noqa: ARG002 + """Test that missing X-Ldap-User header returns 401.""" + response = client.get("/check-header") + + assert response.status_code == 401 + assert "x-auth-user" not in response.headers + assert response.headers.get("cache-control") == "no-cache" + + def test_check_header_not_authorized(self, client, mock_user_manager): + """Test that user not in required group returns 403.""" + mock_user_manager.is_authorized.return_value = False + + response = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": ( + "(&(uid={username})(memberOf=cn=admins,dc=example,dc=com))" + ), + }, + ) + + assert response.status_code == 403 + assert "x-auth-user" not in response.headers + + def test_check_header_no_filter_returns_500( + self, client, mock_user_manager, mocker + ): + """Test that missing effective authorization filter returns 500.""" + from nginx_ldap_auth.app import header_auth + + mocker.patch.object(header_auth.settings, "ldap_authorization_filter", None) + response = client.get( + "/check-header", + headers={"x-ldap-user": "testuser"}, + ) + + assert response.status_code == 500 + assert response.headers.get("x-auth-user") is None + mock_user_manager.is_authorized.assert_not_called() + + def test_check_header_ldap_error(self, client, mock_user_manager): + """Test that LDAP errors return 500.""" + mock_user_manager.is_authorized.side_effect = LDAPError("Connection failed") + + response = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": ( + "(&(uid={username})(memberOf=cn=admins,dc=example,dc=com))" + ), + }, + ) + + assert response.status_code == 500 + + def test_check_header_cache_hit(self, client, mock_user_manager): + """Test that cached results are used (LDAP not queried on second request).""" + mock_user_manager.is_authorized.return_value = True + + headers = { + "x-ldap-user": "cacheuser", + "x-authorization-filter": ( + "(&(uid={username})(memberOf=cn=testers,dc=example,dc=com))" + ), + } + + # First request - should query LDAP + response1 = client.get("/check-header", headers=headers) + assert response1.status_code == 200 + assert mock_user_manager.is_authorized.call_count == 1 + + # Second request - should use cache + response2 = client.get("/check-header", headers=headers) + assert response2.status_code == 200 + assert mock_user_manager.is_authorized.call_count == 1 # Still 1 (cache hit) + + def test_check_header_cache_different_filters(self, client, mock_user_manager): + """Test that different filters result in different cache entries.""" + mock_user_manager.is_authorized.return_value = True + + # Request with filter A + response1 = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": "(memberOf=cn=groupA,dc=example,dc=com)", + }, + ) + assert response1.status_code == 200 + assert mock_user_manager.is_authorized.call_count == 1 + + # Request with filter B - different cache key, queries LDAP again + response2 = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": "(memberOf=cn=groupB,dc=example,dc=com)", + }, + ) + assert response2.status_code == 200 + assert mock_user_manager.is_authorized.call_count == 2 + + def test_check_header_custom_header_name(self, client, mock_user_manager, mocker): # noqa: ARG002 + """Test that custom header name from settings works.""" + from nginx_ldap_auth.app import header_auth + + mocker.patch.object( + header_auth.settings, "ldap_trusted_user_header", "X-Remote-User" + ) + mocker.patch.object( + header_auth.settings, + "ldap_authorization_filter", + "(&(uid={username})(memberOf=cn=default,dc=example,dc=com))", + ) + + response = client.get( + "/check-header", + headers={"x-remote-user": "customuser"}, + ) + + assert response.status_code == 200 + assert response.headers.get("x-auth-user") == "customuser" + + def test_check_header_uses_settings_filter(self, client, mock_user_manager, mocker): + """Test that settings.ldap_authorization_filter is used when no header.""" + from nginx_ldap_auth.app import header_auth + + mocker.patch.object( + header_auth.settings, + "ldap_authorization_filter", + "(&(uid={username})(memberOf=cn=default,dc=example,dc=com))", + ) + mock_user_manager.is_authorized.return_value = True + + response = client.get( + "/check-header", + headers={"x-ldap-user": "testuser"}, + ) + + assert response.status_code == 200 + mock_user_manager.is_authorized.assert_called_once() + call_args = mock_user_manager.is_authorized.call_args + assert "memberOf=cn=default" in call_args[0][1] + + def test_check_header_filter_header_ignored_when_disabled( + self, client, mock_user_manager, mock_settings + ): + """Test that X-Authorization-Filter header is ignored when setting is False.""" + # Disable header override using the shared mock_settings fixture + mock_settings.allow_authorization_filter_header = False + mock_settings.ldap_authorization_filter = ( + "(memberOf=cn=allowed,dc=example,dc=com)" + ) + mock_user_manager.is_authorized.return_value = True + + # Send request with malicious filter header (should be ignored) + response = client.get( + "/check-header", + headers={ + "x-ldap-user": "testuser", + "x-authorization-filter": "(objectClass=*)", # Malicious filter + }, + ) + + assert response.status_code == 200 + # Verify is_authorized was called with the setting filter, not the header + mock_user_manager.is_authorized.assert_called_once() + call_args = mock_user_manager.is_authorized.call_args + assert call_args[0][1] == "(memberOf=cn=allowed,dc=example,dc=com)" + assert "(objectClass=*)" not in str(call_args) + + +class TestAuthCache: + """Tests for the authorization cache module.""" + + @pytest.mark.asyncio + async def test_cache_get_set(self): + """Test cache get/set operations.""" + from nginx_ldap_auth.app.header_auth_cache import ( + get_cached_authorization, + set_cached_authorization, + ) + + # Initially empty + result = await get_cached_authorization("user1", "(filter1)") + assert result is None + + # Set and get + await set_cached_authorization("user1", "(filter1)", authorized=True) + result = await get_cached_authorization("user1", "(filter1)") + assert result is True + + # Different filter + result = await get_cached_authorization("user1", "(filter2)") + assert result is None + + # Set unauthorized + await set_cached_authorization("user2", "(filter1)", authorized=False) + result = await get_cached_authorization("user2", "(filter1)") + assert result is False + + @pytest.mark.asyncio + async def test_authorization_lock_prevents_concurrent_access(self): + """Test that authorization_lock serializes access for same key.""" + import asyncio + + from nginx_ldap_auth.app.header_auth_cache import authorization_lock + + execution_order = [] + + async def task(task_id: str, delay: float): + async with authorization_lock("user1", "(filter1)"): + execution_order.append(f"{task_id}_start") + await asyncio.sleep(delay) + execution_order.append(f"{task_id}_end") + + # Start two tasks concurrently for the same key + await asyncio.gather( + task("A", 0.1), + task("B", 0.1), + ) + + # With locking, one task must complete before the other starts + # Either A_start, A_end, B_start, B_end OR B_start, B_end, A_start, A_end + assert execution_order[0].endswith("_start") + assert execution_order[1].endswith("_end") + assert execution_order[2].endswith("_start") + assert execution_order[3].endswith("_end") + + @pytest.mark.asyncio + async def test_authorization_lock_different_keys_concurrent(self): + """Test that different keys can be accessed concurrently.""" + import asyncio + + from nginx_ldap_auth.app.header_auth_cache import authorization_lock + + execution_order = [] + + async def task(task_id: str, username: str, delay: float): + async with authorization_lock(username, "(filter1)"): + execution_order.append(f"{task_id}_start") + await asyncio.sleep(delay) + execution_order.append(f"{task_id}_end") + + # Start two tasks concurrently for different keys + await asyncio.gather( + task("A", "user1", 0.1), + task("B", "user2", 0.1), + ) + + # Different keys should allow concurrent access + # Both should start before either ends + starts = [e for e in execution_order if e.endswith("_start")] + ends = [e for e in execution_order if e.endswith("_end")] + + # Both starts should happen before both ends (concurrent execution) + first_end_idx = execution_order.index(ends[0]) + assert execution_order.index(starts[0]) < first_end_idx + assert execution_order.index(starts[1]) < first_end_idx + + @pytest.mark.asyncio + async def test_lock_lru_cleanup(self, mocker): + """Test that locks are pruned when exceeding max limit.""" + from nginx_ldap_auth.app import header_auth_cache + from nginx_ldap_auth.app.header_auth_cache import ( + authorization_lock, + get_lock_count, + reset_cache, + ) + + reset_cache() + + # Set a very low max to trigger cleanup easily + mocker.patch.object(header_auth_cache, "_MAX_LOCKS", 5) + + # Create 6 locks (exceeds limit of 5) + for i in range(6): + async with authorization_lock(f"user{i}", "(filter)"): + pass # Just acquire and release + + # Should have pruned some locks (10% of 5 = 1, so 5 remain max) + assert get_lock_count() <= 5 + + @pytest.mark.asyncio + async def test_lock_lru_does_not_prune_held_locks(self, mocker): + """Test that currently held locks are not pruned.""" + import asyncio + + from nginx_ldap_auth.app import header_auth_cache + from nginx_ldap_auth.app.header_auth_cache import ( + authorization_lock, + reset_cache, + ) + + reset_cache() + + # Set a very low max to trigger cleanup + mocker.patch.object(header_auth_cache, "_MAX_LOCKS", 3) + + release_event = asyncio.Event() + + async def hold_lock(): + async with authorization_lock("held_user", "(filter)"): + # Wait while other locks are created + await release_event.wait() + + async def create_locks(): + # Give the held lock time to acquire + await asyncio.sleep(0.05) + + # Create more locks than max, should trigger pruning + for i in range(5): + async with authorization_lock(f"user{i}", "(filter)"): + pass + + release_event.set() + + # Run both concurrently - held lock should survive pruning + await asyncio.gather(hold_lock(), create_locks()) + + # Test completes without error - if held lock was pruned, + # the context manager exit would fail diff --git a/test/test_ldap.py b/test/test_ldap.py index 885b485..2c3dddb 100644 --- a/test/test_ldap.py +++ b/test/test_ldap.py @@ -100,9 +100,9 @@ async def test_user_manager_is_authorized(mocker): assert result is True mock_conn.search.assert_called() - # Test without filter - result = await manager.is_authorized("testuser", None) - assert result is True + # Test without filter should fail + with pytest.raises((AttributeError, ValueError)): + await manager.is_authorized("testuser", None) @pytest.mark.asyncio diff --git a/test/test_settings_validators.py b/test/test_settings_validators.py index 144fc79..1136173 100644 --- a/test/test_settings_validators.py +++ b/test/test_settings_validators.py @@ -1,5 +1,6 @@ import pytest from pydantic import ValidationError + from nginx_ldap_auth.settings import Settings @@ -10,6 +11,7 @@ def make_settings(**kwargs): "ldap_binddn": "cn=admin", "ldap_password": "password", "ldap_basedn": "dc=example,dc=com", + "ldap_authorization_filter": "({username_attribute}={username})", } return Settings(**(base_kwargs | kwargs)) @@ -24,7 +26,10 @@ def test_settings_get_user_filter_validation(): # Invalid filter with pytest.raises(ValidationError) as excinfo: make_settings(ldap_get_user_filter="(invalid") - assert "ldap_get_user_filter" in str(excinfo.value) or "ldap_authorization_filter" in str(excinfo.value) + assert "ldap_get_user_filter" in str( + excinfo.value + ) or "ldap_authorization_filter" in str(excinfo.value) + def test_settings_authorization_filter_validation(): """ @@ -43,12 +48,13 @@ def test_settings_authorization_filter_validation(): assert "does not use the {username} placeholder" in str(excinfo.value) -def test_settings_authorization_filter_none_allowed(): +def test_settings_authorization_filter_none_not_allowed(): """ - Test that ldap_authorization_filter can be None. + Test that ldap_authorization_filter cannot be None. """ - settings = make_settings(ldap_authorization_filter=None) - assert settings.ldap_authorization_filter is None + with pytest.raises(ValidationError) as excinfo: + make_settings(ldap_authorization_filter=None) + assert "ldap_authorization_filter" in str(excinfo.value) def test_settings_ca_cert_is_optional_by_default(): @@ -66,7 +72,9 @@ def test_settings_ca_cert_name_requires_dir(): """ with pytest.raises(ValidationError) as excinfo: make_settings(ldap_ca_cert_name="ca.pem") - assert "ldap_ca_cert_dir is required if ldap_ca_cert_name is set" in str(excinfo.value) + assert "ldap_ca_cert_dir is required if ldap_ca_cert_name is set" in str( + excinfo.value + ) def test_settings_ca_cert_dir_requires_name(tmp_path): @@ -77,7 +85,9 @@ def test_settings_ca_cert_dir_requires_name(tmp_path): cert_dir.mkdir() with pytest.raises(ValidationError) as excinfo: make_settings(ldap_ca_cert_dir=cert_dir) - assert "ldap_ca_cert_name is required if ldap_ca_cert_dir is set" in str(excinfo.value) + assert "ldap_ca_cert_name is required if ldap_ca_cert_dir is set" in str( + excinfo.value + ) def test_settings_ca_cert_dir_and_file_must_exist(tmp_path): diff --git a/test/test_validators.py b/test/test_validators.py index dded66d..a4ead37 100644 --- a/test/test_validators.py +++ b/test/test_validators.py @@ -1,6 +1,8 @@ import pytest + from nginx_ldap_auth.validators import validate_ldap_search_filter + def test_validate_ldap_search_filter_valid(): """ Test validate_ldap_search_filter with a valid LDAP filter. @@ -9,34 +11,37 @@ def test_validate_ldap_search_filter_valid(): validate_ldap_search_filter("(uid={username})") # Filter with all placeholders validate_ldap_search_filter("({username_attribute}={username})") - validate_ldap_search_filter("(&(objectClass=person)({username_full_name_attribute}=*{username}*))") + validate_ldap_search_filter( + "(&(objectClass=person)({username_full_name_attribute}=*{username}*))" + ) + def test_validate_ldap_search_filter_missing_username_placeholder(): """ Test validate_ldap_search_filter when the {username} placeholder is missing. """ # Valid LDAP syntax but missing {username} - with pytest.raises(ValueError) as excinfo: + with pytest.raises(ValueError, match="does not use the \\{username\\} placeholder"): validate_ldap_search_filter("(objectClass=*)") - assert "does not use the {username} placeholder" in str(excinfo.value) + def test_validate_ldap_search_filter_invalid_syntax(): """ Test validate_ldap_search_filter with invalid LDAP syntax. """ # Unbalanced parentheses - with pytest.raises(ValueError) as excinfo: + with pytest.raises(ValueError, match="not a valid LDAP filter"): validate_ldap_search_filter("(objectClass=*") - assert "not a valid LDAP filter" in str(excinfo.value) # Invalid characters/syntax - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="not a valid LDAP filter"): validate_ldap_search_filter("invalid filter") + def test_validate_ldap_search_filter_placeholder_formatting(): """ Test that placeholders are correctly handled (formatted) before parsing. """ - # This should not raise ParseError because validate_ldap_search_filter + # This should not raise ParseError because validate_ldap_search_filter # formats placeholders before passing to Filter.parse validate_ldap_search_filter("{username_attribute}={username}")