├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── dev-requirements.txt ├── firstuseauthenticator ├── __init__.py ├── _version.py ├── firstuseauthenticator.py └── templates │ ├── __init__.py │ └── reset.html ├── pytest.ini ├── setup.py └── tests └── test_authenticator.py /.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/firstuseauthenticator/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 | # Build releases and (on tags) publish to PyPI 2 | name: Release 3 | 4 | # Always tests wheel building, but only publish to PyPI on pushed tags. 5 | on: 6 | pull_request: 7 | paths-ignore: 8 | - ".github/workflows/*.yaml" 9 | - "!.github/workflows/release.yaml" 10 | push: 11 | paths-ignore: 12 | - ".github/workflows/*.yaml" 13 | - "!.github/workflows/release.yaml" 14 | branches-ignore: 15 | - "dependabot/**" 16 | - "pre-commit-ci-update-config" 17 | tags: ["**"] 18 | workflow_dispatch: 19 | 20 | jobs: 21 | release: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: install build package 30 | run: | 31 | pip install --upgrade pip 32 | pip install build 33 | pip freeze 34 | 35 | - name: build release 36 | run: | 37 | python -m build --sdist --wheel . 38 | ls -l dist 39 | 40 | - name: publish to pypi 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | if: startsWith(github.ref, 'refs/tags/') 43 | with: 44 | user: __token__ 45 | password: ${{ secrets.pypi_password }} 46 | -------------------------------------------------------------------------------- /.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 | jobs: 24 | test: 25 | runs-on: ${{ matrix.runs-on }} 26 | timeout-minutes: 10 27 | 28 | strategy: 29 | # Keep running even if one variation of the job fail 30 | fail-fast: false 31 | matrix: 32 | include: 33 | - python-version: "3.6" 34 | runs-on: ubuntu-20.04 35 | - python-version: "3.7" 36 | runs-on: ubuntu-22.04 37 | - python-version: "3.8" 38 | runs-on: ubuntu-22.04 39 | - python-version: "3.9" 40 | runs-on: ubuntu-latest 41 | - python-version: "3.10" 42 | runs-on: ubuntu-latest 43 | - python-version: "3.11" 44 | runs-on: ubuntu-latest 45 | - python-version: "3.12" 46 | runs-on: ubuntu-latest 47 | - python-version: "3.x" 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | 56 | # preserve pip cache to speed up installation 57 | - uses: actions/cache@v4 58 | with: 59 | path: ~/.cache/pip 60 | # Look to see if there is a cache hit for the corresponding requirements file 61 | key: ${{ runner.os }}-pip-${{ hashFiles('*requirements.txt') }} 62 | restore-keys: | 63 | ${{ runner.os }}-pip- 64 | 65 | - name: Install Python dependencies 66 | run: | 67 | pip install --upgrade pip 68 | pip install --upgrade . -r dev-requirements.txt 69 | pip freeze 70 | 71 | - name: Run tests 72 | run: | 73 | pytest -v --color=yes --cov=firstuseauthenticator 74 | 75 | # GitHub action reference: https://github.com/codecov/codecov-action 76 | - uses: codecov/codecov-action@v4 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .pytest_cache 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # Ignore git and virtualenv-related folders. 3 | ignore=.git,lib 4 | 5 | [MESSAGES CONTROL] 6 | disable=all 7 | 8 | # Explicitly whitelist the lint checks we want to have 9 | # Prefer things that catch errors and what not, and not stylistic choices 10 | enable=missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,too-many-lines,multiple-statements,superfluous-parens,multiple-imports,ungrouped-imports,syntax-error,init-is-generator,return-in-init,function-redefined,not-in-loop,return-outside-function,yield-outside-function,nonexistent-operator,duplicate-argument-name,abstract-class-instantiated,bad-reversed-sequence,too-many-star-expressions,invalid-star-assignment-target,star-needs-assignment-target,nonlocal-and-global,continue-in-finally,nonlocal-without-binding,method-hidden,access-member-before-definition,no-method-argument,no-self-argument,invalid-slots-object,assigning-non-slot,invalid-slots,inherit-non-class,inconsistent-mro,duplicate-bases,non-iterator-returned,unexpected-special-method-signature,invalid-length-returned,import-error,used-before-assignment,undefined-variable,undefined-all-variable,unbalanced-tuple-unpacking,unpacking-non-sequence,bad-except-order,raising-bad-type,bad-exception-context,misplaced-bare-raise,raising-non-exception,notimplemented-raised,catching-non-exception,bad-super-call,no-member,not-callable,assignment-from-no-return,no-value-for-parameter,too-many-function-args,unexpected-keyword-arg,redundant-keyword-arg,missing-kwoa,invalid-sequence-index,invalid-slice-index,assignment-from-none,not-context-manager,invalid-unary-operand-type,unsupported-binary-operation,repeated-keyword,not-an-iterable,not-a-mapping,unsupported-membership-test,unsubscriptable-object,logging-unsupported-format,logging-format-truncated,logging-too-many-args,logging-too-few-args,bad-format-character,truncated-format-string,mixed-format-string,format-needs-mapping,missing-format-string-key,too-many-format-args,too-few-format-args,bad-str-strip-call,yield-inside-async-function,not-async-context-manager,fatal,astroid-error,parse-error,method-check-failed,bad-inline-option,useless-suppression,deprecated-pragma,unreachable,dangerous-default-value,pointless-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,useless-else-on-loop,eval-used,exec-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,unnecessary-semicolon,bad-indentation,mixed-indentation,wildcard-import,deprecated-module,reimported,import-self,misplaced-future,global-variable-undefined,global-variable-not-assigned,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,duplicate-except,broad-except,bare-except,binary-op-exception,logging-not-lazy,logging-format-interpolation,bad-format-string-key,bad-format-string,missing-format-argument-key,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,redundant-unittest-assert,deprecated-method 11 | 12 | [REPORTS] 13 | # Do not print a summary report 14 | reports=no 15 | 16 | [FORMAT] 17 | # One of the few stylistic things we try to check. 18 | # Modules shouldn't be *too large* - should be broken up 19 | # Feel free to bump this number if you disagree 20 | max-module-lines=2000 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes in firstuseauthenticator 2 | 3 | For detailed changes from the prior release, click on the version number, and 4 | its link will bring up a GitHub listing of changes. Use `git log` on the 5 | command line for details. 6 | 7 | ## 1.1 8 | 9 | ### [1.1.1] - 2025-03-28 10 | 11 | ([full changelog](https://github.com/jupyterhub/firstuseauthenticator/compare/1.1.0...e1ada8b587cd1d9095c3a230663d3bfafb8d6546)) 12 | 13 | #### Merged PRs 14 | 15 | - Resolve xsrf issue in reset password page [#59](https://github.com/jupyterhub/firstuseauthenticator/pull/59) ([@jiravatt](https://github.com/jiravatt)) 16 | 17 | #### Contributors to this release 18 | 19 | ([GitHub contributors page for this release](https://github.com/jupyterhub/firstuseauthenticator/graphs/contributors?from=2024-09-17&to=2025-03-28&type=c)) 20 | 21 | [@jiravatt](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3Ajiravatt+updated%3A2024-09-17..2025-03-28&type=Issues) 22 | 23 | 24 | ### [1.1.0] - 2024-09-17 25 | 26 | #### Enhancements made 27 | 28 | - default: allow all users if allowed_users is unspecified [#56](https://github.com/jupyterhub/firstuseauthenticator/pull/56) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) 29 | - Register authentication class with jupyterhub [#53](https://github.com/jupyterhub/firstuseauthenticator/pull/53) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 30 | 31 | #### Continuous integration improvements 32 | 33 | - ci: refresh github workflows [#57](https://github.com/jupyterhub/firstuseauthenticator/pull/57) ([@consideRatio](https://github.com/consideRatio)) 34 | 35 | #### Contributors to this release 36 | 37 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 38 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 39 | 40 | ([GitHub contributors page for this release](https://github.com/jupyterhub/firstuseauthenticator/graphs/contributors?from=2021-10-28&to=2024-09-17&type=c)) 41 | 42 | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3AconsideRatio+updated%3A2021-10-28..2024-09-17&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3Aminrk+updated%3A2021-10-28..2024-09-17&type=Issues)) 43 | 44 | ## 1.0 45 | 46 | ### [1.0.0] - 2021-10-27 47 | 48 | 1.0 fixes a critical security vulnerability when `create_users = True` (default behavior) 49 | where unauthorized users could gain access to other users' jupyterhub accounts, if usernames are known. 50 | 51 | Disabling the creation of new users with `c.FirstUseAuthenticator.create_users = False` mitigates this attack while waiting for upgrade. 52 | 53 | #### Bugs fixed 54 | 55 | - Revert authenticate being made sync from async [#45](https://github.com/jupyterhub/firstuseauthenticator/pull/45) ([@consideRatio](https://github.com/consideRatio)) 56 | - normalize username to lock password [#38](https://github.com/jupyterhub/firstuseauthenticator/pull/38) ([@georgejhunt](https://github.com/georgejhunt)) 57 | - Fix failure to await render_template [#37](https://github.com/jupyterhub/firstuseauthenticator/pull/37) ([@georgejhunt](https://github.com/georgejhunt)) 58 | - Fix bug where create_users and password requirement couldn't work together [#33](https://github.com/jupyterhub/firstuseauthenticator/pull/33) ([@saisiddhant12](https://github.com/saisiddhant12)) 59 | 60 | #### Maintenance and upkeep improvements 61 | 62 | - Refactoring for readability [#46](https://github.com/jupyterhub/firstuseauthenticator/pull/46) ([@consideRatio](https://github.com/consideRatio)) 63 | - update package metadata to require Python 3.6 [#41](https://github.com/jupyterhub/firstuseauthenticator/pull/41) ([@minrk](https://github.com/minrk)) 64 | 65 | ## Documentation improvements 66 | 67 | - Add GitHub CI badge to README [#47](https://github.com/jupyterhub/firstuseauthenticator/pull/47) ([@consideRatio](https://github.com/consideRatio)) 68 | 69 | #### Other merged PRs 70 | 71 | - Update master to main [#44](https://github.com/jupyterhub/firstuseauthenticator/pull/44) ([@consideRatio](https://github.com/consideRatio)) 72 | - run tests on GHA [#40](https://github.com/jupyterhub/firstuseauthenticator/pull/40) ([@minrk](https://github.com/minrk)) 73 | 74 | #### Contributors to this release 75 | 76 | ([GitHub contributors page for this release](https://github.com/jupyterhub/firstuseauthenticator/graphs/contributors?from=2020-03-18&to=2021-10-26&type=c)) 77 | 78 | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3AconsideRatio+updated%3A2020-03-18..2021-10-26&type=Issues) | [@georgejhunt](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3Ageorgejhunt+updated%3A2020-03-18..2021-10-26&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3Aminrk+updated%3A2020-03-18..2021-10-26&type=Issues) | [@saisiddhant12](https://github.com/search?q=repo%3Ajupyterhub%2Ffirstuseauthenticator+involves%3Asaisiddhant12+updated%3A2020-03-18..2021-10-26&type=Issues) 79 | 80 | ## 0.14 81 | 82 | ### [0.14.1] - 2020-03-18 83 | 84 | * Fix login error msg [#30](https://github.com/jupyterhub/firstuseauthenticator/pull/30) ([@GeorgianaElena](https://github.com/GeorgianaElena)) 85 | 86 | ### [0.14.0] - 2020-03-03 87 | 88 | * Update badges and add long description to pypi [#28](https://github.com/jupyterhub/firstuseauthenticator/pull/28) ([@GeorgianaElena](https://github.com/GeorgianaElena)) 89 | * Set minimum length on passwords [#21](https://github.com/jupyterhub/firstuseauthenticator/pull/21) ([@GeorgianaElena](https://github.com/GeorgianaElena)) 90 | 91 | 92 | ## 0.13 93 | 94 | ### [0.13.0] - 2020-01-07 95 | 96 | * fixed 'change password' feature for Jupyterhub version 1.0.0 [#23](https://github.com/jupyterhub/firstuseauthenticator/pull/23) ([@ABVitali](https://github.com/ABVitali)) 97 | * Update packages in tests [#22](https://github.com/jupyterhub/firstuseauthenticator/pull/22) ([@minrk](https://github.com/minrk)) 98 | 99 | ## 0.12 100 | 101 | ### [0.12.0] - 2019-01-24 102 | 103 | * Catch deletion of users that have not logged in [#16](https://github.com/jupyterhub/firstuseauthenticator/pull/16) ([@willirath](https://github.com/willirath)) 104 | 105 | ## 0.11 106 | 107 | ### [0.11.1] - 2019-01-24 108 | 109 | * add missing parameter to call of validate_user() [#12](https://github.com/jupyterhub/firstuseauthenticator/pull/12) ([@stv0g](https://github.com/stv0g)) 110 | * add name sanitization [#11](https://github.com/jupyterhub/firstuseauthenticator/pull/11) ([@leportella](https://github.com/leportella)) 111 | * add question on how to change password [#10](https://github.com/jupyterhub/firstuseauthenticator/pull/10) ([@leportella](https://github.com/leportella)) 112 | * add basic tests [#9](https://github.com/jupyterhub/firstuseauthenticator/pull/9) ([@minrk](https://github.com/minrk)) 113 | * Add option to change password [#8](https://github.com/jupyterhub/firstuseauthenticator/pull/8) ([@leportella](https://github.com/leportella)) 114 | * Clean password db when user is deleted [#7](https://github.com/jupyterhub/firstuseauthenticator/pull/7) ([@yuvipanda](https://github.com/yuvipanda)) 115 | 116 | ### [0.11.0] - 2018-09-04 117 | 118 | * First release 119 | 120 | [1.1.0]: https://github.com/jupyterhub/firstuseauthenticator/compare/v1.0.0...v1.1.0 121 | [1.0.0]: https://github.com/jupyterhub/firstuseauthenticator/compare/v0.14.1...v1.0.0 122 | [0.14.1]: https://github.com/jupyterhub/firstuseauthenticator/compare/v0.14.0...v0.14.1 123 | [0.14.0]: https://github.com/jupyterhub/firstuseauthenticator/compare/0.13.0...v0.14.0 124 | [0.13.0]: https://github.com/jupyterhub/firstuseauthenticator/compare/v0.12...0.13.0 125 | [0.12.0]: https://github.com/jupyterhub/firstuseauthenticator/compare/v0.11...v0.12 126 | [0.11.1]: https://github.com/jupyterhub/firstuseauthenticator/compare/v0.11...v0.11.1 127 | 128 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Yuvi Panda 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of KubeSpawner nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub-firstuseauthenticator?logo=pypi)](https://pypi.python.org/pypi/jupyterhub-firstuseauthenticator) 2 | [![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/firstuseauthenticator/Test?logo=github&label=tests)](https://github.com/jupyterhub/zero-to-jupyterhub-k8s/actions) 3 | [![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/jupyterhub-firstuseauthenticator/issues) 4 | [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) 5 | [![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub) 6 | 7 | # JupyterHub First Use Authenticator 8 | 9 | A [JupyterHub](https://jupyterhub.readthedocs.io) authenticator that helps new users set their password on their first login to JupyterHub. 10 | 11 | **Are you running a workshop from a single physical location, such as a university seminar or a user group?** 12 | 13 | JupyterHub First Use Authenticator can simplify the user set up for you. It's very useful when using transient 14 | JupyterHub instances in a single physical location. It allows multiple users to log in, but you do not have install a pre-existing authentication setup. With this authenticator, users can just pick a username and password and get to work! 15 | 16 | ## Installation 17 | 18 | You can install this authenticator with: 19 | 20 | ```bash 21 | pip install jupyterhub-firstuseauthenticator 22 | ``` 23 | 24 | Once installed, configure JupyterHub to use it by adding the following to your `jupyterhub_config.py` file: 25 | 26 | ```python 27 | c.JupyterHub.authenticator_class = 'firstuseauthenticator.FirstUseAuthenticator' 28 | ``` 29 | 30 | ## Configuration 31 | 32 | ### FirstUseAuthenticator.dbm_path 33 | 34 | Path to the [dbm](https://docs.python.org/3.5/library/dbm.html) file, or a UNIX database file such as `passwords.dbm`, used to store usernames and passwords. The dbm file should be put where regular users do not have read/write access to it. 35 | 36 | This authenticator's default setting for the path to the `passwords.dbm` is the current directory from which JupyterHub is spawned. 37 | 38 | ### FirstUseAuthenticator.create_users 39 | 40 | Create users if they do not exist already. 41 | 42 | When set to False, users would have to be explicitly created before 43 | they can log in. Users can be created via the admin panel or by setting 44 | whitelist / admin list. 45 | 46 | Defaults to True. 47 | 48 | ## FAQ 49 | 50 | ### Why have a password DB and not use PAM ? 51 | 52 | For security Reasons. Users are likely to set an, insecure password at 53 | login time, and you do not want a brute-force/dictionary attack to manage to 54 | login by attacking via ssh or another mean. 55 | 56 | ### How can I change my password? 57 | 58 | To change your password, you should login in your jupyterhub account, 59 | go to `/hub/auth/change-password` and change the password. 60 | 61 | ### I'm getting an error when creating my username 62 | 63 | Usernames cannot contain spaces or commas. Please check if your username is free 64 | of these characters. 65 | 66 | ## Security 67 | 68 | When using `FirstUseAuthenticator` it is advised to automatically prepend the 69 | name of the user with a known-prefix (for example `jupyter`). This would prevent 70 | for example, someone to log-in as `root`, as the created user would be 71 | `jupyter-root`. 72 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | 3 | `firstuseauthenticator` is a package [available on 4 | PyPI](https://pypi.org/project/jupyterhub-firstuseauthenticator/). 5 | The PyPI release is done automatically by TravisCI when a tag 6 | is pushed. 7 | 8 | For you to follow along according to these instructions, you need 9 | to have push rights to the [firstuseauthenticator GitHub 10 | repository](https://github.com/jupyterhub/firstuseauthenticator). 11 | 12 | ## Steps to make a release 13 | 14 | 1. Checkout main and make sure it is up to date. 15 | 16 | ```shell 17 | ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo 18 | git checkout main 19 | git fetch $ORIGIN main 20 | git reset --hard $ORIGIN/main 21 | # WARNING! This next command deletes any untracked files in the repo 22 | git clean -xfd 23 | ``` 24 | 25 | 1. Update [CHANGELOG.md](CHANGELOG.md). Doing this can be made easier with the 26 | help of the 27 | [choldgraf/github-activity](https://github.com/choldgraf/github-activity) 28 | utility. 29 | 30 | 1. Set the `version_info` variable in [\_version.py](firstuseauthenticator/_version.py) 31 | appropriately and make a commit. 32 | 33 | ```shell 34 | git add firstuseauthenticator/_version.py 35 | VERSION=... # e.g. 1.2.3 36 | git commit -m "release $VERSION" 37 | ``` 38 | 39 | 1. Reset the `version_info` variable in 40 | [\_version.py](firstuseauthenticator/_version.py) appropriately with an incremented 41 | patch version and a `dev` element, then make a commit. 42 | 43 | ```shell 44 | git add firstuseauthenticator/_version.py 45 | git commit -m "back to dev" 46 | ``` 47 | 48 | 1. Push your two commits to main. 49 | 50 | ```shell 51 | # first push commits without a tags to ensure the 52 | # commits comes through, because a tag can otherwise 53 | # be pushed all alone without company of rejected 54 | # commits, and we want have our tagged release coupled 55 | # with a specific commit in main 56 | git push $ORIGIN main 57 | ``` 58 | 59 | 1. Create a git tag for the pushed release commit and push it. 60 | 61 | ```shell 62 | git tag -a $VERSION -m $VERSION HEAD~1 63 | 64 | # then verify you tagged the right commit 65 | git log 66 | 67 | # then push it 68 | git push $ORIGIN refs/tags/$VERSION 69 | ``` 70 | 71 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-asyncio 3 | pytest-cov 4 | -------------------------------------------------------------------------------- /firstuseauthenticator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JupyterHub Authenticator to let users set their password on first use. 3 | 4 | After installation, you can enable this with: 5 | 6 | ``` 7 | c.JupyterHub.authenticator_class = 'firstuseauthenticator.FirstUseAuthenticator' 8 | ``` 9 | """ 10 | from firstuseauthenticator.firstuseauthenticator import FirstUseAuthenticator 11 | 12 | __all__ = [FirstUseAuthenticator] 13 | -------------------------------------------------------------------------------- /firstuseauthenticator/_version.py: -------------------------------------------------------------------------------- 1 | """firstuseauthenticator version info""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | version_info = ( 7 | 1, 8 | 1, 9 | 2, 10 | 'dev', # comment-out this line for a release 11 | ) 12 | __version__ = '.'.join(map(str, version_info[:3])) 13 | 14 | if len(version_info) > 3: 15 | __version__ = '%s%s' % (__version__, version_info[3]) 16 | -------------------------------------------------------------------------------- /firstuseauthenticator/firstuseauthenticator.py: -------------------------------------------------------------------------------- 1 | """ 2 | JupyterHub Authenticator that lets users set password on first use. 3 | 4 | When users first log in, the password they use becomes their 5 | password for that account. It is hashed with bcrypt & stored 6 | locally in a dbm file, and checked next time they log in. 7 | """ 8 | import os 9 | import shutil 10 | 11 | import bcrypt 12 | import dbm 13 | from jinja2 import ChoiceLoader, FileSystemLoader 14 | from jupyterhub.auth import Authenticator 15 | from jupyterhub.handlers import BaseHandler 16 | from jupyterhub.handlers import LoginHandler 17 | from jupyterhub.orm import User 18 | 19 | from tornado import web 20 | from traitlets import default, Unicode, Bool, Integer 21 | 22 | 23 | TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') 24 | 25 | 26 | class CustomLoginHandler(LoginHandler): 27 | """ 28 | Render the login page. 29 | 30 | Allows customising the login error when more specific 31 | feedback is needed. Checkout 32 | https://github.com/jupyterhub/firstuseauthenticator/pull/21#discussion_r364252009 33 | for more details 34 | """ 35 | custom_login_error = '' 36 | 37 | def _render(self, login_error=None, username=None): 38 | if self.custom_login_error: 39 | login_error = self.custom_login_error 40 | return super()._render(login_error, username) 41 | 42 | 43 | class ResetPasswordHandler(BaseHandler): 44 | """Render the reset password page.""" 45 | def __init__(self, *args, **kwargs): 46 | self._loaded = False 47 | super().__init__(*args, **kwargs) 48 | 49 | def _register_template_path(self): 50 | if self._loaded: 51 | return 52 | 53 | self.log.debug('Adding %s to template path', TEMPLATE_DIR) 54 | loader = FileSystemLoader([TEMPLATE_DIR]) 55 | 56 | env = self.settings['jinja2_env'] 57 | previous_loader = env.loader 58 | env.loader = ChoiceLoader([previous_loader, loader]) 59 | 60 | self._loaded = True 61 | 62 | @web.authenticated 63 | async def get(self): 64 | self._register_template_path() 65 | html = await self.render_template('reset.html') 66 | self.finish(html) 67 | 68 | @web.authenticated 69 | async def post(self): 70 | user = self.current_user 71 | new_password = self.get_body_argument('password', strip=False) 72 | msg = self.authenticator.reset_password(user.name, new_password) 73 | 74 | if "success" in msg: 75 | alert = "success" 76 | else: 77 | alert = "danger" 78 | 79 | html = await self.render_template( 80 | 'reset.html', 81 | result=True, 82 | alert=alert, 83 | result_message=msg, 84 | ) 85 | self.finish(html) 86 | 87 | 88 | class FirstUseAuthenticator(Authenticator): 89 | """ 90 | JupyterHub authenticator that lets users set password on first use. 91 | """ 92 | dbm_path = Unicode( 93 | 'passwords.dbm', 94 | config=True, 95 | help=""" 96 | Path to store the db file with username / pwd hash in 97 | """ 98 | ) 99 | 100 | create_users = Bool( 101 | True, 102 | config=True, 103 | help=""" 104 | Create users if they do not exist already. 105 | 106 | When set to false, users would have to be explicitly created before 107 | they can log in. Users can be created via the admin panel or by setting 108 | whitelist / admin list. 109 | """ 110 | ) 111 | 112 | min_password_length = Integer( 113 | 7, 114 | config=True, 115 | help=""" 116 | The minimum length of the password when user is created. 117 | When set to 0, users will be allowed to set 0 length passwords. 118 | """ 119 | ) 120 | 121 | check_passwords_on_startup = Bool( 122 | True, 123 | config=True, 124 | help=""" 125 | Check for non-normalized-username passwords on startup. 126 | 127 | Prior to 1.0, multiple passwords could be set for the same username, 128 | without normalization. 129 | 130 | When True, duplicate usernames will be detected and removed, 131 | and ensure all usernames are normalized. 132 | 133 | If any duplicates are found, a backup of the original is created, 134 | which can be inspected manually. 135 | 136 | Typically, this will only need to run once. 137 | """, 138 | ) 139 | 140 | def __init__(self, **kwargs): 141 | super().__init__(**kwargs) 142 | if self.check_passwords_on_startup: 143 | self._check_passwords() 144 | 145 | def _check_passwords(self): 146 | """Validation checks on the password database at startup 147 | 148 | Mainly checks for the presence of passwords for non-normalized usernames 149 | 150 | If a username is present only in one non-normalized form, 151 | it will be renamed to the normalized form. 152 | 153 | If multiple forms of the same normalized username are present, 154 | ensure that at least the normalized form is also present. 155 | It will continue to produce warnings until manual intervention removes the non-normalized entries. 156 | 157 | Non-normalized entries will never be used during login. 158 | """ 159 | 160 | # it's nontrival to check for db existence, because there are so many extensions 161 | # and you don't give dbm a path, you give it a *base* name, 162 | # which may point to one or more paths. 163 | # There's no way to retrieve the actual path(s) for a db 164 | dbm_extensions = ("", ".db", ".pag", ".dir", ".dat", ".bak") 165 | dbm_files = list( 166 | filter(os.path.isfile, (self.dbm_path + ext for ext in dbm_extensions)) 167 | ) 168 | if not dbm_files: 169 | # no database, nothing to do 170 | return 171 | 172 | backup_path = self.dbm_path + "-backup" 173 | backup_files = list( 174 | filter(os.path.isfile, (backup_path + ext for ext in dbm_extensions)) 175 | ) 176 | 177 | collision_warning = ( 178 | f"Duplicate password entries have been found, and stored in {backup_path!r}." 179 | f" Duplicate entries have been removed from {self.dbm_path!r}." 180 | f" If you are happy with the solution, you can delete the backup file(s): {' '.join(backup_files)}." 181 | " Or you can inspect the backup database with:\n" 182 | " import dbm\n" 183 | f" with dbm.open({backup_path!r}, 'r') as db:\n" 184 | " for username in db.keys():\n" 185 | " print(username, db[username])\n" 186 | ) 187 | 188 | if backup_files: 189 | self.log.warning(collision_warning) 190 | return 191 | 192 | # create a temporary backup of the passwords db 193 | # to be retained only if collisions are detected 194 | # or deleted if no collisions are detected 195 | backup_files = [] 196 | for path in dbm_files: 197 | base, ext = os.path.splitext(path) 198 | if ext not in dbm_extensions: 199 | # catch weird names with '.' and no .db extension 200 | base = path 201 | ext = "" 202 | backup = f"{base}-backup{ext}" 203 | shutil.copyfile(path, backup) 204 | backup_files.append(backup) 205 | 206 | collision_found = False 207 | 208 | with dbm.open(self.dbm_path, "w") as db: 209 | # load the username:hashed_password dict 210 | passwords = {} 211 | for key in db.keys(): 212 | passwords[key.decode("utf8")] = db[key] 213 | 214 | # normalization map 215 | # compute the full map before checking in case two non-normalized forms are used 216 | # keys are normalized usernames, 217 | # values are lists of all names present in the db 218 | # which normalize to the same user 219 | normalized_usernames = {} 220 | for username in passwords: 221 | normalized_username = self.normalize_username(username) 222 | normalized_usernames.setdefault(normalized_username, []).append( 223 | username 224 | ) 225 | 226 | # check if any non-normalized usernames are in the db 227 | for normalized_username, usernames in normalized_usernames.items(): 228 | # case 1. only one form, make sure it's stored in the normalized username 229 | if len(usernames) == 1: 230 | username = usernames[0] 231 | # case 1.a only normalized form, nothing to do 232 | if username == normalized_username: 233 | continue 234 | # 1.b only one form, not normalized. Unambiguous to fix. 235 | # move password from non-normalized to normalized. 236 | self.log.warning( 237 | f"Normalizing username in password db {username}->{normalized_username}" 238 | ) 239 | db[normalized_username.encode("utf8")] = passwords[username] 240 | del db[username] 241 | else: 242 | # collision! Multiple passwords for the same Hub user with different normalization 243 | # do not clear these automatically because the 'right' answer is ambiguous, 244 | # but make sure the normalized_username is set, 245 | # so that after upgrade, there is always a password set 246 | # the non-normalized username passwords will never be used 247 | # after jupyterhub-firstuseauthenticator 1.0 248 | self.log.warning( 249 | f"{len(usernames)} variations of the username {normalized_username} present in password database: {usernames}." 250 | f" Only the password stored for the normalized {normalized_username} will be used." 251 | ) 252 | collision_found = True 253 | if normalized_username not in passwords: 254 | # we choose usernames[0] as most likely to be the first entry 255 | # this isn't guaranteed, but it's the best information we have 256 | username = usernames[0] 257 | self.log.warning( 258 | f"Normalizing username in password db {username}->{normalized_username}" 259 | ) 260 | db[normalized_username.encode("utf8")] = passwords[username] 261 | for username in usernames: 262 | if username != normalized_username: 263 | self.log.warning( 264 | f"Removing un-normalized username from password db {username}" 265 | ) 266 | del db[username] 267 | 268 | if collision_found: 269 | self.log.warning(collision_warning) 270 | else: 271 | # remove backup files, if we didn't find anything to backup 272 | self.log.debug(f"No collisions found, removing backup files {backup_files}") 273 | for path in backup_files: 274 | try: 275 | os.remove(path) 276 | except FileNotFoundError: 277 | pass 278 | 279 | def _user_exists(self, username): 280 | """ 281 | Return true if given user already exists. 282 | 283 | Note: Depends on internal details of JupyterHub that might change 284 | across versions. Tested with v0.9 285 | """ 286 | return self.db.query(User).filter_by(name=username).first() is not None 287 | 288 | 289 | def _validate_password(self, password): 290 | return len(password) >= self.min_password_length 291 | 292 | 293 | def validate_username(self, name): 294 | invalid_chars = [',', ' '] 295 | if any((char in name) for char in invalid_chars): 296 | return False 297 | return super().validate_username(name) 298 | 299 | @default("allow_all") 300 | def _allow_all_default(self): 301 | # the default behavior: allow all users 302 | # if allowed_users is unspecified 303 | # only affects JupyterHub >=5 304 | return (not self.allowed_users) 305 | 306 | async def authenticate(self, handler, data): 307 | username = self.normalize_username(data["username"]) 308 | password = data["password"] 309 | 310 | if not self.create_users: 311 | if not self._user_exists(username): 312 | return None 313 | 314 | with dbm.open(self.dbm_path, 'c', 0o600) as db: 315 | stored_pw = db.get(username.encode("utf8"), None) 316 | 317 | if stored_pw is not None: 318 | # for existing passwords: ensure password hash match 319 | if bcrypt.hashpw(password.encode("utf8"), stored_pw) != stored_pw: 320 | return None 321 | else: 322 | # for new users: ensure password validity and store password hash 323 | if not self._validate_password(password): 324 | handler.custom_login_error = ( 325 | 'Password too short! Please choose a password at least %d characters long.' 326 | % self.min_password_length 327 | ) 328 | self.log.error(handler.custom_login_error) 329 | return None 330 | db[username] = bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt()) 331 | 332 | return username 333 | 334 | 335 | def delete_user(self, user): 336 | """ 337 | When user is deleted, remove their entry from password db. 338 | 339 | This lets passwords be reset by deleting users. 340 | """ 341 | try: 342 | with dbm.open(self.dbm_path, 'c', 0o600) as db: 343 | del db[user.name] 344 | except KeyError: 345 | pass 346 | 347 | def reset_password(self, username, new_password): 348 | """ 349 | This allows changing the password of a logged user. 350 | """ 351 | if not self._validate_password(new_password): 352 | login_err = ( 353 | 'Password too short! Please choose a password at least %d characters long.' 354 | % self.min_password_length 355 | ) 356 | self.log.error(login_err) 357 | # Resetting the password will fail if the new password is too short. 358 | return login_err 359 | with dbm.open(self.dbm_path, "c", 0o600) as db: 360 | db[username] = bcrypt.hashpw(new_password.encode("utf8"), bcrypt.gensalt()) 361 | login_msg = "Your password has been changed successfully!" 362 | self.log.info(login_msg) 363 | return login_msg 364 | 365 | def get_handlers(self, app): 366 | return [ 367 | (r"/login", CustomLoginHandler), 368 | (r"/auth/change-password", ResetPasswordHandler), 369 | ] 370 | -------------------------------------------------------------------------------- /firstuseauthenticator/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/firstuseauthenticator/6e3afc8a318ad81d43bc4d6a5fcdf21761faf9b4/firstuseauthenticator/templates/__init__.py -------------------------------------------------------------------------------- /firstuseauthenticator/templates/reset.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | {% block main %} 3 |
4 |
5 | 6 |

7 | Change Password 8 |

9 |
10 |
11 | 12 | 19 |
20 |
21 | 28 |
29 |
30 |
31 | {% if result %} 32 | 33 | {% endif %} 34 |
35 | {% endblock %} -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # automatically run coroutine tests with asyncio 3 | asyncio_mode = auto 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | # Get the current package version. 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | version_ns = {} 8 | with open(os.path.join(here, 'firstuseauthenticator', '_version.py')) as f: 9 | exec(f.read(), {}, version_ns) 10 | 11 | setup( 12 | name='jupyterhub-firstuseauthenticator', 13 | version=version_ns['__version__'], 14 | description='JupyterHub Authenticator that lets users set passwords on first use', 15 | long_description=open("README.md").read(), 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/jupyterhub/firstuseauthenticator", 18 | author="Yuvi Panda, Project Jupyter Contributors", 19 | author_email="yuvipanda@gmail.com", 20 | license="BSD-3-Clause", 21 | python_requires=">=3.6", 22 | packages=find_packages(), 23 | entry_points={ 24 | "jupyterhub.authenticators": [ 25 | "firstuse = firstuseauthenticator:FirstUseAuthenticator", 26 | "firstuseauthenticator = firstuseauthenticator:FirstUseAuthenticator", 27 | ], 28 | }, 29 | install_requires=['bcrypt', 'jupyterhub>=1.3'], 30 | package_data={ 31 | '': ['*.html'], 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /tests/test_authenticator.py: -------------------------------------------------------------------------------- 1 | """tests for first-use authenticator""" 2 | 3 | from unittest import mock 4 | 5 | import dbm 6 | import pytest 7 | 8 | from firstuseauthenticator import FirstUseAuthenticator 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def tmpcwd(tmpdir): 13 | tmpdir.chdir() 14 | 15 | 16 | async def test_basic(tmpcwd): 17 | auth = FirstUseAuthenticator() 18 | name = "name" 19 | password = "firstpassword" 20 | username = await auth.authenticate(mock.Mock(), {"username": name, "password": password}) 21 | assert username == "name" 22 | # second login, same password 23 | username = await auth.authenticate(mock.Mock(), {"username": name, "password": password}) 24 | assert username == "name" 25 | 26 | # another login, reusing name but different password 27 | username = await auth.authenticate( 28 | mock.Mock(), {"username": name, "password": "differentpassword"} 29 | ) 30 | assert username is None 31 | 32 | async def test_min_pass_length(caplog, tmpcwd): 33 | users = [] 34 | def user_exists(username): 35 | return username in users 36 | 37 | auth = FirstUseAuthenticator() 38 | 39 | # allow passwords with any length 40 | auth.min_password_length = 0 41 | 42 | # new user, first login, any password allowed 43 | name = "name" 44 | password = "" 45 | with mock.patch.object(auth, '_user_exists', user_exists): 46 | username = await auth.authenticate(mock.Mock(), {"username": name, "password": password}) 47 | assert username == "name" 48 | users.append(name) 49 | 50 | # reject passwords that are less than 10 characters in length 51 | auth.min_password_length = 10 52 | 53 | # existing user, second login, only passwords longer than 10 chars allowed 54 | with mock.patch.object(auth, '_user_exists', user_exists): 55 | username = await auth.authenticate(mock.Mock(), {"username": name, "password": password}) 56 | # assert existing users are not impacted by the new length rule 57 | for record in caplog.records: 58 | assert record.levelname != 'ERROR' 59 | 60 | # new user, first login, only passwords longer than 10 chars allowed 61 | name = "newuser" 62 | password = "tooshort" 63 | with mock.patch.object(auth, '_user_exists', user_exists): 64 | username = await auth.authenticate(mock.Mock(), {"username": name, "password": password}) 65 | assert username is None 66 | # assert that new users' passwords must have the specified length 67 | for record in caplog.records: 68 | if record.levelname == 'ERROR': 69 | assert record.msg == ( 70 | 'Password too short! Please choose a password at least %d characters long.' 71 | % auth.min_password_length 72 | ) 73 | 74 | 75 | async def test_normalized_check(caplog, tmpcwd): 76 | # cases: 77 | # 1.a - normalized 78 | # 1.b not normalized, no collision 79 | # 2.a normalized present, collision 80 | # 2.b normalized not present, collision 81 | # disable normalization, populate db with duplicates 82 | to_load = [ 83 | "onlynormalized", 84 | "onlyNotNormalized", 85 | "collisionnormalized", 86 | "collisionNormalized", 87 | "collisionNotNormalized", 88 | "collisionNotnormalized", 89 | ] 90 | 91 | # load passwords 92 | auth1 = FirstUseAuthenticator() 93 | with mock.patch.object(auth1, "normalize_username", lambda x: x): 94 | for username in to_load: 95 | assert await auth1.authenticate( 96 | mock.Mock(), 97 | { 98 | "username": username, 99 | "password": username, 100 | }, 101 | ) 102 | 103 | # first make sure normalization was skipped 104 | with dbm.open(auth1.dbm_path) as db: 105 | for username in to_load: 106 | assert db.get(username.encode("utf8")) 107 | # at startup, normalization is checked 108 | auth2 = FirstUseAuthenticator() 109 | with dbm.open(auth1.dbm_path) as db: 110 | passwords = {key.decode("utf8"): db[key].decode("utf8") for key in db.keys()} 111 | in_db = set(passwords) 112 | # 1.a no-op 113 | assert "onlynormalized" in in_db 114 | # 1.b renamed 115 | assert "onlynotnormalized" in in_db 116 | assert "onlyNotNormalized" not in in_db 117 | # 2.a collision, preserve normalized 118 | assert "collisionnormalized" in in_db 119 | assert "collisionNormalized" not in in_db 120 | # 2.b collision, preserve and add normalized 121 | assert "collisionnotnormalized" in in_db 122 | assert "collisionNotNormalized" not in in_db 123 | assert "collisionNotnormalized" not in in_db 124 | 125 | # check the backup 126 | with dbm.open(auth1.dbm_path + "-backup") as db: 127 | backup_passwords = { 128 | key.decode("utf8"): db[key].decode("utf8") for key in db.keys() 129 | } 130 | 131 | for name in to_load: 132 | assert name in backup_passwords 133 | 134 | # now verify logins 135 | m = mock.Mock() 136 | for username, password in ( 137 | ("onlynormalized", "onlynormalized"), 138 | ("onlyNormalized", "onlynormalized"), 139 | ("onlynotnormalized", "onlyNotNormalized"), 140 | ("onlyNotNormalized", "onlyNotNormalized"), 141 | ("collisionnormalized", "collisionnormalized"), 142 | ("collisionNormalized", "collisionnormalized"), 143 | ("collisionnotnormalized", "collisionNotNormalized"), 144 | ("collisionNotNormalized", "collisionNotNormalized"), 145 | ): 146 | # normalized form, doesn't reset password 147 | authenticated = await auth2.authenticate( 148 | m, 149 | { 150 | "username": username, 151 | "password": "firstuse", 152 | }, 153 | ) 154 | assert authenticated is None 155 | 156 | # non-normalized form, doesn't reset password 157 | authenticated = await auth2.authenticate( 158 | m, 159 | { 160 | "username": username.upper(), 161 | "password": "firstuse", 162 | }, 163 | ) 164 | assert authenticated is None 165 | 166 | # normalized form, accepts correct password 167 | authenticated = await auth2.authenticate( 168 | m, 169 | { 170 | "username": username, 171 | "password": password, 172 | }, 173 | ) 174 | assert authenticated 175 | assert authenticated == auth2.normalize_username(username) 176 | 177 | # non-normalized form, accepts correct password 178 | authenticated = await auth2.authenticate( 179 | m, 180 | { 181 | "username": username.upper(), 182 | "password": password, 183 | }, 184 | ) 185 | assert authenticated 186 | assert authenticated == auth2.normalize_username(username) 187 | 188 | # load again, should skip the 189 | auth3 = FirstUseAuthenticator() 190 | --------------------------------------------------------------------------------