├── .flake8 ├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── ci └── docker-ldap.sh ├── ldapauthenticator ├── __init__.py ├── ldapauthenticator.py └── tests │ ├── __init__.py │ ├── conftest.py │ └── test_ldapauthenticator.py ├── pyproject.toml └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Ignore style and complexity 3 | # E: style errors 4 | # W: style warnings 5 | # C: complexity 6 | # D: docstring warnings (unused pydocstyle extension) 7 | ignore = E, C, W, D 8 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # dependabot.yaml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | # 3 | # Notes: 4 | # - Status and logs from dependabot are provided at 5 | # https://github.com/jupyterhub/systemdspawner/network/updates. 6 | # 7 | version: 2 8 | updates: 9 | # Maintain dependencies in our GitHub Workflows 10 | - package-ecosystem: github-actions 11 | directory: / 12 | labels: [ci] 13 | schedule: 14 | interval: monthly 15 | time: "05:00" 16 | timezone: Etc/UTC 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. 2 | # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | # 4 | name: Release 5 | 6 | # Always tests wheel building, but only publish to PyPI on pushed tags. 7 | on: 8 | pull_request: 9 | paths-ignore: 10 | - "**.md" 11 | - ".github/workflows/*.yaml" 12 | - "!.github/workflows/release.yaml" 13 | push: 14 | paths-ignore: 15 | - "**.md" 16 | - ".github/workflows/*.yaml" 17 | - "!.github/workflows/release.yaml" 18 | branches-ignore: 19 | - "dependabot/**" 20 | - "pre-commit-ci-update-config" 21 | tags: ["**"] 22 | workflow_dispatch: 23 | 24 | jobs: 25 | build-release: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-python@v5 31 | with: 32 | python-version: "3.12" 33 | 34 | - name: install build package 35 | run: | 36 | pip install --upgrade pip 37 | pip install build 38 | pip freeze 39 | 40 | - name: build release 41 | run: | 42 | python -m build --sdist --wheel . 43 | ls -l dist 44 | 45 | - name: publish to pypi 46 | uses: pypa/gh-action-pypi-publish@release/v1 47 | if: startsWith(github.ref, 'refs/tags/') 48 | with: 49 | user: __token__ 50 | password: ${{ secrets.pypi_password }} 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. 2 | # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | # 4 | name: Tests 5 | 6 | on: 7 | pull_request: 8 | paths-ignore: 9 | - "**.md" 10 | - ".github/workflows/*.yaml" 11 | - "!.github/workflows/test.yaml" 12 | push: 13 | paths-ignore: 14 | - "**.md" 15 | - ".github/workflows/*.yaml" 16 | - "!.github/workflows/test.yaml" 17 | branches-ignore: 18 | - "dependabot/**" 19 | - "pre-commit-ci-update-config" 20 | tags: ["**"] 21 | workflow_dispatch: 22 | 23 | env: 24 | LDAP_HOST: 127.0.0.1 25 | 26 | jobs: 27 | test: 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 10 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | include: 35 | - python-version: "3.9" 36 | pip-install-spec: "jupyterhub==4.*" 37 | - python-version: "3.12" 38 | pip-install-spec: "jupyterhub==5.*" 39 | - python-version: "3.x" 40 | pip-install-spec: "--pre jupyterhub" 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: "${{ matrix.python-version }}" 47 | 48 | - name: Install Python dependencies 49 | run: | 50 | pip install ${{ matrix.pip-install-spec }} 51 | pip install -e ".[test]" 52 | 53 | - name: List packages 54 | run: pip freeze 55 | 56 | - name: Run tests 57 | run: | 58 | # start LDAP server 59 | ci/docker-ldap.sh 60 | 61 | pytest --cov=ldapauthenticator 62 | 63 | # GitHub action reference: https://github.com/codecov/codecov-action 64 | - uses: codecov/codecov-action@v4 65 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # PyCharm 62 | .idea/ 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit is a tool to perform a predefined set of tasks manually and/or 2 | # automatically before git commits are made. 3 | # 4 | # Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level 5 | # 6 | # Common tasks 7 | # 8 | # - Run on all files: pre-commit run --all-files 9 | # - Register git hooks: pre-commit install --install-hooks 10 | # 11 | repos: 12 | # Autoformat: Python code, syntax patterns are modernized 13 | - repo: https://github.com/asottile/pyupgrade 14 | rev: v3.19.0 15 | hooks: 16 | - id: pyupgrade 17 | args: 18 | - --py39-plus 19 | 20 | # Autoformat: Python code 21 | - repo: https://github.com/PyCQA/autoflake 22 | rev: v2.3.1 23 | hooks: 24 | - id: autoflake 25 | # args ref: https://github.com/PyCQA/autoflake#advanced-usage 26 | args: 27 | - --in-place 28 | 29 | # Autoformat: Python code 30 | - repo: https://github.com/pycqa/isort 31 | rev: 5.13.2 32 | hooks: 33 | - id: isort 34 | 35 | # Autoformat: Python code 36 | - repo: https://github.com/psf/black 37 | rev: 24.10.0 38 | hooks: 39 | - id: black 40 | 41 | # Autoformat: markdown, yaml 42 | - repo: https://github.com/pre-commit/mirrors-prettier 43 | rev: v4.0.0-alpha.8 44 | hooks: 45 | - id: prettier 46 | 47 | # Misc... 48 | - repo: https://github.com/pre-commit/pre-commit-hooks 49 | rev: v5.0.0 50 | # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available 51 | hooks: 52 | - id: end-of-file-fixer 53 | - id: check-case-conflict 54 | - id: check-executables-have-shebangs 55 | 56 | # Lint: Python code 57 | - repo: https://github.com/pycqa/flake8 58 | rev: "7.1.1" 59 | hooks: 60 | - id: flake8 61 | 62 | # pre-commit.ci config reference: https://pre-commit.ci/#configuration 63 | ci: 64 | autoupdate_schedule: monthly 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0 4 | 5 | ### 2.0.2 - 2024-11-06 6 | 7 | ([full changelog](https://github.com/jupyterhub/ldapauthenticator/compare/2.0.1...2.0.2)) 8 | 9 | #### Bugs fixed 10 | 11 | - Fix parsing of search response [#294](https://github.com/jupyterhub/ldapauthenticator/pull/294) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) 12 | 13 | #### Maintenance and upkeep improvements 14 | 15 | - Document configuring TLS ciphers and log a link to it on raised handshake error [#297](https://github.com/jupyterhub/ldapauthenticator/pull/297) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), ) 16 | 17 | #### Continuous integration improvements 18 | 19 | - Test bind_dn_template more thoroughly [#290](https://github.com/jupyterhub/ldapauthenticator/pull/290) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio)) 20 | 21 | #### Contributors to this release 22 | 23 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 24 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 25 | 26 | ([GitHub contributors page for this release](https://github.com/jupyterhub/ldapauthenticator/graphs/contributors?from=2024-10-29&to=2024-11-06&type=c)) 27 | 28 | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AconsideRatio+updated%3A2024-10-29..2024-11-06&type=Issues)) | @franciscaestecker ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Afranciscaestecker+updated%3A2024-10-29..2024-11-06&type=Issues)) | @franciscaestecker-bb ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Afranciscaestecker-bb+updated%3A2024-10-29..2024-11-06&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amanics+updated%3A2024-10-29..2024-11-06&type=Issues)) 29 | 30 | ### 2.0.1 - 2024-10-29 31 | 32 | ([full changelog](https://github.com/jupyterhub/ldapauthenticator/compare/2.0.0...2.0.1)) 33 | 34 | #### Bugs fixed 35 | 36 | - fix: Ensure a list `bind_dn_template` is properly validated [#289](https://github.com/jupyterhub/ldapauthenticator/pull/289) ([@mahendrapaipuri](https://github.com/mahendrapaipuri)) 37 | 38 | #### Contributors to this release 39 | 40 | ([GitHub contributors page for this release](https://github.com/jupyterhub/ldapauthenticator/graphs/contributors?from=2024-10-18&to=2024-10-29&type=c)) 41 | 42 | [@mahendrapaipuri](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amahendrapaipuri+updated%3A2024-10-18..2024-10-29&type=Issues) 43 | 44 | ### 2.0.0 - 2024-10-18 45 | 46 | ([full changelog](https://github.com/jupyterhub/ldapauthenticator/compare/1.3.2...2.0.0)) 47 | 48 | #### Breaking Changes 49 | 50 | - `python>=3.9`, `jupyterhub>=4.1.6`, and `ldap3>=2.9.1` is now required. 51 | ([#245](https://github.com/jupyterhub/ldapauthenticator/pull/245), 52 | [#256](https://github.com/jupyterhub/ldapauthenticator/pull/256)) 53 | - Configuring `auth_state_attributes` now leads to user information being put in 54 | `auth_state["user_attributes"]` and not directly in `auth_state`. 55 | ([#269](https://github.com/jupyterhub/ldapauthenticator/pull/269)) 56 | - `use_lookup_dn_username` now defaults to False and is opt-in instead of 57 | opt-out. To retain previous behavior if you had `lookup_dn` set True without 58 | `use_lookup_dn_username` explicitly set, configure `use_lookup_dn_username` to 59 | True. ([#280](https://github.com/jupyterhub/ldapauthenticator/pull/280)) 60 | - `lookup_dn` now rejects an authenticating user if multiple DNs are returned 61 | during lookup. ([#276](https://github.com/jupyterhub/ldapauthenticator/pull/276)) 62 | - In the edge case if both... 63 | 64 | 1. the following config is used: 65 | - `lookup_dn = True`, 66 | - `lookup_dn_user_dn_attribute = "cn"` 67 | - `use_lookup_dn_username = True` (previous default value) 68 | 2. and one or more users previously signed in at least once had a comma in 69 | their `cn` attribute's value 70 | 71 | then such users will get a new JupyterHub username going forward looking like 72 | `"lastname, firstname"` instead of looking like `"lastname\\, firstname"`. 73 | ([#267](https://github.com/jupyterhub/ldapauthenticator/pull/267)) 74 | 75 | #### Deprecations 76 | 77 | - `use_ssl` has been deprecated, instead configure `tls_strategy` going forward. 78 | Configuring `use_ssl=True` should be updated with `tls_strategy="on_connect"`, 79 | and configuring `use_ssl=False` could be updated to either be 80 | `tls_strategy="before_bind"` (default) or `tls_strategy="insecure"`. 81 | ([#258](https://github.com/jupyterhub/ldapauthenticator/pull/258)) 82 | - `escape_userdn` has been deprecated, usernames used to construct DNs are now 83 | always escaped according to LDAP protocol specification of how DNs should be 84 | represented in string format. 85 | ([#267](https://github.com/jupyterhub/ldapauthenticator/pull/267)) 86 | 87 | #### New features added 88 | 89 | - Add LDAPAuthenticator.version_info [#282](https://github.com/jupyterhub/ldapauthenticator/pull/282) ([@consideRatio](https://github.com/consideRatio)) 90 | - Add `tls_kwargs` config to configure underlying ldap3 package tls [#273](https://github.com/jupyterhub/ldapauthenticator/pull/273) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 91 | - Add `tls_strategy` and deprecate `use_ssl` [#258](https://github.com/jupyterhub/ldapauthenticator/pull/258) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@loic-vial](https://github.com/loic-vial), [@1kastner](https://github.com/1kastner)) 92 | - Allow users to configure group search filter and attributes (`group_search_filter` and `group_attributes` config) [#168](https://github.com/jupyterhub/ldapauthenticator/pull/168) ([@kinow](https://github.com/kinow), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@ordlucas](https://github.com/ordlucas), [@mananpreetsingh](https://github.com/mananpreetsingh)) 93 | 94 | #### Enhancements made 95 | 96 | - Docs updates, and a few tweaks to the allow config [#286](https://github.com/jupyterhub/ldapauthenticator/pull/286) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) 97 | - Register authenticator class with jupyterhub as ldap [#249](https://github.com/jupyterhub/ldapauthenticator/pull/249) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 98 | 99 | #### Bugs fixed 100 | 101 | - Require a unique DN to be found when using lookup_dn [#276](https://github.com/jupyterhub/ldapauthenticator/pull/276) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 102 | - Fix duplicated bind operation, only one is needed [#270](https://github.com/jupyterhub/ldapauthenticator/pull/270) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 103 | - Escape username within DN correctly and remove `escape_userdn` [#267](https://github.com/jupyterhub/ldapauthenticator/pull/267) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 104 | - Escape user- or ldap-provided strings in ldap search filters [#238](https://github.com/jupyterhub/ldapauthenticator/pull/238) ([@m-erhardt](https://github.com/m-erhardt), [@consideRatio](https://github.com/consideRatio)) 105 | 106 | #### Maintenance and upkeep improvements 107 | 108 | - Remove empty scripts file [#287](https://github.com/jupyterhub/ldapauthenticator/pull/287) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) 109 | - Comment consistently about escape_rdn and escape_filter_chars [#284](https://github.com/jupyterhub/ldapauthenticator/pull/284) ([@consideRatio](https://github.com/consideRatio)) 110 | - Validate config on startup when possible (allowed_groups, lookup_dn, bind_dn_template) [#283](https://github.com/jupyterhub/ldapauthenticator/pull/283) ([@consideRatio](https://github.com/consideRatio)) 111 | - tests: pass config to constructor instead of configuring after [#281](https://github.com/jupyterhub/ldapauthenticator/pull/281) ([@consideRatio](https://github.com/consideRatio)) 112 | - Change `use_lookup_dn_username` default value to False [#280](https://github.com/jupyterhub/ldapauthenticator/pull/280) ([@consideRatio](https://github.com/consideRatio)) 113 | - Fix a log message about lookup_dn_user_dn_attribute [#278](https://github.com/jupyterhub/ldapauthenticator/pull/278) ([@consideRatio](https://github.com/consideRatio)) 114 | - refactor: distinguish login_username from resolved_username [#277](https://github.com/jupyterhub/ldapauthenticator/pull/277) ([@consideRatio](https://github.com/consideRatio)) 115 | - Add missing docs for `search_filter` and `attributes` and improve logging for `search_filter` [#275](https://github.com/jupyterhub/ldapauthenticator/pull/275) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 116 | - Improve logging, docstring, and variable naming in `resolve_username` function [#274](https://github.com/jupyterhub/ldapauthenticator/pull/274) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 117 | - align `allowed_groups` with other `allowed_` config, consistent in JupyterHub 5 [#269](https://github.com/jupyterhub/ldapauthenticator/pull/269) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) 118 | - refactor: specify param names for Connection.search consistently [#268](https://github.com/jupyterhub/ldapauthenticator/pull/268) ([@consideRatio](https://github.com/consideRatio)) 119 | - refactor: reduce use of temporary variables [#264](https://github.com/jupyterhub/ldapauthenticator/pull/264) ([@consideRatio](https://github.com/consideRatio)) 120 | - Relocate example snippet from code to readme [#257](https://github.com/jupyterhub/ldapauthenticator/pull/257) ([@consideRatio](https://github.com/consideRatio)) 121 | - Require ldap3 2.9.1+ released 2021 (currently latest) as a lower bound [#256](https://github.com/jupyterhub/ldapauthenticator/pull/256) ([@consideRatio](https://github.com/consideRatio)) 122 | - Transition to async functions and remove tornado dependency [#255](https://github.com/jupyterhub/ldapauthenticator/pull/255) ([@consideRatio](https://github.com/consideRatio)) 123 | - tests: avoid reuse of authenticator fixture between tests and add docstring [#254](https://github.com/jupyterhub/ldapauthenticator/pull/254) ([@consideRatio](https://github.com/consideRatio)) 124 | - Fix incorrect log message (debug level) [#252](https://github.com/jupyterhub/ldapauthenticator/pull/252) ([@consideRatio](https://github.com/consideRatio)) 125 | - refactor: reduce use of temporary variables like msg for logging [#251](https://github.com/jupyterhub/ldapauthenticator/pull/251) ([@consideRatio](https://github.com/consideRatio)) 126 | - refactor: put validation logic in traitlets validation functions [#250](https://github.com/jupyterhub/ldapauthenticator/pull/250) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 127 | - Update ldap testing server to the latest available version [#247](https://github.com/jupyterhub/ldapauthenticator/pull/247) ([@consideRatio](https://github.com/consideRatio)) 128 | - Require jupyterhub 4.1.6+ and Python 3.9+ [#245](https://github.com/jupyterhub/ldapauthenticator/pull/245) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 129 | - Fix traitlets warnings when running tests [#169](https://github.com/jupyterhub/ldapauthenticator/pull/169) ([@kinow](https://github.com/kinow), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) 130 | 131 | #### Documentation improvements 132 | 133 | - Docs updates, and a few tweaks to the allow config [#286](https://github.com/jupyterhub/ldapauthenticator/pull/286) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) 134 | - docs: update a few config descriptions [#279](https://github.com/jupyterhub/ldapauthenticator/pull/279) ([@consideRatio](https://github.com/consideRatio)) 135 | - docs: fix readme example based on investigation by MakarovDi [#262](https://github.com/jupyterhub/ldapauthenticator/pull/262) ([@consideRatio](https://github.com/consideRatio)) 136 | - docs: add two docstrings and fix an example in another [#248](https://github.com/jupyterhub/ldapauthenticator/pull/248) ([@consideRatio](https://github.com/consideRatio)) 137 | - Update README.md with details on jupyterhub_config.py [#242](https://github.com/jupyterhub/ldapauthenticator/pull/242) ([@jdkruzr](https://github.com/jdkruzr), [@consideRatio](https://github.com/consideRatio)) 138 | - Update README.md [#228](https://github.com/jupyterhub/ldapauthenticator/pull/228) ([@ehooi](https://github.com/ehooi), [@yuvipanda](https://github.com/yuvipanda)) 139 | - Add study participation notice to readme [#197](https://github.com/jupyterhub/ldapauthenticator/pull/197) ([@sgibson91](https://github.com/sgibson91), [@yuvipanda](https://github.com/yuvipanda), [@manics](https://github.com/manics)) 140 | 141 | #### Continuous integration improvements 142 | 143 | - ci: test jupyterhub 5 and python 3.12, refresh github workflows [#244](https://github.com/jupyterhub/ldapauthenticator/pull/244) ([@consideRatio](https://github.com/consideRatio)) 144 | - ci: fix testing ldap server port mapping for broken gate [#192](https://github.com/jupyterhub/ldapauthenticator/pull/192) ([@bloodeagle40234](https://github.com/bloodeagle40234), [@manics](https://github.com/manics)) 145 | - ci: Replace Travis with GitHub workflow [#188](https://github.com/jupyterhub/ldapauthenticator/pull/188) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio)) 146 | 147 | #### Contributors to this release 148 | 149 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 150 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 151 | 152 | ([GitHub contributors page for this release](https://github.com/jupyterhub/ldapauthenticator/graphs/contributors?from=2020-08-28&to=2024-10-18&type=c)) 153 | 154 | @1kastner ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3A1kastner+updated%3A2020-08-28..2024-10-18&type=Issues)) | @Aethylred ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AAethylred+updated%3A2020-08-28..2024-10-18&type=Issues)) | @bloodeagle40234 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Abloodeagle40234+updated%3A2020-08-28..2024-10-18&type=Issues)) | @brindapabari ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Abrindapabari+updated%3A2020-08-28..2024-10-18&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AconsideRatio+updated%3A2020-08-28..2024-10-18&type=Issues)) | @Cronan ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3ACronan+updated%3A2020-08-28..2024-10-18&type=Issues)) | @dhirschfeld ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adhirschfeld+updated%3A2020-08-28..2024-10-18&type=Issues)) | @dmpe ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Admpe+updated%3A2020-08-28..2024-10-18&type=Issues)) | @edergillian ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aedergillian+updated%3A2020-08-28..2024-10-18&type=Issues)) | @ehooi ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aehooi+updated%3A2020-08-28..2024-10-18&type=Issues)) | @GlennHD ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AGlennHD+updated%3A2020-08-28..2024-10-18&type=Issues)) | @healinyoon ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ahealinyoon+updated%3A2020-08-28..2024-10-18&type=Issues)) | @jdkruzr ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ajdkruzr+updated%3A2020-08-28..2024-10-18&type=Issues)) | @kinow ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Akinow+updated%3A2020-08-28..2024-10-18&type=Issues)) | @loic-vial ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aloic-vial+updated%3A2020-08-28..2024-10-18&type=Issues)) | @m-erhardt ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Am-erhardt+updated%3A2020-08-28..2024-10-18&type=Issues)) | @mananpreetsingh ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amananpreetsingh+updated%3A2020-08-28..2024-10-18&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amanics+updated%3A2020-08-28..2024-10-18&type=Issues)) | @mannevijayakrishna ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amannevijayakrishna+updated%3A2020-08-28..2024-10-18&type=Issues)) | @marcusianlevine ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amarcusianlevine+updated%3A2020-08-28..2024-10-18&type=Issues)) | @marty90 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amarty90+updated%3A2020-08-28..2024-10-18&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aminrk+updated%3A2020-08-28..2024-10-18&type=Issues)) | @mk-raven ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amk-raven+updated%3A2020-08-28..2024-10-18&type=Issues)) | @Nikolai-Hlubek ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3ANikolai-Hlubek+updated%3A2020-08-28..2024-10-18&type=Issues)) | @nylocx ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Anylocx+updated%3A2020-08-28..2024-10-18&type=Issues)) | @ordlucas ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aordlucas+updated%3A2020-08-28..2024-10-18&type=Issues)) | @Ownercz ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AOwnercz+updated%3A2020-08-28..2024-10-18&type=Issues)) | @ragul-inv ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aragul-inv+updated%3A2020-08-28..2024-10-18&type=Issues)) | @reinierpost ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Areinierpost+updated%3A2020-08-28..2024-10-18&type=Issues)) | @sebastian-luna-valero ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Asebastian-luna-valero+updated%3A2020-08-28..2024-10-18&type=Issues)) | @sgibson91 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Asgibson91+updated%3A2020-08-28..2024-10-18&type=Issues)) | @wiltonsr ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Awiltonsr+updated%3A2020-08-28..2024-10-18&type=Issues)) | @wsuzume ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Awsuzume+updated%3A2020-08-28..2024-10-18&type=Issues)) | @ygean ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aygean+updated%3A2020-08-28..2024-10-18&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ayuvipanda+updated%3A2020-08-28..2024-10-18&type=Issues)) 155 | 156 | ## 1.3 157 | 158 | ### 1.3.2 - 2020-08-28 159 | 160 | #### Fixes 161 | 162 | - Exchanging ldap3 constants in if/else ([#175](https://github.com/jupyterhub/ldapauthenticator/pull/175)) ([@1kastner](https://github.com/1kastner)) 163 | 164 | ### 1.3.1 - 2020-08-13 165 | 166 | #### Fixes 167 | 168 | - Pin ldap3 version to lower than 2.8 ([#172](https://github.com/jupyterhub/ldapauthenticator/pull/172)) ([@1kastner](https://github.com/1kastner)) 169 | 170 | ### 1.3.0 - 2020-02-09 171 | 172 | #### Fixes 173 | 174 | - Avoid binding the connection twice [#142](https://github.com/jupyterhub/ldapauthenticator/pull/142) ([@m2hofi94](https://github.com/m2hofi94)) 175 | - Gracefully handle username lookups with list return values [#117](https://github.com/jupyterhub/ldapauthenticator/pull/117) ([@metrofun](https://github.com/metrofun)) 176 | - Misc cleanup + fixes [#95](https://github.com/jupyterhub/ldapauthenticator/pull/95) ([@dhirschfeld](https://github.com/dhirschfeld)) - _Empty DN templates are now ignored, `search_filter` and `allowed_groups` are no longer mutually exclusive._ 177 | 178 | #### Improvements 179 | 180 | - Allow authentication with empty bind_dn_template when using lookup_dn [#106](https://github.com/jupyterhub/ldapauthenticator/pull/106) ([@behrmann](https://github.com/behrmann)) 181 | - Ignore username returned by `resolve_username` [#105](https://github.com/jupyterhub/ldapauthenticator/pull/105) ([@behrmann](https://github.com/behrmann)) - _`use_lookup_dn_username` configuration option added._ 182 | - Lookup additional LDAP user info [#103](https://github.com/jupyterhub/ldapauthenticator/pull/103) ([@manics](https://github.com/manics)) - _`user_info_attributes` is now saved in `auth_state` for a valid user._ 183 | 184 | #### Maintenance 185 | 186 | - Fix CI linting failures and add testing of Py38 [#157](https://github.com/jupyterhub/ldapauthenticator/pull/157) ([@consideRatio](https://github.com/consideRatio)) 187 | - Add long description for pypi [#155](https://github.com/jupyterhub/ldapauthenticator/pull/155) ([@manics](https://github.com/manics)) 188 | - Add badges according to team-compass [#154](https://github.com/jupyterhub/ldapauthenticator/pull/154) ([@consideRatio](https://github.com/consideRatio)) 189 | - Travis deploy tags to PyPI [#153](https://github.com/jupyterhub/ldapauthenticator/pull/153) ([@manics](https://github.com/manics)) 190 | - Add bind_dn_template to Active Directory instructions [#147](https://github.com/jupyterhub/ldapauthenticator/pull/147) ([@irasnyd](https://github.com/irasnyd)) 191 | - Expand contributor's guide [#135](https://github.com/jupyterhub/ldapauthenticator/pull/135) ([@marcusianlevine](https://github.com/marcusianlevine)) 192 | - Add Travis CI setup and simple tests [#134](https://github.com/jupyterhub/ldapauthenticator/pull/134) ([@marcusianlevine](https://github.com/marcusianlevine)) 193 | - Update project url in setup.py [#92](https://github.com/jupyterhub/ldapauthenticator/pull/92) ([@dhirschfeld](https://github.com/dhirschfeld)) 194 | - Update README.md [#85](https://github.com/jupyterhub/ldapauthenticator/pull/85) ([@dhirschfeld](https://github.com/dhirschfeld)) 195 | - Bump version to 1.2.2 [#84](https://github.com/jupyterhub/ldapauthenticator/pull/84) ([@dhirschfeld](https://github.com/dhirschfeld)) 196 | 197 | #### Contributors to this release 198 | 199 | ([GitHub contributors page for this release](https://github.com/jupyterhub/ldapauthenticator/graphs/contributors?from=2018-06-14&to=2020-01-31&type=c)) 200 | 201 | [@behrmann](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Abehrmann+updated%3A2018-06-14..2020-01-31&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Abetatim+updated%3A2018-06-14..2020-01-31&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AconsideRatio+updated%3A2018-06-14..2020-01-31&type=Issues) | [@dhirschfeld](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adhirschfeld+updated%3A2018-06-14..2020-01-31&type=Issues) | [@irasnyd](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Airasnyd+updated%3A2018-06-14..2020-01-31&type=Issues) | [@m2hofi94](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Am2hofi94+updated%3A2018-06-14..2020-01-31&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amanics+updated%3A2018-06-14..2020-01-31&type=Issues) | [@marcusianlevine](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amarcusianlevine+updated%3A2018-06-14..2020-01-31&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ameeseeksmachine+updated%3A2018-06-14..2020-01-31&type=Issues) | [@metrofun](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ametrofun+updated%3A2018-06-14..2020-01-31&type=Issues) | [@ramkrishnan8994](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aramkrishnan8994+updated%3A2018-06-14..2020-01-31&type=Issues) | [@titansmc](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Atitansmc+updated%3A2018-06-14..2020-01-31&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ayuvipanda+updated%3A2018-06-14..2020-01-31&type=Issues) 202 | 203 | ## 1.2 204 | 205 | ### 1.2.2 - 2018-06-14 206 | 207 | Minor patch release for incorrectly escaping commas in `resolved_username` 208 | 209 | #### Fixes 210 | 211 | - Fix comma escape in `resolved_username` [#83](https://github.com/jupyterhub/ldapauthenticator/pull/83) ([@dhirschfeld](https://github.com/dhirschfeld)) 212 | 213 | #### Improvements 214 | 215 | - Add manifest to package license [#74](https://github.com/jupyterhub/ldapauthenticator/pull/74) ([@mariusvniekerk](https://github.com/mariusvniekerk)) - _Adds license file to the sdist_ 216 | 217 | ## Contributors to this release 218 | 219 | ([GitHub contributors page for this release](https://github.com/jupyterhub/ldapauthenticator/graphs/contributors?from=2018-06-08&to=2018-06-14&type=c)) 220 | 221 | [@dhirschfeld](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adhirschfeld+updated%3A2018-06-08..2018-06-14&type=Issues) | [@mariusvniekerk](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amariusvniekerk+updated%3A2018-06-08..2018-06-14&type=Issues) 222 | 223 | ### 1.2.1 - 2018-06-08 224 | 225 | Minor patch release for bug in `resolved_username` regex. 226 | 227 | #### Fixes 228 | 229 | - Fix resolved_username regex [#75](https://github.com/jupyterhub/ldapauthenticator/pull/75) ([@dhirschfeld](https://github.com/dhirschfeld)) 230 | 231 | #### Improvements 232 | 233 | - Improve packaging [#77](https://github.com/jupyterhub/ldapauthenticator/pull/77) ([@dhirschfeld](https://github.com/dhirschfeld)) - _Decoupled runtime dependencies from the build process_ 234 | 235 | #### Maintenance 236 | 237 | - Minor cleanup of setup.py [#73](https://github.com/jupyterhub/ldapauthenticator/pull/73) ([@dhirschfeld](https://github.com/dhirschfeld)) 238 | 239 | #### Contributors to this release 240 | 241 | [@dhirschfeld](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adhirschfeld+updated%3A2018-06-07..2018-06-08&type=Issues) 242 | 243 | ### 1.2.0 - 2018-06-07 244 | 245 | #### Merged PRs 246 | 247 | - Escape comma in resolved_username [#68](https://github.com/jupyterhub/ldapauthenticator/pull/68) ([@dhirschfeld](https://github.com/dhirschfeld)) 248 | - Fixed really bad error [#64](https://github.com/jupyterhub/ldapauthenticator/pull/64) ([@jcrubioa](https://github.com/jcrubioa)) 249 | - Don't force TLS bind if not using SSL. [#61](https://github.com/jupyterhub/ldapauthenticator/pull/61) ([@GrahamDumpleton](https://github.com/GrahamDumpleton)) 250 | - Catch exception thrown in getConnection [#56](https://github.com/jupyterhub/ldapauthenticator/pull/56) ([@dhirschfeld](https://github.com/dhirschfeld)) 251 | - Update LICENSE [#48](https://github.com/jupyterhub/ldapauthenticator/pull/48) ([@fm75](https://github.com/fm75)) 252 | - Switching to StartTLS instead of ssl [#46](https://github.com/jupyterhub/ldapauthenticator/pull/46) ([@toxadx](https://github.com/toxadx)) 253 | - Add yuvipanda's description of local user creation [#43](https://github.com/jupyterhub/ldapauthenticator/pull/43) ([@willingc](https://github.com/willingc)) 254 | - Update ldapauthenticator.py [#40](https://github.com/jupyterhub/ldapauthenticator/pull/40) ([@sauloal](https://github.com/sauloal)) 255 | - import union traitlet [#34](https://github.com/jupyterhub/ldapauthenticator/pull/34) ([@dirkcgrunwald](https://github.com/dirkcgrunwald)) 256 | - User CN name lookup with specific query [#32](https://github.com/jupyterhub/ldapauthenticator/pull/32) ([@mateuszboryn](https://github.com/mateuszboryn)) 257 | - Add better documentation for traitlets [#26](https://github.com/jupyterhub/ldapauthenticator/pull/26) ([@yuvipanda](https://github.com/yuvipanda)) 258 | - Extending ldapauthenticator to allow arbitrary LDAP search-filters [#24](https://github.com/jupyterhub/ldapauthenticator/pull/24) ([@nklever](https://github.com/nklever)) 259 | - Support for multiple bind templates [#23](https://github.com/jupyterhub/ldapauthenticator/pull/23) ([@kishorchintal](https://github.com/kishorchintal)) 260 | 261 | #### Contributors to this release 262 | 263 | [@beenje](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Abeenje+updated%3A2016-11-21..2018-06-07&type=Issues) | [@deebuls](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adeebuls+updated%3A2016-11-21..2018-06-07&type=Issues) | [@dhirschfeld](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adhirschfeld+updated%3A2016-11-21..2018-06-07&type=Issues) | [@dirkcgrunwald](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Adirkcgrunwald+updated%3A2016-11-21..2018-06-07&type=Issues) | [@fm75](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Afm75+updated%3A2016-11-21..2018-06-07&type=Issues) | [@GrahamDumpleton](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3AGrahamDumpleton+updated%3A2016-11-21..2018-06-07&type=Issues) | [@jcrubioa](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ajcrubioa+updated%3A2016-11-21..2018-06-07&type=Issues) | [@kishorchintal](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Akishorchintal+updated%3A2016-11-21..2018-06-07&type=Issues) | [@mateuszboryn](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Amateuszboryn+updated%3A2016-11-21..2018-06-07&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aminrk+updated%3A2016-11-21..2018-06-07&type=Issues) | [@nklever](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Anklever+updated%3A2016-11-21..2018-06-07&type=Issues) | [@pratik705](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Apratik705+updated%3A2016-11-21..2018-06-07&type=Issues) | [@sauloal](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Asauloal+updated%3A2016-11-21..2018-06-07&type=Issues) | [@toxadx](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Atoxadx+updated%3A2016-11-21..2018-06-07&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Awillingc+updated%3A2016-11-21..2018-06-07&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ayuvipanda+updated%3A2016-11-21..2018-06-07&type=Issues) 264 | 265 | ## 1.1 266 | 267 | ### 1.1.0 - 2016-11-21 268 | 269 | #### Merged PRs 270 | 271 | - More options for ldap group membership [#22](https://github.com/jupyterhub/ldapauthenticator/pull/22) ([@m0zes](https://github.com/m0zes)) 272 | - Add info on invalidating existing logins [#18](https://github.com/jupyterhub/ldapauthenticator/pull/18) ([@yuvipanda](https://github.com/yuvipanda)) 273 | - Add more verbose logging for login failures [#17](https://github.com/jupyterhub/ldapauthenticator/pull/17) ([@yuvipanda](https://github.com/yuvipanda)) 274 | - Clarify usage of 'c.' [#16](https://github.com/jupyterhub/ldapauthenticator/pull/16) ([@yuvipanda](https://github.com/yuvipanda)) 275 | - Add support for looking up the account DN post-bind [#12](https://github.com/jupyterhub/ldapauthenticator/pull/12) ([@skemper](https://github.com/skemper)) 276 | 277 | #### Contributors to this release 278 | 279 | [@m0zes](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Am0zes+updated%3A2016-03-28..2016-11-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Aminrk+updated%3A2016-03-28..2016-11-21&type=Issues) | [@skemper](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Askemper+updated%3A2016-03-28..2016-11-21&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fldapauthenticator+involves%3Ayuvipanda+updated%3A2016-03-28..2016-11-21&type=Issues) 280 | 281 | ## 1.0 282 | 283 | ### 1.0.0 - 2016-03-28 284 | 285 | Initial release. 286 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! As a [Jupyter](https://jupyter.org) project, you can follow the [Jupyter contributor guide](https://docs.jupyter.org/en/latest/contributing/content-contributor.html). 4 | 5 | Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/main/conduct/code_of_conduct.md) 6 | for a friendly and welcoming collaborative environment. 7 | 8 | This guide was adapted from the [contributing guide in the main `jupyterhub` repo.](https://github.com/jupyterhub/jupyterhub/blob/main/CONTRIBUTING.md) 9 | 10 | ## Setting up a development environment 11 | 12 | As a Python project, a development install of JupyterHub follows standard practices for installation and testing. 13 | 14 | Note: if you have Docker installed locally, you can run all of the subsequent commands inside of a container after you run the following initial commands: 15 | 16 | ```shell 17 | # starts an openldap server inside a docker container 18 | ./ci/docker-ldap.sh 19 | 20 | # starts a python docker image 21 | docker run --rm -it -v $PWD:/usr/local/src --workdir=/usr/local/src --net=host python:3.11 bash 22 | ``` 23 | 24 | 1. Do a development install with pip 25 | 26 | ```bash 27 | pip install --editable ".[test]" 28 | ``` 29 | 30 | 1. Set up pre-commit hooks for automatic code formatting, etc. 31 | 32 | ```bash 33 | pip install pre-commit 34 | 35 | pre-commit install --install-hooks 36 | ``` 37 | 38 | You can also invoke the pre-commit hook manually at any time with 39 | 40 | ```bash 41 | pre-commit run 42 | ``` 43 | 44 | To clean up your development LDAP deployment, run: 45 | 46 | ``` 47 | docker rm -f test-openldap 48 | ``` 49 | 50 | ## Testing 51 | 52 | It's a good idea to write tests to exercise any new features, 53 | or that trigger any bugs that you have fixed to catch regressions. 54 | 55 | You can run the tests with: 56 | 57 | ```bash 58 | # starts an openldap server inside a docker container 59 | ./ci/docker-ldap.sh 60 | 61 | # run tests 62 | pytest 63 | ``` 64 | 65 | The tests live in `ldapauthenticator/tests`. 66 | 67 | When writing a new test, there should usually be a test of 68 | similar functionality already written and related tests should 69 | be added nearby. 70 | 71 | When in doubt, feel free to ask. 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Project Jupyter Contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ldapauthenticator 2 | 3 | [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub-ldapauthenticator?logo=pypi)](https://pypi.python.org/pypi/jupyterhub-ldapauthenticator) 4 | [![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub-ldapauthenticator?logo=conda-forge)](https://anaconda.org/conda-forge/jupyterhub-ldapauthenticator) 5 | [![GitHub Workflow Status - Test](https://img.shields.io/github/actions/workflow/status/jupyterhub/ldapauthenticator/test.yaml?logo=github&label=tests)](https://github.com/jupyterhub/ldapauthenticator/actions) 6 | [![Test coverage of code](https://codecov.io/gh/jupyterhub/ldapauthenticator/branch/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/ldapauthenticator) 7 | [![Issue tracking - GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/ldapauthenticator/issues) 8 | [![Help forum - Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) 9 | 10 | Simple LDAP Authenticator Plugin for JupyterHub 11 | 12 | ## Installation 13 | 14 | You can install it from pip with: 15 | 16 | ``` 17 | pip install jupyterhub-ldapauthenticator 18 | ``` 19 | 20 | ...or using conda with: 21 | 22 | ``` 23 | conda install -c conda-forge jupyterhub-ldapauthenticator 24 | ``` 25 | 26 | ## Logging people out 27 | 28 | If you make any changes to JupyterHub's authentication setup that changes 29 | which group of users is allowed to login (such as changing `allowed_groups` 30 | or even just turning on LDAPAuthenticator), you **must** change the 31 | jupyterhub cookie secret, or users who were previously logged in and did 32 | not log out would continue to be able to log in! 33 | 34 | You can do this by deleting the `jupyterhub_cookie_secret` file. Note 35 | that this will log out _all_ users who are currently logged in. 36 | 37 | ## Usage 38 | 39 | You can enable this authenticator by adding lines to your `jupyterhub_config.py`. 40 | 41 | **Note: This file may not exist in your current installation! In TLJH, it 42 | is located in /opt/tljh/config/jupyterhub_config.d. Create it there if you 43 | don't already have one.** 44 | 45 | ```python 46 | c.JupyterHub.authenticator_class = 'ldap' 47 | ``` 48 | 49 | ### Required configuration 50 | 51 | At minimum, the following two configuration options must be set before 52 | the LDAP Authenticator can be used: 53 | 54 | #### `LDAPAuthenticator.server_address` 55 | 56 | Address of the LDAP Server to contact. Just use a bare hostname or IP, 57 | without a port name or protocol prefix. 58 | 59 | #### `LDAPAuthenticator.lookup_dn` or `LDAPAuthenticator.bind_dn_template` 60 | 61 | To authenticate a user we need the corresponding DN to bind against the LDAP server. The DN can be acquired by either: 62 | 63 | 1. setting `bind_dn_template`, which is a list of string template used to 64 | generate the full DN for a user from the human readable username, or 65 | 2. setting `lookup_dn` to `True`, which does a reverse lookup to obtain the 66 | user's DN. This is because some LDAP servers, such as Active Directory, don't 67 | always bind with the true DN. 68 | 69 | ##### `lookup_dn = False` 70 | 71 | If `lookup_dn = False`, then `bind_dn_template` is required to be a non-empty 72 | list of templates the users belong to. For example, if some of the users in your 73 | LDAP database have DN of the form `uid=Yuvipanda,ou=people,dc=wikimedia,dc=org` 74 | and some other users have DN like `uid=Mike,ou=developers,dc=wikimedia,dc=org` 75 | where `Yuvipanda` and `Mike` are the usernames, you would set this config item 76 | to be: 77 | 78 | ```python 79 | c.LDAPAuthenticator.bind_dn_template = [ 80 | "uid={username},ou=people,dc=wikimedia,dc=org", 81 | "uid={username},ou=developers,dc=wikimedia,dc=org", 82 | ] 83 | ``` 84 | 85 | Don't forget the preceeding `c.` for setting configuration parameters! JupyterHub 86 | uses [traitlets](https://traitlets.readthedocs.io) for configuration, and the 87 | `c` represents the [config object](https://traitlets.readthedocs.io/en/stable/config.html). 88 | 89 | The `{username}` is expanded into the username the user provides. 90 | 91 | ##### `lookup_dn = True` 92 | 93 | ```python 94 | c.LDAPAuthenticator.lookup_dn = True 95 | ``` 96 | 97 | If `bind_dn_template` isn't explicitly configured, i.e. the empty list, the 98 | dynamically acquired value for DN from the username lookup will be used 99 | instead. If `bind_dn_template` is configured it will be used just like in the 100 | `lookup_dn = False` case. 101 | 102 | The `{username}` is expanded to the full path to the LDAP object returned by the 103 | LDAP lookup. For example, on an Active Directory system `{username}` might 104 | expand to something like `CN=First M. Last,OU=An Example Organizational 105 | Unit,DC=EXAMPLE,DC=COM`. 106 | 107 | Also, when using `lookup_dn = True` the options `user_search_base`, 108 | `user_attribute`, `lookup_dn_user_dn_attribute` and `lookup_dn_search_filter` 109 | are required, although their defaults might be sufficient for your use case. 110 | 111 | ### Optional configuration 112 | 113 | #### `LDAPAuthenticator.allowed_groups` 114 | 115 | LDAP groups whose members are allowed to log in. This must be 116 | set to either empty `[]` (the default, to disable) or to a list of 117 | full DNs that have a `member` attribute that includes the current 118 | user attempting to log in. 119 | 120 | As an example, to restrict access only to people in groups 121 | `researcher` or `operations`, 122 | 123 | ```python 124 | c.LDAPAuthenticator.allowed_groups = [ 125 | "cn=researcher,ou=groups,dc=wikimedia,dc=org", 126 | "cn=operations,ou=groups,dc=wikimedia,dc=org", 127 | ] 128 | ``` 129 | 130 | #### `LDAPAuthenticator.group_search_filter` 131 | 132 | The LDAP group search filter. 133 | 134 | The default value is an LDAP OR search that looks like the following: 135 | 136 | ``` 137 | (|(member={userdn})(uniqueMember={userdn})(memberUid={uid})) 138 | ``` 139 | 140 | So it basically compares the `userdn` attribute against the `member` attribute, 141 | then against the `uniqueMember`, and finally checks the `memberUid` against 142 | the `uid`. 143 | 144 | If you modify this value, you probably want to change `group_attributes` too. 145 | Here is an example that should work with OpenLDAP servers. 146 | 147 | ``` 148 | (member={userdn}) 149 | ``` 150 | 151 | #### `LDAPAuthenticator.group_attributes` 152 | 153 | A list of attributes used when searching for LDAP groups. 154 | 155 | By default, it uses `member`, `uniqueMember`, and `memberUid`. Certain 156 | servers may reject invalid values causing exceptions during 157 | authentication. 158 | 159 | #### `LDAPAuthenticator.valid_username_regex` 160 | 161 | All usernames will be checked against this before being sent 162 | to LDAP. This acts as both an easy way to filter out invalid 163 | usernames as well as protection against LDAP injection attacks. 164 | 165 | By default it looks for the regex `^[a-z][.a-z0-9_-]*$` which 166 | is what most shell username validators do. 167 | 168 | #### `LDAPAuthenticator.use_ssl` 169 | 170 | `use_ssl` is deprecated since 2.0. `use_ssl=True` translates to configuring 171 | `tls_strategy="on_connect"`, but `use_ssl=False` (previous default) doesn't 172 | translate to anything. 173 | 174 | #### `LDAPAuthenticator.tls_strategy` 175 | 176 | When LDAPAuthenticator connects to the LDAP server, it can establish a 177 | SSL/TLS connection directly, or do it before binding, which is LDAP 178 | terminology for authenticating and sending sensitive credentials. 179 | 180 | The LDAP v3 protocol deprecated establishing a SSL/TLS connection 181 | directly (`tls_strategy="on_connect"`) in favor of upgrading the 182 | connection to SSL/TLS before binding (`tls_strategy="before_bind"`). 183 | 184 | Supported `tls_strategy` values are: 185 | 186 | - "before_bind" (default) 187 | - "on_connect" (deprecated in LDAP v3, associated with use of port 636) 188 | - "insecure" 189 | 190 | When configuring `tls_strategy="on_connect"`, the default value of 191 | `server_port` becomes 636. 192 | 193 | #### `LDAPAuthenticator.tls_kwargs` 194 | 195 | A dictionary that will be used as keyword arguments for the constructor 196 | of the ldap3 package's Tls object, influencing encrypted connections to 197 | the LDAP server. 198 | 199 | For details on what can be configured and its effects, refer to the 200 | ldap3 package's documentation and code: 201 | 202 | - ldap3 documentation: https://ldap3.readthedocs.io/en/latest/ssltls.html#the-tls-object 203 | - ldap3 code: https://github.com/cannatag/ldap3/blob/v2.9.1/ldap3/core/tls.py#L59-L82 204 | 205 | You can for example configure this like: 206 | 207 | ```python 208 | c.LDAPAuthenticator.tls_kwargs = { 209 | "ca_certs_file": "file/path.here", 210 | } 211 | ``` 212 | 213 | #### `LDAPAuthenticator.server_port` 214 | 215 | Port on which to contact the LDAP server. 216 | 217 | Defaults to `636` if `tls_strategy="on_connect"` is set, `389` 218 | otherwise. 219 | 220 | #### `LDAPAuthenticator.user_search_base` 221 | 222 | Only used with `lookup_dn=True` or with a configured `search_filter`. 223 | 224 | Defines the search base for looking up users in the directory. 225 | 226 | ```python 227 | c.LDAPAuthenticator.user_search_base = 'ou=People,dc=example,dc=com' 228 | ``` 229 | 230 | LDAPAuthenticator will search all objects matching under this base where 231 | the `user_attribute` is set to the current username to form the userdn. 232 | 233 | For example, if all users objects existed under the base 234 | `ou=people,dc=wikimedia,dc=org`, and the username users use is set with 235 | the attribute `uid`, you can use the following config: 236 | 237 | ```python 238 | c.LDAPAuthenticator.lookup_dn = True 239 | c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' 240 | c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account' 241 | c.LDAPAuthenticator.lookup_dn_search_password = 'secret' 242 | c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org' 243 | c.LDAPAuthenticator.user_attribute = 'uid' 244 | c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn' 245 | ``` 246 | 247 | #### `LDAPAuthenticator.user_attribute` 248 | 249 | Only used with `lookup_dn=True` or with a configured `search_filter`. 250 | 251 | Together with `user_search_base`, this attribute will be searched to 252 | contain the username provided by the user in JupyterHub's login form. 253 | 254 | ```python 255 | # Active Directory 256 | c.LDAPAuthenticator.user_attribute = 'sAMAccountName' 257 | 258 | # OpenLDAP 259 | c.LDAPAuthenticator.user_attribute = 'uid' 260 | ``` 261 | 262 | #### `LDAPAuthenticator.lookup_dn_search_filter` 263 | 264 | Only used with `lookup_dn=True`. 265 | 266 | How to query LDAP for user name lookup. 267 | 268 | Default value `'({login_attr}={login})'` should be good enough for most 269 | use cases. 270 | 271 | #### `LDAPAuthenticator.lookup_dn_search_user`, `LDAPAuthenticator.lookup_dn_search_password` 272 | 273 | Only used with `lookup_dn=True`. 274 | 275 | Technical account for user lookup. If both `lookup_dn_search_user` and 276 | `lookup_dn_search_password` are None, then anonymous LDAP query will be 277 | done. 278 | 279 | #### `LDAPAuthenticator.lookup_dn_user_dn_attribute` 280 | 281 | Only used with `lookup_dn=True`. 282 | 283 | Attribute containing user's name needed for building DN string. 284 | See `user_search_base` for info on how this attribute is used. 285 | For most LDAP servers, this is username. For Active Directory, it is cn. 286 | 287 | #### `LDAPAuthenticator.auth_state_attributes` 288 | 289 | An optional list of attributes to be fetched for a user after login. 290 | If found, these will be available as `auth_state["user_attributes"]`. 291 | 292 | #### `LDAPAuthenticator.use_lookup_dn_username` 293 | 294 | Only used with `lookup_dn=True`. 295 | 296 | If configured True, the `lookup_dn_user_dn_attribute` value used to 297 | build the LDAP user's DN string is also used as the authenticated user's 298 | JuptyerHub username. 299 | 300 | If this is configured True, its important to ensure that the values of 301 | `lookup_dn_user_dn_attribute` are unique even after the are normalized 302 | to be lowercase, otherwise two LDAP users could end up sharing the same 303 | JupyterHub username. 304 | 305 | With ldapauthenticator 2, the default value was changed to False. 306 | 307 | #### `LDAPAuthenticator.search_filter` 308 | 309 | LDAP3 Search Filter to limit allowed users. 310 | 311 | That a unique LDAP user is identified with the search_filter is 312 | necessary but not sufficient to grant access. Grant access by setting 313 | one or more of `allowed_users`, `allow_all`, `allowed_groups`, etc. 314 | 315 | Users who do not match this filter cannot be allowed 316 | by any other configuration. 317 | 318 | The search filter string will be expanded, so that: 319 | 320 | - `{userattr}` is replaced with the `user_attribute` config's value. 321 | - `{username}` is replaced with an escaped username, either provided 322 | directly or previously looked up with `lookup_dn` configured. 323 | 324 | #### `LDAPAuthenticator.attributes` 325 | 326 | List of attributes to be passed in the LDAP search with `search_filter`. 327 | 328 | ## Compatibility 329 | 330 | This has been tested against an OpenLDAP server, with the client 331 | running Python 3.4. Verifications of this code working well with 332 | other LDAP setups are welcome, as are bug reports and patches to make 333 | it work with other LDAP setups! 334 | 335 | ## Active Directory integration 336 | 337 | Please use following options for AD integration. This is useful especially in two cases: 338 | 339 | - LDAP Search requires valid user account in order to query user database 340 | - DN does not contain login but some other field, like CN (actual login is present in sAMAccountName, and we need to lookup CN) 341 | 342 | ```python 343 | c.LDAPAuthenticator.lookup_dn = True 344 | c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' 345 | c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account' 346 | c.LDAPAuthenticator.lookup_dn_search_password = 'secret' 347 | c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org' 348 | c.LDAPAuthenticator.user_attribute = 'sAMAccountName' 349 | c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn' 350 | ``` 351 | 352 | In setup above, first LDAP will be searched (with account ldap_search_user_technical_account) for users that have sAMAccountName=login 353 | Then DN will be constructed using found CN value. 354 | 355 | ## Configuration note on local user creation 356 | 357 | Currently, local user creation by the LDAPAuthenticator is unsupported as 358 | this is insecure since there's no cleanup method for these created users. As a 359 | result, users who are disabled in LDAP will have access to this for far longer. 360 | 361 | Alternatively, there's good support in Linux for integrating LDAP into the 362 | system user setup directly, and users can just use PAM (which is supported in 363 | not just JupyterHub, but ssh and a lot of other tools) to log in. You can see 364 | http://www.tldp.org/HOWTO/archived/LDAP-Implementation-HOWTO/pamnss.html and 365 | lots of other documentation on the web on how to set up LDAP to provide user 366 | accounts for your system. Those methods are very widely used, much more secure 367 | and more widely documented. We recommend you use them rather than have 368 | JupyterHub create local accounts using the LDAPAuthenticator. 369 | 370 | Issue [#19](https://github.com/jupyterhub/ldapauthenticator/issues/19) provides 371 | additional discussion on local user creation. 372 | 373 | ## Handling SSL/TLS handshake errors 374 | 375 | If you have received a SSL/TLS handshake error, it could be that no [cipher 376 | suite] accepted by LDAPAuthenticator is also accepted by the LDAP server. This 377 | is likely because LDAPAuthenticator is stricter than the LDAP server and only 378 | accepts modern cipher suites than the LDAP server doesn't accept. Due to this, 379 | you should from a security perspective ideally modernize the LDAP server's 380 | accepted cipher suites rather than expand the LDAPAuthenticator accepted cipher 381 | suites to include older cipher suites. 382 | 383 | The cipher suites that LDAPAuthenticator accepted by default come from 384 | [ssl.create_default_context().get_ciphers()], which in turn can change with 385 | Python version. Upgrading Python from 3.7 - 3.9 to 3.10 - 3.13 is known to 386 | strictly reduce the set of accepted cipher suites from 30 to 17 for example. Due 387 | to this, upgrading Python could lead to observing a handshake error previously 388 | not observed. 389 | 390 | If you want to configure LDAPAuthenticator to accept older cipher suites instead 391 | of updating the LDAP server to accept modern cipher suites, you can do it using 392 | `LDAPAuthenticator.tls_kwargs` as demonstrated below. 393 | 394 | ```python 395 | # default cipher suites accepted by LDAPAuthenticator in Python 3.7 - 3.9 396 | # it includes 30 cipher suites, where 13 of them were considered less secure 397 | # and removed as default cipher suites in Python 3.10 398 | old_ciphers_list_considered_less_secure = "AES128-SHA:AES256-SHA:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES128-SHA256:AES256-GCM-SHA384:AES256-SHA256:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-SHA256:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-CHACHA20-POLY1305:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" 399 | 400 | # default cipher suites accepted by LDAPAuthenticator in Python 3.10 - 3.13 401 | # this list includes 17 cipher suites out of the 30 in the old list, with no 402 | # new additions 403 | new_ciphers_list = "DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-CHACHA20-POLY1305:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" 404 | 405 | c.LDAPAuthenticator.tls_kwargs = { 406 | "ciphers": old_ciphers_list_considered_less_secure, 407 | } 408 | ``` 409 | 410 | For reference, you can use a command like below to see what the default cipher 411 | suites LDAPAuthenticator will use in various Python versions. 412 | 413 | ```shell 414 | docker run -it --rm python:3.13 python -c 'import ssl; c = ssl.create_default_context(); print(":".join(sorted([c["name"] for c in c.get_ciphers()])))' 415 | ``` 416 | 417 | [cipher suite]: https://en.wikipedia.org/wiki/Cipher_suite#Full_handshake:_coordinating_cipher_suites 418 | [ssl.create_default_context().get_ciphers()]: https://docs.python.org/3/library/ssl.html#ssl.create_default_context 419 | 420 | ## Testing LDAPAuthenticator without JupyterHub 421 | 422 | This script can be written to a file such as `test_ldap_auth.py`, and run with 423 | `python test_ldap_auth.py`, to test use of LDAPAuthenticator with a given config 424 | without involving JupyterHub. 425 | 426 | If the authenticator works, this script should print either None or a username 427 | depending if the user was considered allowed access. 428 | 429 | ```python 430 | import asyncio 431 | import getpass 432 | 433 | from traitlets.config import Config 434 | from ldapauthenticator import LDAPAuthenticator 435 | 436 | # Configure LDAPAuthenticator below to work against your ldap server 437 | c = Config() 438 | c.LDAPAuthenticator.server_address = "ldap.organisation.org" 439 | c.LDAPAuthenticator.server_port = 636 440 | c.LDAPAuthenticator.bind_dn_template = "uid={username},ou=people,dc=organisation,dc=org" 441 | c.LDAPAuthenticator.user_attribute = "uid" 442 | c.LDAPAuthenticator.user_search_base = "ou=people,dc=organisation,dc=org" 443 | c.LDAPAuthenticator.attributes = ["uid", "cn", "mail", "ou", "o"] 444 | # The following is an example of a search_filter which is build on LDAP AND and OR operations 445 | # here in this example as a combination of the LDAP attributes 'ou', 'mail' and 'uid' 446 | sf = "(&(o={o})(ou={ou}))".format(o="yourOrganisation", ou="yourOrganisationalUnit") 447 | sf += "(&(o={o})(mail={mail}))".format(o="yourOrganisation", mail="yourMailAddress") 448 | c.LDAPAuthenticator.search_filter = f"(&({{userattr}}={{username}})(|{sf}))" 449 | 450 | # Run test 451 | authenticator = LDAPAuthenticator(config=c) 452 | username = input("Username: ") 453 | password = getpass.getpass() 454 | data = dict(username=username, password=password) 455 | return_value = asyncio.run(authenticator.authenticate(None, data)) 456 | print(return_value) 457 | ``` 458 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | 3 | `jupyterhub-ldapauthenticator` is a package available on [PyPI] and on 4 | [conda-forge]. 5 | 6 | These are the instructions on how to make a release. 7 | 8 | ## Pre-requisites 9 | 10 | - Push rights to this GitHub repository 11 | 12 | ## Steps to make a release 13 | 14 | 1. Create a PR updating `CHANGELOG.md` with [github-activity] and continue when 15 | its merged. 16 | 17 | Advice on this procedure can be found in [this team compass 18 | issue](https://github.com/jupyterhub/team-compass/issues/563). 19 | 20 | 2. Checkout main and make sure it is up to date. 21 | 22 | ```shell 23 | git checkout main 24 | git fetch origin main 25 | git reset --hard origin/main 26 | ``` 27 | 28 | 3. Update the version, make commits, and push a git tag with `tbump`. 29 | 30 | ```shell 31 | pip install tbump 32 | ``` 33 | 34 | `tbump` will ask for confirmation before doing anything. 35 | 36 | ```shell 37 | # Example versions to set: 1.0.0, 1.0.0b1 38 | VERSION= 39 | tbump ${VERSION} 40 | ``` 41 | 42 | Following this, the [CI system] will build and publish a release. 43 | 44 | 4. Reset the version back to dev, e.g. `1.0.1.dev` after releasing `1.0.0`. 45 | 46 | ```shell 47 | # Example version to set: 1.0.1.dev 48 | NEXT_VERSION= 49 | tbump --no-tag ${NEXT_VERSION}.dev 50 | ``` 51 | 52 | 5. Following the release to PyPI, an automated PR should arrive within 24 hours 53 | to [conda-forge/jupyterhub-ldapauthenticator-feedstock] with instructions on 54 | releasing to conda-forge. You are welcome to volunteer doing this, but aren't 55 | required as part of making this release to PyPI. 56 | 57 | [github-activity]: https://github.com/executablebooks/github-activity 58 | [pypi]: https://pypi.org/project/jupyterhub-ldapauthenticator/ 59 | [ci system]: https://github.com/jupyterhub/jupyterhub-ldapauthenticator/actions/workflows/release.yaml 60 | [conda-forge]: https://anaconda.org/conda-forge/jupyterhub-ldapauthenticator 61 | [conda-forge/jupyterhub-ldapauthenticator-feedstock]: https://github.com/conda-forge/jupyterhub-ldapauthenticator-feedstock 62 | -------------------------------------------------------------------------------- /ci/docker-ldap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # This file (re-)starts an openldap server to test against within a docker 5 | # container based on the image rroemhild/test-openldap. 6 | # 7 | # ref: https://github.com/rroemhild/docker-test-openldap 8 | # ref: https://github.com/rroemhild/docker-test-openldap/pkgs/container/docker-test-openldap 9 | # 10 | # Stop any existing test-openldap container 11 | docker rm --force test-openldap 2>/dev/null || true 12 | # Start a container, and expose some ports, where 389 and 636 are the local 13 | # system's ports that are redirected to the started container. 14 | # 15 | # - 389:10389 (ldap) 16 | # - 636:10636 (ldaps) 17 | # 18 | # Image updated 2024-09-12 to the latest commit's build 19 | # https://github.com/rroemhild/docker-test-openldap/commit/2645f2164ffb51ec4b5b4a9af0065ad7f2ffc1cf 20 | # 21 | IMAGE=ghcr.io/rroemhild/docker-test-openldap@sha256:107ecba713dd233f6f84047701d1b4dda03307d972814f2ae1db69b0d250544f 22 | docker run --detach --name=test-openldap -p 389:10389 -p 636:10636 $IMAGE 23 | 24 | # It takes a bit more than one second for the container to become ready 25 | sleep 3 26 | -------------------------------------------------------------------------------- /ldapauthenticator/__init__.py: -------------------------------------------------------------------------------- 1 | from ldapauthenticator.ldapauthenticator import LDAPAuthenticator # noqa 2 | 3 | # __version__ should be updated using tbump, based on configuration in 4 | # pyproject.toml, according to instructions in RELEASE.md. 5 | # 6 | __version__ = "2.0.3.dev" 7 | 8 | # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev 9 | version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) 10 | -------------------------------------------------------------------------------- /ldapauthenticator/ldapauthenticator.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | from inspect import isawaitable 4 | 5 | import ldap3 6 | from jupyterhub.auth import Authenticator 7 | from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError 8 | from ldap3.core.tls import Tls 9 | from ldap3.utils.conv import escape_filter_chars 10 | from ldap3.utils.dn import escape_rdn 11 | from traitlets import Bool, Dict, Int, List, Unicode, Union, UseEnum, observe, validate 12 | 13 | 14 | class TlsStrategy(enum.Enum): 15 | """ 16 | Represents a SSL/TLS strategy for LDAPAuthenticator to use when interacting 17 | with the LDAP server. 18 | """ 19 | 20 | before_bind = 1 21 | on_connect = 2 22 | insecure = 3 23 | 24 | 25 | class LDAPAuthenticator(Authenticator): 26 | server_address = Unicode( 27 | config=True, 28 | help=""" 29 | Address of the LDAP server to contact. 30 | 31 | Could be an IP address or hostname. 32 | """, 33 | ) 34 | server_port = Int( 35 | config=True, 36 | help=""" 37 | Port on which to contact the LDAP server. 38 | 39 | Defaults to `636` if `tls_strategy="on_connect"` is set, `389` 40 | otherwise. 41 | """, 42 | ) 43 | 44 | def _server_port_default(self): 45 | if self.tls_strategy == TlsStrategy.on_connect: 46 | return 636 # default SSL port for LDAP 47 | else: 48 | return 389 # default plaintext port for LDAP 49 | 50 | use_ssl = Bool( 51 | None, 52 | allow_none=True, 53 | config=True, 54 | help=""" 55 | `use_ssl` is deprecated since 2.0. `use_ssl=True` translates to configuring 56 | `tls_strategy="on_connect"`, but `use_ssl=False` (previous default) doesn't 57 | translate to anything. 58 | """, 59 | ) 60 | 61 | @observe("use_ssl") 62 | def _observe_use_ssl(self, change): 63 | if change.new: 64 | self.tls_strategy = TlsStrategy.on_connect 65 | self.log.warning( 66 | "LDAPAuthenticator.use_ssl is deprecated in 2.0 in favor of LDAPAuthenticator.tls_strategy, " 67 | 'instead of configuring use_ssl=True, configure tls_strategy="on_connect" from now on.' 68 | ) 69 | else: 70 | self.log.warning( 71 | "LDAPAuthenticator.use_ssl is deprecated in 2.0 in favor of LDAPAuthenticator.tls_strategy, " 72 | "you can stop configuring use_ssl=False from now on as doing so has no effect." 73 | ) 74 | 75 | tls_strategy = UseEnum( 76 | TlsStrategy, 77 | default_value=TlsStrategy.before_bind, 78 | config=True, 79 | help=""" 80 | When LDAPAuthenticator connects to the LDAP server, it can establish a 81 | SSL/TLS connection directly, or do it before binding, which is LDAP 82 | terminology for authenticating and sending sensitive credentials. 83 | 84 | The LDAP v3 protocol deprecated establishing a SSL/TLS connection 85 | directly (`tls_strategy="on_connect"`) in favor of upgrading the 86 | connection to SSL/TLS before binding (`tls_strategy="before_bind"`). 87 | 88 | Supported `tls_strategy` values are: 89 | - "before_bind" (default) 90 | - "on_connect" (deprecated in LDAP v3, associated with use of port 636) 91 | - "insecure" 92 | 93 | When configuring `tls_strategy="on_connect"`, the default value of 94 | `server_port` becomes 636. 95 | """, 96 | ) 97 | 98 | tls_kwargs = Dict( 99 | config=True, 100 | help=""" 101 | A dictionary that will be used as keyword arguments for the constructor 102 | of the ldap3 package's TLS object, influencing encrypted connections to 103 | the LDAP server. 104 | 105 | For details on what can be configured and its effects, refer to the 106 | ldap3 package's documentation and code: 107 | 108 | - ldap3 documentation: https://ldap3.readthedocs.io/en/latest/ssltls.html#the-tls-object 109 | - ldap3 code: https://github.com/cannatag/ldap3/blob/v2.9.1/ldap3/core/tls.py#L59-L82 110 | 111 | You can for example configure this like: 112 | 113 | ```python 114 | c.LDAPAuthenticator.tls_kwargs = { 115 | "ca_certs_file": "file/path.here", 116 | } 117 | ``` 118 | """, 119 | ) 120 | 121 | bind_dn_template = Union( 122 | [List(), Unicode()], 123 | config=True, 124 | help=""" 125 | Template from which to construct the full dn 126 | when authenticating to LDAP. {username} is replaced 127 | with the actual username used to log in. 128 | 129 | If your LDAP is set in such a way that the userdn can not 130 | be formed from a template, but must be looked up with an attribute 131 | (such as uid or sAMAccountName), please see `lookup_dn`. It might 132 | be particularly relevant for ActiveDirectory installs. 133 | 134 | String example: 135 | uid={username},ou=people,dc=wikimedia,dc=org 136 | 137 | List example: 138 | [ 139 | uid={username},ou=people,dc=wikimedia,dc=org, 140 | uid={username},ou=Developers,dc=wikimedia,dc=org 141 | ] 142 | """, 143 | ) 144 | 145 | @validate("bind_dn_template") 146 | def _validate_bind_dn_template(self, proposal): 147 | """ 148 | Ensure a List[str] is set, filtered from empty string elements. 149 | """ 150 | rv = [] 151 | if isinstance(proposal.value, str): 152 | rv = [proposal.value] 153 | else: 154 | rv = proposal.value 155 | if "" in rv: 156 | self.log.warning("Ignoring blank 'bind_dn_template' entry!") 157 | rv = [e for e in rv if e] 158 | return rv 159 | 160 | @observe("lookup_dn", "bind_dn_template") 161 | def _require_either_lookup_dn_or_bind_dn_template(self, change): 162 | if not self.lookup_dn and not self.bind_dn_template: 163 | raise ValueError( 164 | "LDAPAuthenticator requires either lookup_dn or " 165 | "bind_dn_template to be configured" 166 | ) 167 | 168 | allowed_groups = List( 169 | config=True, 170 | allow_none=True, 171 | default_value=None, 172 | help=""" 173 | List of LDAP group DNs that users could be members of to be granted access. 174 | 175 | If a user is in any one of the listed groups, then that user is granted access. 176 | Membership is tested by fetching info about each group and looking for the User's 177 | dn to be a value of one of `member` or `uniqueMember`, *or* if the username being 178 | used to log in with is value of the `uid`. 179 | 180 | Set to an empty list or None to allow all users that have an LDAP account to log in, 181 | without performing any group membership checks. 182 | 183 | When combined with `search_filter`, this strictly reduces the allowed users, 184 | i.e. `search_filter` AND `allowed_groups` must both be satisfied. 185 | """, 186 | ) 187 | 188 | group_search_filter = Unicode( 189 | config=True, 190 | default_value="(|(member={userdn})(uniqueMember={userdn})(memberUid={uid}))", 191 | help=""" 192 | The search filter template used to locate groups that the user belongs to. 193 | 194 | `{userdn}` and `{uid}` will be replaced with the LDAP user's attributes. 195 | 196 | Certain server types may use different values, and may also 197 | reject invalid values by raising exceptions. 198 | """, 199 | ) 200 | 201 | group_attributes = List( 202 | config=True, 203 | default_value=["member", "uniqueMember", "memberUid"], 204 | help="List of attributes in the LDAP group to be searched", 205 | ) 206 | 207 | @observe("allowed_groups", "group_search_filter", "group_attributes") 208 | def _ensure_allowed_groups_requirements(self, change): 209 | if not self.allowed_groups: 210 | return 211 | if not self.group_search_filter or not self.group_attributes: 212 | raise ValueError( 213 | "LDAPAuthenticator.allowed_groups requires both " 214 | "group_search_filter and group_attributes to be configured" 215 | ) 216 | 217 | valid_username_regex = Unicode( 218 | r"^[a-z][.a-z0-9_-]*$", 219 | config=True, 220 | help=""" 221 | Regex for validating usernames - those that do not match this regex will 222 | be rejected. 223 | 224 | This config was primarily introduced to prevent LDAP injection 225 | (https://www.owasp.org/index.php/LDAP_injection), but that is since 2.0 226 | being mitigated by escaping all sensitive characters when interacting 227 | with the LDAP server. 228 | """, 229 | ) 230 | 231 | lookup_dn = Bool( 232 | False, 233 | config=True, 234 | help=""" 235 | Form user's DN by looking up an entry from directory 236 | 237 | By default, LDAPAuthenticator finds the user's DN by using `bind_dn_template`. 238 | However, in some installations, the user's DN does not contain the username, and 239 | hence needs to be looked up. You can set this to True and then use `user_search_base` 240 | and `user_attribute` to accomplish this. 241 | """, 242 | ) 243 | 244 | user_search_base = Unicode( 245 | config=True, 246 | default_value=None, 247 | allow_none=True, 248 | help=""" 249 | Only used with `lookup_dn=True` or with a configured `search_filter`. 250 | 251 | Defines the search base for looking up users in the directory. 252 | 253 | ```python 254 | c.LDAPAuthenticator.user_search_base = 'ou=People,dc=example,dc=com' 255 | ``` 256 | 257 | LDAPAuthenticator will search all objects under this base where 258 | the `user_attribute` is set to the current username to form the userdn. 259 | 260 | For example, if all users objects existed under the base 261 | `ou=people,dc=wikimedia,dc=org`, the username is set with 262 | the attribute `uid`, you can use the following config: 263 | 264 | ```python 265 | c.LDAPAuthenticator.lookup_dn = True 266 | c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' 267 | c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account' 268 | c.LDAPAuthenticator.lookup_dn_search_password = 'secret' 269 | c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org' 270 | c.LDAPAuthenticator.user_attribute = 'uid' 271 | c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'sAMAccountName' 272 | ``` 273 | """, 274 | ) 275 | 276 | user_attribute = Unicode( 277 | config=True, 278 | default_value=None, 279 | allow_none=True, 280 | help=""" 281 | Only used with `lookup_dn=True` or with a configured `search_filter`. 282 | 283 | Together with `user_search_base`, this attribute will be searched to 284 | contain the username provided by the user in JupyterHub's login form. 285 | 286 | ```python 287 | # Active Directory 288 | c.LDAPAuthenticator.user_attribute = 'sAMAccountName' 289 | 290 | # OpenLDAP 291 | c.LDAPAuthenticator.user_attribute = 'uid' 292 | ``` 293 | """, 294 | ) 295 | 296 | lookup_dn_search_filter = Unicode( 297 | config=True, 298 | default_value="({login_attr}={login})", 299 | allow_none=True, 300 | help=""" 301 | Only used with `lookup_dn=True`. 302 | 303 | How to query LDAP for user name lookup. 304 | 305 | Default value `'({login_attr}={login})'` should be good enough for most 306 | use cases. 307 | """, 308 | ) 309 | 310 | lookup_dn_search_user = Unicode( 311 | config=True, 312 | default_value=None, 313 | allow_none=True, 314 | help=""" 315 | Only used with `lookup_dn=True`. 316 | 317 | Technical account for user lookup. If both `lookup_dn_search_user` and 318 | `lookup_dn_search_password` are None, then anonymous LDAP query will be 319 | done. 320 | """, 321 | ) 322 | 323 | lookup_dn_search_password = Unicode( 324 | config=True, 325 | default_value=None, 326 | allow_none=True, 327 | help=""" 328 | Only used with `lookup_dn=True`. 329 | 330 | Password for a `lookup_dn_search_user`. 331 | """, 332 | ) 333 | 334 | lookup_dn_user_dn_attribute = Unicode( 335 | config=True, 336 | default_value=None, 337 | allow_none=True, 338 | help=""" 339 | Only used with `lookup_dn=True`. 340 | 341 | Attribute containing user's name needed for building DN string. See 342 | `user_search_base` for info on how this attribute is used. For most LDAP 343 | servers, this is username. For Active Directory, it is `sAMAccountName`. 344 | """, 345 | ) 346 | 347 | escape_userdn = Bool( 348 | False, 349 | config=True, 350 | help=""" 351 | Removed in 2.0, configuring this no longer has any effect. 352 | """, 353 | ) 354 | 355 | @observe("escape_userdn") 356 | def _observe_escape_userdn(self, change): 357 | self.log.warning( 358 | "LDAPAuthenticator.escape_userdn was removed in 2.0 and no longer has any effect." 359 | ) 360 | 361 | search_filter = Unicode( 362 | config=True, 363 | help=""" 364 | LDAP3 Search Filter to limit allowed users. 365 | 366 | That a unique LDAP user is identified with the search_filter is 367 | necessary but not sufficient to grant access. Grant access by setting 368 | one or more of `allowed_users`, `allow_all`, `allowed_groups`, etc. 369 | 370 | Users who do not match this filter cannot be allowed 371 | by any other configuration. 372 | 373 | The search filter string will be expanded, so that: 374 | 375 | - `{userattr}` is replaced with the `user_attribute` config's value. 376 | - `{username}` is replaced with an escaped username, either provided 377 | directly or previously looked up with `lookup_dn` configured. 378 | """, 379 | ) 380 | 381 | attributes = List( 382 | config=True, 383 | help=""" 384 | List of attributes to be passed in the LDAP search with `search_filter`. 385 | """, 386 | ) 387 | 388 | auth_state_attributes = List( 389 | config=True, 390 | help=""" 391 | List of user attributes to be returned in auth_state 392 | 393 | Will be available in `auth_state["user_attributes"]` 394 | """, 395 | ) 396 | 397 | use_lookup_dn_username = Bool( 398 | False, 399 | config=True, 400 | help=""" 401 | Only used with `lookup_dn=True`. 402 | 403 | If configured True, the `lookup_dn_user_dn_attribute` value used to 404 | build the LDAP user's DN string is also used as the authenticated user's 405 | JupyterHub username. 406 | 407 | If this is configured True, its important to ensure that the values of 408 | `lookup_dn_user_dn_attribute` are unique even after the are normalized 409 | to be lowercase, otherwise two LDAP users could end up sharing the same 410 | JupyterHub username. 411 | 412 | With ldapauthenticator 2, the default value was changed to False. 413 | """, 414 | ) 415 | 416 | def resolve_username(self, username_supplied_by_user): 417 | """ 418 | Resolves a username (that could be used to construct a DN through a 419 | template), and a DN, based on a username supplied by a user via a login 420 | prompt in JupyterHub. 421 | 422 | Returns (username, userdn) if found, or (None, None) if an error occurred, 423 | or if `username_supplied_by_user` does not correspond to a unique user. 424 | """ 425 | conn = self.get_connection( 426 | userdn=self.lookup_dn_search_user, 427 | password=self.lookup_dn_search_password, 428 | ) 429 | if not conn: 430 | self.log.error( 431 | f"Failed to bind lookup_dn_search_user '{self.lookup_dn_search_user}'" 432 | ) 433 | return (None, None) 434 | 435 | search_filter = self.lookup_dn_search_filter.format( 436 | # A search filter matching against string literals, should 437 | # have the string literals escaped with escape_filter_chars. 438 | # Escaped characters are `/()*` (and null). 439 | # 440 | # ref: https://datatracker.ietf.org/doc/html/rfc4515#section-3 441 | # ref: https://ldap3.readthedocs.io/en/latest/searches.html?highlight=escape_filter_chars 442 | # 443 | login_attr=self.user_attribute, 444 | login=escape_filter_chars(username_supplied_by_user), 445 | ) 446 | self.log.debug( 447 | "Looking up user with:\n" 448 | f" search_base = '{self.user_search_base}'\n" 449 | f" search_filter = '{search_filter}'\n" 450 | f" attributes = '[{self.lookup_dn_user_dn_attribute}]'" 451 | ) 452 | conn.search( 453 | search_base=self.user_search_base, 454 | search_scope=ldap3.SUBTREE, 455 | search_filter=search_filter, 456 | attributes=[self.lookup_dn_user_dn_attribute], 457 | ) 458 | 459 | # identify unique search response entry 460 | n_entries = len(conn.entries) 461 | if n_entries == 0: 462 | self.log.warning(f"No response looking up '{username_supplied_by_user}'") 463 | return (None, None) 464 | if n_entries > 1: 465 | self.log.error( 466 | f"Looking up '{username_supplied_by_user}' gave multiple entries, " 467 | f"expected 0 or 1 search response entries but received {n_entries}. " 468 | "Is lookup_dn_search_filter and user_attribute configured to get a " 469 | "unique match?" 470 | ) 471 | return (None, None) 472 | entry = conn.entries[0] 473 | 474 | # identify unique attribute value within the entry 475 | attribute_values = entry.entry_attributes_as_dict.get( 476 | self.lookup_dn_user_dn_attribute 477 | ) 478 | if not attribute_values: 479 | if attribute_values is None: 480 | self.log.error( 481 | f"No attribute '{self.lookup_dn_user_dn_attribute}' found. " 482 | "Is lookup_dn_user_dn_attribute configured correctly?" 483 | ) 484 | else: 485 | self.log.error( 486 | f"No attribute values for '{self.lookup_dn_user_dn_attribute}'. " 487 | "Is lookup_dn_user_dn_attribute configured correctly?" 488 | ) 489 | return (None, None) 490 | if len(attribute_values) > 1: 491 | self.log.error( 492 | f"Attribute '{self.lookup_dn_user_dn_attribute}' had multiple values, " 493 | f"expected one attribute value but it had {len(attribute_values)} " 494 | f"({';'.join(attribute_values)}). " 495 | "Is lookup_dn_user_dn_attribute configured correctly?" 496 | ) 497 | return None, None 498 | 499 | userdn = entry.entry_dn 500 | username = attribute_values[0] 501 | return (username, userdn) 502 | 503 | def get_connection(self, userdn, password): 504 | """ 505 | Returns either an ldap3 Connection object automatically bound to the 506 | user, or None if the bind operation failed for some reason. 507 | 508 | Raises errors on connectivity or TLS issues. 509 | 510 | ldap3 Connection ref: 511 | - docs: https://ldap3.readthedocs.io/en/latest/connection.html 512 | - code: https://github.com/cannatag/ldap3/blob/dev/ldap3/core/connection.py 513 | """ 514 | if self.tls_strategy == TlsStrategy.on_connect: 515 | use_ssl = True 516 | auto_bind = ldap3.AUTO_BIND_NO_TLS 517 | elif self.tls_strategy == TlsStrategy.before_bind: 518 | use_ssl = False 519 | auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND 520 | else: # TlsStrategy.insecure 521 | use_ssl = False 522 | auto_bind = ldap3.AUTO_BIND_NO_TLS 523 | 524 | tls = Tls(**self.tls_kwargs) 525 | server = ldap3.Server( 526 | self.server_address, 527 | port=self.server_port, 528 | use_ssl=use_ssl, 529 | tls=tls, 530 | ) 531 | try: 532 | self.log.debug(f"Attempting to bind {userdn}") 533 | conn = ldap3.Connection( 534 | server, 535 | user=userdn, 536 | password=password, 537 | auto_bind=auto_bind, 538 | ) 539 | except LDAPSocketOpenError as e: 540 | if "handshake" in str(e).lower(): 541 | self.log.error( 542 | "A TLS handshake failure has occurred. " 543 | "It could be an indication that no cipher suite accepted by " 544 | "LDAPAuthenticator was accepted by the LDAP server. For " 545 | "guidance on how to handle this, refer to documentation at " 546 | "https://github.com/consideRatio/ldapauthenticator/tree/main?tab=readme-ov-file#handling-ssltls-handshake-errors" 547 | ) 548 | raise 549 | except LDAPBindError as e: 550 | self.log.debug( 551 | "Failed to bind {userdn}\n{e_type}: {e_msg}".format( 552 | userdn=userdn, 553 | e_type=e.__class__.__name__, 554 | e_msg=e.args[0] if e.args else "", 555 | ) 556 | ) 557 | return None 558 | else: 559 | self.log.debug(f"Successfully bound {userdn}") 560 | return conn 561 | 562 | def get_user_attributes(self, conn, userdn): 563 | if self.auth_state_attributes: 564 | conn.search( 565 | search_base=userdn, 566 | search_scope=ldap3.SUBTREE, 567 | search_filter="(objectClass=*)", 568 | attributes=self.auth_state_attributes, 569 | ) 570 | 571 | # identify unique search response entry 572 | n_entries = len(conn.entries) 573 | if n_entries == 1: 574 | return conn.entries[0].entry_attributes_as_dict 575 | self.log.error( 576 | f"Expected 1 but got {n_entries} search response entries for DN '{userdn}' " 577 | "when looking up attributes configured via auth_state_attributes. The user's " 578 | "auth state will not include any attributes." 579 | ) 580 | return {} 581 | 582 | async def authenticate(self, handler, data): 583 | """ 584 | Note: This function is really meant to identify a user, and 585 | check_allowed and check_blocked are meant to determine if its an 586 | authorized user. Authorization is currently handled by returning 587 | None here instead. 588 | 589 | ref: https://jupyterhub.readthedocs.io/en/latest/reference/authenticators.html#authenticator-authenticate 590 | """ 591 | login_username = data["username"] 592 | password = data["password"] 593 | 594 | # Protect against invalid usernames as well as LDAP injection attacks 595 | if not re.match(self.valid_username_regex, login_username): 596 | self.log.warning( 597 | "username:%s Illegal characters in username, must match regex %s", 598 | login_username, 599 | self.valid_username_regex, 600 | ) 601 | return None 602 | 603 | # No empty passwords! 604 | if password is None or password.strip() == "": 605 | self.log.warning( 606 | "username:%s Login denied for blank password", login_username 607 | ) 608 | return None 609 | 610 | bind_dn_template = self.bind_dn_template 611 | resolved_username = login_username 612 | if self.lookup_dn: 613 | resolved_username, resolved_dn = self.resolve_username(login_username) 614 | if not resolved_dn: 615 | self.log.warning( 616 | "username:%s Login denied for failed lookup", login_username 617 | ) 618 | return None 619 | if not bind_dn_template: 620 | bind_dn_template = [resolved_dn] 621 | 622 | # bind to ldap user 623 | conn = None 624 | for dn in bind_dn_template: 625 | # A DN represented as a string should have its attribute values 626 | # escaped with escape_rdn. Escaped characters are `\,+"<>;=` (and 627 | # null). 628 | # 629 | # ref: https://datatracker.ietf.org/doc/html/rfc4514#section-2.4. 630 | # ref: https://ldap3.readthedocs.io/en/latest/connection.html?highlight=escape_rdn 631 | # 632 | userdn = dn.format(username=escape_rdn(resolved_username)) 633 | conn = self.get_connection(userdn, password) 634 | if conn: 635 | break 636 | if not conn: 637 | if login_username == resolved_username: 638 | self.log.warning( 639 | f"Failed to bind user '{login_username}' to an LDAP user." 640 | ) 641 | else: 642 | self.log.warning( 643 | f"Failed to bind login username '{login_username}', " 644 | f"with looked up user attribute value '{resolved_username}', " 645 | "to an LDAP user." 646 | ) 647 | return None 648 | 649 | if self.search_filter: 650 | conn.search( 651 | search_base=self.user_search_base, 652 | search_scope=ldap3.SUBTREE, 653 | search_filter=self.search_filter.format( 654 | # A search filter matching against string literals, should 655 | # have the string literals escaped with escape_filter_chars. 656 | # Escaped characters are `/()*` (and null). 657 | # 658 | # ref: https://datatracker.ietf.org/doc/html/rfc4515#section-3 659 | # ref: https://ldap3.readthedocs.io/en/latest/searches.html?highlight=escape_filter_chars 660 | # 661 | userattr=self.user_attribute, 662 | username=escape_filter_chars(resolved_username), 663 | ), 664 | attributes=self.attributes, 665 | ) 666 | n_entries = len(conn.entries) 667 | if n_entries != 1: 668 | self.log.warning( 669 | f"Login of '{login_username}' denied. Configured search_filter " 670 | f"found {n_entries} users associated with " 671 | f"userattr='{self.user_attribute}' and username='{resolved_username}', " 672 | "and a unique match is required." 673 | ) 674 | return None 675 | 676 | ldap_groups = [] 677 | if self.allowed_groups: 678 | self.log.debug("username:%s Using dn %s", resolved_username, userdn) 679 | for group in self.allowed_groups: 680 | found = conn.search( 681 | search_base=group, 682 | search_scope=ldap3.BASE, 683 | search_filter=self.group_search_filter.format( 684 | # A search filter matching against string literals, should 685 | # have the string literals escaped with escape_filter_chars. 686 | # Escaped characters are `/()*` (and null). 687 | # 688 | # ref: https://datatracker.ietf.org/doc/html/rfc4515#section-3 689 | # ref: https://ldap3.readthedocs.io/en/latest/searches.html?highlight=escape_filter_chars 690 | # 691 | userdn=escape_filter_chars(userdn), 692 | uid=escape_filter_chars(resolved_username), 693 | ), 694 | attributes=self.group_attributes, 695 | ) 696 | if found: 697 | ldap_groups.append(group) 698 | # Returned in auth_state, so fetch the full list 699 | 700 | user_attributes = self.get_user_attributes(conn, userdn) 701 | self.log.debug("username:%s attributes:%s", login_username, user_attributes) 702 | 703 | username = resolved_username if self.use_lookup_dn_username else login_username 704 | auth_state = { 705 | "ldap_groups": ldap_groups, 706 | "user_attributes": user_attributes, 707 | } 708 | return {"name": username, "auth_state": auth_state} 709 | 710 | async def check_allowed(self, username, auth_model): 711 | if not hasattr(self, "allow_all"): 712 | # super for JupyterHub < 5 713 | # default behavior: no allow config => allow all 714 | if not self.allowed_users and not self.allowed_groups: 715 | return True 716 | if self.allowed_users and username in self.allowed_users: 717 | return True 718 | else: 719 | allowed = super().check_allowed(username, auth_model) 720 | if isawaitable(allowed): 721 | allowed = await allowed 722 | if allowed is True: 723 | return True 724 | if self.allowed_groups: 725 | # check allowed groups 726 | in_groups = set((auth_model.get("auth_state") or {}).get("ldap_groups", [])) 727 | for group in self.allowed_groups: 728 | if group in in_groups: 729 | self.log.debug("Allowing %s as member of group %s", username, group) 730 | return True 731 | if self.search_filter: 732 | self.log.info( 733 | "User %s matches search_filter %s, but not allowed by allowed_users, allowed_groups, or allow_all.", 734 | username, 735 | self.search_filter, 736 | ) 737 | return False 738 | -------------------------------------------------------------------------------- /ldapauthenticator/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/ldapauthenticator/63faed592deb0173b8385463b9512ae7c0c0c055/ldapauthenticator/tests/__init__.py -------------------------------------------------------------------------------- /ldapauthenticator/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from traitlets.config import Config 5 | 6 | 7 | @pytest.fixture() 8 | def c(): 9 | """ 10 | A base configuration for LDAPAuthenticator that individual tests can adjust. 11 | """ 12 | c = Config() 13 | c.LDAPAuthenticator.server_address = os.environ.get("LDAP_HOST", "localhost") 14 | c.LDAPAuthenticator.lookup_dn = True 15 | c.LDAPAuthenticator.bind_dn_template = ( 16 | "cn={username},ou=people,dc=planetexpress,dc=com" 17 | ) 18 | c.LDAPAuthenticator.user_search_base = "ou=people,dc=planetexpress,dc=com" 19 | c.LDAPAuthenticator.user_attribute = "uid" 20 | c.LDAPAuthenticator.lookup_dn_user_dn_attribute = "cn" 21 | c.LDAPAuthenticator.attributes = ["uid", "cn", "mail", "ou"] 22 | 23 | c.LDAPAuthenticator.allowed_groups = [ 24 | "cn=admin_staff,ou=people,dc=planetexpress,dc=com", 25 | "cn=ship_crew,ou=people,dc=planetexpress,dc=com", 26 | ] 27 | 28 | return c 29 | -------------------------------------------------------------------------------- /ldapauthenticator/tests/test_ldapauthenticator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Inspired by https://github.com/jupyterhub/jupyterhub/blob/main/jupyterhub/tests/test_auth.py 3 | 4 | Testing data is hardcoded in docker-test-openldap, described at 5 | https://github.com/rroemhild/docker-test-openldap?tab=readme-ov-file#ldap-structure 6 | """ 7 | 8 | import pytest 9 | from ldap3.core.exceptions import LDAPSSLConfigurationError 10 | 11 | from ..ldapauthenticator import LDAPAuthenticator, TlsStrategy 12 | 13 | 14 | async def test_ldap_auth_allowed(c): 15 | authenticator = LDAPAuthenticator(config=c) 16 | # proper username and password in allowed group 17 | authorized = await authenticator.get_authenticated_user( 18 | None, {"username": "fry", "password": "fry"} 19 | ) 20 | assert authorized["name"] == "fry" 21 | 22 | 23 | async def test_ldap_auth_disallowed(c): 24 | authenticator = LDAPAuthenticator(config=c) 25 | # invalid username 26 | authorized = await authenticator.get_authenticated_user( 27 | None, {"username": "3fry/", "password": "raw"} 28 | ) 29 | assert authorized is None 30 | 31 | # incorrect password 32 | authorized = await authenticator.get_authenticated_user( 33 | None, {"username": "fry", "password": "raw"} 34 | ) 35 | assert authorized is None 36 | 37 | # blank password 38 | authorized = await authenticator.get_authenticated_user( 39 | None, {"username": "fry", "password": ""} 40 | ) 41 | assert authorized is None 42 | 43 | # nonexistant username 44 | authorized = await authenticator.get_authenticated_user( 45 | None, {"username": "flexo", "password": "imposter"} 46 | ) 47 | assert authorized is None 48 | 49 | # proper username and password but not in allowed group 50 | authorized = await authenticator.get_authenticated_user( 51 | None, {"username": "zoidberg", "password": "zoidberg"} 52 | ) 53 | assert authorized is None 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "bind_dn_template", 58 | [ 59 | "cn={username},ou=people,dc=planetexpress,dc=com", 60 | ["cn={username},ou=people,dc=planetexpress,dc=com"], 61 | [ 62 | "cn={username},ou=people,dc=planetexpress,dc=com", 63 | "", 64 | ], 65 | ], 66 | ) 67 | async def test_ldap_auth_bind_dn_template(c, bind_dn_template): 68 | c.LDAPAuthenticator.bind_dn_template = bind_dn_template 69 | authenticator = LDAPAuthenticator(config=c) 70 | 71 | # proper username and password in allowed group 72 | authorized = await authenticator.get_authenticated_user( 73 | None, {"username": "fry", "password": "fry"} 74 | ) 75 | assert authorized["name"] == "fry" 76 | 77 | 78 | async def test_ldap_use_ssl_deprecation(c): 79 | authenticator = LDAPAuthenticator(config=c) 80 | assert authenticator.tls_strategy == TlsStrategy.before_bind 81 | 82 | # setting use_ssl to True should result in tls_strategy being set to 83 | # on_connect 84 | authenticator.use_ssl = True 85 | assert authenticator.tls_strategy == TlsStrategy.on_connect 86 | 87 | 88 | async def test_ldap_auth_tls_strategy_on_connect(c): 89 | """ 90 | Verifies basic function of the authenticator with a given tls_strategy 91 | without actually confirming use of that strategy. 92 | """ 93 | c.LDAPAuthenticator.tls_strategy = "on_connect" 94 | authenticator = LDAPAuthenticator(config=c) 95 | 96 | # proper username and password in allowed group 97 | authorized = await authenticator.get_authenticated_user( 98 | None, {"username": "fry", "password": "fry"} 99 | ) 100 | assert authorized["name"] == "fry" 101 | 102 | 103 | async def test_ldap_auth_tls_strategy_insecure(c): 104 | """ 105 | Verifies basic function of the authenticator with a given tls_strategy 106 | without actually confirming use of that strategy. 107 | """ 108 | c.LDAPAuthenticator.tls_strategy = "insecure" 109 | authenticator = LDAPAuthenticator(config=c) 110 | 111 | # proper username and password in allowed group 112 | authorized = await authenticator.get_authenticated_user( 113 | None, {"username": "fry", "password": "fry"} 114 | ) 115 | assert authorized["name"] == "fry" 116 | 117 | 118 | async def test_ldap_auth_use_lookup_dn(c): 119 | c.LDAPAuthenticator.use_lookup_dn_username = True 120 | authenticator = LDAPAuthenticator(config=c) 121 | 122 | # proper username and password in allowed group 123 | authorized = await authenticator.get_authenticated_user( 124 | None, {"username": "fry", "password": "fry"} 125 | ) 126 | assert authorized["name"] == "philip j. fry" 127 | 128 | 129 | async def test_ldap_auth_search_filter(c): 130 | c.LDAPAuthenticator.allowed_groups = [] 131 | c.LDAPAuthenticator.allow_all = True 132 | c.LDAPAuthenticator.search_filter = ( 133 | "(&(objectClass=inetOrgPerson)(ou= Delivering Crew)(cn={username}))" 134 | ) 135 | authenticator = LDAPAuthenticator(config=c) 136 | 137 | # proper username and password in allowed group 138 | authorized = await authenticator.get_authenticated_user( 139 | None, {"username": "fry", "password": "fry"} 140 | ) 141 | assert authorized is not None 142 | assert authorized["name"] == "fry" 143 | 144 | # proper username and password but not in search filter 145 | authorized = await authenticator.get_authenticated_user( 146 | None, {"username": "zoidberg", "password": "zoidberg"} 147 | ) 148 | assert authorized is None 149 | 150 | 151 | async def test_allow_config(c): 152 | """ 153 | test various sources of allow config 154 | """ 155 | # this group allows fry, leela, bender 156 | c.LDAPAuthenticator.allowed_groups = [ 157 | "cn=ship_crew,ou=people,dc=planetexpress,dc=com" 158 | ] 159 | c.LDAPAuthenticator.allowed_users = {"zoidberg"} 160 | authenticator = LDAPAuthenticator(config=c) 161 | 162 | # in allowed_groups 163 | authorized = await authenticator.get_authenticated_user( 164 | None, {"username": "fry", "password": "fry"} 165 | ) 166 | assert authorized is not None 167 | assert authorized["name"] == "fry" 168 | 169 | # in allowed_users 170 | authorized = await authenticator.get_authenticated_user( 171 | None, {"username": "zoidberg", "password": "zoidberg"} 172 | ) 173 | assert authorized is not None 174 | assert authorized["name"] == "zoidberg" 175 | 176 | # no match 177 | authorized = await authenticator.get_authenticated_user( 178 | None, {"username": "professor", "password": "professor"} 179 | ) 180 | assert authorized is None 181 | # allow_all grants access 182 | if hasattr(authenticator, "allow_all"): 183 | authenticator.allow_all = True 184 | else: 185 | # clear allow config for JupyterHub < 5 186 | authenticator.allowed_groups = [] 187 | authenticator.allowed_users = set() 188 | authorized = await authenticator.get_authenticated_user( 189 | None, {"username": "professor", "password": "professor"} 190 | ) 191 | assert authorized is not None 192 | assert authorized["name"] == "professor" 193 | 194 | 195 | async def test_ldap_auth_state_attributes(c): 196 | c.LDAPAuthenticator.auth_state_attributes = ["employeeType"] 197 | authenticator = LDAPAuthenticator(config=c) 198 | 199 | # proper username and password in allowed group 200 | authorized = await authenticator.get_authenticated_user( 201 | None, {"username": "fry", "password": "fry"} 202 | ) 203 | assert authorized["name"] == "fry" 204 | assert authorized["auth_state"]["user_attributes"] == { 205 | "employeeType": ["Delivery boy"] 206 | } 207 | 208 | 209 | async def test_ldap_auth_state_attributes2(c): 210 | c.LDAPAuthenticator.group_search_filter = "(cn=ship_crew)" 211 | c.LDAPAuthenticator.group_attributes = ["cn"] 212 | c.LDAPAuthenticator.auth_state_attributes = ["description"] 213 | authenticator = LDAPAuthenticator(config=c) 214 | 215 | # proper username and password in allowed group 216 | authorized = await authenticator.get_authenticated_user( 217 | None, {"username": "leela", "password": "leela"} 218 | ) 219 | assert authorized["name"] == "leela" 220 | assert authorized["auth_state"]["user_attributes"] == {"description": ["Mutant"]} 221 | 222 | 223 | async def test_ldap_tls_kwargs_config_passthrough(c): 224 | """ 225 | This test is just meant to verify that tls_kwargs is passed through to the 226 | ldap3 Tls object when its constructed. 227 | """ 228 | c.LDAPAuthenticator.tls_kwargs = { 229 | "ca_certs_file": "does-not-exist-so-error-expected", 230 | } 231 | authenticator = LDAPAuthenticator(config=c) 232 | 233 | with pytest.raises(LDAPSSLConfigurationError): 234 | await authenticator.get_authenticated_user( 235 | None, {"username": "leela", "password": "leela"} 236 | ) 237 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # autoflake is used for autoformatting Python code 2 | # 3 | # ref: https://github.com/PyCQA/autoflake#readme 4 | # 5 | [tool.autoflake] 6 | ignore-init-module-imports = true 7 | remove-all-unused-imports = true 8 | remove-duplicate-keys = true 9 | remove-unused-variables = true 10 | 11 | 12 | # isort is used for autoformatting Python code 13 | # 14 | # ref: https://pycqa.github.io/isort/ 15 | # 16 | [tool.isort] 17 | profile = "black" 18 | 19 | 20 | # black is used for autoformatting Python code 21 | # 22 | # ref: https://black.readthedocs.io/en/stable/ 23 | # 24 | [tool.black] 25 | # target-version should be all supported versions, see 26 | # https://github.com/psf/black/issues/751#issuecomment-473066811 27 | target_version = [ 28 | "py39", 29 | "py310", 30 | "py311", 31 | "py312", 32 | ] 33 | 34 | 35 | # pytest is used for running Python based tests 36 | # 37 | # ref: https://docs.pytest.org/en/stable/ 38 | # 39 | [tool.pytest.ini_options] 40 | addopts = "--verbose --color=yes --durations=10" 41 | asyncio_mode = "auto" 42 | testpaths = ["ldapauthenticator/tests"] 43 | # warnings we can safely ignore stemming from jupyterhub 3 + sqlalchemy 2 44 | filterwarnings = [ 45 | 'ignore:.*The new signature is "def engine_connect\(conn\)"*:sqlalchemy.exc.SADeprecationWarning', 46 | 'ignore:.*The new signature is "def engine_connect\(conn\)"*:sqlalchemy.exc.SAWarning', 47 | ] 48 | 49 | 50 | # pytest-cov / coverage is used to measure code coverage of tests 51 | # 52 | # ref: https://coverage.readthedocs.io/en/stable/config.html 53 | # 54 | [tool.coverage.run] 55 | omit = [ 56 | "ldapauthenticator/tests/**", 57 | ] 58 | 59 | 60 | # tbump is used to simplify and standardize the release process when updating 61 | # the version, making a git commit and tag, and pushing changes. 62 | # 63 | # ref: https://github.com/your-tools/tbump#readme 64 | # 65 | [tool.tbump] 66 | github_url = "https://github.com/jupyterhub/systemdspawner" 67 | 68 | [tool.tbump.version] 69 | current = "2.0.3.dev" 70 | regex = ''' 71 | (?P\d+) 72 | \. 73 | (?P\d+) 74 | \. 75 | (?P\d+) 76 | (?P
((a|b|rc)\d+)|)
77 |     \.?
78 |     (?P(?<=\.)dev\d*|)
79 | '''
80 | 
81 | [tool.tbump.git]
82 | message_template = "Bump to {new_version}"
83 | tag_template = "{new_version}"
84 | 
85 | [[tool.tbump.file]]
86 | src = "setup.py"
87 | 
88 | [[tool.tbump.file]]
89 | src = "ldapauthenticator/__init__.py"
90 | 


--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
 1 | from setuptools import setup
 2 | 
 3 | setup(
 4 |     name="jupyterhub-ldapauthenticator",
 5 |     version="2.0.3.dev",
 6 |     description="LDAP Authenticator for JupyterHub",
 7 |     long_description=open("README.md").read(),
 8 |     long_description_content_type="text/markdown",
 9 |     url="https://github.com/jupyterhub/ldapauthenticator",
10 |     author="Yuvi Panda",
11 |     author_email="yuvipanda@riseup.net",
12 |     license="3 Clause BSD",
13 |     packages=["ldapauthenticator"],
14 |     python_requires=">=3.9",
15 |     install_requires=[
16 |         "jupyterhub>=4.1.6",
17 |         "ldap3>=2.9.1",
18 |         "traitlets",
19 |     ],
20 |     extras_require={
21 |         "test": [
22 |             "pytest",
23 |             "pytest-asyncio",
24 |             "pytest-cov",
25 |         ],
26 |     },
27 |     entry_points={
28 |         "jupyterhub.authenticators": [
29 |             "ldap = ldapauthenticator:LDAPAuthenticator",
30 |             "ldapauthenticator = ldapauthenticator:LDAPAuthenticator",
31 |         ],
32 |     },
33 | )
34 | 


--------------------------------------------------------------------------------