├── nginx_ldap_auth ├── py.typed ├── app │ ├── __init__.py │ ├── static │ │ ├── logo.png │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── apple-touch-icon.png │ ├── templates │ │ └── login.html │ ├── forms.py │ ├── middleware.py │ ├── models.py │ └── main.py ├── cli │ ├── __init__.py │ ├── cli.py │ └── server.py ├── types.py ├── main.py ├── __init__.py ├── ldap.py ├── settings.py └── logging.py ├── doc ├── source │ ├── _static │ │ └── overrides.css │ ├── api │ │ ├── ldap.rst │ │ ├── middleware.rst │ │ ├── models.rst │ │ ├── settings.rst │ │ └── views.rst │ ├── installation.rst │ ├── index.rst │ ├── monitoring.rst │ ├── conf.py │ ├── contributing.rst │ ├── running.rst │ ├── changelog.rst │ ├── nginx.rst │ └── configuration.rst ├── requirements.txt └── Makefile ├── .semgrepignore ├── .autoenv ├── .autoenv.leave ├── MANIFEST.in ├── .dockerignore ├── .readthedocs.yaml ├── .bumpversion.cfg ├── README.md ├── docker-compose.yml ├── etc ├── environment.txt └── nginx │ ├── certs │ ├── localhost.crt │ └── localhost.key │ └── nginx.conf ├── LICENSE.txt ├── bin └── release.sh ├── Dockerfile ├── .gitignore ├── Makefile ├── requirements.txt └── pyproject.toml /nginx_ldap_auth/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/source/_static/overrides.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | docker-compose.yml 2 | etc/nginx/certs/localhost.key 3 | -------------------------------------------------------------------------------- /.autoenv: -------------------------------------------------------------------------------- 1 | if [[ -f .venv/bin/activate ]]; then 2 | source .venv/bin/activate 3 | fi 4 | -------------------------------------------------------------------------------- /.autoenv.leave: -------------------------------------------------------------------------------- 1 | CWD=$(pwd) 2 | if [[ ! $CWD == *"nginx-ldap-auth-service"* ]]; then 3 | deactivate 4 | fi 5 | -------------------------------------------------------------------------------- /nginx_ldap_auth/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli # noqa: F401 2 | from .server import * # noqa: F403 3 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caltechads/nginx-ldap-auth-service/HEAD/nginx_ldap_auth/app/static/logo.png -------------------------------------------------------------------------------- /nginx_ldap_auth/app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caltechads/nginx-ldap-auth-service/HEAD/nginx_ldap_auth/app/static/favicon.ico -------------------------------------------------------------------------------- /nginx_ldap_auth/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias 2 | 3 | LDAPValue: TypeAlias = list[str] 4 | LDAPObject: TypeAlias = dict[str, LDAPValue] 5 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caltechads/nginx-ldap-auth-service/HEAD/nginx_ldap_auth/app/static/favicon-16x16.png -------------------------------------------------------------------------------- /nginx_ldap_auth/app/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caltechads/nginx-ldap-auth-service/HEAD/nginx_ldap_auth/app/static/favicon-32x32.png -------------------------------------------------------------------------------- /nginx_ldap_auth/main.py: -------------------------------------------------------------------------------- 1 | def main() -> None: 2 | from .cli import cli 3 | 4 | cli(obj={}) 5 | 6 | 7 | if __name__ == "__main__": 8 | main() 9 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caltechads/nginx-ldap-auth-service/HEAD/nginx_ldap_auth/app/static/apple-touch-icon.png -------------------------------------------------------------------------------- /doc/source/api/ldap.rst: -------------------------------------------------------------------------------- 1 | .. _api__ldap: 2 | 3 | LDAP 4 | ==== 5 | 6 | .. automodule:: nginx_ldap_auth.ldap 7 | :members: 8 | :inherited-members: 9 | :undoc-members: -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.md 3 | graft etc 4 | include Makefile 5 | include nginx_ldap_auth/app/templates/* 6 | include nginx_ldap_auth/app/static/* 7 | include nginx_ldap_auth/py.typed 8 | -------------------------------------------------------------------------------- /doc/source/api/middleware.rst: -------------------------------------------------------------------------------- 1 | .. _api__middleware: 2 | 3 | Middleware 4 | ========== 5 | 6 | .. automodule:: nginx_ldap_auth.app.middleware 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | -------------------------------------------------------------------------------- /doc/source/api/models.rst: -------------------------------------------------------------------------------- 1 | .. _api__models: 2 | 3 | Models 4 | ====== 5 | 6 | .. module:: nginx_ldap_auth.app.models 7 | 8 | .. autoclass:: UserManager 9 | :members: 10 | 11 | .. autoclass:: User 12 | :members: -------------------------------------------------------------------------------- /doc/source/api/settings.rst: -------------------------------------------------------------------------------- 1 | .. _api__settings: 2 | 3 | Settings 4 | ======== 5 | 6 | .. module:: nginx_ldap_auth.settings 7 | 8 | .. autoclass:: Settings 9 | :members: 10 | :inherited-members: 11 | :undoc-members: -------------------------------------------------------------------------------- /doc/source/api/views.rst: -------------------------------------------------------------------------------- 1 | .. _api__views: 2 | 3 | Views 4 | ===== 5 | 6 | .. module:: nginx_ldap_auth.app.main 7 | 8 | .. autofunction:: login 9 | 10 | .. autofunction:: login_handler 11 | 12 | .. autofunction:: logout 13 | 14 | .. autofunction:: check_auth -------------------------------------------------------------------------------- /nginx_ldap_auth/__init__.py: -------------------------------------------------------------------------------- 1 | __version__: str = "2.3.0" 2 | __author__: str = "Caltech IMSS ADS" 3 | __author_email__: str = "imss-ads-staff@caltech.edu" 4 | __description__: str = ( 5 | "A FastAPI app that authenticates users via LDAP and sets a cookie for nginx" 6 | ) 7 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | # Documentation 2 | # ------------------------------------------------------------------------------ 3 | Sphinx<8 4 | sphinxcontrib-images==0.9.4 5 | sphinx_rtd_theme==1.2.1 6 | sphinxcontrib-jsonglobaltoc==0.1.1 # https://github.com/caltechads/sphinxcontrib-jsonglobaltoc -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore any deveopment environment artifacts 2 | .git 3 | *.sw* 4 | .python-version 5 | .DS_Store 6 | .idea 7 | *.bak 8 | tags 9 | config.codekit3 10 | docker-compose* 11 | bitbucket-pipelines.yml 12 | buildspec.yml 13 | jmeter.log 14 | jmeter 15 | terraform 16 | .mypy_cache 17 | 18 | # Ignore the sql folder 19 | sql/docker 20 | 21 | # Ignore any SQL dumps 22 | *.sql 23 | .venv 24 | 25 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | version: 2 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.11" 8 | apt_packages: 9 | - gcc 10 | - libsasl2-dev 11 | - libldap-common 12 | - libldap-dev 13 | 14 | sphinx: 15 | configuration: doc/source/conf.py 16 | 17 | formats: all 18 | 19 | python: 20 | install: 21 | - requirements: requirements.txt -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.3.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-dev\.(?P\d+))? 7 | serialize = 8 | {major}.{minor}.{patch}-dev.{dev} 9 | {major}.{minor}.{patch} 10 | 11 | [bumpversion:file:nginx_ldap_auth/__init__.py] 12 | 13 | [bumpversion:file:Makefile] 14 | 15 | [bumpversion:file:pyproject.toml] 16 | 17 | [bumpversion:file:doc/source/conf.py] 18 | 19 | -------------------------------------------------------------------------------- /nginx_ldap_auth/cli/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | import click 5 | 6 | import nginx_ldap_auth 7 | 8 | 9 | @click.group(invoke_without_command=True) 10 | @click.option( 11 | "--version/--no-version", 12 | "-v", 13 | default=False, 14 | help="Print the current version and exit.", 15 | ) 16 | @click.pass_context 17 | def cli(_, version: bool) -> None: # noqa: FBT001 18 | """ 19 | The nginx_ldap_auth command line interface. 20 | """ 21 | if version: 22 | click.echo(nginx_ldap_auth.__version__) 23 | sys.exit(0) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nginx-ldap-auth-service 2 | 3 | `nginx-ldap-auth-service` provides a daemon (`nginx-ldap-auth`) that 4 | communicates with an LDAP or Active Directory server to authenticate users with 5 | their username and password, as well as a login form for actually allowing users 6 | to authenticate. You can use this in combination with the nginx module 7 | [ngx_http_auth_request_module](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) 8 | to provide authentication for your nginx server. 9 | 10 | See the [Documentation](https://nginx-ldap-auth-service.readthedocs.io) for more 11 | information. -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | nginx: 4 | image: nginx:latest 5 | container_name: nginx 6 | ports: 7 | - "443:443" 8 | volumes: 9 | - ./etc/nginx/nginx.conf:/etc/nginx/nginx.conf 10 | - ./etc/nginx/certs:/certs 11 | depends_on: 12 | - nginx_ldap_auth_service 13 | links: 14 | - nginx_ldap_auth_service 15 | 16 | nginx_ldap_auth_service: 17 | image: nginx-ldap-auth-service:latest 18 | hostname: nginx-ldap-auth-service 19 | ports: 20 | - "8888:8888" 21 | env_file: 22 | - .env 23 | container_name: nginx-ldap-auth-service 24 | volumes: 25 | - .:/app 26 | 27 | redis: 28 | image: redis:latest 29 | container_name: "redis" 30 | ports: 31 | - "6379:6379" 32 | -------------------------------------------------------------------------------- /etc/environment.txt: -------------------------------------------------------------------------------- 1 | SECRET_KEY=__SECRET_KEY__ 2 | CSRF_SECRET_KEY=__CSRF_SECRET_KEY__ 3 | LDAP_URI=ldap://host.docker.internal:1389 4 | LDAP_BINDDN=__LDAP_BINDDN__ 5 | LDAP_PASSWORD=__LDAP_PASSWORD__ 6 | LDAP_BASEDN=ou=users,dc=example,dc=com 7 | # SENTRY_URL=None 8 | 9 | # Uncomment the below if you want to use the 10 | # Redis backend for session storage in dev 11 | # ------------------------------------------ 12 | # SESSION_BACKEND=redis 13 | # REDIS_URL=redis://localhost 14 | 15 | # Set any of the below as necessary 16 | # ------------------------------------------ 17 | # COOKIE_NAME=nginxauth 18 | # SESSION_MAX_AGE=0 19 | # USE_ROLLING_SESSIONS=False 20 | # REDIS_PREFIX=nginx_ldap_auth. 21 | # LDAP_STARTTLS=True 22 | # LDAP_DISABLE_REFERRALS=False 23 | # LDAP_USERNAME_ATTRIBUTE=uid 24 | # LDAP_FULL_NAME_ATTRIBUTE=cn 25 | # LDAP_GET_USER_FILTER={username_attribute}={username} 26 | # LDAP_AUTHORIZATION_FILTER=None 27 | # LDAP_TIMEOUT=15 28 | # LDAP_MIN_POOL_SIZE=1 29 | # LDAP_MAX_POOL_SIZE=30 30 | # LDAP_POOL_LIFETIME_SECONDS=20 31 | # STATSD_HOST=None 32 | # STATSD_PORT=8125 33 | # STATSD_PREFIX=nginx_ldap_auth.dev 34 | -------------------------------------------------------------------------------- /etc/nginx/certs/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFDCCAfwCCQDlxusjRatsqTANBgkqhkiG9w0BAQsFADBMMQswCQYDVQQGEwJV 3 | UzELMAkGA1UECAwCQ0ExEDAOBgNVBAoMB0NhbHRlY2gxHjAcBgNVBAMMFWxvY2Fs 4 | aG9zdC5sb2NhbGRvbWFpbjAeFw0yMzA0MTQyMTE3NDRaFw0zMzA0MTEyMTE3NDRa 5 | MEwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEQMA4GA1UECgwHQ2FsdGVjaDEe 6 | MBwGA1UEAwwVbG9jYWxob3N0LmxvY2FsZG9tYWluMIIBIjANBgkqhkiG9w0BAQEF 7 | AAOCAQ8AMIIBCgKCAQEAtBKFmGO4fu4RVU3S/4ZhetgNs7Rs+FV4cT3U9AVt4Qgx 8 | nUwOM0RkuWfevcHOvkV7vhSk4FH58cLJrgOHNxn0Rg4tNL41XjO5qRzWA8U5YHMa 9 | jxbxQWKruBZpAFCT/BqoRidd6W1Lv91loYusr+roRXrTur+4ouw0MeOVDN0KMzZr 10 | LdmHW15pzuk1ofWHVnjOkVGG3wdKhBTdAlKAYeCNIOqd0UGx1hHD70tq+YNCgBS+ 11 | 8Ff/KpmwiI/d79wfNt+JzjTaimRJEwtQxJtzry3lAQh8UD6DT8Oo7VotlGlkaABm 12 | pHekEXD3hsdYHlUnAp8ZhEeO1TOFaAQR87BwRAbgBwIDAQABMA0GCSqGSIb3DQEB 13 | CwUAA4IBAQBYa4Zxn6bX6APVIKF8khU7qoUcVVY8mTgx6gGcW4D0rbHnKhalcb+N 14 | Sf4udyfOJyMAS6CoeALr3lKNjbGheF/p7rD29CxgHoXBo/zytXuIKcMpy3EqFJ+/ 15 | Z5YvkohIgniObDye5LMCvR3J1VwMnTNUBhztao+qWghOAzgncTAWG8e6+ReGmQ4F 16 | mNTcZ4v92c5e+u42J1z4dbqCL1BsZLRJwdruJBkNoisQxzqSaIP9gdYIoMuyk3bK 17 | FXyiD+ejmf6YIr5slZdUGjUbHh1gcF+DvALA98BZHfsosc81YuHpvQt/5uMDhupv 18 | e3X3wkBWufE7/Zx4/tTV3gERONNi69YX 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright California Institute of Technology. Questions or comments 2 | may be directed to the author, the Academic Development Services group of 3 | Caltech's Information Management Systems and Services department, at 4 | imss-ads-staff@caltech.edu. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | Neither the name of the copyright holder nor the names of its contributors may 17 | be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 22 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 23 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 24 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 25 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /doc/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | .. highlight:: bash 5 | 6 | :Requirements: **Python 3.x >= 3.11** 7 | 8 | To install the latest released version:: 9 | 10 | $ pip install nginx-ldap-auth-service 11 | 12 | From Source 13 | ----------- 14 | 15 | You can install ``nginx-ldap-auth-service`` from source just as you would 16 | install any other Python package:: 17 | 18 | $ pip install git+https://github.com/caltechads/nginx-ldap-auth-service.git 19 | 20 | This will allow you to keep up to date with development on GitHub:: 21 | 22 | $ pip install -U git+https://github.com/caltechads/nginx-ldap-auth-service.git 23 | 24 | From Docker Hub 25 | --------------- 26 | 27 | You can also run ``nginx-ldap-auth-service`` from Docker Hub:: 28 | 29 | $ docker pull caltechads/nginx-ldap-auth-service:latest 30 | $ docker run \ 31 | -d \ 32 | -p 8888:8888 \ 33 | -e LDAP_URI=ldap://ldap.example.com \ 34 | -e LDAP_BASEDN=dc=example,dc=com \ 35 | -e LDAP_BINDDN=cn=admin,dc=example,dc=com \ 36 | -e LDAP_PASSWORD=secret \ 37 | caltechads/nginx-ldap-auth-service 38 | 39 | Or use ``docker-compose``. Create a ``docker-compose.yml`` file with the 40 | following contents:: 41 | 42 | services: 43 | nginx_ldap_auth_service: 44 | image: caltechads/nginx-ldap-auth-service:latest 45 | hostname: nginx-ldap-auth-service 46 | container_name: nginx-ldap-auth-service 47 | ports: 48 | - 8888:8888 49 | environment: 50 | - LDAP_URI=ldap://ldap.example.com 51 | - LDAP_BASEDN=dc=example,dc=com \ 52 | - LDAP_BINDDN=cn=admin,dc=example,dc=com 53 | - LDAP_PASSWORD=secret 54 | 55 | Then run:: 56 | 57 | $ docker-compose up -d 58 | -------------------------------------------------------------------------------- /etc/nginx/certs/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC0EoWYY7h+7hFV 3 | TdL/hmF62A2ztGz4VXhxPdT0BW3hCDGdTA4zRGS5Z969wc6+RXu+FKTgUfnxwsmu 4 | A4c3GfRGDi00vjVeM7mpHNYDxTlgcxqPFvFBYqu4FmkAUJP8GqhGJ13pbUu/3WWh 5 | i6yv6uhFetO6v7ii7DQx45UM3QozNmst2YdbXmnO6TWh9YdWeM6RUYbfB0qEFN0C 6 | UoBh4I0g6p3RQbHWEcPvS2r5g0KAFL7wV/8qmbCIj93v3B8234nONNqKZEkTC1DE 7 | m3OvLeUBCHxQPoNPw6jtWi2UaWRoAGakd6QRcPeGx1geVScCnxmER47VM4VoBBHz 8 | sHBEBuAHAgMBAAECggEBAJXPTvvgAq7+6sa4T1EsgE7ODmAyO/JCUUiM82zsU2TD 9 | B1vg2XOHc/DX3HSsF48uiWszC5RgPvwGXPl7j/OkkRfzVWKq2AV+LPjnt5k9bKW0 10 | PSVMJfyK1Wf6pPKRFvzHRLXQrI210y5VR+clJ87XNNQRArM8K6THtAjJWMhx4LmH 11 | KB/x1wwvNN7mAUNGNMk/8jXn4XGlloBOvsLV+ruMvExwzHETHR5FYg0Jnc5U1xsv 12 | JMU2Lqk9+CsBpF0BRCjGOIdCOrWciepOq6aP/Geu/H1G6OjJ7IbpuBD5ID3f8Lk1 13 | bAYzCVEbm1CMgcg1ydl0yxdg2UCDP5JMUZze4jHOy2ECgYEA3jFEAol+KaUMNg9r 14 | cs/mPukQDnhosM8R7Q1TPY+lwD3H/M+pwvBqTS1+G6R0AYWGs4VkBuaYw8NadJbu 15 | pdX83ngRJjEBESEiPsNZ00YXHwyjPfYZON4xonoB4ruUH0CslG0cPtKATxn47UC1 16 | K9QN8BYlG2RgnzjDt4Fl3QcBmLcCgYEAz3idR7csPRLUMaacBAw/fFPZODzRQvs2 17 | xJ8ma+vMQ91NpI+mnnvxTNXyPSg55Oi9ZrmW7J6QHU9kJrHtmtY0SlWwmtmUzYI5 18 | 4EWEkTHXtdgOw+kPm0hlftHiF70w8nSH8AE5meqrd2t4ELQvv/zO67lmZKdULaAL 19 | 2LhVPOAFgzECgYEAnhyYtOV6bdARPHcEkxL2WVYoIuP0O71emD2fOnN6E67jHTf5 20 | KctDGeCBmNIR6vNFw4HsiCyYENZ3C/hLop7/7p+qNG8yvynA4MDKrtl1opavo2v4 21 | zsrurxv7M4kgAo1XQdfS/bF1tNRamxos0h94O5zGkxN+3k7alz7xabOOo0kCgYEA 22 | xwc4KpIoGDa15SOly6RMSuLNIUwGm7EO6zSZ0TIVdI0abOF5v9O6ujEL/2tVjqlO 23 | +PrVNA0wx01gEFbkT4NqCl2F3CcavsNM7j8CW59rBgFMuNgdpqOe6jhCIu/VwuHT 24 | foROU79x2k/4kF2q6QyHHE9xUOHMuTAt7St4abumziECgYBlMb3+g2ZSERNzpmLp 25 | cubIlvhbo6xPEUGD+EXiY65vDDRUiTl5I5Y/6KUqR5L4M0z8giaDvZwey1AzMW4A 26 | KSEX8OVXjJ9Y/ofNlvB1tjftuaFNWna9oUe1N3Vtn5KriiVmK/GGbRodLJRE0c0t 27 | CoopCaJQy6YTIukd2C6g4EnvrQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /bin/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if test $(git rev-parse --abbrev-ref HEAD) = "master"; then 4 | if test -z "$(git status --untracked-files=no --porcelain)"; then 5 | MSG="$(git log -1 --pretty=%B)" 6 | echo "$MSG" | grep "Bump version" 7 | if test $? -eq 0; then 8 | VERSION=$(echo "$MSG" | awk -F→ '{print $2}') 9 | echo "---------------------------------------------------" 10 | echo "Releasing version ${VERSION} ..." 11 | echo "---------------------------------------------------" 12 | echo 13 | echo 14 | git checkout build 15 | git merge master 16 | echo "Pushing build to origin ..." 17 | git push --tags origin build 18 | git checkout master 19 | # We do this sleep here so our codepipeline can be triggered on the build push 20 | # If you push master and build simultaneously, the pipeline gets confused and 21 | # won't trigger. Possibly master triggers the pipeline first and then the build 22 | # push notification gets lost in a race condition. 23 | echo "Sleeping 3 seconds to allow the build push to trigger the CodePipeline ..." 24 | sleep 3 25 | echo "Pushing master to origin ..." 26 | git push --tags origin master 27 | else 28 | echo "Last commit was not a bumpversion; aborting." 29 | echo "Last commit message: ${MSG}" 30 | fi 31 | else 32 | git status 33 | echo 34 | echo 35 | echo "------------------------------------------------------" 36 | echo "You have uncommitted changes; aborting." 37 | echo "------------------------------------------------------" 38 | fi 39 | else 40 | echo "You're not on master; aborting." 41 | fi 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine3.21 AS build 2 | 3 | ENV UV_PROJECT_ENVIRONMENT=/ve \ 4 | UV_COMPILE_BYTECODE=1 \ 5 | UV_LINK_MODE=copy \ 6 | UV_PYTHON_DOWNLOADS=never 7 | 8 | RUN apk update && \ 9 | apk upgrade && \ 10 | apk add \ 11 | gcc \ 12 | musl-dev \ 13 | libffi-dev \ 14 | openssl \ 15 | openssl-dev \ 16 | python3-dev \ 17 | libxml2-dev \ 18 | libxslt-dev \ 19 | openldap-dev 20 | 21 | RUN --mount=type=cache,target=/uv-cache \ 22 | --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \ 23 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 24 | --mount=type=bind,source=uv.lock,target=uv.lock \ 25 | uv --cache-dir=/uv-cache sync --frozen --no-install-project 26 | 27 | FROM python:3.13-alpine3.21 28 | 29 | ENV HISTCONTROL=ignorespace:ignoredups \ 30 | PATH=/ve/bin:/app:$PATH \ 31 | PYCURL_SSL_LIBRARY=nss \ 32 | UV_PROJECT_ENVIRONMENT=/ve \ 33 | UV_LINK_MODE=copy \ 34 | VIRTUAL_ENV=/ve 35 | 36 | RUN apk update && \ 37 | apk upgrade && \ 38 | apk add \ 39 | openssl \ 40 | openldap \ 41 | && \ 42 | # Add the user under which we will run. 43 | adduser -H -D app && \ 44 | # Generate a self-signed SSL cert for nginx to use. 45 | mkdir -p /certs && \ 46 | openssl req -x509 -nodes \ 47 | -subj "/C=US/ST=CA/O=Caltech/CN=localhost.localdomain" \ 48 | # 10 years 49 | -days 3650 \ 50 | -newkey rsa:2048 \ 51 | -keyout /certs/server.key \ 52 | -out /certs/server.crt && \ 53 | chown app:app /certs/* && \ 54 | pip install --upgrade uv pip setuptools 55 | 56 | COPY --from=build --chown=app:app /ve /ve 57 | ENV PATH=/ve/bin:$PATH PYTHONPATH=/app 58 | 59 | COPY . /app 60 | WORKDIR /app 61 | 62 | RUN --mount=type=cache,target=/uv-cache \ 63 | uv --cache-dir=/uv-cache sync --frozen 64 | 65 | USER app 66 | 67 | EXPOSE 8888 68 | 69 | CMD ["nginx-ldap-auth", "start"] 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # vscode 98 | .vscode 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # Development artifacts 110 | .python-version 111 | .DS_Store 112 | /*.sql 113 | config.codekit3 114 | sql/docker/mysql-data 115 | 116 | # Vim 117 | *.sw* 118 | *.bak 119 | 120 | # Terraform 121 | .terraform 122 | twistd.pid 123 | tags 124 | supervisord.pid 125 | requirements.txt.new 126 | docs.tar.gz 127 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | nginx-ldap-auth-service 3 | ======================= 4 | 5 | .. toctree:: 6 | :hidden: 7 | :caption: Overview 8 | 9 | changelog 10 | installation 11 | running 12 | configuration 13 | nginx 14 | monitoring 15 | contributing 16 | 17 | .. toctree:: 18 | :caption: Developer Interface 19 | :hidden: 20 | 21 | api/views.rst 22 | api/models.rst 23 | api/ldap.rst 24 | api/middleware.rst 25 | api/settings.rst 26 | 27 | 28 | ``nginx-ldap-auth-service`` provides a daemon (``nginx-ldap-auth``) that 29 | communicates with an LDAP or Active Directory server to authenticate users with 30 | their username and password, as well as a login form for actually allowing users 31 | to authenticate. You can use this in combination with the nginx module 32 | `ngx_http_auth_request_module `_ 33 | to provide authentication for your nginx server. 34 | 35 | Features 36 | ======== 37 | 38 | User authentication 39 | ------------------- 40 | 41 | - Built for use with the 42 | `ngx_http_auth_request_module `_ 43 | - Provides its own login form and authentication backend 44 | - Users login once via the login form, creating a login session that will be 45 | used for all subsequent requests to determine that the user is logged in. 46 | - Session data can be either in memory or Redis for high availability and session 47 | persistence though server restarts. 48 | - The same ``nginx_ldap_auth_service`` server can be used by multiple nginx 49 | servers. This allows you to use a single login form for multiple sites 50 | (single signon like), or you can configure each nginx server to use different 51 | session cookies so that login sessions are not shared between sites. 52 | 53 | User authorization 54 | ------------------ 55 | 56 | - Users can be authorized to access resources based on an LDAP search filter 57 | you supply. 58 | 59 | Other features 60 | -------------- 61 | - Implemented in `FastAPI `_ for speed and 62 | connection management. 63 | - Available a Docker image that can be used as a sidecar container with nginx. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = 2.3.0 2 | 3 | PACKAGE = nginx-ldap-auth-service 4 | 5 | DOCKER_REGISTRY = caltechads 6 | #====================================================================== 7 | 8 | image_name: 9 | @echo ${PACKAGE} 10 | 11 | version: 12 | @echo ${VERSION} 13 | 14 | docs: 15 | @echo "Generating docs..." 16 | @cd doc && rm -rf build && make json 17 | @cd doc/build && tar zcf docs.tar.gz json 18 | @mv doc/build/docs.tar.gz . 19 | @echo "New doc package is in docs.tar.gz" 20 | 21 | clean: 22 | rm -rf *.tar.gz dist *.egg-info *.rpm 23 | find . -name "*.pyc" -exec rm '{}' ';' 24 | 25 | dist: clean 26 | @uv build --sdist 27 | 28 | build: 29 | docker build --platform linux/amd64,linux/arm64 --sbom=true --provenance=true -t ${PACKAGE}:${VERSION} . 30 | docker tag ${PACKAGE}:${VERSION} ${PACKAGE}:latest 31 | docker image prune -f 32 | 33 | force-build: 34 | docker build --platform linux/amd64,linux/arm64 --sbom=true --provenance=true --no-cache -t ${PACKAGE}:${VERSION} . 35 | docker tag ${PACKAGE}:${VERSION} ${PACKAGE}:latest 36 | 37 | tag: 38 | docker tag ${PACKAGE}:${VERSION} ${DOCKER_REGISTRY}/${PACKAGE}:${VERSION} 39 | docker tag ${PACKAGE}:latest ${DOCKER_REGISTRY}/${PACKAGE}:latest 40 | 41 | push: tag 42 | docker push ${DOCKER_REGISTRY}/${PACKAGE}:latest 43 | docker push ${DOCKER_REGISTRY}/${PACKAGE}:${VERSION} 44 | 45 | pull: 46 | docker pull ${DOCKER_REGISTRY}/${PACKAGE}:${VERSION} 47 | 48 | dev: 49 | docker compose up 50 | 51 | dev-detached: 52 | docker compose up -d 53 | 54 | devdown: 55 | docker compose down 56 | 57 | restart: 58 | docker compose restart nginx-ldap-auth-service 59 | 60 | exec: 61 | docker exec -it nginx-ldap-auth-service /bin/sh 62 | 63 | scout: 64 | docker scout cves --only-severity=critical,high ${PACKAGE}:${VERSION} 65 | 66 | release: dist 67 | @bin/release.sh 68 | @twine upload dist/* 69 | 70 | log: 71 | docker compose logs -f nginx-ldap-auth-service 72 | 73 | logall: 74 | docker compose logs -f 75 | 76 | compile: uv.lock 77 | @uv pip compile --group=docs pyproject.toml -o requirements.txt 78 | 79 | docker-clean: 80 | docker stop $(shell docker ps -a -q) 81 | docker rm $(shell docker ps -a -q) 82 | 83 | .PHONY: list build force-build 84 | list: 85 | @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs 86 | -------------------------------------------------------------------------------- /doc/source/monitoring.rst: -------------------------------------------------------------------------------- 1 | .. _monitoring: 2 | 3 | Monitoring nginx_ldap_auth_service 4 | ================================== 5 | 6 | nginx_ldap_auth_service provides two endpoints for monitoring the status of the 7 | auth service and the LDAP connection. 8 | 9 | - ``/status`` - Returns the status of the auth service. 10 | - ``/status/ldap`` - Returns the status of the LDAP connection. 11 | 12 | These endpoints are useful for monitoring the health of the auth service and the 13 | LDAP connection. 14 | 15 | .. important:: 16 | 17 | Note that you have to be able to reach the ``/status`` and ``/status/ldap`` 18 | endpoints on the auth service itself in order to get the status of the auth 19 | service and the LDAP connection. This means you need to carefully expose 20 | these endpoints to your monitoring system without exposing them to the 21 | public. 22 | 23 | /status 24 | ------- 25 | 26 | The ``/status`` endpoint does not do any checks on any backend services, it only 27 | answers when asked, if it is able. The purpose of this endpoint is to provide a 28 | simple way to monitor whether the auth service is running and responding to 29 | requests. 30 | 31 | The ``/status`` endpoint returns the status of the auth service. It will return 32 | a JSON object with the following fields: 33 | 34 | - ``status`` - The status of the auth service. This will always be ``ok``. 35 | - ``message`` - this will always be ``Auth service is running``. 36 | 37 | 38 | 39 | If the auth service is running, you'll get a 200 OK response. If the auth 40 | service is not running, you'll get a timeout error, since the error condition 41 | monitored here is that the auth service is not responding to requests. 42 | 43 | /status/ldap 44 | ------------ 45 | 46 | The ``/status/ldap`` endpoint does a simple connect to the LDAP server to see if 47 | the LDAP connection is successful. Use this endpoint to monitor the health of 48 | the LDAP connection. 49 | 50 | .. important:: 51 | 52 | If ``/status`` is not responding, this endpoint won't respond either, since 53 | that means the auth service is not running or is not responding to requests. 54 | 55 | The ``/status/ldap`` endpoint returns the status of the LDAP connection. It 56 | will return a JSON object with the following fields: 57 | 58 | - ``status`` - The status of the LDAP connection. This will be ``ok`` if the LDAP connection is successful, otherwise it will be ``error``. 59 | - ``message`` - The message of the status. This will be the error message if the LDAP connection is not successful. 60 | 61 | If the LDAP connection is successful, you'll get a 200 OK response. If the LDAP 62 | connection is not successful, you'll get a 500 Internal Server Error response. 63 | -------------------------------------------------------------------------------- /nginx_ldap_auth/cli/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pprint 3 | 4 | import click 5 | import uvicorn 6 | 7 | from ..settings import Settings 8 | from .cli import cli 9 | 10 | settings = Settings() 11 | 12 | 13 | @cli.command("settings", short_help="Print our application settings.") 14 | def print_settings(): 15 | """ 16 | Print our settings to stdout. This should be the completely evaluated 17 | settings including those imported from any environment variable. 18 | """ 19 | pp = pprint.PrettyPrinter(indent=2) 20 | pp.pprint(settings.model_dump()) 21 | 22 | 23 | @cli.command("start", short_help="Start the nginx_ldap_auth service.") 24 | @click.option( 25 | "--host", 26 | "-h", 27 | default=lambda: os.environ.get("HOSTNAME", "0.0.0.0"), # noqa: S104 28 | help="The host to listen on.", 29 | ) 30 | @click.option( 31 | "--port", 32 | "-p", 33 | default=lambda: int(os.environ.get("PORT", "8888")), 34 | type=int, 35 | help="The port to listen on.", 36 | ) 37 | @click.option( 38 | "--reload/--no-reload", 39 | "-r", 40 | default=lambda: os.environ.get("RELOAD", "False") == "True", 41 | type=bool, 42 | help="Reload the server on code changes.", 43 | ) 44 | @click.option( 45 | "--keyfile", 46 | "-k", 47 | default=lambda: os.environ.get("SSL_KEYFILE", "/certs/server.key"), 48 | type=click.Path(exists=True, dir_okay=False), 49 | help="The path to the SSL key file.", 50 | ) 51 | @click.option( 52 | "--certfile", 53 | "-c", 54 | default=lambda: os.environ.get("SSL_CERTFILE", "/certs/server.crt"), 55 | type=click.Path(exists=True, dir_okay=False), 56 | help="The path to the SSL certificate file.", 57 | ) 58 | @click.option( 59 | "--insecure", 60 | default=lambda: os.environ.get("INSECURE", "False") == "True", 61 | type=bool, 62 | help="If the server should run over HTTP instead of HTTPS.", 63 | ) 64 | @click.option( 65 | "--workers", 66 | "-w", 67 | default=lambda: int(os.environ.get("WORKERS", "1")), 68 | type=int, 69 | help="The number of worker processes to spawn.", 70 | ) 71 | @click.option( 72 | "--env-file", 73 | default=None, 74 | type=click.Path(exists=True, dir_okay=False), 75 | help="The path to the environment file to load.", 76 | ) 77 | def start(**kwargs): 78 | """ 79 | Start the nginx_ldap_auth service. 80 | """ 81 | uvicorn_kwargs = { 82 | "host": kwargs["host"], 83 | "port": kwargs["port"], 84 | "reload": kwargs["reload"], 85 | "workers": kwargs["workers"], 86 | } 87 | if not kwargs["insecure"]: 88 | # adding in SSL settings results in `uvicorn` running over HTTPS 89 | # the SSL settings will be ignored when insecure mode is enabled 90 | ssl_kwargs = { 91 | "ssl_keyfile": kwargs["keyfile"], 92 | "ssl_certfile": kwargs["certfile"], 93 | "ssl_version": 2, 94 | } 95 | uvicorn_kwargs |= ssl_kwargs 96 | if kwargs["env_file"]: 97 | uvicorn_kwargs["env_file"] = kwargs["env_file"] 98 | uvicorn.run("nginx_ldap_auth.app.main:app", **uvicorn_kwargs) 99 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. # noqa: INP001 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../..")) # noqa: PTH100 17 | 18 | from typing import Any 19 | 20 | import sphinx_rtd_theme # pylint: disable=unused-import # noqa:F401 21 | 22 | # These are required for sphinx-apidoc to work 23 | os.environ["LDAP_URI"] = "ldap://ldap.example.com" 24 | os.environ["LDAP_BINDDN"] = "cn=admin,dc=example,dc=com" 25 | os.environ["LDAP_PASSWORD"] = "password" # noqa: S105 26 | os.environ["LDAP_BASEDN"] = "dc=example,dc=com" 27 | os.environ["SECRET_KEY"] = "my-key" # noqa: S105 28 | 29 | # -- Project information ----------------------------------------------------- 30 | 31 | # the master toctree document 32 | master_doc: str = "index" 33 | 34 | project: str = "nginx-ldap-auth-service" 35 | copyright: str = "2023, Caltech IMSS ADS" # noqa: A001 36 | author: str = "Caltech IMSS ADS" 37 | 38 | # The full version, including alpha/beta/rc tags 39 | release: str = "2.3.0" 40 | 41 | 42 | # -- General configuration --------------------------------------------------- 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions: list[str] = [ 48 | "sphinx.ext.autodoc", 49 | "sphinxcontrib.images", 50 | "sphinx.ext.napoleon", 51 | "sphinx.ext.viewcode", 52 | "sphinx.ext.intersphinx", 53 | "sphinx_rtd_theme", 54 | "sphinx_json_globaltoc", 55 | ] 56 | 57 | source_suffix: str = ".rst" 58 | 59 | # Add any paths that contain templates here, relative to this directory. 60 | templates_path: list[str] = ["_templates"] 61 | 62 | # List of patterns, relative to source directory, that match files and 63 | # directories to ignore when looking for source files. 64 | # This pattern also affects html_static_path and html_extra_path. 65 | exclude_patterns: list[str] = ["_build"] 66 | 67 | add_function_parentheses: bool = False 68 | add_module_names: bool = True 69 | 70 | autodoc_member_order: str = "bysource" 71 | autodoc_type_aliases: dict[str, str] = {} 72 | 73 | # the locations and names of other projects that should be linked to this one 74 | intersphinx_mapping: dict[str, tuple[str, str | None]] = { 75 | "python": ("https://docs.python.org/3", None), 76 | } 77 | 78 | # -- Options for HTML output ------------------------------------------------- 79 | 80 | # The theme to use for HTML and HTML Help pages. See the documentation for 81 | # a list of builtin themes. 82 | # 83 | html_theme: str = "sphinx_rtd_theme" 84 | 85 | html_show_sourcelink: bool = False 86 | html_show_sphinx: bool = False 87 | html_show_copyright: bool = True 88 | html_theme_options: dict[str, Any] = {"collapse_navigation": False} 89 | -------------------------------------------------------------------------------- /doc/source/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _runbook__contributing: 2 | 3 | Contributing 4 | ============ 5 | 6 | Instructions for contributors 7 | ----------------------------- 8 | 9 | In order to make a clone of the Github repo: 10 | 11 | .. code-block:: shell 12 | 13 | $ git clone https://github.com/caltechads/nginx-ldap-auth-service.git 14 | 15 | 16 | Workflow is pretty straightforward: 17 | 18 | #. Make sure you are reading the latest version of this document. 19 | #. Setup your machine with the required development environment 20 | #. Checkout a new branch, named for yourself and a summary of what you're trying to accomplish. 21 | #. Make a change 22 | #. Make sure all tests passed 23 | #. Update the documentation and ensure that it looks correct. 24 | #. Commit changes to your branch 25 | #. Merge your changes into master and push. 26 | 27 | 28 | Preconditions for working on nginx-ldap-auth-service 29 | ---------------------------------------------------- 30 | 31 | You'll need some version of Python 3.11 installed, and ``uv`` and ``pip``. 32 | 33 | .. code-block:: shell 34 | 35 | $ cd nginx-ldap-auth-service 36 | $ pip install uv 37 | $ uv venv 38 | $ source .venv/bin/activate 39 | $ uv sync --extra=docs 40 | 41 | After that please install libraries into your ``uv`` tool folder that are 42 | required for development: 43 | 44 | .. code-block:: shell 45 | 46 | $ uv tool install ruff 47 | $ uv tool install twine 48 | $ uv tool install bumpversion 49 | 50 | Precondiions for running the docker-compose stack in development 51 | ---------------------------------------------------------------- 52 | 53 | Since ``nginx-ldap-auth-service`` authenticates against an LDAP or Active 54 | Directory service, you will need to provide one. The LDAP/AD server you use 55 | needs these features: 56 | 57 | * It must support ``STARTTLS`` 58 | * It must support ``LDAPv3`` 59 | * It must support ``SIMPLE`` bind 60 | * It must have an account that with sufficient privileges to bind to the LDAP/AD 61 | server with a password and search for users. 62 | 63 | Prepare the docker environment 64 | ------------------------------ 65 | 66 | Now copy in the Docker environment file to the appropriate place on your dev box: 67 | 68 | .. code-block:: shell 69 | 70 | $ cp etc/environment.txt .env 71 | 72 | Edit ``.env`` replace these with settings appropriate for your LDAP/AD server: 73 | 74 | - ``__LDAP_URI__`` 75 | - ``__LDAP_BINDDN__`` 76 | - ``__LDAP_BASEDN__`` 77 | - ``__LDAP_PASSWORD__`` 78 | 79 | Build the Docker image 80 | ---------------------- 81 | 82 | .. code-block:: shell 83 | 84 | $ make build 85 | 86 | Run the stack 87 | ------------- 88 | 89 | .. code-block:: shell 90 | 91 | $ make dev 92 | 93 | This will bring up the full dev stack: 94 | 95 | - ``nginx`` 96 | - ``nginx-ldap-auth-service`` 97 | 98 | If you want to bring up a redis instance for session storage, you can do that by 99 | uncommenting the ``redis`` service in ``docker-compose.yml`` and adding these 100 | two settings to the ``environment`` section of the ``nginx_ldap_auth_service`` 101 | service:: 102 | 103 | - SESSION_BACKEND=redis 104 | - REDIS_URL=redis://redis:6379/0 105 | 106 | Use your dev environment 107 | ------------------------ 108 | 109 | You should how be able to browse to https://localhost/ and be redirected to 110 | the login page. 111 | 112 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {# As of June 2018, this is the most up-to-date "responsive design" viewport tag. #} 7 | 8 | 9 | {{ site_title|default('Restricted', true) }} | Login 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 34 | 35 | 36 | 37 |
38 |
39 |
40 |
41 |

{{ site_title }}

42 | {% if errors %} 43 | {% for error in errors %} 44 | 45 | {% endfor %} 46 | {% endif %} 47 | {# 48 | semgrep-reason: 49 | semgrep is confused -- we're clearly using a csrf_token here, and more than that, this is 50 | not Django. 51 | #} 52 | {# nosemgrep: django-no-csrf-token #} 53 |
54 | 55 | 56 |
57 | 58 |
59 |
60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 | 74 | -------------------------------------------------------------------------------- /nginx_ldap_auth/ldap.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any 3 | 4 | import bonsai 5 | from bonsai.asyncio import AIOConnectionPool, AIOLDAPConnection 6 | from bonsai.pool import ClosedPool, EmptyPool 7 | 8 | from .logging import logger 9 | from .settings import Settings 10 | 11 | 12 | class TimeLimitedAIOLDAPConnection(AIOLDAPConnection): 13 | """ 14 | A time-limited LDAP connection. This allows us to have a connection pool 15 | that will close connections after a certain amount of time. 16 | 17 | Args: 18 | client: The LDAP client. 19 | 20 | Keyword Args: 21 | expires: The number of seconds after which the connection will expire. 22 | loop: The asyncio event loop. 23 | 24 | """ 25 | 26 | def __init__(self, client: bonsai.LDAPClient, expires: int = 20, loop=None) -> None: 27 | super().__init__(client, loop=loop) 28 | self.expires = expires 29 | self.create_time = time.time() 30 | 31 | @property 32 | def is_expired(self) -> bool: 33 | return (time.time() - self.create_time) > self.expires 34 | 35 | 36 | class TimeLimitedAIOConnectionPool(AIOConnectionPool): 37 | """ 38 | A pool of time-limited LDAP connections. This allows us to have relatively 39 | fresh connections to our LDAP server while not having to create a new 40 | connection for every request. 41 | 42 | Args: 43 | settings: The application settings. 44 | client: The LDAP client. 45 | 46 | Keyword Args: 47 | minconn: The minimum number of connections to keep in the pool. 48 | maxconn: The maximum number of connections to keep in the pool. 49 | loop: The asyncio event loop. 50 | 51 | """ 52 | 53 | def __init__( 54 | self, 55 | settings: Settings, 56 | client: bonsai.LDAPClient, 57 | minconn: int = 1, 58 | maxconn: int = 10, 59 | loop=None, 60 | **kwargs: Any, 61 | ) -> None: 62 | super().__init__(client, minconn, maxconn, loop=loop, **kwargs) 63 | self.settings = settings 64 | 65 | async def get(self) -> AIOLDAPConnection: # type: ignore[override] 66 | """ 67 | Get a connection from the pool. If a connection has expired, close it 68 | and create a new connection, then return the new connection. 69 | 70 | Raises: 71 | ClosedPool: The pool has not been initialized. 72 | EmptyPool: There are no connections in the pool. 73 | 74 | Returns: 75 | A connection from the pool. 76 | 77 | """ 78 | async with self._lock: 79 | if self._closed: 80 | msg = "The pool is closed." 81 | raise ClosedPool(msg) 82 | await self._lock.wait_for(lambda: not self.empty or self._closed) 83 | try: 84 | conn = self._idles.pop() 85 | except KeyError: 86 | if len(self._used) < self._maxconn: 87 | conn = await self._client.connect( 88 | is_async=True, loop=self._loop, **self._kwargs 89 | ) 90 | else: 91 | msg = "Pool is empty." 92 | raise EmptyPool(msg) from None 93 | if conn.is_expired: 94 | logger.info( 95 | "ldap.pool.connection.recycle", 96 | lifetime_seconds=self.settings.ldap_pool_connection_lifetime_seconds, 97 | ) 98 | # Does this need to be awaited? 99 | conn.close() 100 | conn = await self._client.connect( 101 | is_async=True, loop=self._loop, **self._kwargs 102 | ) 103 | self._used.add(conn) 104 | self._lock.notify() 105 | return conn 106 | -------------------------------------------------------------------------------- /etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /dev/stderr info; 5 | pid /tmp/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | 12 | http { 13 | proxy_cache_path /tmp/nginx-cache keys_zone=auth_cache:10m; 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | map "$time_iso8601 # $msec" $time_iso8601_ms { 18 | "~(^.+)-0[78]:00 # \d+\.(\d+)$" $1,$2; 19 | } 20 | log_format main '$http_x_forwarded_for - $remote_user "$time_iso8601_ms" "$request" ' 21 | '$status $body_bytes_sent "$http_referer" "$http_user_agent"'; 22 | access_log /dev/stdout main; 23 | 24 | sendfile on; 25 | tcp_nopush on; 26 | 27 | server { 28 | listen 443 ssl; 29 | http2 on; 30 | server_name localhost; 31 | client_max_body_size 100M; 32 | client_header_timeout 305s; 33 | client_body_timeout 305s; 34 | keepalive_timeout 305s; 35 | 36 | server_tokens off; 37 | 38 | ssl_certificate /certs/localhost.crt; 39 | ssl_certificate_key /certs/localhost.key; 40 | ssl_session_cache shared:SSL:50m; 41 | ssl_session_timeout 1d; 42 | ssl_session_tickets on; 43 | add_header Strict-Transport-Security "max-age=63072000"; 44 | add_header X-XSS-Protection "1; mode=block"; 45 | 46 | # Disable the TRACE and TRACK methods. 47 | if ($request_method ~ ^(TRACE|TRACK)$ ) { 48 | return 405; 49 | } 50 | 51 | location = /favicon.ico { access_log off; log_not_found off; } 52 | location / { 53 | auth_request /check-auth; 54 | root /usr/share/nginx/html; 55 | index index.html index.htm; 56 | 57 | # If the auth service returns a 401, redirect to the login page. 58 | error_page 401 =200 /auth/login?service=$request_uri; 59 | } 60 | 61 | location /auth { 62 | proxy_pass https://nginx_ldap_auth_service:8888/auth; 63 | proxy_set_header X-Cookie-Name "nginxauth"; 64 | proxy_set_header X-Cookie-Domain "localhost"; 65 | proxy_set_header X-Auth-Realm "Caltech Restricted"; 66 | proxy_set_header Host $host; 67 | proxy_set_header X-Real-IP $remote_addr; 68 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 69 | proxy_set_header Cookie nginxauth_csrf=$cookie_nginxauth_csrf; 70 | } 71 | 72 | location /check-auth { 73 | internal; 74 | 75 | proxy_pass https://nginx_ldap_auth_service:8888/check; 76 | # Ensure that we don't pass the user's headers or request body to 77 | # the auth service. 78 | proxy_pass_request_headers off; 79 | proxy_pass_request_body off; 80 | proxy_set_header Content-Length ""; 81 | 82 | # We use the same auth service for managing the login and logout and 83 | # checking auth. The SessionMiddleware, which is used for all requests, 84 | # will always be trying to set cookies even on our /check path. Thus we 85 | # need to ignore the Set-Cookie header so that nginx will cache the 86 | # response. Otherwise, it will think this is a dynamic page that 87 | # shouldn't be cached. 88 | proxy_ignore_headers "Set-Cookie"; 89 | proxy_hide_header "Set-Cookie"; 90 | 91 | # Cache our auth responses for 10 minutes so that we're not 92 | # hitting the auth service on every request. 93 | proxy_cache auth_cache; 94 | proxy_cache_valid 200 10m; 95 | 96 | proxy_set_header X-Cookie-Name "nginxauth"; 97 | proxy_set_header X-Cookie-Domain "localhost"; 98 | proxy_set_header Cookie nginxauth=$cookie_nginxauth; 99 | proxy_cache_key "$http_authorization$cookie_nginxauth"; 100 | } 101 | 102 | error_page 500 502 503 504 /50x.html; 103 | location = /50x.html { 104 | root /usr/share/nginx/html; 105 | } 106 | } 107 | } 108 | 109 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/forms.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from fastapi import Request 4 | 5 | from ..logging import get_logger 6 | from ..settings import Settings 7 | from .models import User 8 | 9 | settings = Settings() 10 | 11 | 12 | class LoginForm: 13 | """ 14 | The form class for the login form. 15 | """ 16 | 17 | def __init__(self, request: Request) -> None: 18 | #: The current request object 19 | self.request: Request = request 20 | #: The errors to display to the user 21 | self.errors: list = [] 22 | #: The username to authenticate 23 | self.username: str | None = None 24 | #: The password to authenticate 25 | self.password: str | None = None 26 | #: The service to redirect to after authentication 27 | self.service: str = "/" 28 | #: The title of the site 29 | self.site_title: str = settings.auth_realm 30 | 31 | async def load_data(self) -> None: 32 | """ 33 | Load data from request form. 34 | """ 35 | form = await self.request.form() 36 | self.username = cast("str", form.get("username")) 37 | self.password = cast("str", form.get("password")) 38 | self.service = cast("str", form.get("service", "/")) 39 | 40 | async def is_valid(self) -> bool: 41 | """ 42 | Return whether the form is valid. 43 | 44 | * the form must have a non-empty username and password 45 | * the user must exist in LDAP meaning that the user must be in the 46 | results of the ldap search 47 | named by :py:attr:`nginx_ldap_auth.settings.Settings.ldap_get_user_filter` 48 | * If :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` 49 | is not ``None``, the user must be in the results of that LDAP search 50 | * the bind to LDAP must be successful 51 | 52 | If all those tests pass, return ``True``. Otherwise, return ``False``. 53 | 54 | Returns: 55 | ``True`` if the form is valid, ``False`` otherwise. 56 | 57 | """ 58 | _logger = get_logger(self.request) 59 | if not self.username: 60 | _logger.info("auth.failed.no_username") 61 | self.errors.append("Username is required") 62 | if not self.password: 63 | _logger.info("auth.failed.no_password") 64 | self.errors.append("A valid password is required") 65 | if user := await User.objects.get(cast("str", self.username)): 66 | # The user exists in LDAP 67 | user = cast("User", user) 68 | # Ensure that the user is authorized to access this service 69 | if not await User.objects.is_authorized(cast("str", self.username)): 70 | self.errors.append("You are not authorized to access this service.") 71 | _logger.warning( 72 | "auth.failed.not_authorized", 73 | username=self.username, 74 | full_name=user.full_name, 75 | ldap_url=settings.ldap_uri, 76 | target=self.service, 77 | ) 78 | # Now try to authenticate the user 79 | if await user.authenticate(cast("str", self.password)): 80 | # The user has provided valid credentials 81 | _logger.info( 82 | "auth.success", 83 | username=self.username, 84 | full_name=user.full_name, 85 | ldap_url=settings.ldap_uri, 86 | target=self.service, 87 | ) 88 | else: 89 | self.errors.append("Invalid username or password.") 90 | _logger.info( 91 | "auth.failed.invalid_credentials", 92 | username=self.username, 93 | target=self.service, 94 | ) 95 | else: 96 | self.errors.append("Invalid username or password.") 97 | _logger.warning( 98 | "auth.failed.no_such_user", 99 | username=self.username, 100 | target=self.service, 101 | ) 102 | return bool(not self.errors) 103 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --group=docs pyproject.toml -o requirements.txt 3 | aiodogstatsd==0.16.0.post0 4 | # via nginx-ldap-auth-service (pyproject.toml) 5 | alabaster==0.7.16 6 | # via sphinx 7 | annotated-types==0.7.0 8 | # via pydantic 9 | anyio==3.7.1 10 | # via 11 | # starlette 12 | # watchfiles 13 | babel==2.16.0 14 | # via sphinx 15 | bonsai==1.5.3 16 | # via nginx-ldap-auth-service (pyproject.toml) 17 | certifi==2024.12.14 18 | # via 19 | # requests 20 | # sentry-sdk 21 | charset-normalizer==3.4.1 22 | # via requests 23 | click==8.1.8 24 | # via 25 | # nginx-ldap-auth-service (pyproject.toml) 26 | # uvicorn 27 | docutils==0.19 28 | # via 29 | # sphinx 30 | # sphinx-rtd-theme 31 | fastapi==0.115.7 32 | # via 33 | # nginx-ldap-auth-service (pyproject.toml) 34 | # fastapi-csrf-protect 35 | fastapi-csrf-protect==1.0.0 36 | # via nginx-ldap-auth-service (pyproject.toml) 37 | h11==0.14.0 38 | # via uvicorn 39 | httptools==0.6.4 40 | # via 41 | # nginx-ldap-auth-service (pyproject.toml) 42 | # uvicorn 43 | idna==3.10 44 | # via 45 | # anyio 46 | # requests 47 | imagesize==1.4.1 48 | # via sphinx 49 | itsdangerous==2.2.0 50 | # via 51 | # fastapi-csrf-protect 52 | # starsessions 53 | jinja2==3.1.5 54 | # via 55 | # nginx-ldap-auth-service (pyproject.toml) 56 | # sphinx 57 | markupsafe==3.0.2 58 | # via jinja2 59 | packaging==24.2 60 | # via sphinx 61 | pydantic==2.10.6 62 | # via 63 | # nginx-ldap-auth-service (pyproject.toml) 64 | # fastapi 65 | # fastapi-csrf-protect 66 | # pydantic-settings 67 | pydantic-core==2.27.2 68 | # via pydantic 69 | pydantic-settings==2.7.1 70 | # via 71 | # nginx-ldap-auth-service (pyproject.toml) 72 | # fastapi-csrf-protect 73 | pygments==2.19.1 74 | # via sphinx 75 | python-dotenv==1.0.1 76 | # via 77 | # nginx-ldap-auth-service (pyproject.toml) 78 | # pydantic-settings 79 | # uvicorn 80 | python-multipart==0.0.20 81 | # via nginx-ldap-auth-service (pyproject.toml) 82 | pyyaml==6.0.2 83 | # via uvicorn 84 | redis==5.2.1 85 | # via starsessions 86 | requests==2.32.3 87 | # via 88 | # sphinx 89 | # sphinxcontrib-images 90 | sentry-sdk==2.20.0 91 | # via nginx-ldap-auth-service (pyproject.toml) 92 | setuptools==75.8.0 93 | # via nginx-ldap-auth-service (pyproject.toml:docs) 94 | sniffio==1.3.1 95 | # via anyio 96 | snowballstemmer==2.2.0 97 | # via sphinx 98 | sphinx==6.2.1 99 | # via 100 | # nginx-ldap-auth-service (pyproject.toml:docs) 101 | # sphinx-rtd-theme 102 | # sphinxcontrib-images 103 | # sphinxcontrib-jquery 104 | # sphinxcontrib-jsonglobaltoc 105 | sphinx-rtd-theme==2.0.0 106 | # via nginx-ldap-auth-service (pyproject.toml:docs) 107 | sphinxcontrib-applehelp==2.0.0 108 | # via sphinx 109 | sphinxcontrib-devhelp==2.0.0 110 | # via sphinx 111 | sphinxcontrib-htmlhelp==2.1.0 112 | # via sphinx 113 | sphinxcontrib-images==0.9.4 114 | # via nginx-ldap-auth-service (pyproject.toml:docs) 115 | sphinxcontrib-jquery==4.1 116 | # via sphinx-rtd-theme 117 | sphinxcontrib-jsmath==1.0.1 118 | # via sphinx 119 | sphinxcontrib-jsonglobaltoc==0.1.1 120 | # via nginx-ldap-auth-service (pyproject.toml:docs) 121 | sphinxcontrib-qthelp==2.0.0 122 | # via sphinx 123 | sphinxcontrib-serializinghtml==2.0.0 124 | # via 125 | # sphinx 126 | # sphinxcontrib-jsonglobaltoc 127 | starlette==0.45.3 128 | # via 129 | # fastapi 130 | # starsessions 131 | starsessions==2.2.1 132 | # via nginx-ldap-auth-service (pyproject.toml) 133 | structlog==25.1.0 134 | # via nginx-ldap-auth-service (pyproject.toml) 135 | tabulate==0.9.0 136 | # via nginx-ldap-auth-service (pyproject.toml) 137 | typing-extensions==4.12.2 138 | # via 139 | # fastapi 140 | # pydantic 141 | # pydantic-core 142 | urllib3==2.3.0 143 | # via 144 | # requests 145 | # sentry-sdk 146 | uvicorn==0.34.0 147 | # via nginx-ldap-auth-service (pyproject.toml) 148 | uvloop==0.21.0 149 | # via uvicorn 150 | watchfiles==1.0.4 151 | # via 152 | # nginx-ldap-auth-service (pyproject.toml) 153 | # uvicorn 154 | websockets==14.2 155 | # via uvicorn 156 | -------------------------------------------------------------------------------- /doc/source/running.rst: -------------------------------------------------------------------------------- 1 | Running nginx_ldap_auth_service 2 | =============================== 3 | 4 | .. highlight:: bash 5 | 6 | You can run ``nginx_ldap_auth_service`` as daemon running alongside your nginx 7 | process on your web server, or as a Docker sidecar container. 8 | 9 | .. _nginx_ldap_auth-cmd: 10 | 11 | nginx-ldap-auth command line 12 | ---------------------------- 13 | 14 | After installing ``nginx_ldap_auth_service`` you will have access to the command 15 | line script ``nginx-ldap-auth``. 16 | 17 | Basic usage:: 18 | 19 | $ nginx-ldap-auth start [OPTIONS] 20 | 21 | 22 | Positional and keyword arguments can also be passed, but it is recommended to 23 | load configuration from environment variables or with the ``--env-file`` option 24 | rather than the command line. 25 | 26 | Arguments 27 | ^^^^^^^^^ 28 | 29 | * ``-env-file FILE`` - Specify an environment file to use to configure 30 | ``nginx-ldap-auth-service``. This is the recommended way to configure 31 | ``nginx-ldap-auth-service``. Note that you can't configure any of 32 | the below options with an environment file; those environment variables 33 | if used must be set in the shell environment. 34 | 35 | * ``-h BIND, --host=BIND`` - Specify an IP address to which to bind. Defaults 36 | to the value of the ``HOST`` environment variable or ``0.0.0.0`` 37 | * ``-p PORT, --port=PORT`` - Specify an port to which to bind. Defaults 38 | to the value of the ``PORT`` environment variable or ``8888`` 39 | * ``-w WORKERS, --workers=WORKERS`` - Number of worker processes. Defaults to 40 | the value of the ``WORKERS`` environment variable, or ``1`` if neither is set. 41 | * ``--keyfile=KEYFILE`` - Specify a keyfile to use for SSL. Defaults to the 42 | value of the ``SSL_KEYFILE`` environment variable, or ``/certs/server.key``. 43 | ``/certs/server.key``. 44 | * ``--certfile=CERTFILE`` - Specify a certfile to use for SSL. Defaults to 45 | the value of the ``SSL_CERTFILE`` environment variable, or ``/certs/server.crt``. 46 | 47 | Deployments 48 | ----------- 49 | 50 | Docker sidecar container 51 | ^^^^^^^^^^^^^^^^^^^^^^^^ 52 | 53 | The preferred way to run ``nginx_ldap_auth_service`` is as a Docker sidecar 54 | container. This allows you to run ``nginx_ldap_auth_service`` alongside your 55 | nginx container, and have nginx talk to it when it needs to perform authentication 56 | or authorization. 57 | 58 | Here is an example ``docker-compose.yml`` file that runs ``nginx`` and 59 | ``nginx_ldap_auth_service``: 60 | 61 | .. code-block:: yaml 62 | 63 | services: 64 | nginx: 65 | image: nginx:latest 66 | container_name: nginx 67 | ports: 68 | - "8443:443" 69 | volumes: 70 | - ./etc/nginx/nginx.conf:/etc/nginx/nginx.conf 71 | - ./etc/nginx/certs:/certs 72 | depends_on: 73 | - nginx_ldap_auth_service 74 | links: 75 | - nginx_ldap_auth_service 76 | 77 | nginx_ldap_auth_service: 78 | image: caltechads/nginx-ldap-auth-service:latest 79 | hostname: auth-service 80 | container_name: nginx-ldap-auth-service 81 | ports: 82 | - "8888:8888" 83 | environment: 84 | - LDAP_URI=ldap://ldap.example.com 85 | - LDAP_BASEDN=dc=example,dc=com 86 | - LDAP_BINDDN=cn=readonly,dc=example,dc=com 87 | - LDAP_PASSWORD=readonly 88 | ... 89 | 90 | 91 | Kubernetes/AWS Elastic Container Service deployment details are left as an exercise 92 | for the reader. 93 | 94 | As a daemon 95 | ^^^^^^^^^^^ 96 | 97 | ``nginx-ldap-auth-service`` runs only in the foreground and it writes its logs 98 | to stdout, so if you want to run it as a daemon you will need to use a process 99 | manager like ``supervisord`` or ``systemd`` that can put it in the background and 100 | capture its output. 101 | 102 | Here is an example of running it with ``supervisord``. First make the log folder: 103 | 104 | .. code-block:: shell 105 | 106 | $ mkdir -p /var/log/nginx-ldap-auth-service 107 | $ chown $supervisor_user /var/log/nginx-ldap-auth-service 108 | 109 | Then configure ``supervisord`` to run ``nginx-ldap-auth-service`` as a daemon. 110 | Below we've configured it to read its configuration from an environment file. 111 | See :ref:`nginx_ldap_auth-cmd` and :ref:`nginx-ldap-auth-service-env`) for 112 | details about the environment variables that can be set in the environment file. 113 | 114 | .. code-block:: 115 | 116 | [program:nginx-ldap-auth-service] 117 | command=/path/to/nginx-ldap-auth --env-file /path/to/env-file 118 | directory=/tmp 119 | childlogdir=/var/log/nginx-ldap-auth-service 120 | stdout_logfile=/var/log/nginx-ldap-auth-service/stdout.log 121 | stdout_logfile_maxbytes=1MB 122 | redirect_stderr=true 123 | user=nobody 124 | autostart=true 125 | autorestart=true 126 | redirect_stderr=true 127 | 128 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/middleware.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from starlette.datastructures import MutableHeaders 4 | from starlette.requests import HTTPConnection 5 | from starlette.types import Message, Receive, Scope, Send 6 | from starsessions import SessionMiddleware as StarsessionsSessionMiddleware 7 | from starsessions.middleware import LoadGuard 8 | from starsessions.session import SessionHandler, get_session_remaining_seconds 9 | 10 | 11 | class SessionMiddleware(StarsessionsSessionMiddleware): 12 | """ 13 | Override the :py:class:`starsession.SessionMiddleware` to allow us to set 14 | the cookie name and domain via the ``X-Cookie-Name`` and ``X-Cookie-Domain`` 15 | headers, respectively. If those headers are not present, the values from 16 | the constructor are used. 17 | 18 | We need this so that we can set the cookie name and domain dynamically based 19 | on the request. This is necessary because we may have multiple nginx severs 20 | that use a single ``nginx_ldap_auth`` server for authentication. 21 | 22 | Note: 23 | Unfortunately, the :py:meth:``__call__`` method is monolithic in the 24 | superclass, so we have to re-implement it here in is entirety to do 25 | what we want to do. 26 | 27 | """ 28 | 29 | #: The header name for the cookie name passed in by nginx. 30 | COOKIE_NAME_HEADER: typing.Final[str] = "X-Cookie-Name" 31 | #: The header name for the cookie domain passed in by nginx. 32 | COOKIE_DOMAIN_HEADER: typing.Final[str] = "X-Cookie-Domain" 33 | 34 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 35 | if scope["type"] not in ("http", "websocket"): # pragma: no cover 36 | await self.app(scope, receive, send) 37 | return 38 | 39 | connection = HTTPConnection(scope) 40 | cookie_name = connection.headers.get(self.COOKIE_NAME_HEADER, self.cookie_name) 41 | cookie_domain = connection.headers.get( 42 | self.COOKIE_DOMAIN_HEADER, self.cookie_domain 43 | ) 44 | session_id = connection.cookies.get(cookie_name) 45 | handler = SessionHandler( 46 | connection, session_id, self.store, self.serializer, self.lifetime 47 | ) 48 | 49 | scope["session"] = LoadGuard() 50 | scope["session_handler"] = handler 51 | 52 | async def send_wrapper(message: Message) -> None: 53 | if message["type"] != "http.response.start": 54 | await send(message) 55 | return 56 | 57 | if not handler.is_loaded: # session was not loaded, do nothing 58 | await send(message) 59 | return 60 | 61 | nonlocal session_id 62 | path = self.cookie_path or scope.get("root_path", "") or "/" 63 | 64 | if handler.is_empty: 65 | # if session was initially empty then do nothing 66 | if handler.initially_empty: 67 | await send(message) 68 | return 69 | 70 | # session data loaded but empty, no matter whether it was 71 | # initially empty or cleared we have to remove the cookie and 72 | # clear the storage 73 | if not self.cookie_path or ( 74 | self.cookie_path and scope["path"].startswith(self.cookie_path) 75 | ): 76 | headers = MutableHeaders(scope=message) 77 | header_value = "{}={}; {}".format( 78 | cookie_name, 79 | f"null; path={path}; expires=Thu, 01 Jan 1970 00:00:00 GMT;", 80 | self.security_flags, 81 | ) 82 | headers.append("Set-Cookie", header_value) 83 | await handler.destroy() 84 | await send(message) 85 | return 86 | 87 | # calculate cookie/storage expiry seconds based on selected strategy 88 | remaining_time = 0 89 | 90 | # if lifetime is zero then don't send max-age at all 91 | # this will create session-only cookie 92 | if self.lifetime > 0: 93 | if self.rolling: 94 | # rolling strategy always extends cookie max-age by lifetime 95 | remaining_time = self.lifetime 96 | else: 97 | # non-rolling strategy reuses initial expiration date 98 | remaining_time = get_session_remaining_seconds(connection) 99 | 100 | # persist session data 101 | session_id = await handler.save(remaining_time) 102 | 103 | headers = MutableHeaders(scope=message) 104 | header_parts = [ 105 | f"{cookie_name}={session_id}", 106 | f"path={path}", 107 | ] 108 | 109 | username = scope["session"].get("username") 110 | if username: 111 | headers.append("X-Authenticated-User", username) 112 | 113 | if self.lifetime > 0: # always send max-age for non-session scoped cookie 114 | header_parts.append(f"max-age={remaining_time}") 115 | 116 | if cookie_domain: 117 | header_parts.append(f"domain={cookie_domain}") 118 | 119 | header_parts.append(self.security_flags) 120 | header_value = "; ".join(header_parts) 121 | headers.append("set-cookie", header_value) 122 | 123 | await send(message) 124 | 125 | await self.app(scope, receive, send_wrapper) 126 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 2.3.0 (2025-10-29) 5 | ------------------ 6 | 7 | - Added a ``/status`` endpoint to the auth service. This endpoint returns the status of the auth service. 8 | - Added a ``/status/ldap`` endpoint to the auth service. This endpoint returns the status of the LDAP connection. 9 | - Updated all dependencies to the latest versions. 10 | 11 | 2.2.0 (2025-10-03) 12 | ------------------ 13 | 14 | Enhancements 15 | ^^^^^^^^^^^^ 16 | 17 | - Added the ``INSECURE`` setting. If set to ``True``, the auth service will run over HTTP instead of HTTPS -- konrad@spatialedge.ai 18 | - Updated all dependencies to the latest versions. 19 | 20 | 2.1.8 (2025-06-25) 21 | ------------------ 22 | 23 | Documentation 24 | ^^^^^^^^^^^^^ 25 | 26 | - Corrected the default for ``LDAP_STARTTLS`` to be ``True`` instead of ``False``. 27 | 28 | 2.1.7 (2025-06-23) 29 | ------------------ 30 | 31 | Enhancements 32 | ^^^^^^^^^^^^ 33 | 34 | - Updated all dependencies to the latest versions. 35 | 36 | 2.1.6 (2025-05-02) 37 | ------------------ 38 | 39 | Enhancements 40 | ^^^^^^^^^^^^ 41 | 42 | - Added the ``X-Authenticated-User`` header to the response. This is the username of the authenticated user. This is useful for for passing the username to the actual service being authenticated. [Thanks @micchickenburger] 43 | - Updated all dependencies to the latest versions. 44 | - Now using ``python:3.13-alpine3.21`` as the base image for Dockerhub. 45 | - Updated the Dockerfile build strategy to our best practices here at Caltech. 46 | 47 | Documentation 48 | ^^^^^^^^^^^^^ 49 | 50 | - Added the ``changelog`` to the documentation 51 | 52 | 2.1.5 (2025-03-17) 53 | ------------------ 54 | 55 | Enhancements 56 | ^^^^^^^^^^^^ 57 | 58 | - Now using ``python:3.12-alpine3.21`` as the base image for Dockerhub. 59 | 60 | Bugfixes 61 | ^^^^^^^^ 62 | 63 | - Don't distribute wheels -- some people were having issues with them 64 | 65 | 66 | 2.1.4 (2025-02-19) 67 | ------------------ 68 | 69 | Enhancements 70 | ^^^^^^^^^^^^ 71 | 72 | - Added the ``LDAP_USER_BASEDN`` setting. This is the base DN for the user search. It defaults to ``LDAP_BASEDN`` if not set. [@JustGitting] 73 | - Updated dependencies to the latest versions. 74 | 75 | 2.1.3 (2025-02-11) 76 | ------------------ 77 | 78 | Bugfixes 79 | ^^^^^^^^ 80 | 81 | - Actually package the templates and static files in the distribution 82 | - Use :py:attr:`nginx_ldap_auth.Settings.ldap_username_attribute`` and :py:attr:`nginx_ldap_auth.Settings.ldap_full_name_attribute`` to load the user object 83 | - More ReadTheDocs config file fixes 84 | 85 | 2.1.2 (2025-01-30) 86 | ------------------ 87 | 88 | Bugfixes 89 | ^^^^^^^^ 90 | 91 | - Fixed the messed up ``nosemgrep`` comment in the login template. 92 | 93 | 2.1.1 (2025-01-30) 94 | ------------------ 95 | 96 | Enhancements 97 | ^^^^^^^^^^^^ 98 | 99 | - Now building multi-arch images for Dockerhub (amd64 and arm64) 100 | - Changed the package name to reflect what modern Python packaging tools expect. The package is now called ``nginx_ldap_auth`` instead of ``nginx-ldap-auth``. 101 | 102 | Bugfixes 103 | ^^^^^^^^ 104 | 105 | - Added pyproject.toml to MANIFEST.in so it gets included in the sdist package 106 | - TERRAFORM: hopefully the runner instance creation now properly installs acrunner 107 | 108 | 2.1.0 (2025-01-30) 109 | ------------------ 110 | 111 | Enhancements 112 | ^^^^^^^^^^^^ 113 | 114 | - Added CSRF protection to the ``nginx-ldap-auth`` login page. 115 | - Now using ``uv`` for managing the virtualenv and doing packaging 116 | 117 | Documentation 118 | ^^^^^^^^^^^^^ 119 | 120 | - Updated :doc:`/contributing` for the new ``uv`` workflow 121 | - Various other documentation updates 122 | 123 | 2.0.5 (2023-07-23) 124 | ------------------ 125 | 126 | Bugfixes 127 | ^^^^^^^^ 128 | 129 | - Docs build again. 130 | 131 | 132 | 2.0.4 (2023-07-14) 133 | ------------------ 134 | 135 | Enhancements 136 | ^^^^^^^^^^^^ 137 | 138 | - Added ``USE_ROLLING_SESSIONS``. If ``True``, the session lifetime will be reset on every request. Defaults to ``False``. 139 | - ``REDIS_URL`` is now required if ``SESSION_BACKEND`` is set to ``ldap``. 140 | - ``LDAP_BASEDN`` is now required. 141 | - ``SECRET_KEY`` is now required. 142 | 143 | Bugfixes 144 | ^^^^^^^^ 145 | 146 | - On startup, don't log the full LDAP URL. This is a security issue, as it may contain sensitive information. 147 | 148 | Documentation 149 | ^^^^^^^^^^^^^ 150 | 151 | - Documented ``MAX_SESSION_AGE``. 152 | - Noted which settings are required to localize the app to your environment. 153 | - Various other documentation updates. 154 | 155 | 2.0.3 (2023-07-11) 156 | ------------------ 157 | 158 | Bugfixes 159 | ^^^^^^^^ 160 | 161 | - Actually obey :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` if it is set. 162 | - ``nginx-ldap-auth`` now chooses the correct cert file. 163 | - Fix typo in ``etc/environment.txt`` 164 | 165 | Documentation 166 | ^^^^^^^^^^^^^ 167 | 168 | - ReadTheDocs config actually works now. 169 | - Documented how to use ``nginx-ldap-auth`` as a dockerhub Docker container. 170 | 171 | 2.0.2 (2023-07-11) 172 | ------------------ 173 | 174 | Enhancements 175 | ^^^^^^^^^^^^ 176 | 177 | - Added a ReadTheDocs configuration file 178 | 179 | Bugfixes 180 | ^^^^^^^^ 181 | 182 | - Removed ``gunicorn`` from the requirements. It was never needed. 183 | 184 | 2.0.1 (2023-07-11) 185 | ------------------ 186 | 187 | Documentation 188 | ^^^^^^^^^^^^^ 189 | 190 | - Update docs to reflect that you need to use an ``nginx`` with ``http_auth_request_modele`` built in. 191 | 192 | 1.0.0 (2023-07-07) 193 | ------------------ 194 | 195 | Enhancements 196 | ^^^^^^^^^^^^ 197 | 198 | - First release of the project 199 | -------------------------------------------------------------------------------- /nginx_ldap_auth/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import RedisDsn, ValidationError, model_validator 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | 6 | 7 | class Settings(BaseSettings): 8 | """ 9 | Settings for the nginx_ldap_auth service. 10 | """ 11 | 12 | # ================== 13 | # Logging 14 | # ================== 15 | 16 | #: FastAPI debug mode 17 | debug: bool = False 18 | #: Default log level. Choose from any of the standard Python log levels. 19 | loglevel: Literal["NOTSET", "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"] = "INFO" 20 | #: What format should we log in? Valid values are ``json`` and ``text`` 21 | log_type: Literal["json", "text"] = "text" 22 | 23 | # ================== 24 | # HTTP 25 | # ================== 26 | 27 | #: Use this as the title for the login form, to give a hint to the 28 | #: user as to what they're logging into 29 | auth_realm: str = "Restricted" 30 | 31 | # ================== 32 | # Session 33 | # ================== 34 | 35 | #: The name of the cookie to set when a user authenticates 36 | cookie_name: str = "nginxauth" 37 | #: The domain to use for our session cookie, if any. 38 | cookie_domain: str | None = None 39 | #: The secret key to use for session cookies 40 | secret_key: str 41 | #: The maximum age of a session cookie in seconds 42 | session_max_age: int = 0 43 | #: Reset the session lifetime to :py:attr:`session_max_age` every time the 44 | #: user accesses the protected site 45 | use_rolling_session: bool = False 46 | #: Session type: either ``redis`` or ``memory`` 47 | session_backend: Literal["redis", "memory"] = "memory" 48 | #: If using the Redis session backend, the DSN on which to connect to Redis. 49 | #: 50 | #: A fully specified Redis DSN looks like this:: 51 | #: 52 | #: redis://[username][:password]@host:port/db 53 | #: 54 | #: * The username is only necessary if you are using role-based access 55 | #: controls on your Redis server. Otherwise the password is sufficient if you 56 | #: have a server password for your Redis server. 57 | #: * If you don't specify a database, ``0`` is used. 58 | #: * If you don't specify a password, no password is used. 59 | #: * If you don't specify a port, ``6379`` is used. 60 | redis_url: RedisDsn | None = None 61 | #: If using the Redis session backend, the prefix to use for session keys 62 | redis_prefix: str = "nginx_ldap_auth." 63 | 64 | # ================== 65 | # LDAP 66 | # ================== 67 | 68 | #: The URI via which to connect to LDAP 69 | ldap_uri: str 70 | #: The DN as which to bind to LDAP 71 | ldap_binddn: str 72 | #: The password to use when binding to LDAP when doing our searches 73 | ldap_password: str 74 | #: Whether to use TLS when connecting to LDAP 75 | ldap_starttls: bool = True 76 | #: Whether to disable LDAP referrals 77 | ldap_disable_referrals: bool = False 78 | #: The base DN under which to perform searches 79 | ldap_basedn: str 80 | #: The base DN to append to the user's username when binding. This is only 81 | #: important for Active Directory, where we need to use the value of 82 | #: ``userPrincipalName`` (typically the user's email address) as the 83 | #: username intead of the dn which would be built as 84 | #: ``sAMAccountName=user,{LDAP_BASEDN}``. Include the ``@`` at the begining 85 | #: of the string. If this is set, the binddn will be 86 | #: ``{username}{ldap_user_basedn}`` 87 | ldap_user_basedn: str | None = None 88 | #: The LDAP attribute to use as the username when searching for a user 89 | ldap_username_attribute: str = "uid" 90 | #: The LDAP attribute to use as the full name when getting search results 91 | ldap_full_name_attribute: str = "cn" 92 | #: The LDAP search filter to use when searching for a user. This should 93 | #: be a valid LDAP search filter. The search will be a SUBTREE search 94 | #: with the base DN of :py:attr:`ldap_basedn`. 95 | #: 96 | #: You may use these replacement fields in the filter: 97 | #: 98 | #: - ``{username_attribute}``: the value of 99 | #: :py:class:`Settings.ldap_username_attribute` 100 | #: - ``{username_full_name_attribute}``: the value of 101 | #: :py:class:`Settings.ldap_full_name_attribute` 102 | #: 103 | #: Use ``{username}`` in the search filter as the placeholder for the username 104 | #: supplied by the user from the login form. 105 | ldap_get_user_filter: str = "{username_attribute}={username}" 106 | #: The LDAP search filter to use to determine whether a user is authorized. This 107 | #: should a valid LDAP search filter. If this is ``None``, all users who can 108 | #: successfully authenticate will be authorized. If this is not ``None``, 109 | #: the search with this filter must return at least one result for the user 110 | #: to be authorized. 111 | #: 112 | #: You may use these replacement fields in the filter: 113 | #: 114 | #: - ``{username_attribute}``: the value of 115 | #: :py:attr:`ldap_username_attribute` 116 | #: - ``{username_full_name_attribute}``: the value of 117 | #: :py:attr:`ldap_full_name_attribute` 118 | #: 119 | #: Use ``{username}`` in the search filter as the placeholder for the username 120 | #: supplied by the user from the login form. 121 | ldap_authorization_filter: str | None = None 122 | #: Number of seconds to wait for an LDAP connection to be established 123 | ldap_timeout: int = 15 124 | #: Min number of LDAP connections to keep in the pool 125 | ldap_min_pool_size: int = 1 126 | #: Max number of LDAP connections to keep in the pool 127 | ldap_max_pool_size: int = 30 128 | #: Recycle LDAP connections after this many seconds 129 | ldap_pool_connection_lifetime_seconds: int = 20 130 | 131 | # ================== 132 | # Sentry 133 | # ================== 134 | #: The sentry DSN to use for error reporting. If this is ``None``, no 135 | #: error reporting will be done. 136 | sentry_url: str | None = None 137 | 138 | model_config = SettingsConfigDict() 139 | 140 | @model_validator(mode="after") #: type: ignore 141 | def redis_url_required_if_session_type_is_redis(self): 142 | """ 143 | If we've configured the session backend to be ``redis``, 144 | :py:attr:`redis_url` is required. 145 | 146 | Raises: 147 | ValidationError: ``redis_url`` is required if ``session_backend`` is 148 | ``redis`` 149 | 150 | """ 151 | if self.session_backend == "redis" and not self.redis_url: 152 | msg = "redis_url is required if session_backend is redis" 153 | raise ValidationError(msg) 154 | return self 155 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nginx_ldap_auth_service" 3 | version = "2.3.0" 4 | description = "A FastAPI app that authenticates users via LDAP and sets a cookie for nginx" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | authors = [ 8 | {name = "Caltech IMSS ADS", email = "imss-ads-staff@caltech.edu"}, 9 | ] 10 | maintainers = [ 11 | {name = "Christopher Malek", email = "cmalek@caltech.edu"}, 12 | ] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Development Status :: 5 - Production/Stable", 21 | "Framework :: FastAPI", 22 | "Intended Audience :: Information Technology", 23 | "Intended Audience :: System Administrators", 24 | "Intended Audience :: Developers", 25 | "Topic :: Internet :: WWW/HTTP", 26 | "Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP", 27 | ] 28 | keywords = ["nginx", "ldap", "auth", "fastapi", "devops"] 29 | dependencies = [ 30 | "aiodogstatsd==0.16.0.post0", 31 | "bonsai==1.5.3", 32 | "click>=8.0.1", 33 | "fastapi>=0.115.7 ", 34 | "fastapi-csrf-protect>=1.0.0", 35 | "jinja2>=3.0.3", 36 | "pydantic-settings>=2.0.0", 37 | "pydantic>=2.0.0", 38 | "python-dotenv>=1.0.1", 39 | "python-multipart>=0.0.6", 40 | "sentry-sdk>=2.20.0", 41 | "starsessions[redis]>=2.3.0", 42 | "structlog>=23.2.0", 43 | "tabulate>=0.8.9", 44 | "uvicorn[standard]>=0.34.0", 45 | "watchfiles>=1.0.4", 46 | "httptools>=0.6.4", 47 | ] 48 | 49 | [project.scripts] 50 | nginx-ldap-auth = "nginx_ldap_auth.main:main" 51 | 52 | [tool.uv] 53 | python-preference = "only-system" 54 | default-groups = ["docs"] 55 | 56 | [dependency-groups] 57 | dev = [ 58 | "ipython>=8.0.1", 59 | ] 60 | docs = [ 61 | "Sphinx<8", 62 | "sphinx_rtd_theme == 2.0.0", 63 | "sphinxcontrib-jsonglobaltoc==0.1.1", 64 | "sphinxcontrib-images >= 0.9.4", 65 | "setuptools>=75.1.0", 66 | ] 67 | 68 | [build-system] 69 | requires = [ 70 | "setuptools >= 48", 71 | "wheel >= 0.29.0", 72 | ] 73 | build-backend = "setuptools.build_meta" 74 | 75 | [tool.setuptools] 76 | # ... 77 | # By default, include-package-data is true in pyproject.toml, so you do 78 | # NOT have to specify this line. 79 | include-package-data = true 80 | 81 | [tool.setuptools.packages.find] 82 | where = ["."] 83 | 84 | [tool.mypy] 85 | exclude = "(^build/.*$|^doc/.*\\.py$|test_.*\\.py$)" 86 | plugins = ["pydantic.mypy"] 87 | 88 | [[tool.mypy.overrides]] 89 | module = "bonsai.*" 90 | ignore_missing_imports = true 91 | 92 | [[tool.mypy.overrides]] 93 | module = "sphinx_rtd_theme.*" 94 | ignore_missing_imports = true 95 | 96 | [[tool.mypy.overrides]] 97 | module = "redis.*" 98 | ignore_missing_imports = true 99 | 100 | [[tool.mypy.overrides]] 101 | module = "sentry_sdk.*" 102 | ignore_missing_imports = true 103 | 104 | [[tool.mypy.overrides]] 105 | module = "fastapi_csrf_protect.*" 106 | ignore_missing_imports = true 107 | 108 | [[tool.mypy.overrides]] 109 | module = "starlette.*" 110 | ignore_missing_imports = true 111 | 112 | [[tool.mypy.overrides]] 113 | module = "starlette.*" 114 | ignore_missing_imports = true 115 | 116 | [tool.ruff] 117 | # Same as Black. 118 | line-length = 88 119 | indent-width = 4 120 | 121 | [tool.ruff.lint] 122 | select = ["ALL"] 123 | fixable = ["ALL"] 124 | unfixable = [] 125 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 126 | ignore = [ 127 | #### modules 128 | "ANN", # flake8-annotations 129 | "COM", # flake8-commas 130 | "C90", # mccabe complexity 131 | "EXE", # flake8-executable 132 | "T10", # debugger 133 | "TID", # flake8-tidy-imports 134 | 135 | #### specific rules 136 | "CPY001", # ignore missing copyright notices 137 | "D100", # Missing docstring in public module 138 | "D102", # Missing docstring in public method 139 | "D103", # Missing docstring in public function 140 | "D104", # Missing docstring in public package 141 | "D105", # Missing docstring in magic method 142 | "D106", # Missing docstring in nested class 143 | "D107", # ignore Missing docstring in __init__ method 144 | "D200", # One-line docstring should fit on one line 145 | "D203", # 1 blank required before class docstring 146 | "D205", # 1 blank line required between summary line and description 147 | "D211", # No blank lines allowed before class docstring 148 | "D212", # Multi-line docstring summary should start at the first line 149 | "D400", # First line should end with a period 150 | "D401", # First line of docstring should be in imperative mood 151 | "D415", # First line should end with a period, question mark, or exclamation point 152 | "DOC201", # Ignore missing "Return" section in docstring 153 | "E402", # false positives for local imports 154 | "FIX002", # Line contains "TODO", consider resolving the issue 155 | "N818", # stop bugging me about not ending my exceptions with "Error" 156 | "PLC0415", # Ignore imports that aren't at the top level. Sometimes that's needed to avoid circular imports. 157 | "S603", # ignore subprocess calls that do not check return code 158 | "S607", # ignore subprocess programs that are not absolute paths 159 | "SIM102", # combine nested ifs 160 | "SLF001", # Ignore access to attributes starting with a single _. 161 | "TD002", # Missing author in TODO; try: # TODO(): ... or # TODO @: 162 | "TD003", # Missing issue link on the line following this TODO 163 | ] 164 | 165 | [tool.ruff.format] 166 | # Like Black, use double quotes for strings. 167 | quote-style = "double" 168 | # Like Black, indent with spaces, rather than tabs. 169 | indent-style = "space" 170 | # Like Black, respect magic trailing commas. 171 | skip-magic-trailing-comma = false 172 | # Like Black, automatically detect the appropriate line ending. 173 | line-ending = "auto" 174 | # Enable auto-formatting of code examples in docstrings. 175 | docstring-code-format = false 176 | # Set the line length limit used when formatting code snippets in 177 | # docstrings. 178 | docstring-code-line-length = "dynamic" 179 | 180 | [tool.ruff.lint.pylint] 181 | # Django signal handlers use a lot of positional args. 182 | max-args = 10 183 | max-positional-args = 10 184 | 185 | [tool.vulture] 186 | # Configuration for vulture: https://github.com/jendrikseipp/vulture 187 | # Install in your virtual environment and run: 188 | # python -m vulture | tail -r | less 189 | # The below configuration tries to remove some false positives, but there are 190 | # still many, for example for model properties used only in templates. 191 | # See also: 192 | # https://adamj.eu/tech/2023/07/12/django-clean-up-unused-code-vulture/ 193 | ignore_decorators = [ 194 | # pytest 195 | "@pytest.fixture", 196 | ] 197 | ignore_names = [ 198 | ] 199 | paths = [ 200 | "example", 201 | ] 202 | min_confidence = 80 203 | sort_by_size = true -------------------------------------------------------------------------------- /doc/source/nginx.rst: -------------------------------------------------------------------------------- 1 | .. _nginx: 2 | 3 | Configuring nginx 4 | ================= 5 | 6 | This page describes how to configure nginx to use ``nginx-ldap-auth-service`` to 7 | password protect your site using LDAP. 8 | 9 | ngx_http_auth_request_module 10 | ---------------------------- 11 | 12 | ``nginx-ldap-auth-service`` requires your ``nginx`` to have the 13 | ``ngx_http_auth_request_module`` to do its work. To see if your version of nginx 14 | has that installed, do ``nginx -V`` and look for ``--with-http_auth_request_module``: 15 | 16 | .. code-block:: bash 17 | 18 | $ nginx -V 19 | nginx version: nginx/1.23.4 20 | built by gcc 10.2.1 20210110 (Debian 10.2.1-6) 21 | built with OpenSSL 1.1.1n 15 Mar 2022 22 | TLS SNI support enabled 23 | configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-g -O2 -ffile-prefix-map=/data/builder/debuild/nginx-1.23.4/debian/debuild-base/nginx-1.23.4=. -fstack-protector-strong -Wformat -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -Wl,--as-needed -pie' 24 | 25 | nginx.conf 26 | ---------- 27 | 28 | There four bits to this configuration: 29 | 30 | #. Configuring your site's ``location`` block to use ``auth_request`` and 31 | to redirect any unauthenticated requests to the ``nginx-ldap-auth-service`` 32 | login page. 33 | #. Configuring a ``location`` for ``nginx-ldap-auth-service`` to use to 34 | authenticate and logout users. 35 | #. Configuring the ``location`` that ``auth_request`` will use to 36 | see if a user is authenticated. 37 | #. (optional) Configuring a cache for the ``auth_request`` location so that we don't 38 | have to hit the auth service on every request. 39 | 40 | Below is a minimal example configuration for a site that uses LDAP to 41 | authenticate users that want to access the site whose root page is ``/``. 42 | You need everything there in order to make it work. 43 | 44 | Things to note: 45 | 46 | - We serve all the login related views in an ``server`` block that is HTTPS only. 47 | This is because we don't want to send the user's password over the wire in 48 | plain text. 49 | - In the ``proxy_pass`` lines below, we're naming the server that hosts the auth 50 | service ``nginx_ldap_auth_service`` on port 8888. Change this to whatever 51 | hostname and port the service answers on in your architecture. 52 | - The login and logout related views are served by ``nginx-ldap-auth-service`` 53 | and always use the paths ``/auth/login`` and ``/auth/logout``, and those paths 54 | are hard-coded into the login form; you can't change them. The ``/auth`` 55 | location handles the proxying of those paths to ``nginx-ldap-auth-service``. 56 | - If you set the :envvar:`COOKIE_NAME` environment variable in the 57 | ``nginx_ldap_auth_service`` service, you need to change the ``proxy_set_header 58 | Cookie nginxauth`` line in the ``/auth`` location to match that value, 59 | changing ``nginxauth`` to whatever you set it to in all places in that line. 60 | You will also need to do the same things to the ``proxy_set_header Cookie`` 61 | and ``proxy_cache_key`` lines in the ``/check-auth`` location. Finally, you 62 | will have to change the ``proxy_set_header Cookie nginxauth_conf`` line 63 | in the ``/auth`` location to match the value of :envvar:`COOKIE_NAME` with 64 | ``_csrf`` appended, again in all places in that line. 65 | - If you set the :envvar:`CSRF_COOKIE_NAME`, you will have to change the 66 | ``proxy_set_header Cookie nginxauth_conf`` line in the ``/auth`` location to 67 | match that value with in all places in that line. 68 | - See :ref:`nginx_header_config` for information on how to configure 69 | ``nginx-ldap-auth-service`` behavior using custom headers. 70 | 71 | 72 | .. code-block:: nginx 73 | :emphasize-lines: 12,23,28,29,30,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68 74 | 75 | user nginx; 76 | worker_processes auto; 77 | 78 | error_log /dev/stderr info; 79 | pid /tmp/nginx.pid; 80 | 81 | events { 82 | worker_connections 1024; 83 | } 84 | 85 | http { 86 | proxy_cache_path /tmp/nginx-cache keys_zone=auth_cache:10m; 87 | include /etc/nginx/mime.types; 88 | default_type application/octet-stream; 89 | 90 | server { 91 | listen 443 ssl; 92 | http2 on; 93 | 94 | ssl_certificate /certs/localhost.crt; 95 | ssl_certificate_key /certs/localhost.key; 96 | 97 | location / { 98 | auth_request /check-auth; 99 | root /usr/share/nginx/html; 100 | index index.html index.htm; 101 | 102 | # If the auth service returns a 401, redirect to the login page. 103 | error_page 401 =200 /auth/login?service=$request_uri; 104 | } 105 | 106 | location /auth { 107 | proxy_pass https://nginx_ldap_auth_service:8888/auth; 108 | proxy_set_header Host $host; 109 | proxy_set_header X-Real-IP $remote_addr; 110 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 111 | # We need to pass in the CSRF cookie we set in the login code so 112 | # that we can validate it 113 | proxy_set_header Cookie nginxauth_csrf=$cookie_nginxauth_csrf; 114 | } 115 | 116 | location /check-auth { 117 | internal; 118 | proxy_pass https://nginx_ldap_auth_service:8888/check; 119 | 120 | # Ensure that we don't pass the user's headers or request body to 121 | # the auth service. 122 | proxy_pass_request_headers off; 123 | proxy_pass_request_body off; 124 | proxy_set_header Content-Length ""; 125 | 126 | # We use the same auth service for managing the login and logout and 127 | # checking auth. The SessionMiddleware, which is used for all requests, 128 | # will always be trying to set cookies even on our /check path. Thus we 129 | # need to ignore the Set-Cookie header so that nginx will cache the 130 | # response. Otherwise, it will think this is a dynamic page that 131 | # shouldn't be cached. 132 | proxy_ignore_headers "Set-Cookie"; 133 | proxy_hide_header "Set-Cookie"; 134 | 135 | # Cache our auth responses for 10 minutes so that we're not 136 | # hitting the auth service on every request. 137 | proxy_cache auth_cache; 138 | proxy_cache_valid 200 10m; 139 | 140 | proxy_set_header Cookie nginxauth=$cookie_nginxauth; 141 | proxy_cache_key "$http_authorization$cookie_nginxauth"; 142 | } 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /nginx_ldap_auth/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import sys 4 | from typing import TYPE_CHECKING, Any, cast 5 | 6 | import structlog 7 | from fastapi import Request 8 | 9 | from nginx_ldap_auth.settings import Settings 10 | 11 | if TYPE_CHECKING: 12 | from starlette.datastructures import Address 13 | 14 | settings = Settings() 15 | logger = structlog.get_logger("nginx_ldap_auth") 16 | 17 | 18 | def get_logger(request: Request | None = None) -> structlog.BoundLogger: 19 | """ 20 | Return a structlog logger with request context information. 21 | 22 | Creates a logger that includes relevant request context when available, making 23 | it easier to trace logs related to specific requests. 24 | 25 | Note: 26 | When a request is provided, the logger is bound with the following values: 27 | 28 | * realm: The authentication realm (from x-auth-realm header or settings) 29 | * host: The server hostname (from host header) 30 | * remote_ip: The client IP address (from x-forwarded-for or request.client) 31 | 32 | 33 | Args: 34 | request: The FastAPI/Starlette request object. If provided, adds request 35 | context information to the logger. 36 | 37 | Returns: 38 | structlog.BoundLogger: A configured structlog logger with optional 39 | request context 40 | 41 | """ 42 | if request is None: 43 | return logger 44 | remote_ip = request.headers.get("x-forwarded-for") 45 | if remote_ip: 46 | remote_ip = remote_ip.split(",")[0] 47 | else: 48 | remote_ip = cast("Address", request.client).host 49 | request = cast("Request", request) 50 | return logger.bind( 51 | realm=request.headers.get("x-auth-realm", settings.auth_realm), 52 | host=request.headers.get("host", "unknown"), 53 | remote_ip=remote_ip, 54 | ) 55 | 56 | 57 | class ContextLoggingProcessor: 58 | """ 59 | Structlog processor that adds additional context to log events. 60 | 61 | This processor allows you to define default key-value pairs that will be 62 | added to all log events processed by structlog. Values provided explicitly 63 | during logging will take precedence over these defaults. 64 | 65 | Attributes: 66 | log_kwargs: Dictionary of key-value pairs to add to log events 67 | 68 | Example: 69 | To make all log events include ``app_name`` and ``environment`` unless 70 | explicitly overridden: 71 | 72 | .. code-block:: python 73 | 74 | import structlog 75 | from nginx_ldap_auth.logging import ContextLoggingProcessor 76 | 77 | # Create a processor that adds app_name and environment to all log events 78 | processor = ContextLoggingProcessor( 79 | app_name="my-app", 80 | environment="production" 81 | ) 82 | 83 | """ 84 | 85 | def __init__(self, **kwargs: Any) -> None: 86 | """ 87 | Initialize the processor with context values. 88 | 89 | Args: 90 | **kwargs: Key-value pairs to add to all log events 91 | 92 | """ 93 | self.log_kwargs = kwargs 94 | 95 | def __call__(self, _, __, event_dict): 96 | """ 97 | Add context values to the log event dictionary. 98 | 99 | This processor adds all key-value pairs from the initialization to the 100 | event dictionary, but doesn't overwrite any existing values. 101 | 102 | Args: 103 | _: Logger (unused) 104 | __: Method name (unused) 105 | event_dict: The log event dictionary to modify 106 | 107 | Returns: 108 | dict: The modified log event dictionary 109 | 110 | """ 111 | for k, v in self.log_kwargs.items(): 112 | event_dict.setdefault(k, v) 113 | return event_dict 114 | 115 | 116 | class CensorPasswordProcessor: 117 | """ 118 | Structlog processor that censors password fields in log events. 119 | 120 | This processor automatically detects and censors common password field names 121 | in log events, preventing sensitive information from being logged. 122 | 123 | Attributes: 124 | log_kwargs: Additional configuration options (unused currently) 125 | 126 | Example: 127 | >>> # Add to structlog processors 128 | >>> processors = [ 129 | >>> # other processors 130 | >>> CensorPasswordProcessor(), 131 | >>> # more processors 132 | >>> ] 133 | 134 | """ 135 | 136 | def __init__(self, **kwargs: Any) -> None: 137 | """ 138 | Initialize the processor. 139 | 140 | Args: 141 | **kwargs: Configuration options (reserved for future use) 142 | 143 | """ 144 | self.log_kwargs = kwargs 145 | 146 | def __call__(self, _, __, event_dict): 147 | """ 148 | Censor password fields in the log event dictionary. 149 | 150 | Automatically identifies and censors values for keys named "password", 151 | "password1", or "password2" to prevent sensitive data from being logged. 152 | 153 | Args: 154 | _: Logger (unused) 155 | __: Method name (unused) 156 | event_dict: The log event dictionary to modify 157 | 158 | Returns: 159 | dict: The modified log event dictionary with passwords censored 160 | 161 | """ 162 | for password_key_name in ("password", "password1", "password2"): 163 | if password_key_name in event_dict: 164 | event_dict[password_key_name] = "*CENSORED*" 165 | return event_dict 166 | 167 | 168 | structlog.configure( 169 | processors=[ 170 | structlog.stdlib.filter_by_level, 171 | structlog.stdlib.add_logger_name, 172 | structlog.stdlib.add_log_level, 173 | structlog.stdlib.PositionalArgumentsFormatter(), 174 | structlog.processors.TimeStamper(fmt="iso"), 175 | structlog.processors.StackInfoRenderer(), 176 | structlog.processors.format_exc_info, 177 | ContextLoggingProcessor(), 178 | CensorPasswordProcessor(), 179 | structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 180 | ], 181 | context_class=dict, 182 | logger_factory=structlog.stdlib.LoggerFactory(), 183 | wrapper_class=structlog.stdlib.BoundLogger, 184 | cache_logger_on_first_use=False, 185 | ) 186 | 187 | pre_chain = [ 188 | structlog.processors.StackInfoRenderer(), 189 | structlog.processors.format_exc_info, 190 | structlog.stdlib.add_logger_name, 191 | structlog.stdlib.add_log_level, 192 | structlog.processors.TimeStamper(fmt="iso"), 193 | ] 194 | 195 | LOGGING = { 196 | "version": 1, 197 | "disable_existing_loggers": True, 198 | "handlers": { 199 | "default": { 200 | "level": settings.loglevel, 201 | "class": "logging.StreamHandler", 202 | "formatter": "default", 203 | }, 204 | "access": {"class": "logging.StreamHandler", "formatter": "access"}, 205 | }, 206 | "loggers": { 207 | "uvicorn.error": { 208 | "level": settings.loglevel, 209 | "handlers": ["default"], 210 | "propagate": False, 211 | }, 212 | "uvicorn.access": { 213 | "level": settings.loglevel, 214 | "handlers": ["access"], 215 | "propagate": False, 216 | }, 217 | }, 218 | "root": { 219 | # Set up the root logger. This will make all otherwise unconfigured 220 | # loggers log through structlog processor. 221 | "handlers": ["default"], 222 | "level": settings.loglevel, 223 | }, 224 | "formatters": { 225 | "default": { 226 | "()": structlog.stdlib.ProcessorFormatter, 227 | "processor": structlog.processors.JSONRenderer(), 228 | "foreign_pre_chain": pre_chain, 229 | "format": "%(message)s", 230 | }, 231 | "access": { 232 | "()": "uvicorn.logging.AccessFormatter", 233 | "format": "%(asctime)s %(message)s", 234 | }, 235 | }, 236 | } 237 | 238 | 239 | if settings.log_type == "text": 240 | LOGGING["formatters"]["default"]["processor"] = structlog.dev.ConsoleRenderer() # type: ignore[index] 241 | 242 | 243 | logging.config.dictConfig(LOGGING) 244 | 245 | 246 | def handle_exception(exc_type, exc_value, exc_traceback): 247 | """ 248 | Log any uncaught exception instead of letting it be printed by Python 249 | (but leave KeyboardInterrupt untouched to allow users to Ctrl+C to stop) 250 | See https://stackoverflow.com/a/16993115/3641865 251 | """ 252 | if issubclass(exc_type, KeyboardInterrupt): 253 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 254 | return 255 | 256 | logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) 257 | 258 | 259 | sys.excepthook = handle_exception 260 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/models.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional, cast 2 | 3 | import bonsai 4 | from bonsai.errors import ( 5 | AuthenticationError, 6 | LDAPError, 7 | ) 8 | from bonsai.utils import escape_filter_exp 9 | from pydantic import BaseModel 10 | 11 | from nginx_ldap_auth.ldap import ( 12 | TimeLimitedAIOConnectionPool, 13 | TimeLimitedAIOLDAPConnection, 14 | ) 15 | from nginx_ldap_auth.logging import logger 16 | from nginx_ldap_auth.settings import Settings 17 | from nginx_ldap_auth.types import LDAPObject 18 | 19 | 20 | class UserManager: 21 | """ 22 | Manage users in the LDAP directory. 23 | """ 24 | 25 | #: The model class for users 26 | model: ClassVar[type["User"]] 27 | 28 | def __init__(self) -> None: 29 | #: The application settings 30 | self.settings = Settings() 31 | #: The LDAP connection pool 32 | self.pool: TimeLimitedAIOConnectionPool | None = None 33 | 34 | def client(self) -> bonsai.LDAPClient: 35 | """ 36 | Return a new LDAP client instance. 37 | 38 | If :py:attr:`nginx_ldap_auth.settings.Settings.ldap_starttls` is ``True``, 39 | the client will be configured to use TLS. 40 | """ 41 | client = bonsai.LDAPClient( 42 | cast("str", self.settings.ldap_uri), tls=self.settings.ldap_starttls 43 | ) 44 | client.set_cert_policy("never") 45 | client.set_ca_cert(None) 46 | client.set_ca_cert_dir(None) 47 | client.ignore_referrals = self.settings.ldap_disable_referrals 48 | client.set_server_chase_referrals(not self.settings.ldap_disable_referrals) 49 | client.set_async_connection_class(TimeLimitedAIOLDAPConnection) 50 | return client 51 | 52 | async def create_pool(self) -> None: 53 | """ 54 | Create the LDAP connection pool and save it as :py:attr:`pool`. 55 | """ 56 | client = self.client() 57 | if self.settings.ldap_binddn and self.settings.ldap_password: 58 | client.set_credentials( 59 | "SIMPLE", 60 | user=self.settings.ldap_binddn, 61 | password=self.settings.ldap_password, 62 | ) 63 | self.pool = TimeLimitedAIOConnectionPool( 64 | self.settings, 65 | client, 66 | minconn=self.settings.ldap_min_pool_size, 67 | maxconn=self.settings.ldap_max_pool_size, 68 | expires=self.settings.ldap_pool_connection_lifetime_seconds, 69 | ) 70 | await self.pool.open() 71 | 72 | async def authenticate(self, username: str, password: str) -> bool: 73 | """ 74 | Authenticate a user against the LDAP server. 75 | 76 | If :py:attr:`nginx_ldap_auth.settings.Settings.ldap_user_basedn` is set, 77 | we will prepend the username with that value to create the DN to bind 78 | with like so: "{username}{ldap_user_base_dn}. Otherwise, we will use 79 | the value of 80 | :py:attr:`nginx_ldap_auth.settings.Settings.ldap_username_attribute` to 81 | create the DN as ``{username_attribute}={username},{ldap_basedn}``. 82 | 83 | Args: 84 | username: the username to authenticate 85 | password: the password to authenticate with 86 | 87 | Raises: 88 | LDAPError: if an error occurs while communicating with the LDAP server 89 | 90 | Returns: 91 | ``True`` if the user is authenticated, ``False`` otherwise 92 | 93 | """ 94 | if self.settings.ldap_user_basedn: 95 | # This is AD and we need to use the userPrincipalName 96 | dn = f"{username}{self.settings.ldap_user_basedn}" 97 | else: 98 | dn = ( 99 | f"{self.settings.ldap_username_attribute}={username}," 100 | f"{self.settings.ldap_basedn}" 101 | ) 102 | client = self.client() 103 | client.set_credentials("SIMPLE", user=dn, password=password) 104 | logger.info( 105 | "ldap.authenticate", 106 | dn=dn, 107 | uri=self.settings.ldap_uri, 108 | ) 109 | try: 110 | await client.connect(is_async=True) 111 | except AuthenticationError as e: 112 | logger.error( 113 | "ldap.authenticate.error.invalid_credentials", 114 | dn=dn, 115 | uri=self.settings.ldap_uri, 116 | exc_info=str(e), 117 | ) 118 | return False 119 | except LDAPError: 120 | logger.exception("ldap.authenticate.exception", uid=username) 121 | raise 122 | return True 123 | 124 | async def exists(self, username: str) -> bool: 125 | """ 126 | Return ``True`` if the user exists in the LDAP directory, ``False`` 127 | otherwise. 128 | 129 | Args: 130 | username: the username to check 131 | 132 | Raises: 133 | LDAPError: if an error occurred while communicating with the LDAP server 134 | AuthenticationError: if the LDAP server rejects the credentials of 135 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_binddn` and 136 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_password` 137 | 138 | Returns: 139 | ``True`` if the user exists in the LDAP directory, ``False`` 140 | otherwise 141 | 142 | """ 143 | return await self.get(username) is not None 144 | 145 | async def is_authorized(self, username: str) -> bool: 146 | """ 147 | Test whether the user is authorized to log in. This is done by 148 | performing an LDAP search using the filter specified in 149 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter`. 150 | If that setting is ``None``, the user is considered authorized. 151 | 152 | Args: 153 | username: the username to check 154 | 155 | Raises: 156 | LDAPError: if an error occurred while communicating with the LDAP server 157 | AuthenticationError: if the LDAP server rejects the credentials of 158 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_binddn` and 159 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_password` 160 | 161 | Returns: 162 | ``True`` if the user is authorized to log in, ``False`` otherwise. 163 | 164 | """ 165 | if not self.pool: 166 | await self.create_pool() 167 | pool = cast("TimeLimitedAIOConnectionPool", self.pool) 168 | if self.settings.ldap_authorization_filter is None: 169 | return True 170 | try: 171 | async with pool.spawn() as conn: 172 | results = await conn.search( 173 | base=self.settings.ldap_basedn, 174 | scope=bonsai.LDAPSearchScope.SUBTREE, 175 | filter_exp=self.settings.ldap_authorization_filter.format( 176 | username_attribute=self.settings.ldap_username_attribute, 177 | fullname_attribute=self.settings.ldap_full_name_attribute, 178 | username=escape_filter_exp(username), 179 | ), 180 | attrlist=[self.settings.ldap_username_attribute], 181 | ) 182 | except AuthenticationError: 183 | logger.error( 184 | "ldap.is_authorized.error.invalid_credentials", 185 | bind_dn=self.settings.ldap_binddn, 186 | ) 187 | raise 188 | except LDAPError: 189 | logger.exception( 190 | "ldap.is_authorized.exception", 191 | bind_dn=self.settings.ldap_binddn, 192 | username=username, 193 | ) 194 | raise 195 | return len(results) > 0 196 | 197 | async def get(self, username: str) -> Optional["User"]: 198 | """ 199 | Get a user from the LDAP directory, and return it as a :py:class:`User`. 200 | When getting the user, we will use the LDAP search filter specified in 201 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_get_user_filter`. 202 | 203 | Args: 204 | username: the username for which to get user information 205 | 206 | Raises: 207 | LDAPError: if an error occurred while communicating with the LDAP server 208 | AuthenticationError: if the LDAP server rejects the credentials of 209 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_binddn` and 210 | :py:class:`nginx_ldap_auth.settings.Settings.ldap_password` 211 | 212 | Returns: 213 | The user information as a :py:class:`User` instance, or ``None`` if 214 | the user is not returned by the LDAP search filter 215 | 216 | """ 217 | if not self.pool: 218 | await self.create_pool() 219 | pool = cast("TimeLimitedAIOConnectionPool", self.pool) 220 | try: 221 | async with pool.spawn() as conn: 222 | results = await conn.search( 223 | base=self.settings.ldap_basedn, 224 | scope=bonsai.LDAPSearchScope.SUBTREE, 225 | filter_exp=self.settings.ldap_get_user_filter.format( 226 | username_attribute=self.settings.ldap_username_attribute, 227 | fullname_attribute=self.settings.ldap_full_name_attribute, 228 | username=escape_filter_exp(username), 229 | ), 230 | attrlist=[ 231 | self.settings.ldap_username_attribute, 232 | self.settings.ldap_full_name_attribute, 233 | ], 234 | ) 235 | except AuthenticationError: 236 | logger.error( 237 | "ldap.get_user.error.invalid_credentials", 238 | bind_dn=self.settings.ldap_binddn, 239 | ) 240 | raise 241 | except LDAPError: 242 | logger.exception( 243 | "ldap.get_user.exception", 244 | bind_dn=self.settings.ldap_binddn, 245 | username=username, 246 | ) 247 | raise 248 | if results: 249 | if len(results) > 1: 250 | logger.warning( 251 | "ldap.get_user.error.multiple_results", 252 | bind_dn=self.settings.ldap_binddn, 253 | username=username, 254 | dns=";".join([r[0] for r in results]), 255 | ) 256 | return self.model.parse_ldap(results[0]) 257 | return None 258 | 259 | async def cleanup(self) -> None: 260 | """ 261 | Close the LDAP connection pool. 262 | """ 263 | if self.pool: 264 | await self.pool.close() 265 | 266 | 267 | class User(BaseModel): 268 | """ 269 | Used to represent a user in the LDAP directory. It is constructed from the 270 | LDAP response, and is used to authenticate the user against the LDAP server. 271 | """ 272 | 273 | objects: ClassVar["UserManager"] = UserManager() 274 | 275 | #: The username of the user. 276 | uid: str 277 | #: The full name of the user. We really only use this for logging. 278 | full_name: str 279 | 280 | async def authenticate(self, password: str) -> bool: 281 | """ 282 | Authenticate this user against the LDAP server. 283 | 284 | Args: 285 | password: the password to authenticate with 286 | 287 | Returns: 288 | ``True`` if the user is authenticated, ``False`` otherwise 289 | 290 | """ 291 | return await self.objects.authenticate(self.uid, password) 292 | 293 | @classmethod 294 | def parse_ldap(cls, data: LDAPObject) -> "User": 295 | """ 296 | Parse the LDAP response, and extract the uid and full name from 297 | the LDAP server to use in constructing this class. 298 | 299 | We use 300 | :py:attr:`nginx_ldap_auth.settings.Settings.ldap_username_attribute` to 301 | determine which LDAP attribute on ``data`` holds our :py:attr:`uid` value, and 302 | :py:attr:`nginx_ldap_auth.settings.Settings.ldap_full_name_attribute` to 303 | determine which LDAP attribute holds our :py:attr:`full_name` value. 304 | 305 | Args: 306 | data: the raw LDAP data 307 | 308 | Returns: 309 | A configured :py:class:`User` object 310 | 311 | """ 312 | settings = Settings() 313 | username_attribute = settings.ldap_username_attribute 314 | fullname_attribute = settings.ldap_full_name_attribute 315 | kwargs = { 316 | "uid": data[username_attribute][0], 317 | "full_name": data[fullname_attribute][0], 318 | } 319 | logger.info("user.parse_ldap", **kwargs) 320 | return cls(**kwargs) 321 | 322 | 323 | UserManager.model = User 324 | -------------------------------------------------------------------------------- /nginx_ldap_auth/app/main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Annotated, Any, cast 3 | 4 | from bonsai import LDAPError 5 | from fastapi import Depends, FastAPI, Request, Response, status 6 | from fastapi.responses import HTMLResponse, RedirectResponse 7 | from fastapi.staticfiles import StaticFiles 8 | from fastapi.templating import Jinja2Templates 9 | from fastapi_csrf_protect import CsrfProtect 10 | from fastapi_csrf_protect.exceptions import CsrfProtectError 11 | from pydantic import AnyUrl, Field 12 | from pydantic_settings import BaseSettings 13 | from starsessions import ( 14 | InMemoryStore, 15 | SessionStore, 16 | get_session_handler, 17 | load_session, 18 | ) 19 | from starsessions.stores.redis import RedisStore 20 | 21 | from nginx_ldap_auth import __version__ 22 | from nginx_ldap_auth.settings import Settings 23 | 24 | from ..logging import get_logger 25 | from .forms import LoginForm 26 | from .middleware import SessionMiddleware 27 | from .models import User 28 | 29 | current_dir: Path = Path(__file__).resolve().parent 30 | static_dir: Path = current_dir / "static" 31 | templates_dir: Path = current_dir / "templates" 32 | 33 | 34 | settings = Settings() 35 | 36 | # -------------------------------------- 37 | # Session Store 38 | # -------------------------------------- 39 | 40 | if settings.session_backend == "memory": 41 | store: SessionStore = InMemoryStore() 42 | get_logger().info("session.store", backend=settings.session_backend) 43 | elif settings.session_backend == "redis": 44 | store = RedisStore( 45 | str(settings.redis_url), 46 | prefix=settings.redis_prefix, 47 | gc_ttl=settings.session_max_age, 48 | ) 49 | redis_url = cast("AnyUrl", settings.redis_url) 50 | get_logger().info( 51 | "session.store", 52 | backend=settings.session_backend, 53 | server=redis_url.host, 54 | port=redis_url.port, 55 | db=redis_url.path, 56 | ) 57 | 58 | # -------------------------------------- 59 | # CSRF Protection 60 | # -------------------------------------- 61 | 62 | 63 | class CsrfSettings(BaseSettings): 64 | """ 65 | Settings for CSRF protection. Used by the `fastapi-csrf-protect` library. 66 | 67 | See: https://github.com/fastapi-csrf-protect/fastapi-csrf-protect 68 | """ 69 | 70 | #: The secret key to use for CSRF tokens 71 | secret_key: str = Field(validation_alias="CSRF_SECRET_KEY") 72 | #: We'll set the SameSite attribute on our CSRF cookies to this value 73 | cookie_samesite: str = "lax" 74 | #: Set our CSRF cookie to be secure 75 | cookie_secure: bool = True 76 | #: Set the maximum age of our CSRF cookie to 5 minutes 77 | max_age: int = 300 78 | #: Cookie name 79 | cookie_key: str = f"{settings.cookie_name}_csrf" 80 | #: Cookie domain 81 | cookie_domain: str | None = settings.cookie_domain 82 | #: Token location for validation -- in the csrf_token field in the body 83 | token_location: str = "body" # noqa: S105 84 | #: The key to use for the CSRF token -- the name of the field in the body 85 | token_key: str = "csrf_token" # noqa: S105 86 | 87 | 88 | @CsrfProtect.load_config 89 | def get_csrf_config(): 90 | return CsrfSettings() 91 | 92 | 93 | # -------------------------------------- 94 | # The FastAPI app 95 | # -------------------------------------- 96 | 97 | app = FastAPI(title="nginx_ldap_auth", debug=settings.debug, version=__version__) 98 | app.mount("/auth/static", StaticFiles(directory=str(static_dir)), name="static") 99 | app.add_middleware( 100 | SessionMiddleware, 101 | store=store, 102 | cookie_name=settings.cookie_name, 103 | lifetime=settings.session_max_age, 104 | ) 105 | get_logger().info( 106 | "session.setup.complete", 107 | backend=settings.session_backend, 108 | cookie_name=settings.cookie_name, 109 | cookie_domain=settings.cookie_domain, 110 | max_age=settings.session_max_age, 111 | rolling=settings.use_rolling_session, 112 | ) 113 | templates = Jinja2Templates(directory=str(templates_dir)) 114 | 115 | 116 | # -------------------------------------- 117 | # Startup and Shutdown Events 118 | # -------------------------------------- 119 | 120 | 121 | @app.on_event("startup") 122 | async def startup() -> None: 123 | """ 124 | Create the LDAP connection pool when we start up. 125 | """ 126 | await User.objects.create_pool() 127 | 128 | 129 | @app.on_event("shutdown") 130 | async def shutdown() -> None: 131 | """ 132 | Close the LDAP connection pool when we shut down. 133 | """ 134 | await User.objects.cleanup() 135 | 136 | 137 | # -------------------------------------- 138 | # Helper Functions 139 | # -------------------------------------- 140 | 141 | 142 | async def kill_session(request: Request) -> None: 143 | """ 144 | Kill the current session. 145 | 146 | This means empty the session object of all contents, and delete it from the 147 | backend. 148 | 149 | Args: 150 | request: The request object 151 | 152 | """ 153 | for key in list(request.session.keys()): 154 | del request.session[key] 155 | await get_session_handler(request).destroy() 156 | 157 | 158 | # -------------------------------------- 159 | # Views 160 | # -------------------------------------- 161 | 162 | 163 | @app.get("/auth/login", response_model=None, name="login") 164 | async def login( 165 | request: Request, 166 | csrf_protect: Annotated[CsrfProtect, Depends()], 167 | service: str = "/", 168 | ) -> HTMLResponse | RedirectResponse: 169 | """ 170 | If the user is already logged in -- they have a session cookie, the session 171 | the cookie points to exists, and the "username" key in the session exists -- 172 | redirect to the URI named by the ``service``, query paremeter, defaulting to 173 | ``/`` if that is not present. Otherwise, render the login page with a fresh 174 | CSRF token, storing ``service`` in the login form as a hidden field. 175 | 176 | If the header ``X-Auth-Realm`` is set, use that as the title for the login 177 | page. Otherwise use 178 | :py:attr:`nginx_ldap_auth.settings.Settings.auth_realm`. 179 | 180 | Args: 181 | request: The request object 182 | csrf_protect: The CSRF protection dependency 183 | 184 | Keyword Args: 185 | service: redirect the user to this URL after successful login 186 | 187 | Returns: 188 | If the user is already logged in, a redirect response to the service URL. 189 | Otherwise, a rendered login page. 190 | 191 | """ 192 | csrf_token, signed_token = csrf_protect.generate_csrf_tokens() 193 | auth_realm = request.headers.get("x-auth-realm", settings.auth_realm) 194 | _logger = get_logger(request) 195 | _logger.info("auth.login.start", target=service) 196 | await load_session(request) 197 | if request.session.get("username"): 198 | session_id = get_session_handler(request).session_id 199 | _logger.info( 200 | "auth.login.success.already_logged_in", 201 | username=request.session["username"], 202 | session_id=session_id, 203 | target=service, 204 | ) 205 | # semgrep-reason: 206 | # The service URL is passed in by nginx, and the user cannot directly 207 | # reach this URL unless nginx says it needs to, so this is safe. 208 | # nosemgrep: tainted-redirect-fastapi # noqa: ERA001 209 | return RedirectResponse(url=service) 210 | # semgrep-reason: 211 | # The service URL is passed in by nginx, and the user cannot directly 212 | # reach this URL unless nginx says it needs to, so this is safe. 213 | # nosemgrep: tainted-direct-response-fastapi # noqa: ERA001 214 | response = templates.TemplateResponse( 215 | "login.html", 216 | { 217 | "request": request, 218 | "site_title": auth_realm, 219 | "service": service, 220 | "csrf_token": csrf_token, 221 | }, 222 | ) 223 | csrf_protect.set_csrf_cookie(signed_token, response) 224 | return response 225 | 226 | 227 | @app.post("/auth/login", response_model=None, name="login_handler") 228 | async def login_handler( 229 | request: Request, 230 | csrf_protect: Annotated[CsrfProtect, Depends()], 231 | ) -> HTMLResponse | RedirectResponse: 232 | """ 233 | Process our user's login request. Validate the CSRF token from the login form, 234 | and attempt to bind to our LDAP server with the supplied username and password. 235 | If authentication is successful, redirect to the value of the ``service`` 236 | hidden input field on our form. If authentication fails, display the login 237 | form again. 238 | 239 | If the header ``X-Auth-Realm`` is set, use that as the title for the login 240 | page. Otherwise use 241 | :py:attr:`nginx_ldap_auth.settings.Settings.auth_realm`. 242 | 243 | Side Effects: 244 | If authentication is successful, the user's username is stored in the 245 | session. 246 | 247 | No matter what, the CSRF cookie is unset to prevent token reuse. 248 | 249 | Args: 250 | request: The request object 251 | csrf_protect: The CSRF protection dependency 252 | 253 | Returns: 254 | A redirect response to the service URL if authentication is successful. 255 | Otherwise, a rendered login page. 256 | 257 | """ 258 | auth_realm = request.headers.get("x-auth-realm", settings.auth_realm) 259 | await csrf_protect.validate_csrf(request) 260 | form = LoginForm(request) 261 | form.site_title = auth_realm 262 | await form.load_data() 263 | if await form.is_valid(): 264 | await load_session(request) 265 | request.session["username"] = form.username 266 | response: HTMLResponse | RedirectResponse = RedirectResponse( 267 | url=form.service, status_code=status.HTTP_302_FOUND 268 | ) 269 | else: 270 | response = templates.TemplateResponse("login.html", form.__dict__) 271 | csrf_protect.unset_csrf_cookie(response) # prevent token reuse 272 | return response 273 | 274 | 275 | @app.get("/auth/logout", response_model=None, name="logout") 276 | async def logout(request: Request) -> RedirectResponse: 277 | """ 278 | Log the user out by invalidating the sesision, and redirect them to the 279 | login page. 280 | 281 | Args: 282 | request: The request object 283 | 284 | Returns: 285 | A redirect response to the login page. 286 | 287 | """ 288 | _logger = get_logger(request) 289 | await load_session(request) 290 | if username := request.session.get("username"): 291 | await kill_session(request) 292 | _logger.info("auth.logout", username=username) 293 | return RedirectResponse(url="/auth/login?service=/") 294 | 295 | 296 | @app.get("/check") 297 | async def check_auth(request: Request, response: Response) -> dict[str, Any]: 298 | """ 299 | Ensure the user is still authorized. If the user is authorized, return 300 | 200 OK, otherwise return 401 Unauthorized. 301 | 302 | The user is authorized if the cookie exists, the session the cookie refers 303 | to exists, and the ``username`` key in the settings is set. Additionally, 304 | the user must still exist in LDAP, and if 305 | :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` is 306 | not ``None``, the user must also match the filter. 307 | 308 | Side Effects: 309 | If the user is not authorized, the session is destroyed, and the user is 310 | status_code on ``response`` is set to 401. 311 | 312 | Args: 313 | request: The request object 314 | response: The response object 315 | 316 | Returns: 317 | An empty dictionary. 318 | 319 | """ 320 | if request.cookies.get(settings.cookie_name): 321 | await load_session(request) 322 | if request.session.get("username"): 323 | # We have a valid session 324 | if not await User.objects.get(request.session["username"]): 325 | # The user does not exist in LDAP; log them out 326 | await kill_session(request) 327 | if not await User.objects.is_authorized(request.session["username"]): 328 | # The user is no longer authorized; log them out 329 | await kill_session(request) 330 | return {} 331 | # Destroy the session because it is not valid 332 | await kill_session(request) 333 | # Force the user to authenticate 334 | response.headers["Cache-Control"] = "no-cache" 335 | response.status_code = status.HTTP_401_UNAUTHORIZED 336 | return {} 337 | 338 | 339 | @app.get("/status") 340 | async def app_status(request: Request) -> tuple[dict[str, Any], int]: # noqa: ARG001 341 | """ 342 | Return the status of the auth service. 343 | 344 | Args: 345 | request: The request object 346 | 347 | Returns: 348 | A tuple containing the status of the auth service and the HTTP status code. 349 | The status is "ok" if the auth service is successful, otherwise "error". 350 | The message is the error message if the auth service is not successful. 351 | 352 | """ 353 | return {"status": "ok", "message": "Auth service is running"}, status.HTTP_200_OK 354 | 355 | 356 | @app.get("/status/ldap") 357 | async def ldap_status(request: Request) -> tuple[dict[str, Any], int]: # noqa: ARG001 358 | """ 359 | Return the status of the LDAP connection. 360 | 361 | Args: 362 | request: The request object 363 | 364 | Returns: 365 | A tuple containing the status of the LDAP connection and the HTTP status code. 366 | The status is "ok" if the LDAP connection is successful, otherwise "error". 367 | The message is the error message if the LDAP connection is not successful. 368 | 369 | """ 370 | # Try to bind to the LDAP server 371 | try: 372 | await User.objects.client().connect(is_async=True) 373 | except LDAPError as e: 374 | return { 375 | "status": "error", 376 | "message": str(e), 377 | }, status.HTTP_500_INTERNAL_SERVER_ERROR 378 | return {"status": "ok", "message": "LDAP connection successful"}, status.HTTP_200_OK 379 | 380 | 381 | @app.exception_handler(CsrfProtectError) 382 | def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError) -> Response: 383 | """ 384 | Handle CSRF protection errors. All we're going to do is redirect the user 385 | back to the login page after logging the error. 386 | 387 | Args: 388 | request: The request object 389 | exc: The exception object from the CSRF protection middleware 390 | 391 | Returns: 392 | A redirect response to the login page. 393 | 394 | """ 395 | _logger = get_logger(request) 396 | _logger.error("auth.login.csrf.error", error=str(exc)) 397 | return RedirectResponse( 398 | url=app.url_path_for("login"), status_code=status.HTTP_302_FOUND 399 | ) 400 | -------------------------------------------------------------------------------- /doc/source/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration Overview 4 | ====================== 5 | 6 | .. important:: 7 | This page deals with configuring ``nginx-ldap-auth-service``. For 8 | configuring ``nginx`` to use ``nginx-ldap-auth-service``, see :doc:`nginx`. 9 | 10 | ``nginx-ldap-auth-service`` reads configuration from three places, in 11 | decreasing order of precedence: 12 | 13 | #. Command line options for ``nginx-ldap-auth start`` 14 | #. Headers set in the location blocks of the ``nginx`` config file 15 | #. Environment variables 16 | 17 | Not all configuration options are available in all places. 18 | 19 | .. note:: 20 | 21 | To print your resolved configuration when using the command line, 22 | you can run the following command:: 23 | 24 | $ nginx-ldap-auth settings 25 | 26 | .. note:: 27 | 28 | Active Directory works somewhat differently than other LDAP servers. 29 | 30 | See the "Active Directory" section in :ref:`nginx-ldap-auth-service-env` 31 | for more information. 32 | 33 | Command Line 34 | ------------ 35 | 36 | If an option is specified on the command line, it overrides all other values 37 | that may have been specified in the app specific environment variables. 38 | configuration file. Not all ``nginx-ldap-auth-service`` settings are available 39 | to be set from the command line. To see the full list of command line settings 40 | you can do the usual:: 41 | 42 | $ nginx-ldap-auth start --help 43 | 44 | .. _nginx_header_config: 45 | 46 | nginx Header Configuration 47 | -------------------------- 48 | 49 | If an option is specified in the ``nginx`` configuration file, it overrides the 50 | associated setting in ``nginx-ldap-auth-service``. 51 | 52 | You can set the following headers in your nginx configuration to configure 53 | ``nginx-ldap-auth-service`` on a per ``nginx`` server basis. You might do this 54 | if you have multiple ``nginx`` servers all using the same 55 | ``nginx-ldap-auth-service`` instance, but want to configure them differently. 56 | 57 | .. note:: 58 | 59 | You can only set the following headers in the ``location`` blocks that 60 | proxy to ``nginx-ldap-auth-service``. If you set them in the ``server`` 61 | block, they will be ignored. 62 | 63 | CSRF Cookie in the /auth location [required] 64 | 65 | In order for the login page to work, we need to pass the session cookie to 66 | the auth service. See :envvar:`CSRF_COOKIE_NAME` for more details on what 67 | the name of the CSRF cookie will be for you. 68 | 69 | Here's an example of how to set the cookie in the ``/auth`` location: 70 | 71 | Example: 72 | 73 | .. code-block:: nginx 74 | :emphasize-lines: 4 75 | 76 | location /auth { 77 | proxy_pass http://nginx-ldap-auth-service:8888/auth; 78 | proxy_set_header Cookie mycookie_csrf=$cookie_mycookie_csrf; 79 | 80 | # other lines omitted for brevity 81 | } 82 | 83 | 84 | X-Cookie-Name 85 | 86 | The name of the session cookie. Either set this header or set the 87 | :envvar:`COOKIE_NAME` environment variable. If ``X-Cookie-Name`` is set, it 88 | will override the value of :envvar:`COOKIE_NAME`. 89 | 90 | The ``proxy_set_header X-Cookie-Name`` line goes in the ``location`` block 91 | for the ``/auth`` and ``/check-auth`` locations. 92 | 93 | .. important:: 94 | 95 | Whether or not you change the cookie name from its default of ``nginxauth``, 96 | you'll need the ``proxy_set_header Cookie`` and ``proxy_cache_key`` lines 97 | below. Change "mycookie" to whatever you set :envvar:`COOKIE_NAME` to in 98 | all the places it occurs. 99 | 100 | Example: 101 | 102 | .. code-block:: nginx 103 | :emphasize-lines: 3,4,19,20,21 104 | 105 | location /auth { 106 | proxy_pass http://nginx-ldap-auth-service:8888/auth; 107 | proxy_set_header X-Cookie-Name "mycookie"; 108 | 109 | # other lines omitted for brevity 110 | } 111 | 112 | location /check-auth { 113 | proxy_pass http://nginx-ldap-auth-service:8888/check; 114 | 115 | # Cache our auth responses for 10 minutes so that we're not 116 | # hitting the auth service on every request. 117 | proxy_cache auth_cache; 118 | proxy_cache_valid 200 10m; 119 | 120 | # other lines omitted for brevity 121 | 122 | proxy_set_header X-Cookie-Name "mycookie"; 123 | proxy_set_header Cookie mycookie=$cookie_mycookie; 124 | proxy_cache_key "$http_authorization$cookie_mycookie"; 125 | } 126 | 127 | If you're not doing any caching, you can ignore the cache related lines 128 | above. 129 | 130 | X-Cookie-Domain 131 | 132 | The domain for the session cookie. This goes in the ``location`` block for 133 | the ``/auth`` and ``/check-auth`` locations. If you don't specify this 134 | header, the value of the domain will be that set for :envvar:`COOKIE_DOMAIN`. 135 | If ``X-Cookie-Domain`` is set, it will override the value of 136 | :envvar:`COOKIE_DOMAIN`. 137 | 138 | Example: 139 | 140 | .. code-block:: nginx 141 | :emphasize-lines: 3,13 142 | 143 | location /auth { 144 | proxy_pass http://nginx-ldap-auth-service:8888/auth; 145 | proxy_set_header X-Cookie-Domain ".example.com"; 146 | 147 | # other lines omitted for brevity 148 | } 149 | 150 | location /check-auth { 151 | proxy_pass http://nginx-ldap-auth-service:8888/check; 152 | 153 | # other lines omitted for brevity 154 | 155 | proxy_set_header X-Cookie-Domain ".example.com"; 156 | } 157 | 158 | X-Auth-Realm 159 | 160 | The title for the login form. This goes in the ``location`` block for the 161 | ``/auth`` location. Defaults to the value of 162 | :py:attr:`nginx_ldap_auth.settings.Settings.auth_realm` for the 163 | ``nginx-ldap-auth-service`` instance. You should either set it here in 164 | ``nginx.conf`` or with the :envvar:`AUTH_REALM` environment variable, but 165 | not both. 166 | 167 | Example: 168 | 169 | .. code-block:: nginx 170 | :emphasize-lines: 3 171 | 172 | location /auth { 173 | proxy_pass http://nginx-ldap-auth-service:8888/auth; 174 | proxy_set_header X-Auth-Realm "My Login Form"; 175 | } 176 | 177 | X-Authenticated-User 178 | 179 | The username of the authenticated user from ``nginx-ldap-auth-service``. 180 | This goes in the ``location`` block for your app's location. This is used 181 | to pass the authenticated username back to your application so that it can 182 | be used for provisioning users or other purposes. This is not required. be 183 | used for authorization checks. 184 | 185 | Example: 186 | 187 | .. code-block:: nginx 188 | :emphasize-lines: 3,4 189 | 190 | location / { 191 | auth_request /check-auth; 192 | auth_request_set $auth_user $upstream_http_x_authenticated_user; 193 | proxy_set_header X-Authenticated-User $auth_user; 194 | } 195 | 196 | .. _nginx-ldap-auth-service-env: 197 | 198 | Environment 199 | ----------- 200 | 201 | You can either export the appropriate variables directly into your shell 202 | environment, or you can use an environment file and specify it with the 203 | ``--env-file`` option to ``nginx-ldap-auth start``. 204 | 205 | The following environment variables are available to configure 206 | ``nginx-ldap-auth-service``: 207 | 208 | .. important:: 209 | 210 | You must set at least these variables to localize to your organization: 211 | 212 | * :envvar:`LDAP_URI` 213 | * :envvar:`LDAP_BINDDN` 214 | * :envvar:`LDAP_PASSWORD`, 215 | * :envvar:`LDAP_BASEDN` 216 | * :envvar:`SECRET_KEY`. 217 | * :envvar:`CSRF_SECRET_KEY`. 218 | 219 | You should also look at these variables to see whether their defaults work 220 | for you: 221 | 222 | * :envvar:`LDAP_USERNAME_ATTRIBUTE` 223 | * :envvar:`LDAP_FULL_NAME_ATTRIBUTE` 224 | * :envvar:`LDAP_GET_USER_FILTER` 225 | * :envvar:`LDAP_AUTHORIZATION_FILTER` 226 | * :envvar:`AUTH_REALM` 227 | * :envvar:`SESSION_MAX_AGE` 228 | 229 | LDAP (389, openldap, etc.) 230 | 231 | If you're using an LDAP server that's not Active Directory, and you're using 232 | posixAccount objects, the :envvar:`LDAP_USERNAME_ATTRIBUTE` and 233 | :envvar:`LDAP_FULL_NAME_ATTRIBUTE` defaults will probably just work for you. 234 | You will still need to set/look at the other LDAP settings. 235 | 236 | Active Directory 237 | 238 | If you use Active Directory as your LDAP server, you should set the 239 | :envvar:`LDAP_USERNAME_ATTRIBUTE` to ``sAMAccountName`` and the 240 | :envvar:`LDAP_FULL_NAME_ATTRIBUTE` to ``cn``. You will probably 241 | also need to set :envvar:`LDAP_USER_BASEDN` to the base DN of your users 242 | which is probably not the same as your :envvar:`LDAP_BASEDN`. Auth for 243 | normal users in AD is sometimes done with the ``userPrincipalName`` attribute 244 | which is the user's email address, thus you would set :envvar:`LDAP_USER_BASEDN` 245 | to ``@{__YOUR_EMAIL_DOMAIN__}``, (e.g. ``@example.com``) and the bare username 246 | will be prepended to that to form the bind DN for the user. 247 | 248 | Web Server 249 | ^^^^^^^^^^ 250 | 251 | These settings configure the web server that ``nginx-ldap-auth-service`` runs, 252 | ``uvicorn``. 253 | 254 | .. envvar:: HOSTNAME 255 | 256 | The hostname to listen on. Defaults to ``0.0.0.0``. 257 | 258 | .. envvar:: PORT 259 | 260 | The port to listen on. Defaults to ``8888``. 261 | 262 | .. envvar:: SSL_KEYFILE 263 | 264 | The path to the SSL key file. Defaults to ``/certs/server.key``. 265 | 266 | .. envvar:: SSL_CERTFILE 267 | 268 | The path to the SSL certificate file. Defaults to ``/certs/server.crt``. 269 | 270 | .. envvar:: WORKERS 271 | 272 | The number of worker processes to spawn. Defaults to ``1``. 273 | 274 | .. envvar:: DEBUG 275 | 276 | Set to ``1`` or ``True`` to enable debug mode. Defaults to ``False``. 277 | 278 | .. envvar:: INSECURE 279 | 280 | Set to ``True`` to run our auth service web server over HTTP instead of HTTPS. Defaults to ``False``. 281 | 282 | 283 | Login form and sessions 284 | ^^^^^^^^^^^^^^^^^^^^^^^ 285 | 286 | These settings configure the login form and session handling. 287 | 288 | .. envvar:: AUTH_REALM 289 | 290 | The title for the login form. Defaults to ``Restricted``. 291 | 292 | .. envvar:: COOKIE_NAME 293 | 294 | The name of the cookie to use for the session. Defaults to ``nginxauth``. 295 | 296 | .. envvar:: CSRF_COOKIE_NAME 297 | 298 | The name of the cookie to use for the CSRF cookie. Defaults to whatever you 299 | set :envvar:`COOKIE_NAME` to with ``_csrf`` appended. 300 | 301 | .. envvar:: COOKIE_DOMAIN 302 | 303 | The domain for the cookie to use for the session. Defaults to no domain. 304 | 305 | .. envvar:: SESSION_MAX_AGE 306 | 307 | How many seconds a session should last after first login. Defaults to 308 | ``0``, no expiry. If :envvar:`USE_ROLLING_SESSIONS` is ``True``, this 309 | value is used to reset the session lifetime on every request. 310 | 311 | .. envvar:: USE_ROLLING_SESSIONS 312 | 313 | If ``True``, session lifetime will be reset to :envvar:`SESSION_MAX_AGE` on 314 | every request. Defaults to ``False``. 315 | 316 | .. envvar:: SECRET_KEY 317 | 318 | **Required** The secret key to use for the session. 319 | 320 | .. envvar:: CSRF_SECRET_KEY 321 | 322 | **Required** The secret key to use for the CSRF cookie. 323 | 324 | .. envvar:: SESSION_BACKEND 325 | 326 | The session backend to use. Defaults to ``memory``. Valid options are 327 | ``memory`` and ``redis``. If you choose ``redis``, you must also set 328 | :envvar:`REDIS_URL`. 329 | 330 | .. envvar:: REDIS_URL 331 | 332 | The DSN to the Redis server. See :py:attr:`nginx_ldap_auth.settings.Settings.redis_url` for details on the format of the DSN. 333 | 334 | Defaults to ``None`` 335 | 336 | .. envvar:: REDIS_PREFIX 337 | 338 | The prefix to use for Redis keys. Defaults to ``nginx_ldap_auth``. 339 | 340 | 341 | LDAP 342 | ^^^^ 343 | 344 | These settings configure the LDAP server to use for authentication. 345 | 346 | .. envvar:: LDAP_URI 347 | 348 | **Required**. The URL to the LDAP server. Defaults to ``ldap://localhost``. 349 | 350 | .. envvar:: LDAP_BINDDN 351 | 352 | **Required**. The DN of a privileged user in your LDAP/AD server that can be 353 | used to to bind to the LDAP server for doing our user and authorization searches. 354 | 355 | .. envvar:: LDAP_PASSWORD 356 | 357 | **Required**. The password to use to with :envvar:`LDAP_BINDDN` to bind to 358 | the LDAP server for doing our user and authorization searches. 359 | 360 | .. envvar:: LDAP_STARTTLS 361 | 362 | Set to ``0`` or ``False`` to disable STARTTLS on our LDAP connections. Defaults to ``True``. 363 | 364 | .. envvar:: LDAP_DISABLE_REFERRALS 365 | 366 | Set to ``1`` or ``True`` to disable LDAP referrals. Defaults to ``False``. 367 | 368 | .. envvar:: LDAP_BASEDN 369 | 370 | **Required** The base DN to use for our LDAP searches that find users, and to 371 | construct the DN for the user to bind with, unless ``LDAP_USER_BASEDN`` is also 372 | set (see below). For authentication, the user's DN will be constructed as 373 | ``{LDAP_USERNAME_ATTRIBUTE}={username},{LDAP_BASEDN}``. 374 | 375 | .. envvar:: LDAP_USER_BASEDN 376 | 377 | The base DN to append to the user's username when binding. This is only 378 | important for Active Directory, where we may need to use the value of 379 | ``userPrincipalName`` (typically the user's email address) as the username 380 | intead of the usual LDAP style dn which would be constructed as 381 | ``sAMAccountName=user,{LDAP_BASEDN}``. Include the ``@`` at the beginning 382 | of the value. The resulting bind DN will be ``{username}{LDAP_USER_BASEDN}``. 383 | 384 | Defaults to ``None``. 385 | 386 | Example: 387 | 388 | .. code-block:: bash 389 | 390 | export LDAP_USER_BASEDN="@example.com" 391 | 392 | This will cause the bind DN to be ``user@example.com`` 393 | 394 | This envvar is normally unset, and if so, the bind DN will be constructed 395 | as ``{LDAP_USERNAME_ATTRIBUTE}={username},{LDAP_BASEDN}``. 396 | 397 | .. envvar:: LDAP_USERNAME_ATTRIBUTE 398 | 399 | The LDAP attribute to use for the username. Defaults to ``uid``. 400 | 401 | .. envvar:: LDAP_FULL_NAME_ATTRIBUTE 402 | 403 | The LDAP attribute to use for the full name. Defaults to ``cn``. 404 | 405 | .. envvar:: LDAP_GET_USER_FILTER 406 | 407 | The LDAP search filter to use when searching for users. Defaults to 408 | ``{username_attribute}={username}``, where ``{username_attribute}`` is the 409 | value of :envvar:`LDAP_USERNAME_ATTRIBUTE` and ``{username}`` is the 410 | username provided by the user. See 411 | :py:attr:`nginx_ldap_auth.settings.Settings.ldap_get_user_filter` for more 412 | details. 413 | 414 | The filter will within the base DN given by :envvar:`LDAP_BASEDN` and with 415 | scope of ``SUBTREE``. 416 | 417 | .. envvar:: LDAP_AUTHORIZATION_FILTER 418 | 419 | The LDAP search filter to use when determining if a user is authorized to login. 420 | for authorizations. Defaults to no filter, meaning all users are authorized if 421 | they exist in LDAP. See :py:attr:`nginx_ldap_auth.settings.Settings.ldap_authorization_filter` for more details. 422 | 423 | The filter will within the base DN given by :envvar:`LDAP_BASEDN` and with 424 | scope of ``SUBTREE``. 425 | 426 | .. envvar:: LDAP_TIMEOUT 427 | 428 | The maximum number of seconds to wait when acquiring a connection to the LDAP 429 | server. Defaults to ``15``. 430 | 431 | .. envvar:: LDAP_MIN_POOL_SIZE 432 | 433 | The minimum number of connections to keep in the LDAP connection pool. Defaults 434 | to ``1``. 435 | 436 | .. envvar:: LDAP_MAX_POOL_SIZE 437 | 438 | The maximum number of connections to keep in the LDAP connection pool. Defaults 439 | to ``30``. 440 | 441 | .. envvar:: LDAP_POOL_CONNECTION_LIFETIME_SECONDS 442 | 443 | The maximum number of seconds to keep a connection in the LDAP connection pool. 444 | Defaults to ``20``. 445 | --------------------------------------------------------------------------------