├── .flake8 ├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── dev-jupyterhub_config.py ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _static │ ├── authorization_area.png │ ├── block_user_failed_logins.png │ ├── change_password_self.png │ ├── change_password_user.png │ ├── login-two-factor-auth.png │ ├── native_auth_flow.png │ ├── signup-two-factor-auth.png │ └── wrong_signup.png │ ├── conf.py │ ├── index.md │ ├── options.md │ ├── quickstart.md │ └── troubleshooting.md ├── nativeauthenticator ├── __init__.py ├── common-credentials.txt ├── crypto │ ├── LICENSE │ ├── __init__.py │ ├── crypto.py │ ├── encoding.py │ └── signing.py ├── handlers.py ├── nativeauthenticator.py ├── orm.py ├── templates │ ├── authorization-area.html │ ├── change-password-admin.html │ ├── change-password.html │ ├── my_message.html │ ├── native-login.html │ ├── page.html │ └── signup.html └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_authenticator.py │ └── test_orm.py ├── pyproject.toml └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | # flake8 is used for linting Python code setup to automatically run with 2 | # pre-commit. 3 | # 4 | # ref: https://flake8.pycqa.org/en/latest/user/configuration.html 5 | # 6 | 7 | [flake8] 8 | # Ignore style and complexity 9 | # E: style errors 10 | # W: style warnings 11 | # C: complexity 12 | # D: docstring warnings (unused pydocstyle extension) 13 | ignore = E, C, W, D 14 | builtins = 15 | c 16 | get_config 17 | -------------------------------------------------------------------------------- /.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/nativeauthenticator/network/updates. 6 | # - YAML anchors are not supported here or in GitHub Workflows. 7 | # 8 | version: 2 9 | updates: 10 | # Maintain dependencies in our GitHub Workflows 11 | - package-ecosystem: github-actions 12 | directory: / 13 | labels: [ci] 14 | schedule: 15 | interval: monthly 16 | time: "05:00" 17 | timezone: Etc/UTC 18 | -------------------------------------------------------------------------------- /.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 | - "docs/**" 11 | - "**.md" 12 | - ".github/workflows/*.yaml" 13 | - "!.github/workflows/release.yaml" 14 | push: 15 | paths-ignore: 16 | - "docs/**" 17 | - "**.md" 18 | - ".github/workflows/*.yaml" 19 | - "!.github/workflows/release.yaml" 20 | branches-ignore: 21 | - "dependabot/**" 22 | - "pre-commit-ci-update-config" 23 | tags: ["**"] 24 | workflow_dispatch: 25 | 26 | jobs: 27 | build-release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | # id-token=write is required for pypa/gh-action-pypi-publish, and the PyPI 31 | # project needs to be configured to trust this workflow. 32 | # 33 | # ref: https://github.com/jupyterhub/team-compass/issues/648 34 | # 35 | id-token: write 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.12" 42 | 43 | - name: install build package 44 | run: | 45 | pip install --upgrade pip 46 | pip install build 47 | pip freeze 48 | 49 | - name: build release 50 | run: | 51 | python -m build --sdist --wheel . 52 | ls -l dist 53 | 54 | - name: publish to pypi 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | if: startsWith(github.ref, 'refs/tags/') 57 | -------------------------------------------------------------------------------- /.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/learn-github-actions/workflow-syntax-for-github-actions 3 | # 4 | name: Test 5 | 6 | on: 7 | pull_request: 8 | paths-ignore: 9 | - "docs/**" 10 | - "**.md" 11 | - ".github/workflows/*" 12 | - "!.github/workflows/test.yaml" 13 | push: 14 | paths-ignore: 15 | - "docs/**" 16 | - "**.md" 17 | - ".github/workflows/*" 18 | - "!.github/workflows/test.yaml" 19 | branches-ignore: 20 | - "dependabot/**" 21 | - "pre-commit-ci-update-config" 22 | workflow_dispatch: 23 | 24 | jobs: 25 | pytest: 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | include: 32 | - python-version: "3.9" 33 | pip-install-spec: "jupyterhub==4.*" 34 | - python-version: "3.12" 35 | pip-install-spec: "jupyterhub==5.*" 36 | - python-version: "3.x" 37 | pip-install-spec: "--pre jupyterhub" 38 | accept-failure: true 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-python@v5 43 | with: 44 | python-version: "${{ matrix.python-version }}" 45 | 46 | - name: Install Python dependencies 47 | run: | 48 | pip install ${{ matrix.pip-install-spec }} 49 | pip install ".[test]" 50 | 51 | - name: List packages 52 | run: pip freeze 53 | 54 | - name: Run tests 55 | continue-on-error: ${{ matrix.accept-failure == true }} 56 | run: | 57 | pytest --maxfail=2 --cov=nativeauthenticator 58 | 59 | # GitHub action reference: https://github.com/codecov/codecov-action 60 | - uses: codecov/codecov-action@v4 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | .vscode/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | jupyterhub.sqlite 111 | jupyterhub_cookie_secret 112 | -------------------------------------------------------------------------------- /.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.20.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: 6.0.1 32 | hooks: 33 | - id: isort 34 | 35 | # Autoformat: Python code 36 | - repo: https://github.com/psf/black 37 | rev: 25.1.0 38 | hooks: 39 | - id: black 40 | 41 | # Autoformat: markdown, yaml (but not helm templates) 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 | # Autoformat: Makes sure files end in a newline and only a newline. 53 | - id: end-of-file-fixer 54 | 55 | # Autoformat: Sorts entries in requirements.txt. 56 | - id: requirements-txt-fixer 57 | 58 | # Lint: Check for files with names that would conflict on a 59 | # case-insensitive filesystem like MacOS HFS+ or Windows FAT. 60 | - id: check-case-conflict 61 | 62 | # Lint: Checks that non-binary executables have a proper shebang. 63 | - id: check-executables-have-shebangs 64 | 65 | # Lint: Python code 66 | - repo: https://github.com/pycqa/flake8 67 | rev: "7.2.0" 68 | hooks: 69 | - id: flake8 70 | 71 | # pre-commit.ci config reference: https://pre-commit.ci/#configuration 72 | ci: 73 | autoupdate_schedule: monthly 74 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | nativeauthenticator/templates/ 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Configuration on how ReadTheDocs (RTD) builds our documentation 2 | # ref: https://readthedocs.org/projects/native-authenticator/ 3 | # ref: https://docs.readthedocs.io/en/stable/config-file/v2.html 4 | # 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-lts-latest 9 | tools: 10 | python: "3.12" 11 | 12 | sphinx: 13 | configuration: docs/source/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 8 | 9 | ## 1.3 10 | 11 | ### 1.3.0 - 2024-09-17 12 | 13 | With this release, `jupyterhub` 4.1.6 and Python 3.9 or higher becomes required. 14 | 15 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.2.0...1.3.0)) 16 | 17 | #### Bugs fixed 18 | 19 | - Fix user was added on sign-up even if password didn't match confirmation [#275](https://github.com/jupyterhub/nativeauthenticator/pull/275) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 20 | - Fix page.html menu bar [#270](https://github.com/jupyterhub/nativeauthenticator/pull/270) ([@paolocarinci](https://github.com/paolocarinci), [@manics](https://github.com/manics), [@lambdaTotoro](https://github.com/lambdaTotoro)) 21 | 22 | #### Maintenance and upkeep improvements 23 | 24 | - Update .flake8 config to match other jupyterhub repos [#276](https://github.com/jupyterhub/nativeauthenticator/pull/276) ([@consideRatio](https://github.com/consideRatio)) 25 | - Require jupyterhub 4.1.6+ and Python 3.9+ [#274](https://github.com/jupyterhub/nativeauthenticator/pull/274) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 26 | - Update rtd build environment etc [#273](https://github.com/jupyterhub/nativeauthenticator/pull/273) ([@consideRatio](https://github.com/consideRatio)) 27 | 28 | #### Continuous integration improvements 29 | 30 | - build(deps): bump codecov/codecov-action from 3 to 4 [#263](https://github.com/jupyterhub/nativeauthenticator/pull/263) ([@consideRatio](https://github.com/consideRatio)) 31 | - build(deps): bump actions/setup-python from 4 to 5 [#260](https://github.com/jupyterhub/nativeauthenticator/pull/260) ([@consideRatio](https://github.com/consideRatio)) 32 | - build(deps): bump actions/checkout from 3 to 4 [#254](https://github.com/jupyterhub/nativeauthenticator/pull/254) ([@consideRatio](https://github.com/consideRatio)) 33 | 34 | #### Contributors to this release 35 | 36 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 37 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 38 | 39 | ([GitHub contributors page for this release](https://github.com/jupyterhub/nativeauthenticator/graphs/contributors?from=2023-05-22&to=2024-09-17&type=c)) 40 | 41 | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AconsideRatio+updated%3A2023-05-22..2024-09-17&type=Issues)) | @lambdaTotoro ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AlambdaTotoro+updated%3A2023-05-22..2024-09-17&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Amanics+updated%3A2023-05-22..2024-09-17&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aminrk+updated%3A2023-05-22..2024-09-17&type=Issues)) | @paolocarinci ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Apaolocarinci+updated%3A2023-05-22..2024-09-17&type=Issues)) 42 | 43 | ## 1.2 44 | 45 | ### 1.2.0 - 2023-05-22 46 | 47 | The release adds support for JupyterHub 4. 48 | 49 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.1.0...1.2.0)) 50 | 51 | #### Enhancements made 52 | 53 | - Register native as an jupyterhub authenticator class [#247](https://github.com/jupyterhub/nativeauthenticator/pull/247) ([@consideRatio](https://github.com/consideRatio)) 54 | 55 | #### Maintenance and upkeep improvements 56 | 57 | - Add test as optional dependency, refactor fixtures to conftest.py, fix code coverage setup [#245](https://github.com/jupyterhub/nativeauthenticator/pull/245) ([@consideRatio](https://github.com/consideRatio)) 58 | - pre-commit: add autoflake, use isort over reorder-python-imports [#244](https://github.com/jupyterhub/nativeauthenticator/pull/244) ([@consideRatio](https://github.com/consideRatio)) 59 | - maint: transition to pypi trusted workflow release, put tbump conf in pyproject.toml [#243](https://github.com/jupyterhub/nativeauthenticator/pull/243) ([@consideRatio](https://github.com/consideRatio)) 60 | - Add xsrf token to the login, signup, and password changing pages [#239](https://github.com/jupyterhub/nativeauthenticator/pull/239) ([@agostof](https://github.com/agostof)) 61 | - Use XSRF tokens for cross-site protections [#236](https://github.com/jupyterhub/nativeauthenticator/pull/236) ([@djangoliv](https://github.com/djangoliv)) 62 | - dependabot: rename to .yaml [#235](https://github.com/jupyterhub/nativeauthenticator/pull/235) ([@consideRatio](https://github.com/consideRatio)) 63 | - dependabot: monthly updates of github actions [#233](https://github.com/jupyterhub/nativeauthenticator/pull/233) ([@consideRatio](https://github.com/consideRatio)) 64 | 65 | #### Continuous integration improvements 66 | 67 | - ci: pin sqlalchemy 1 on jupyterhub 1 and 2 [#240](https://github.com/jupyterhub/nativeauthenticator/pull/240) ([@minrk](https://github.com/minrk)) 68 | 69 | #### Contributors to this release 70 | 71 | ([GitHub contributors page for this release](https://github.com/jupyterhub/nativeauthenticator/graphs/contributors?from=2022-09-09&to=2023-05-21&type=c)) 72 | 73 | [@agostof](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aagostof+updated%3A2022-09-09..2023-05-21&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AconsideRatio+updated%3A2022-09-09..2023-05-21&type=Issues) | [@djangoliv](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adjangoliv+updated%3A2022-09-09..2023-05-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aminrk+updated%3A2022-09-09..2023-05-21&type=Issues) 74 | 75 | ## 1.1 76 | 77 | ### 1.1.0 - 2022-09-09 78 | 79 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.5...1.1.0)) 80 | 81 | #### Enhancements made 82 | 83 | - Ask for new passwords twice, ask for current password on change [#180](https://github.com/jupyterhub/nativeauthenticator/pull/180) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 84 | 85 | #### Bugs fixed 86 | 87 | - Add slash restriction to username validation [#197](https://github.com/jupyterhub/nativeauthenticator/pull/197) ([@marc-marcos](https://github.com/marc-marcos)) 88 | - Correct login procedure for admins [#195](https://github.com/jupyterhub/nativeauthenticator/pull/195) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 89 | - Enforce password criteria on password change [#169](https://github.com/jupyterhub/nativeauthenticator/pull/169) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 90 | - Systematic refresh of templates [#162](https://github.com/jupyterhub/nativeauthenticator/pull/162) ([@consideRatio](https://github.com/consideRatio)) 91 | 92 | #### Maintenance and upkeep improvements 93 | 94 | - document and refactor the handlers [#183](https://github.com/jupyterhub/nativeauthenticator/pull/183) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 95 | - chore: use async instead of gen.coroutine [#178](https://github.com/jupyterhub/nativeauthenticator/pull/178) ([@consideRatio](https://github.com/consideRatio)) 96 | - pre-commit: apply black formatting [#174](https://github.com/jupyterhub/nativeauthenticator/pull/174) ([@consideRatio](https://github.com/consideRatio)) 97 | - Smaller tweaks from pre-commit autoformatting hooks [#173](https://github.com/jupyterhub/nativeauthenticator/pull/173) ([@consideRatio](https://github.com/consideRatio)) 98 | - Support JupyterHub 2's fine grained RBAC permissions, and test against Py3.10 and JH2 [#160](https://github.com/jupyterhub/nativeauthenticator/pull/160) ([@consideRatio](https://github.com/consideRatio)) 99 | 100 | #### Documentation improvements 101 | 102 | - update documentation screenshots [#194](https://github.com/jupyterhub/nativeauthenticator/pull/194) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 103 | - docs: fix c.JupyterHub.template_paths [#192](https://github.com/jupyterhub/nativeauthenticator/pull/192) ([@yapatta](https://github.com/yapatta)) 104 | - docs: c.NativeAuthenticator instead of c.Authenticator [#188](https://github.com/jupyterhub/nativeauthenticator/pull/188) ([@consideRatio](https://github.com/consideRatio)) 105 | - docs: update from rST to MyST and to common theme [#187](https://github.com/jupyterhub/nativeauthenticator/pull/187) ([@consideRatio](https://github.com/consideRatio)) 106 | - document and refactor the handlers [#183](https://github.com/jupyterhub/nativeauthenticator/pull/183) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 107 | - docs: add inline documentation to orm.py [#179](https://github.com/jupyterhub/nativeauthenticator/pull/179) ([@consideRatio](https://github.com/consideRatio)) 108 | - Update CONTRIBUTING.md [#161](https://github.com/jupyterhub/nativeauthenticator/pull/161) ([@consideRatio](https://github.com/consideRatio)) 109 | 110 | #### Continuous integration improvements 111 | 112 | - ci: add RELEASE.md and tbump.toml [#217](https://github.com/jupyterhub/nativeauthenticator/pull/217) ([@consideRatio](https://github.com/consideRatio)) 113 | - ci: add tests against jupyterhub 3 and python 3.11 [#216](https://github.com/jupyterhub/nativeauthenticator/pull/216) ([@consideRatio](https://github.com/consideRatio)) 114 | - ci: add dependabot bumping of github actions [#215](https://github.com/jupyterhub/nativeauthenticator/pull/215) ([@consideRatio](https://github.com/consideRatio)) 115 | - ci: rely on pre-commit.ci & run tests only if needed [#175](https://github.com/jupyterhub/nativeauthenticator/pull/175) ([@consideRatio](https://github.com/consideRatio)) 116 | 117 | #### Contributors to this release 118 | 119 | ([GitHub contributors page for this release](https://github.com/jupyterhub/nativeauthenticator/graphs/contributors?from=2021-10-19&to=2022-09-09&type=c)) 120 | 121 | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AconsideRatio+updated%3A2021-10-19..2022-09-09&type=Issues) | [@harshu1470](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aharshu1470+updated%3A2021-10-19..2022-09-09&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AlambdaTotoro+updated%3A2021-10-19..2022-09-09&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2021-10-19..2022-09-09&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Amanics+updated%3A2021-10-19..2022-09-09&type=Issues) | [@marc-marcos](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Amarc-marcos+updated%3A2021-10-19..2022-09-09&type=Issues) | [@yapatta](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ayapatta+updated%3A2021-10-19..2022-09-09&type=Issues) 122 | 123 | ## 1.0 124 | 125 | ### 1.0.5 - 2021-10-19 126 | 127 | This releases resolves an incorrect Python module structure that lead to a failing import when installing from a built wheel. 128 | 129 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.4...1.0.5)) 130 | 131 | ### 1.0.4 - 2021-10-19 132 | 133 | This release includes a bugfix related to an import statement. 134 | 135 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.3...1.0.4)) 136 | 137 | ### 1.0.3 - 2021-10-19 138 | 139 | This release includes a bugfix of an import statement. 140 | 141 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.2...1.0.3)) 142 | 143 | ### 1.0.2 - 2021-10-19 144 | 145 | This release includes documentation updates and an attempted bugfix of an import statement. 146 | 147 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.1...1.0.2)) 148 | 149 | ### 1.0.1 - 2021-10-18 150 | 151 | A small release hiccup resolved. 152 | 153 | ([full changelog](https://github.com/jupyterhub/nativeauthenticator/compare/1.0.0...1.0.1)) 154 | 155 | ### 1.0.0 - 2021-10-18 156 | 157 | As tracked by #149 and especially since the incorporation of multiple substantial and new features, we are now happy to call this version 1.0.0. 158 | 159 | #### What's Changed 160 | 161 | Here are the main contributions in this release: 162 | 163 | - Unauthorized users now get a better error message on failed login (#119) 164 | - GitHub Page About Section now links to Docs (#143) 165 | - Improved documentation 166 | - feat: keep position in authorization area by @djangoliv in https://github.com/jupyterhub/nativeauthenticator/pull/141 167 | - Feature/page template by @raethlein in https://github.com/jupyterhub/nativeauthenticator/pull/79 168 | 169 | A very warm and special thanks goes out to @consideRatio who helped tremendously in keeping the project up to date: 170 | 171 | - Update changelog from 0.0.1-0.0.7: https://github.com/jupyterhub/nativeauthenticator/pull/137 172 | - Add various README badges: https://github.com/jupyterhub/nativeauthenticator/pull/135 173 | - ci: transition from circleci to github workflows: https://github.com/jupyterhub/nativeauthenticator/pull/134 174 | - ci: add publish to pypi workflow: https://github.com/jupyterhub/nativeauthenticator/pull/136 175 | 176 | Special shoutout also goes to @davidedelvento who contributed a lot of work in these PRs: 177 | 178 | - Terms of Service: https://github.com/jupyterhub/nativeauthenticator/pull/148 179 | - Recaptcha: https://github.com/jupyterhub/nativeauthenticator/pull/146 180 | - Allow some users (but not all) to not need admin approval: https://github.com/jupyterhub/nativeauthenticator/pull/145 181 | 182 | **Full Changelog**: https://github.com/jupyterhub/nativeauthenticator/compare/0.0.7...1.0.0 183 | 184 | ## 0.0 185 | 186 | ### 0.0.7 - 2021-01-14 187 | 188 | #### Merged PRs 189 | 190 | - fix: we now need to await render_template method [#129](https://github.com/jupyterhub/nativeauthenticator/pull/129) ([@djangoliv](https://github.com/djangoliv)) 191 | - Bump notebook from 5.7.8 to 6.1.5 [#125](https://github.com/jupyterhub/nativeauthenticator/pull/125) ([@dependabot](https://github.com/dependabot)) 192 | 193 | #### Contributors to this release 194 | 195 | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adependabot+updated%3A2020-11-12..2021-01-11&type=Issues) | [@djangoliv](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adjangoliv+updated%3A2020-11-12..2021-01-11&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AlambdaTotoro+updated%3A2020-11-12..2021-01-11&type=Issues) 196 | 197 | ### 0.0.6 - 2020-11-14 198 | 199 | #### Merged PRs 200 | 201 | - Discard from authorize [#121](https://github.com/jupyterhub/nativeauthenticator/pull/121) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 202 | - Allowed users [#120](https://github.com/jupyterhub/nativeauthenticator/pull/120) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 203 | - Ensure that jinja loader is not registered at each render [#115](https://github.com/jupyterhub/nativeauthenticator/pull/115) ([@fbessou](https://github.com/fbessou)) 204 | - Allow admin to change any password [#112](https://github.com/jupyterhub/nativeauthenticator/pull/112) ([@djangoliv](https://github.com/djangoliv)) 205 | - Bump notebook from 5.7.2 to 5.7.8 [#111](https://github.com/jupyterhub/nativeauthenticator/pull/111) ([@dependabot](https://github.com/dependabot)) 206 | - Signup error [#109](https://github.com/jupyterhub/nativeauthenticator/pull/109) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 207 | - fix broken test [#107](https://github.com/jupyterhub/nativeauthenticator/pull/107) ([@leportella](https://github.com/leportella)) 208 | - fix flake8 errors [#106](https://github.com/jupyterhub/nativeauthenticator/pull/106) ([@leportella](https://github.com/leportella)) 209 | - Add option for disable user to signup [#103](https://github.com/jupyterhub/nativeauthenticator/pull/103) ([@mayswind](https://github.com/mayswind)) 210 | - Error on signup with taken username [#102](https://github.com/jupyterhub/nativeauthenticator/pull/102) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 211 | - add changelog [#101](https://github.com/jupyterhub/nativeauthenticator/pull/101) ([@leportella](https://github.com/leportella)) 212 | - fix orm for support mysql [#57](https://github.com/jupyterhub/nativeauthenticator/pull/57) ([@00Kai0](https://github.com/00Kai0)) 213 | 214 | #### Contributors to this release 215 | 216 | [@00Kai0](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3A00Kai0+updated%3A2020-02-20..2020-11-12&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adependabot+updated%3A2020-02-20..2020-11-12&type=Issues) | [@djangoliv](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adjangoliv+updated%3A2020-02-20..2020-11-12&type=Issues) | [@fbessou](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Afbessou+updated%3A2020-02-20..2020-11-12&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AlambdaTotoro+updated%3A2020-02-20..2020-11-12&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2020-02-20..2020-11-12&type=Issues) | [@mayswind](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Amayswind+updated%3A2020-02-20..2020-11-12&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aminrk+updated%3A2020-02-20..2020-11-12&type=Issues) | [@shreeishitagupta](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ashreeishitagupta+updated%3A2020-02-20..2020-11-12&type=Issues) 217 | 218 | ### 0.0.5 - 2020-02-20 219 | 220 | #### Merged PRs 221 | 222 | - Revert "fix timedelta.seconds misuse" [#100](https://github.com/jupyterhub/nativeauthenticator/pull/100) ([@leportella](https://github.com/leportella)) 223 | - upgrade version to launch at pypi [#99](https://github.com/jupyterhub/nativeauthenticator/pull/99) ([@leportella](https://github.com/leportella)) 224 | - Add announcement_login handling to login template [#97](https://github.com/jupyterhub/nativeauthenticator/pull/97) ([@JohnPaton](https://github.com/JohnPaton)) 225 | - add contributing file [#89](https://github.com/jupyterhub/nativeauthenticator/pull/89) ([@leportella](https://github.com/leportella)) 226 | - Add normalization to username [#87](https://github.com/jupyterhub/nativeauthenticator/pull/87) ([@leportella](https://github.com/leportella)) 227 | - Authenticator should accept passwords that are exactly the minimum length [#86](https://github.com/jupyterhub/nativeauthenticator/pull/86) ([@lambdaTotoro](https://github.com/lambdaTotoro)) 228 | - Add missing base_url prefix to links for correct routing [#83](https://github.com/jupyterhub/nativeauthenticator/pull/83) ([@raethlein](https://github.com/raethlein)) 229 | - fix timedelta.seconds misuse [#82](https://github.com/jupyterhub/nativeauthenticator/pull/82) ([@meownoid](https://github.com/meownoid)) 230 | - fix typos and improve performance [#81](https://github.com/jupyterhub/nativeauthenticator/pull/81) ([@meownoid](https://github.com/meownoid)) 231 | - Add check for None before trying to delete a user. [#80](https://github.com/jupyterhub/nativeauthenticator/pull/80) ([@raethlein](https://github.com/raethlein)) 232 | - Fix failed to change password due to compatibility issues with JupyterHub 1.0.0 [#78](https://github.com/jupyterhub/nativeauthenticator/pull/78) ([@hiroki-sawano](https://github.com/hiroki-sawano)) 233 | - postgres says `opt_secret` is too long for `varying(10)` [#76](https://github.com/jupyterhub/nativeauthenticator/pull/76) ([@databasedav](https://github.com/databasedav)) 234 | - Add 2 factor authentication as optional feature [#70](https://github.com/jupyterhub/nativeauthenticator/pull/70) ([@leportella](https://github.com/leportella)) 235 | - Add stylized login page [#69](https://github.com/jupyterhub/nativeauthenticator/pull/69) ([@leportella](https://github.com/leportella)) 236 | - Add importation of db from FirstUse Auth [#67](https://github.com/jupyterhub/nativeauthenticator/pull/67) ([@leportella](https://github.com/leportella)) 237 | - Change setup to version 0.0.4 [#65](https://github.com/jupyterhub/nativeauthenticator/pull/65) ([@leportella](https://github.com/leportella)) 238 | 239 | #### Contributors to this release 240 | 241 | [@chicocvenancio](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Achicocvenancio+updated%3A2019-02-15..2020-02-20&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Acholdgraf+updated%3A2019-02-15..2020-02-20&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AconsideRatio+updated%3A2019-02-15..2020-02-20&type=Issues) | [@databasedav](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Adatabasedav+updated%3A2019-02-15..2020-02-20&type=Issues) | [@harshu1470](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aharshu1470+updated%3A2019-02-15..2020-02-20&type=Issues) | [@hiroki-sawano](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ahiroki-sawano+updated%3A2019-02-15..2020-02-20&type=Issues) | [@JohnPaton](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AJohnPaton+updated%3A2019-02-15..2020-02-20&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3AlambdaTotoro+updated%3A2019-02-15..2020-02-20&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2019-02-15..2020-02-20&type=Issues) | [@meownoid](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ameownoid+updated%3A2019-02-15..2020-02-20&type=Issues) | [@paulbaracch](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Apaulbaracch+updated%3A2019-02-15..2020-02-20&type=Issues) | [@raethlein](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Araethlein+updated%3A2019-02-15..2020-02-20&type=Issues) | [@xrdy511623](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Axrdy511623+updated%3A2019-02-15..2020-02-20&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ayuvipanda+updated%3A2019-02-15..2020-02-20&type=Issues) 242 | 243 | ### 0.0.4 - 2019-02-15 244 | 245 | #### Merged PRs 246 | 247 | - Change image of block attemps workflow [#64](https://github.com/jupyterhub/nativeauthenticator/pull/64) ([@leportella](https://github.com/leportella)) 248 | - add MANIFEST.in to ensure files get packaged [#63](https://github.com/jupyterhub/nativeauthenticator/pull/63) ([@minrk](https://github.com/minrk)) 249 | - Remove duplication creation of user [#62](https://github.com/jupyterhub/nativeauthenticator/pull/62) ([@leportella](https://github.com/leportella)) 250 | - Change package_data to include_package_data [#60](https://github.com/jupyterhub/nativeauthenticator/pull/60) ([@leportella](https://github.com/leportella)) 251 | - fix raise error when email is none [#56](https://github.com/jupyterhub/nativeauthenticator/pull/56) ([@00Kai0](https://github.com/00Kai0)) 252 | - fix password is not bytes in mysql [#55](https://github.com/jupyterhub/nativeauthenticator/pull/55) ([@00Kai0](https://github.com/00Kai0)) 253 | 254 | #### Contributors to this release 255 | 256 | [@00Kai0](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3A00Kai0+updated%3A2019-02-13..2019-02-15&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2019-02-13..2019-02-15&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aminrk+updated%3A2019-02-13..2019-02-15&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ayuvipanda+updated%3A2019-02-13..2019-02-15&type=Issues) 257 | 258 | ### 0.0.3 - 2019-02-13 259 | 260 | #### Merged PRs 261 | 262 | - Change package_data to include_package_data [#60](https://github.com/jupyterhub/nativeauthenticator/pull/60) ([@leportella](https://github.com/leportella)) 263 | - Increase Native Auth version to 0.0.2 [#59](https://github.com/jupyterhub/nativeauthenticator/pull/59) ([@leportella](https://github.com/leportella)) 264 | 265 | #### Contributors to this release 266 | 267 | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2019-02-13..2019-02-13&type=Issues) 268 | 269 | #### Merged PRs 270 | 271 | - Change package_data to include_package_data [#60](https://github.com/jupyterhub/nativeauthenticator/pull/60) ([@leportella](https://github.com/leportella)) 272 | - Increase Native Auth version to 0.0.2 [#59](https://github.com/jupyterhub/nativeauthenticator/pull/59) ([@leportella](https://github.com/leportella)) 273 | 274 | #### Contributors to this release 275 | 276 | ([GitHub contributors page for this release](https://github.com/jupyterhub/nativeauthenticator/graphs/contributors?from=2019-02-13&to=2019-02-13&type=c)) 277 | 278 | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2019-02-13..2019-02-13&type=Issues) 279 | 280 | ### 0.0.2 - 2019-02-13 281 | 282 | #### Merged PRs 283 | 284 | - Change button from Deauthorize to Unauthorize [#58](https://github.com/jupyterhub/nativeauthenticator/pull/58) ([@leportella](https://github.com/leportella)) 285 | - Add description to README and setup.py [#54](https://github.com/jupyterhub/nativeauthenticator/pull/54) ([@leportella](https://github.com/leportella)) 286 | - Fix data packaging on setup.py [#53](https://github.com/jupyterhub/nativeauthenticator/pull/53) ([@leportella](https://github.com/leportella)) 287 | 288 | #### Contributors to this release 289 | 290 | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2019-02-12..2019-02-13&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ayuvipanda+updated%3A2019-02-12..2019-02-13&type=Issues) 291 | 292 | ### 0.0.1 - 2019-02-12 293 | 294 | #### Merged PRs 295 | 296 | - Improving docs with images [#50](https://github.com/jupyterhub/nativeauthenticator/pull/50) ([@leportella](https://github.com/leportella)) 297 | - Delete user info from admin panel [#49](https://github.com/jupyterhub/nativeauthenticator/pull/49) ([@leportella](https://github.com/leportella)) 298 | - Add all info from signup post to get_or_create_user [#48](https://github.com/jupyterhub/nativeauthenticator/pull/48) ([@leportella](https://github.com/leportella)) 299 | - Fix installation errors [#46](https://github.com/jupyterhub/nativeauthenticator/pull/46) ([@leportella](https://github.com/leportella)) 300 | - Username sanitization [#43](https://github.com/jupyterhub/nativeauthenticator/pull/43) ([@leportella](https://github.com/leportella)) 301 | - Remove message decision from Signup post method [#42](https://github.com/jupyterhub/nativeauthenticator/pull/42) ([@leportella](https://github.com/leportella)) 302 | - Add email as an option to be asked on sign up [#41](https://github.com/jupyterhub/nativeauthenticator/pull/41) ([@leportella](https://github.com/leportella)) 303 | - Add endpoint for changing password [#40](https://github.com/jupyterhub/nativeauthenticator/pull/40) ([@leportella](https://github.com/leportella)) 304 | - Add option for open signup and add style to result msgs [#38](https://github.com/jupyterhub/nativeauthenticator/pull/38) ([@leportella](https://github.com/leportella)) 305 | - Add option to see password on signup [#37](https://github.com/jupyterhub/nativeauthenticator/pull/37) ([@leportella](https://github.com/leportella)) 306 | - Add check if user exceeded attempt of logins [#36](https://github.com/jupyterhub/nativeauthenticator/pull/36) ([@leportella](https://github.com/leportella)) 307 | - Improve docs [#35](https://github.com/jupyterhub/nativeauthenticator/pull/35) ([@leportella](https://github.com/leportella)) 308 | - Add missing template from authorization page [#32](https://github.com/jupyterhub/nativeauthenticator/pull/32) ([@leportella](https://github.com/leportella)) 309 | - Add password strength option [#31](https://github.com/jupyterhub/nativeauthenticator/pull/31) ([@leportella](https://github.com/leportella)) 310 | - Add admin authorization system to new users [#29](https://github.com/jupyterhub/nativeauthenticator/pull/29) ([@leportella](https://github.com/leportella)) 311 | - Add relationship between User and UserInfo [#27](https://github.com/jupyterhub/nativeauthenticator/pull/27) ([@leportella](https://github.com/leportella)) 312 | - Add authentication based on userinfo [#25](https://github.com/jupyterhub/nativeauthenticator/pull/25) ([@leportella](https://github.com/leportella)) 313 | - Add user info table [#24](https://github.com/jupyterhub/nativeauthenticator/pull/24) ([@leportella](https://github.com/leportella)) 314 | - Add new user [#16](https://github.com/jupyterhub/nativeauthenticator/pull/16) ([@leportella](https://github.com/leportella)) 315 | - Fix Circle Ci badge [#15](https://github.com/jupyterhub/nativeauthenticator/pull/15) ([@leportella](https://github.com/leportella)) 316 | - Add signup post form [#14](https://github.com/jupyterhub/nativeauthenticator/pull/14) ([@leportella](https://github.com/leportella)) 317 | - Add badges to README [#13](https://github.com/jupyterhub/nativeauthenticator/pull/13) ([@leportella](https://github.com/leportella)) 318 | - Add codecov to circle ci and requirements [#12](https://github.com/jupyterhub/nativeauthenticator/pull/12) ([@leportella](https://github.com/leportella)) 319 | - Add signup area [#11](https://github.com/jupyterhub/nativeauthenticator/pull/11) ([@leportella](https://github.com/leportella)) 320 | - Add docs link on README [#10](https://github.com/jupyterhub/nativeauthenticator/pull/10) ([@leportella](https://github.com/leportella)) 321 | - Add docs with sphinx [#9](https://github.com/jupyterhub/nativeauthenticator/pull/9) ([@leportella](https://github.com/leportella)) 322 | - Add circleci for flake8 [#7](https://github.com/jupyterhub/nativeauthenticator/pull/7) ([@leportella](https://github.com/leportella)) 323 | - Add minimal tests [#5](https://github.com/jupyterhub/nativeauthenticator/pull/5) ([@leportella](https://github.com/leportella)) 324 | - Add first structure to NativeAuthenticator [#4](https://github.com/jupyterhub/nativeauthenticator/pull/4) ([@leportella](https://github.com/leportella)) 325 | 326 | #### Contributors to this release 327 | 328 | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aleportella+updated%3A2018-12-03..2019-02-12&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Aminrk+updated%3A2018-12-03..2019-02-12&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fnativeauthenticator+involves%3Ayuvipanda+updated%3A2018-12-03..2019-02-12&type=Issues) 329 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Native Authenticator 2 | 3 | Welcome! As a [Jupyter](https://jupyter.org) project, 4 | you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html). 5 | 6 | Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md) 7 | for a friendly and welcoming collaborative environment. 8 | 9 | ## Setting up a development environment 10 | 11 | ### Install 12 | 13 | ```shell 14 | pip install -e ".[test]" 15 | ``` 16 | 17 | ### Configure pre-commit 18 | 19 | [`pre-commit`](https://pre-commit.com/) is a tool we use to validate code and 20 | autoformat it. The kind of validation and auto formatting can be inspected via 21 | the `.pre-commit-config.yaml` file. 22 | 23 | As the name implies, `pre-commit` can be configured to run its validation and 24 | auto formatting just before you make a commit. By configuring it to do so, you 25 | can avoid having to have a separate commit later that applies auto formatting. 26 | 27 | To configure `pre-commit` to run act before you commit, you can run the 28 | following command from the root of this repository next to the 29 | `.pre-commit-config.yaml` file. 30 | 31 | ```shell 32 | pip install pre-commit 33 | pre-commit install --install-hooks 34 | ``` 35 | 36 | ### Running your local project 37 | 38 | For developing the Native Authenticator, you can start a JupyterHub server using `dev-jupyterhub_config.py`. 39 | 40 | ```shell 41 | jupyterhub -f dev-jupyterhub_config.py 42 | ``` 43 | 44 | ### Runing tests 45 | 46 | On the project folder you can run tests by using pytest 47 | 48 | ```shell 49 | pytest 50 | ``` 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, JupyterHub 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 | 3 | graft docs 4 | prune docs/_build 5 | 6 | # package data 7 | include nativeauthenticator/*.txt 8 | include nativeauthenticator/templates/*.html 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Native Authenticator 2 | 3 | [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub-nativeauthenticator?logo=pypi&logoColor=white)](https://pypi.python.org/pypi/jupyterhub-nativeauthenticator) 4 | [![Documentation build status](https://img.shields.io/readthedocs/native-authenticator?logo=read-the-docs&logoColor=white)](https://native-authenticator.readthedocs.org/en/latest/) 5 | [![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/nativeauthenticator/Test?logo=github&label=tests)](https://github.com/jupyterhub/nativeauthenticator/actions) 6 | [![Code coverage](https://img.shields.io/codecov/c/github/jupyterhub/nativeauthenticator.svg)](https://codecov.io/github/jupyterhub/nativeauthenticator) 7 |
8 | [![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/nativeauthenticator/issues) 9 | [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) 10 | [![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub) 11 | [![Contribute](https://img.shields.io/badge/I_want_to_contribute!-grey?logo=jupyter)](https://github.com/jupyterhub/nativeauthenticator/blob/master/CONTRIBUTING.md) 12 | 13 | This is a relatively simple authenticator for small or medium-sized [JupyterHub](http://github.com/jupyter/jupyterhub/) applications. Signup and authentication are implemented as native to JupyterHub without relying on external services. 14 | 15 | NativeAuthenticator provides the following features: 16 | 17 | - New users can signup on the system; 18 | - New users can be blocked from accessing the system awaiting admin authorization; 19 | - Option of enforcing password security by disallowing common passwords or requiring a minimum password length; 20 | - Option to block users after a set number of failed login attempts; 21 | - Option of open signup without need for initial authorization; 22 | - Option of asking more information about users on signup (e-mail). 23 | - Option of requiring users to agree with given Terms of Service; 24 | - Option of protection against scripting attacks via reCAPTCHA; 25 | - Option for users with an org-internal e-mail address to self-approve via secure link; 26 | 27 | ## Documentation 28 | 29 | The latest documentation is always on readTheDocs, available [here](https://native-authenticator.readthedocs.io). 30 | 31 | ## Running tests 32 | 33 | To run the tests locally, you can install the development dependencies like so: 34 | 35 | ```shell 36 | pip install -e ".[test]" 37 | ``` 38 | 39 | Then run tests with pytest: 40 | 41 | ```shell 42 | pytest 43 | ``` 44 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | 3 | `jupyterhub-tmpauthenticator` is a package available on [PyPI]. 4 | 5 | These are the instructions on how to make a release. 6 | 7 | ## Pre-requisites 8 | 9 | - Push rights to this GitHub repository 10 | 11 | ## Steps to make a release 12 | 13 | 1. Create a PR updating `CHANGELOG.md` with [github-activity] and continue when 14 | its merged. 15 | 16 | Advice on this procedure can be found in [this team compass 17 | issue](https://github.com/jupyterhub/team-compass/issues/563). 18 | 19 | 2. Checkout main and make sure it is up to date. 20 | 21 | ```shell 22 | git checkout main 23 | git fetch origin main 24 | git reset --hard origin/main 25 | ``` 26 | 27 | 3. Update the version, make commits, and push a git tag with `tbump`. 28 | 29 | ```shell 30 | pip install tbump 31 | ``` 32 | 33 | `tbump` will ask for confirmation before doing anything. 34 | 35 | ```shell 36 | # Example versions to set: 1.0.0, 1.0.0b1 37 | VERSION= 38 | tbump ${VERSION} 39 | ``` 40 | 41 | Following this, the [CI system] will build and publish a release. 42 | 43 | 4. Reset the version back to dev, e.g. `1.0.1.dev` after releasing `1.0.0`. 44 | 45 | ```shell 46 | # Example version to set: 1.0.1.dev 47 | NEXT_VERSION= 48 | tbump --no-tag ${NEXT_VERSION}.dev 49 | ``` 50 | 51 | [github-activity]: https://github.com/executablebooks/github-activity 52 | [pypi]: https://pypi.org/project/jupyterhub-nativeauthenticator/ 53 | [ci system]: https://github.com/jupyterhub/jupyterhub-nativeauthenticator/actions/workflows/release.yaml 54 | -------------------------------------------------------------------------------- /dev-jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import nativeauthenticator 4 | 5 | c.JupyterHub.spawner_class = "simple" 6 | c.JupyterHub.authenticator_class = "native" 7 | c.Authenticator.admin_users = {"admin"} 8 | 9 | # Required configuration of templates location 10 | if not isinstance(c.JupyterHub.template_paths, list): 11 | c.JupyterHub.template_paths = [] 12 | c.JupyterHub.template_paths.append( 13 | f"{os.path.dirname(nativeauthenticator.__file__)}/templates/" 14 | ) 15 | 16 | 17 | # Below are all the available configuration options for NativeAuthenticator 18 | # ------------------------------------------------------------------------- 19 | 20 | c.NativeAuthenticator.check_common_password = False 21 | c.NativeAuthenticator.minimum_password_length = 8 22 | c.NativeAuthenticator.allowed_failed_logins = 0 23 | c.NativeAuthenticator.seconds_before_next_try = 600 24 | 25 | c.NativeAuthenticator.enable_signup = True 26 | c.NativeAuthenticator.open_signup = False 27 | c.NativeAuthenticator.ask_email_on_signup = True 28 | 29 | c.NativeAuthenticator.allow_2fa = True 30 | 31 | c.NativeAuthenticator.tos = 'I agree to the TOS.' 32 | 33 | # c.NativeAuthenticator.recaptcha_key = "your key" 34 | # c.NativeAuthenticator.recaptcha_secret = "your secret" 35 | 36 | # c.NativeAuthenticator.allow_self_approval_for = '[^@]+@example\.com$' 37 | # c.NativeAuthenticator.secret_key = "your-arbitrary-key" 38 | # c.NativeAuthenticator.self_approval_email = ( 39 | # "from", 40 | # "subject", 41 | # "email body including https://example.com{approval_url}", 42 | # ) 43 | # c.NativeAuthenticator.self_approval_server = { 44 | # 'url': 'smtp.gmail.com', 45 | # 'usr': 'myself', 46 | # 'pwd': 'mypassword' 47 | # } 48 | 49 | c.NativeAuthenticator.import_from_firstuse = False 50 | c.NativeAuthenticator.firstuse_dbm_path = "/home/user/passwords.dbm" 51 | c.NativeAuthenticator.delete_firstuse_db_after_import = False 52 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation generated by sphinx-quickstart 2 | # ---------------------------------------------------------------------------- 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) 21 | 22 | 23 | # Manually added commands 24 | # ---------------------------------------------------------------------------- 25 | 26 | # For local development: 27 | # - builds and rebuilds html on changes to source 28 | # - starts a livereload enabled webserver and opens up a browser 29 | devenv: 30 | sphinx-autobuild -b html --open-browser "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) 31 | 32 | # For local development and CI: 33 | # - verifies that links are valid 34 | linkcheck: 35 | $(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS) 36 | @echo 37 | @echo "Link check complete; look for any errors in the above output " \ 38 | "or in $(BUILDDIR)/linkcheck/output.txt." 39 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | if "%1" == "devenv" goto devenv 15 | if "%1" == "linkcheck" goto linkcheck 16 | goto default 17 | 18 | 19 | :default 20 | %SPHINXBUILD% >NUL 2>NUL 21 | if errorlevel 9009 ( 22 | echo. 23 | echo.The 'sphinx-build' command was not found. Open and read README.md! 24 | exit /b 1 25 | ) 26 | %SPHINXBUILD% -M %1 "%SOURCEDIR%" "%BUILDDIR%" %SPHINXOPTS% 27 | goto end 28 | 29 | 30 | :help 31 | %SPHINXBUILD% -M help "%SOURCEDIR%" "%BUILDDIR%" %SPHINXOPTS% 32 | goto end 33 | 34 | 35 | :devenv 36 | sphinx-autobuild >NUL 2>NUL 37 | if errorlevel 9009 ( 38 | echo. 39 | echo.The 'sphinx-autobuild' command was not found. Open and read README.md! 40 | exit /b 1 41 | ) 42 | sphinx-autobuild -b html --open-browser "%SOURCEDIR%" "%BUILDDIR%/html" %SPHINXOPTS% 43 | goto end 44 | 45 | 46 | :linkcheck 47 | %SPHINXBUILD% -b linkcheck "%SOURCEDIR%" "%BUILDDIR%/linkcheck" %SPHINXOPTS% 48 | echo. 49 | echo.Link check complete; look for any errors in the above output 50 | echo.or in "%BUILDDIR%/linkcheck/output.txt". 51 | goto end 52 | 53 | 54 | :end 55 | popd 56 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Beware that the ordering of these can matter when dependencies to them are 2 | # already installed, such as they are in the ReadTheDocs build system. 3 | # Dependency constraints for a given library will be better respected if its 4 | # listed earlier. 5 | # 6 | myst-parser 7 | pydata-sphinx-theme 8 | sphinx-autobuild 9 | sphinx_copybutton 10 | -------------------------------------------------------------------------------- /docs/source/_static/authorization_area.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/authorization_area.png -------------------------------------------------------------------------------- /docs/source/_static/block_user_failed_logins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/block_user_failed_logins.png -------------------------------------------------------------------------------- /docs/source/_static/change_password_self.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/change_password_self.png -------------------------------------------------------------------------------- /docs/source/_static/change_password_user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/change_password_user.png -------------------------------------------------------------------------------- /docs/source/_static/login-two-factor-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/login-two-factor-auth.png -------------------------------------------------------------------------------- /docs/source/_static/native_auth_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/native_auth_flow.png -------------------------------------------------------------------------------- /docs/source/_static/signup-two-factor-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/signup-two-factor-auth.png -------------------------------------------------------------------------------- /docs/source/_static/wrong_signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/docs/source/_static/wrong_signup.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # -- Project information ----------------------------------------------------- 4 | 5 | project = "Native Authenticator" 6 | copyright = "2021, Leticia Portella" 7 | author = "Leticia Portella" 8 | 9 | 10 | # -- General MyST configuration ---------------------------------------------- 11 | # ref: https://myst-parser.readthedocs.io/en/latest/syntax/optional.html 12 | 13 | myst_enable_extensions = [ 14 | "substitution", 15 | ] 16 | 17 | 18 | # -- General Sphinx configuration -------------------------------------------- 19 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = [ 22 | "sphinx_copybutton", 23 | "myst_parser", 24 | ] 25 | templates_path = ["_templates"] 26 | source_suffix = [".rst", ".md"] 27 | root_doc = master_doc = "index" 28 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 29 | 30 | 31 | # -- Options for linkcheck builder ------------------------------------------- 32 | # ref: http://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder 33 | 34 | linkcheck_ignore = [ 35 | r"(.*)github\.com(.*)#", # javascript based anchors 36 | ] 37 | 38 | 39 | # -- Options for HTML output ------------------------------------------------- 40 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 41 | 42 | # pydata_spinx_theme's html_theme_options reference: 43 | # https://pydata-sphinx-theme.readthedocs.io/en/latest/user_guide/configuring.html 44 | html_theme = "pydata_sphinx_theme" 45 | html_theme_options = { 46 | "github_url": "https://github.com/jupyterhub/nativeauthenticator/", 47 | "use_edit_page_button": True, 48 | } 49 | html_context = { 50 | "github_user": "jupyterhub", 51 | "github_repo": "nativeauthenticator", 52 | "github_version": "main", 53 | "doc_path": "docs/source", 54 | } 55 | 56 | html_static_path = ["_static"] 57 | 58 | # FIXME: This should be configured. 59 | # 60 | # html_favicon = "_static/logo/favicon.ico" 61 | # html_logo = "_static/logo/logo.png" 62 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Native Authenticator's documentation! 2 | 3 | This is a relatively simple authenticator for small or medium-sized [JupyterHub](https://github.com/jupyterhub/) applications. Signup and authentication are implemented as native to JupyterHub without relying on external services. 4 | 5 | NativeAuthenticator provides the following features: 6 | 7 | - New users can signup on the system; 8 | - New users can be blocked from accessing the system awaiting admin authorization; 9 | - Option of enforcing password security by disallowing common passwords or requiring a minimum password length; 10 | - Option to block users after a set number of failed login attempts; 11 | - Option of open signup without need for initial authorization; 12 | - Option of asking more information about users on signup (e-mail). 13 | - Option of requiring users to agree with given Terms of Service; 14 | - Option of protection against scripting attacks via reCAPTCHA; 15 | - Option for users with an org-internal e-mail address to self-approve via secure link; 16 | 17 | # Indices and tables 18 | 19 | ```{toctree} 20 | :caption: 'Contents:' 21 | :maxdepth: 2 22 | 23 | quickstart 24 | options 25 | troubleshooting 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/source/options.md: -------------------------------------------------------------------------------- 1 | # Optional Configuration 2 | 3 | ## Password Strength 4 | 5 | By default, when a user signs up through Native Authenticator there is no password strength verification, so any type of password is valid. There are two methods that you can add to increase password strength: a verification for commmon passowords and a minimum length of password. 6 | 7 | To verify if the password is not common (such as 'qwerty' or '1234'), you can add the following line to your config file: 8 | 9 | ```python 10 | c.NativeAuthenticator.check_common_password = True 11 | ``` 12 | 13 | The Authenticator will verify if the password is a common password and the user won't be able to sign up if it is. The list of the common passwords that are in our verification is available [on this link](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10-million-password-list-top-10000.txt). 14 | 15 | You can also add a minimum password length that the user must have. To do this add the following line on the config file with an integer as a value: 16 | 17 | ```python 18 | c.NativeAuthenticator.minimum_password_length = 10 19 | ``` 20 | 21 | If any of this configuration is available, the user will receive this message on SignUp: 22 | 23 | ![](_static/wrong_signup.png) 24 | 25 | ## Block users after failed logins 26 | 27 | One thing that can make systems more safe is to block users after a number of failed logins. With Native Authenticator you can add this feature by adding `allowed_failed_logins` on the config file. The default is 0, which means that the system will not block users ever. 28 | 29 | ```python 30 | c.NativeAuthenticator.allowed_failed_logins = 3 31 | ``` 32 | 33 | You can also define the number of seconds a user must wait before trying again. The default value is 600 seconds. 34 | 35 | ```python 36 | c.NativeAuthenticator.seconds_before_next_try = 1200 37 | ``` 38 | 39 | ![](_static/block_user_failed_logins.png) 40 | 41 | ## Disable SignUp 42 | 43 | By default Native Authenticator allows everyone to register user accounts. But you can add a option to disable signup. To do so, just add the following line to the config file: 44 | 45 | ```python 46 | c.NativeAuthenticator.enable_signup = False 47 | ``` 48 | 49 | ## Open SignUp 50 | 51 | By default all users that make sign up on Native Authenticator need an admin approval so 52 | they can actually log in the system. You can change this behavior by adding an option of 53 | open signup, where all users that do sign up can already log in the system. To do so, just add this line to the config file: 54 | 55 | ```python 56 | c.NativeAuthenticator.open_signup = True 57 | ``` 58 | 59 | ## Ask for extra information on SignUp 60 | 61 | Native Authenticator is based on username and password only. But if you need extra information about the users, you can add them on the sign up. 62 | For now, the only extra information you can ask is email. To do so, you can add the following line to the config file: 63 | 64 | ```python 65 | c.NativeAuthenticator.ask_email_on_signup = True 66 | ``` 67 | 68 | ## Use reCaptcha to prevent scripted SignUp attacks 69 | 70 | Since by default, anybody can sign up to the system, you may want to use the lightweight 71 | single-click "I am not a robot" checkbox provided by Google's reCAPTCHA v2 to reduce your 72 | risk from scripting attacks. 73 | To use this feature, you will need to [register with reCaptcha](https://www.google.com/recaptcha/admin/create) (you will need a Google account to do so). 74 | 75 | You can learn more about reCAPTCHA [here](https://developers.google.com/recaptcha/intro). 76 | If you would like to simply test this functionality without creating an account, you can do 77 | so as explained [here](https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do). 78 | Note that this test in itself does not provide actual security so please do **NOT** use 79 | these test credentials for your actual production system. 80 | 81 | To enable reCAPTCHA on signup, add the following two lines to the configuration file and 82 | substitute your own credentials. 83 | 84 | ```python 85 | c.NativeAuthenticator.recaptcha_key = "your key" 86 | c.NativeAuthenticator.recaptcha_secret = "your secret" 87 | ``` 88 | 89 | ## Allow self-serve approval 90 | 91 | By default, all users who sign up on NativeAuthenticator need a manual admin approval so they can actually log in the system. Or you can allow anybody without approval as described above with `open_signup`. 92 | Alternatively, depending on your situation, you may want something _like_ `open_signup` but only for users in your own organization. This is what this option permits. 93 | 94 | New users are still created as non-authorized, but they can self-authorize by navigating to a (cryptographically verified) URL which will be e-mailed to them _only_ if the provided email address matches the specified regular expression. 95 | 96 | For example, to allow any users who have an `example.com` email address to self-approve, you add the following to your configuration file: 97 | 98 | ```python 99 | c.NativeAuthenticator.allow_self_approval_for = '[^@]+@example\.com$' 100 | ``` 101 | 102 | Please note that activating this setting automatically also enables `ask_email_on_signup`. 103 | 104 | To use the code, you must also provide a secret key (i.e. an arbitrary string, not too short) to cryptographically sign the URL. To prevents attacks, it is crucial that this key stays secret. 105 | 106 | ```python 107 | c.NativeAuthenticator.secret_key = "your-arbitrary-key" 108 | ``` 109 | 110 | You should also customize the email sent to users with something as follows: 111 | 112 | ```python 113 | c.NativeAuthenticator.self_approval_email = ("from", "subject", "email body, including https://example.com{approval_url}") 114 | ``` 115 | 116 | Note that you need to specify the domain where JupyterHub is running (`example.com` in the code block above) as well as the port, if you are using a non-standard one (e.g. `8000`). 117 | Also the protocol must be the correct one you are serving your connections from (`https` in the example). 118 | 119 | Furthermore, you may specify the SMTP server to use for sending the email. You can do that with 120 | 121 | ```python 122 | c.NativeAuthenticator.self_approval_server = {'url': 'smtp.gmail.com', 'usr': 'myself', 'pwd': 'mypassword'} 123 | ``` 124 | 125 | If you do not specify a `self_approval_server`, it will attempt to use `localhost` without authentication. 126 | 127 | Using GMail (as in the example above) is entirely optional, any other SMTP server accepting password authentication also works. However, if you _do_ wish to use GMail as your SMTP server, you must also allow "less secure apps" for this to work, as described at [this link](https://support.google.com/accounts/answer/6010255). 128 | If you have 2FA enabled (with GMail, not NativeAuthenticator) you should disable it for JupyterHub to be able to send emails, as described [over here](https://support.google.com/accounts/answer/185833). 129 | Also see [this helpful StackExchange post](https://stackoverflow.com/questions/16512592/login-credentials-not-working-with-gmail-smtp) for additional GMail-specific SMTP details. 130 | 131 | Finally, the entire procedure so far will only correctly create and enable JupyterHub users. 132 | However, the people wishing to login as this users, will **also** need to have accounts on the system that is running Jupyterhub. If the system is one of the more common Linux distributions, adding the following to the configuration file will automatically create their Linux account the first time they log in JupyterHub. 133 | If the system where JupyterHub is running is another OS, such as BSD or Windows, the corresponding user creation command must be invoked instead of useradd with the appropriate arguments. 134 | 135 | ```python 136 | def pre_spawn_hook(spawner): 137 | username = spawner.user.name 138 | try: 139 | import pwd 140 | pwd.getpwnam(username) 141 | except KeyError: 142 | import subprocess 143 | subprocess.check_call(['useradd', '-ms', '/bin/bash', username]) 144 | 145 | c.Spawner.pre_spawn_hook = pre_spawn_hook 146 | ``` 147 | 148 | ## Mandatory acceptance of Terms of Service before SignUp 149 | 150 | You may require that users to click a checkbox agreeing to your TOS before they can sign up. This might be legally binding in some jurisditions. 151 | To do so, you only need to add the following line to your config file and provide a link the where users can find your TOS. 152 | 153 | ```python 154 | c.NativeAuthenticator.tos = 'I agree to the TOS' 155 | ``` 156 | 157 | ## Import users from FirstUse Authenticator 158 | 159 | If you are using [FirstUse Authenticator](https://github.com/jupyterhub/firstuseauthenticator) and wish to change to Native Authenticator, you can import users from that authenticator to Native authenticator with minimum work! 160 | 161 | To do so, you have to add the following line on the configuration file: 162 | 163 | ```python 164 | c.NativeAuthenticator.import_from_firstuse = True 165 | ``` 166 | 167 | **Remark: unless you have configured the open signup configuration, the users will be created but they will not be able to login, because they don't have authorization by default.** 168 | 169 | By default, Native Authenticator assumes that the path for the database is the same directory. If that's not the case, you can change the path the file through this variables: 170 | 171 | ```python 172 | c.NativeAuthenticator.firstuse_dbm_path = '/home/user/passwords.dbm' 173 | ``` 174 | 175 | Native Authenticator ensures that usernames are sanitized, so they won't have commas 176 | or white spaces. Additionaly, you can add password verification such as 177 | avoiding common passwords. If usernames or passwords imported from the 178 | FirstUse Authenticator don't comply with these verifications, the importating will raise an 179 | error. 180 | 181 | You can also remove FirstUse's database file after the importation to Native Authenticator, to avoid leaving unused files on the system. To do so, you must add the following line to the configuration file: 182 | 183 | ```python 184 | c.NativeAuthenticator.delete_firstuse_db_after_import = True 185 | ``` 186 | 187 | ## Add two factor authentication obligatory for users 188 | 189 | You can increase security making two factor authentication obligatory for all users. 190 | To do so, add the following line on the config file: 191 | 192 | ```python 193 | c.NativeAuthenticator.allow_2fa = True 194 | ``` 195 | 196 | Users will receive a message after signup with the two factor authentication code: 197 | 198 | ![](_static/signup-two-factor-auth.png) 199 | 200 | And login will now require the two factor authentication code as well: 201 | 202 | ![](_static/login-two-factor-auth.png) 203 | -------------------------------------------------------------------------------- /docs/source/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## Installation 4 | 5 | NativeAuthenticator is a authenticator plugin for [JupyterHub](https://github.com/jupyterhub/). 6 | 7 | It is available on [PyPI](https://pypi.org/project/jupyterhub-nativeauthenticator/). The easiest way to install it is via pip: 8 | 9 | ```shell 10 | pip install jupyterhub-nativeauthenticator 11 | ``` 12 | 13 | Alternatively, you can install this authenticator through the project's GitHub repository: 14 | 15 | ```shell 16 | git clone https://github.com/jupyterhub/nativeauthenticator.git 17 | cd nativeauthenticator 18 | pip install -e . 19 | ``` 20 | 21 | After running the installation method of your choice, you must create the configuration file for JupyterHub: 22 | 23 | ```shell 24 | jupyterhub --generate-config -f /etc/jupyterhub/jupyterhub_config.py 25 | ``` 26 | 27 | Also, change the default authenticator class to NativeAuthenticator: 28 | 29 | ```python 30 | c.JupyterHub.authenticator_class = 'native' 31 | ``` 32 | 33 | Lastly, you need to add the following to the configuration file as well: 34 | 35 | ```python 36 | import os, nativeauthenticator 37 | c.JupyterHub.template_paths = [f"{os.path.dirname(nativeauthenticator.__file__)}/templates/"] 38 | ``` 39 | 40 | Now you can run JupyterHub using the updated configuration file and start using JupyterHub with NativeAuthenticator: 41 | 42 | ```shell 43 | jupyterhub -f /etc/jupyterhub/jupyterhub_config.py 44 | ``` 45 | 46 | ## Default workflow 47 | 48 | A new user that wants access to a system running NativeAuthenticator must first visit the signup page and create a new account with a username and password. By default, this user will not have access to the system, they will need the authorization of an admin to actually be able to login the system. Thus, after executing the signup, the user will receive a message letting them know that their information was sent to an admin. 49 | 50 | ![](_static/native_auth_flow.png) 51 | 52 | The admin must access the authorization panel and authorize the user so they be able to login: 53 | 54 | ![](_static/authorization_area.png) 55 | 56 | ## Adding new users 57 | 58 | To create a new user one must go to `/hub/signup` and sign up with a username and a password. The information asked for on signup can change depending on admin configuration, but all fields are obligatory. By default, when a new user is created on the system they will need an administrator authorization to access the system. 59 | 60 | It is important to note that **admin accounts must also be created through signup**. However, usernames listed in the config file as admins (see below) will automatically have authorization to enter the system. 61 | 62 | ```python 63 | c.Authenticator.admin_users = {'username'} 64 | ``` 65 | 66 | ## Username restrictions 67 | 68 | Usernames cannot be empty or contain commas, spaces or slashes. If any of these apply, the user will receive an error and will not be able to sign up. 69 | 70 | ## Authorize, un-authorize or discard users 71 | 72 | To authorize new users to enter the system or to manage those that already have access to the system you can go to `/hub/authorize` while logged in as an admin user. Alternatively, you can click the "Authorize Users" element on your home page. Authorized users will have a green background with a button to un-authorize them while un-authorized users will have a white background and an authorization button. 73 | 74 | ![](_static/authorization_area.png) 75 | 76 | From here, you can also discard users that attempted to sign up but whom you do not want to authorize. Users that are discarded will not be notified. 77 | 78 | To delete existing (authorized) users, first un-authorize and then discard them. Note that while discarding users will delete them from the database for both JupyterHub and NativeAuthenticator, **it will not delete data for accounts on the machine that is running JupyterHub!** 79 | Make sure to delete these separately, otherwise someone else could sign up with the same username later and inadvertently gain access to data that is not theirs. 80 | 81 | ## Changing your own password 82 | 83 | Users that are logged in the system can easily change their password by going to: `/hub/change-password` or clicking the "Change Password" element on their home page. 84 | 85 | ![](_static/change_password_self.png) 86 | 87 | ## Changing a user's password as admin 88 | 89 | In case any user forgets or misplaces their account password, admins can reset it to a password of their choosing. Simply navigate to `/hub/change-password/SomeUserName` or click the "Change Password" element of that user in the authorization area. 90 | 91 | ![](_static/change_password_user.png) 92 | -------------------------------------------------------------------------------- /docs/source/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Errors & Troubleshooting 2 | 3 | These are some common problems that users sometimes run into and their respective solutions. 4 | 5 | If you find yourself running into issues that are not resolved with the advice found here, please consider [opening an issue or filing a bug report](https://github.com/jupyterhub/nativeauthenticator/issues). 6 | 7 | ## Unable to log in with admin account 8 | 9 | We often hear about problems with logging in with admin accounts on a fresh install. Note that adding an account into the `admin_users` configuration as shown below does not also create that account. 10 | You still need to sign up an account of that name and set a password (see also the [relevant documentation](https://native-authenticator.readthedocs.io/en/latest/quickstart.html#adding-new-users)). 11 | If the problem persists, make sure that your JupyterHub is using the correct configuration file. 12 | 13 | ```python 14 | c.Authenticator.admin_users = {'my-admin-account'} 15 | ``` 16 | 17 | ## Internal Server Errors (500) after upgrading to >= 1.0 18 | 19 | One possible reason for this is that you're using an older database that doesn't have all necessary columns in the `users_info` table, as the column `login_email_sent` was only introduced in version 1.0. 20 | You can verify this by looking into your system's journal (`journalctl`). If you find a line like the following with your error, then this is indeed the problem. 21 | 22 | ```shell 23 | # ... 24 | sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such column: users_info.login_email_sent 25 | # ... 26 | ``` 27 | 28 | To remedy this, you merely need to add the column to your `jupyterhub.sqlite` database with the command below. This is not done programatically on account of JupyterHub's SQL library [not being intended](https://docs.sqlalchemy.org/en/14/core/metadata.html#sqlalchemy.schema.Table.append_column) for a use-case such as this. They therefore recommend migrating the database manually. 29 | 30 | ```shell 31 | sqlite3 /path/to/your/jupyterhub.sqlite "ALTER TABLE users_info ADD login_email_sent Boolean NOT NULL DEFAULT (0)" 32 | ``` 33 | -------------------------------------------------------------------------------- /nativeauthenticator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``` 3 | """ 4 | 5 | from nativeauthenticator.nativeauthenticator import NativeAuthenticator 6 | 7 | __all__ = [NativeAuthenticator] 8 | -------------------------------------------------------------------------------- /nativeauthenticator/crypto/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Django Software Foundation and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /nativeauthenticator/crypto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/nativeauthenticator/crypto/__init__.py -------------------------------------------------------------------------------- /nativeauthenticator/crypto/crypto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django's standard crypto functions and utilities. 3 | """ 4 | 5 | import hashlib 6 | import hmac 7 | import secrets 8 | 9 | from .encoding import force_bytes 10 | 11 | 12 | class InvalidAlgorithm(ValueError): 13 | """Algorithm is not supported by hashlib.""" 14 | 15 | 16 | def salted_hmac(key_salt, value, secret, *, algorithm="sha1"): 17 | """ 18 | Return the HMAC of 'value', using a key generated from key_salt and a 19 | secret. Default algorithm is SHA1, 20 | but any algorithm name supported by hashlib can be passed. 21 | 22 | A different key_salt should be passed in for every application of HMAC. 23 | """ 24 | 25 | key_salt = force_bytes(key_salt) 26 | secret = force_bytes(secret) 27 | try: 28 | hasher = getattr(hashlib, algorithm) 29 | except AttributeError as e: 30 | raise InvalidAlgorithm( 31 | "%r is not an algorithm accepted by the hashlib module." % algorithm 32 | ) from e 33 | # We need to generate a derived key from our base key. We can do this by 34 | # passing the key_salt and our base key through a pseudo-random function. 35 | key = hasher(key_salt + secret).digest() 36 | # If len(key_salt + secret) > block size of the hash algorithm, the above 37 | # line is redundant and could be replaced by key = key_salt + secret, since 38 | # the hmac module does the same thing for keys longer than the block size. 39 | # However, we need to ensure that we *always* do this. 40 | return hmac.new(key, msg=force_bytes(value), digestmod=hasher) 41 | 42 | 43 | RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 44 | 45 | 46 | def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS): 47 | """ 48 | Return a securely generated random string. 49 | 50 | The bit length of the returned value can be calculated with the formula: 51 | log_2(len(allowed_chars)^length) 52 | 53 | For example, with default `allowed_chars` (26+26+10), this gives: 54 | * length: 12, bit length =~ 71 bits 55 | * length: 22, bit length =~ 131 bits 56 | """ 57 | return "".join(secrets.choice(allowed_chars) for i in range(length)) 58 | 59 | 60 | def constant_time_compare(val1, val2): 61 | """Return True if the two strings are equal, False otherwise.""" 62 | return secrets.compare_digest(force_bytes(val1), force_bytes(val2)) 63 | 64 | 65 | def pbkdf2(password, salt, iterations, dklen=0, digest=None): 66 | """Return the hash of password using pbkdf2.""" 67 | if digest is None: 68 | digest = hashlib.sha256 69 | dklen = dklen or None 70 | password = force_bytes(password) 71 | salt = force_bytes(salt) 72 | return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen) 73 | -------------------------------------------------------------------------------- /nativeauthenticator/crypto/encoding.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import datetime 3 | import locale 4 | from decimal import Decimal 5 | from urllib.parse import quote 6 | 7 | 8 | class DjangoUnicodeDecodeError(UnicodeDecodeError): 9 | def __init__(self, obj, *args): 10 | self.obj = obj 11 | super().__init__(*args) 12 | 13 | def __str__(self): 14 | return f"{super().__str__()}. You passed in {self.obj!r} ({type(self.obj)})" 15 | 16 | 17 | _PROTECTED_TYPES = ( 18 | type(None), 19 | int, 20 | float, 21 | Decimal, 22 | datetime.datetime, 23 | datetime.date, 24 | datetime.time, 25 | ) 26 | 27 | 28 | def is_protected_type(obj): 29 | """Determine if the object instance is of a protected type. 30 | 31 | Objects of protected types are preserved as-is when passed to 32 | force_str(strings_only=True). 33 | """ 34 | return isinstance(obj, _PROTECTED_TYPES) 35 | 36 | 37 | def force_str(s, encoding="utf-8", strings_only=False, errors="strict"): 38 | """ 39 | Similar to smart_str(), except that lazy instances are resolved to 40 | strings, rather than kept as lazy objects. 41 | 42 | If strings_only is True, don't convert (some) non-string-like objects. 43 | """ 44 | # Handle the common case first for performance reasons. 45 | if issubclass(type(s), str): 46 | return s 47 | if strings_only and is_protected_type(s): 48 | return s 49 | try: 50 | if isinstance(s, bytes): 51 | s = str(s, encoding, errors) 52 | else: 53 | s = str(s) 54 | except UnicodeDecodeError as e: 55 | raise DjangoUnicodeDecodeError(s, *e.args) 56 | return s 57 | 58 | 59 | def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"): 60 | """ 61 | Similar to smart_bytes, except that lazy instances are resolved to 62 | strings, rather than kept as lazy objects. 63 | 64 | If strings_only is True, don't convert (some) non-string-like objects. 65 | """ 66 | # Handle the common case first for performance reasons. 67 | if isinstance(s, bytes): 68 | if encoding == "utf-8": 69 | return s 70 | else: 71 | return s.decode("utf-8", errors).encode(encoding, errors) 72 | if strings_only and is_protected_type(s): 73 | return s 74 | if isinstance(s, memoryview): 75 | return bytes(s) 76 | return str(s).encode(encoding, errors) 77 | 78 | 79 | # List of byte values that uri_to_iri() decodes from percent encoding. 80 | # First, the unreserved characters from RFC 3986: 81 | _ascii_ranges = [[45, 46, 95, 126], range(65, 91), range(97, 123)] 82 | _hextobyte = { 83 | (fmt % char).encode(): bytes((char,)) 84 | for ascii_range in _ascii_ranges 85 | for char in ascii_range 86 | for fmt in ["%02x", "%02X"] 87 | } 88 | # And then everything above 128, because bytes ≥ 128 are part of multibyte 89 | # Unicode characters. 90 | _hexdig = "0123456789ABCDEFabcdef" 91 | _hextobyte.update( 92 | {(a + b).encode(): bytes.fromhex(a + b) for a in _hexdig[8:] for b in _hexdig} 93 | ) 94 | 95 | 96 | def uri_to_iri(uri): 97 | """ 98 | Convert a Uniform Resource Identifier(URI) into an Internationalized 99 | Resource Identifier(IRI). 100 | 101 | This is the algorithm from section 3.2 of RFC 3987, excluding step 4. 102 | 103 | Take an URI in ASCII bytes (e.g. '/I%20%E2%99%A5%20Django/') and return 104 | a string containing the encoded result (e.g. '/I%20♥%20Django/'). 105 | """ 106 | if uri is None: 107 | return uri 108 | uri = force_bytes(uri) 109 | # Fast selective unquote: First, split on '%' and then starting with the 110 | # second block, decode the first 2 bytes if they represent a hex code to 111 | # decode. The rest of the block is the part after '%AB', not containing 112 | # any '%'. Add that to the output without further processing. 113 | bits = uri.split(b"%") 114 | if len(bits) == 1: 115 | iri = uri 116 | else: 117 | parts = [bits[0]] 118 | append = parts.append 119 | hextobyte = _hextobyte 120 | for item in bits[1:]: 121 | hex = item[:2] 122 | if hex in hextobyte: 123 | append(hextobyte[item[:2]]) 124 | append(item[2:]) 125 | else: 126 | append(b"%") 127 | append(item) 128 | iri = b"".join(parts) 129 | return repercent_broken_unicode(iri).decode() 130 | 131 | 132 | def escape_uri_path(path): 133 | """ 134 | Escape the unsafe characters from the path portion of a Uniform Resource 135 | Identifier (URI). 136 | """ 137 | # These are the "reserved" and "unreserved" characters specified in 138 | # sections 2.2 and 2.3 of RFC 2396: 139 | # reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | "," 140 | # unreserved = alphanum | mark 141 | # mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")" 142 | # The list of safe characters here is constructed subtracting ";", "=", 143 | # and "?" according to section 3.3 of RFC 2396. 144 | # The reason for not subtracting and escaping "/" is that we are escaping 145 | # the entire path, not a path segment. 146 | return quote(path, safe="/:@&+$,-_.!~*'()") 147 | 148 | 149 | def punycode(domain): 150 | """Return the Punycode of the given domain if it's non-ASCII.""" 151 | return domain.encode("idna").decode("ascii") 152 | 153 | 154 | def repercent_broken_unicode(path): 155 | """ 156 | As per section 3.2 of RFC 3987, step three of converting a URI into an IRI, 157 | repercent-encode any octet produced that is not part of a strictly legal 158 | UTF-8 octet sequence. 159 | """ 160 | while True: 161 | try: 162 | path.decode() 163 | except UnicodeDecodeError as e: 164 | # CVE-2019-14235: A recursion shouldn't be used since the exception 165 | # handling uses massive amounts of memory 166 | repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~") 167 | path = path[: e.start] + repercent.encode() + path[e.end :] 168 | else: 169 | return path 170 | 171 | 172 | def filepath_to_uri(path): 173 | """Convert a file system path to a URI portion that is suitable for 174 | inclusion in a URL. 175 | 176 | Encode certain chars that would normally be recognized as special chars 177 | for URIs. Do not encode the ' character, as it is a valid character 178 | within URIs. See the encodeURIComponent() JavaScript function for details. 179 | """ 180 | if path is None: 181 | return path 182 | # I know about `os.sep` and `os.altsep` but I want to leave 183 | # some flexibility for hardcoding separators. 184 | return quote(str(path).replace("\\", "/"), safe="/~!*()'") 185 | 186 | 187 | def get_system_encoding(): 188 | """ 189 | The encoding of the default system locale. Fallback to 'ascii' if the 190 | #encoding is unsupported by Python or could not be determined. See tickets 191 | #10335 and #5846. 192 | """ 193 | try: 194 | encoding = locale.getdefaultlocale()[1] or "ascii" 195 | codecs.lookup(encoding) 196 | except Exception: 197 | encoding = "ascii" 198 | return encoding 199 | 200 | 201 | DEFAULT_LOCALE_ENCODING = get_system_encoding() 202 | -------------------------------------------------------------------------------- /nativeauthenticator/crypto/signing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for creating and restoring url-safe signed JSON objects. 3 | 4 | The format used looks like this: 5 | 6 | >>> signing.dumps("hello") 7 | 'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk' 8 | 9 | There are two components here, separated by a ':'. The first component is a 10 | URLsafe base64 encoded JSON of the object passed to dumps(). The second 11 | component is a base64 encoded hmac/SHA1 hash of "$first_component:$secret" 12 | 13 | signing.loads(s) checks the signature and returns the deserialized object. 14 | If the signature fails, a BadSignature exception is raised. 15 | 16 | >>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk") 17 | 'hello' 18 | >>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422n-modified") 19 | ... 20 | BadSignature: Signature failed: ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422n-modified 21 | 22 | You can optionally compress the JSON prior to base64 encoding it to save 23 | space, using the compress=True argument. This checks if compression actually 24 | helps and only applies compression if the result is a shorter string: 25 | 26 | >>> signing.dumps(list(range(1, 20)), compress=True) 27 | '.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ' 28 | 29 | The fact that the string is compressed is signalled by the prefixed '.' at the 30 | start of the base64 JSON. 31 | 32 | There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'. 33 | These functions make use of all of them. 34 | """ 35 | 36 | import base64 37 | import datetime 38 | import json 39 | import re 40 | import time 41 | import zlib 42 | 43 | from .crypto import constant_time_compare, salted_hmac 44 | 45 | _SEP_UNSAFE = re.compile(r"^[A-z0-9-_=]*$") 46 | BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 47 | 48 | 49 | class BadSignature(Exception): 50 | """Signature does not match.""" 51 | 52 | 53 | class SignatureExpired(BadSignature): 54 | """Signature timestamp is older than required max_age.""" 55 | 56 | 57 | def b62_encode(s): 58 | if s == 0: 59 | return "0" 60 | sign = "-" if s < 0 else "" 61 | s = abs(s) 62 | encoded = "" 63 | while s > 0: 64 | s, remainder = divmod(s, 62) 65 | encoded = BASE62_ALPHABET[remainder] + encoded 66 | return sign + encoded 67 | 68 | 69 | def b62_decode(s): 70 | if s == "0": 71 | return 0 72 | sign = 1 73 | if s[0] == "-": 74 | s = s[1:] 75 | sign = -1 76 | decoded = 0 77 | for digit in s: 78 | decoded = decoded * 62 + BASE62_ALPHABET.index(digit) 79 | return sign * decoded 80 | 81 | 82 | def b64_encode(s): 83 | return base64.urlsafe_b64encode(s).strip(b"=") 84 | 85 | 86 | def b64_decode(s): 87 | pad = b"=" * (-len(s) % 4) 88 | return base64.urlsafe_b64decode(s + pad) 89 | 90 | 91 | def base64_hmac(salt, value, key, algorithm="sha1"): 92 | return b64_encode( 93 | salted_hmac(salt, value, key, algorithm=algorithm).digest() 94 | ).decode() 95 | 96 | 97 | class JSONSerializer: 98 | """ 99 | Simple wrapper around json to be used in signing.dumps and 100 | signing.loads. 101 | """ 102 | 103 | def dumps(self, obj): 104 | return json.dumps(obj, separators=(",", ":")).encode("latin-1") 105 | 106 | def loads(self, data): 107 | return json.loads(data.decode("latin-1")) 108 | 109 | 110 | def dumps( 111 | obj, key=None, salt="django.core.signing", serializer=JSONSerializer, compress=False 112 | ): 113 | """ 114 | Return URL-safe, hmac signed base64 compressed JSON string. If key is 115 | None, raises Exception. The hmac algorithm is the default 116 | Signer algorithm. 117 | 118 | If compress is True (not the default), check if compressing using zlib can 119 | save some space. Prepend a '.' to signify compression. This is included 120 | in the signature, to protect against zip bombs. 121 | 122 | Salt can be used to namespace the hash, so that a signed string is 123 | only valid for a given namespace. Leaving this at the default 124 | value or re-using a salt value across different parts of your 125 | application without good cause is a security risk. 126 | 127 | The serializer is expected to return a bytestring. 128 | """ 129 | return TimestampSigner(key, salt=salt).sign_object( 130 | obj, serializer=serializer, compress=compress 131 | ) 132 | 133 | 134 | def loads( 135 | s, key=None, salt="django.core.signing", serializer=JSONSerializer, max_age=None 136 | ): 137 | """ 138 | Reverse of dumps(), raise BadSignature if signature fails. 139 | 140 | The serializer is expected to accept a bytestring. 141 | """ 142 | return TimestampSigner(key, salt=salt).unsign_object( 143 | s, serializer=serializer, max_age=max_age 144 | ) 145 | 146 | 147 | class Signer: 148 | def __init__(self, key, sep=":", salt=None, algorithm=None): 149 | self.key = key 150 | self.sep = sep 151 | if _SEP_UNSAFE.match(self.sep): 152 | raise ValueError( 153 | "Unsafe Signer separator: %r (cannot be empty or consist of " 154 | "only A-z0-9-_=)" % sep, 155 | ) 156 | self.salt = salt or "{}.{}".format( 157 | self.__class__.__module__, self.__class__.__name__ 158 | ) 159 | self.algorithm = algorithm or "sha256" 160 | 161 | def signature(self, value): 162 | return base64_hmac( 163 | self.salt + "signer", value, self.key, algorithm=self.algorithm 164 | ) 165 | 166 | def sign(self, value): 167 | return f"{value}{self.sep}{self.signature(value)}" 168 | 169 | def unsign(self, signed_value): 170 | if self.sep not in signed_value: 171 | raise BadSignature('No "%s" found in value' % self.sep) 172 | value, sig = signed_value.rsplit(self.sep, 1) 173 | if constant_time_compare(sig, self.signature(value)): 174 | return value 175 | raise BadSignature('Signature "%s" does not match' % sig) 176 | 177 | def sign_object(self, obj, serializer=JSONSerializer, compress=False): 178 | """ 179 | Return URL-safe, hmac signed base64 compressed JSON string. 180 | 181 | If compress is True (not the default), check if compressing using zlib 182 | can save some space. Prepend a '.' to signify compression. This is 183 | included in the signature, to protect against zip bombs. 184 | 185 | The serializer is expected to return a bytestring. 186 | """ 187 | data = serializer().dumps(obj) 188 | # Flag for if it's been compressed or not. 189 | is_compressed = False 190 | 191 | if compress: 192 | # Avoid zlib dependency unless compress is being used. 193 | compressed = zlib.compress(data) 194 | if len(compressed) < (len(data) - 1): 195 | data = compressed 196 | is_compressed = True 197 | base64d = b64_encode(data).decode() 198 | if is_compressed: 199 | base64d = "." + base64d 200 | return self.sign(base64d) 201 | 202 | def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs): 203 | # Signer.unsign() returns str but base64 and zlib compression operate 204 | # on bytes. 205 | base64d = self.unsign(signed_obj, **kwargs).encode() 206 | decompress = base64d[:1] == b"." 207 | if decompress: 208 | # It's compressed; uncompress it first. 209 | base64d = base64d[1:] 210 | data = b64_decode(base64d) 211 | if decompress: 212 | data = zlib.decompress(data) 213 | return serializer().loads(data) 214 | 215 | 216 | class TimestampSigner(Signer): 217 | def timestamp(self): 218 | return b62_encode(int(time.time())) 219 | 220 | def sign(self, value): 221 | value = f"{value}{self.sep}{self.timestamp()}" 222 | return super().sign(value) 223 | 224 | def unsign(self, value, max_age=None): 225 | """ 226 | Retrieve original value and check it wasn't signed more 227 | than max_age seconds ago. 228 | """ 229 | result = super().unsign(value) 230 | value, timestamp = result.rsplit(self.sep, 1) 231 | timestamp = b62_decode(timestamp) 232 | if max_age is not None: 233 | if isinstance(max_age, datetime.timedelta): 234 | max_age = max_age.total_seconds() 235 | # Check timestamp is not older than max_age 236 | age = time.time() - timestamp 237 | if age > max_age: 238 | raise SignatureExpired(f"Signature age {age} > {max_age} seconds") 239 | return value 240 | -------------------------------------------------------------------------------- /nativeauthenticator/handlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import date, datetime 3 | from datetime import timezone as tz 4 | 5 | from jinja2 import ChoiceLoader, FileSystemLoader 6 | from jupyterhub.handlers import BaseHandler 7 | from jupyterhub.handlers.login import LoginHandler 8 | 9 | try: 10 | from jupyterhub.scopes import needs_scope 11 | 12 | admin_users_scope = needs_scope("admin:users") 13 | except ImportError: 14 | from jupyterhub.utils import admin_only 15 | 16 | admin_users_scope = admin_only 17 | 18 | import requests 19 | from tornado import web 20 | from tornado.escape import url_escape 21 | from tornado.httputil import url_concat 22 | 23 | from .orm import UserInfo 24 | 25 | TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates") 26 | 27 | 28 | class LocalBase(BaseHandler): 29 | """Base class that all handlers below extend.""" 30 | 31 | _template_dir_registered = False 32 | 33 | def __init__(self, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | if not LocalBase._template_dir_registered: 36 | self.log.debug("Adding %s to template path", TEMPLATE_DIR) 37 | loader = FileSystemLoader([TEMPLATE_DIR]) 38 | env = self.settings["jinja2_env"] 39 | previous_loader = env.loader 40 | env.loader = ChoiceLoader([previous_loader, loader]) 41 | LocalBase._template_dir_registered = True 42 | 43 | 44 | class SignUpHandler(LocalBase): 45 | """Responsible for rendering the /hub/signup page, validating input to that 46 | page, account creation and giving accurate feedback to users.""" 47 | 48 | async def get(self): 49 | """Rendering on GET requests ("normal" visits).""" 50 | 51 | # 404 if signup is not currently open. 52 | if not self.authenticator.enable_signup: 53 | raise web.HTTPError(404) 54 | 55 | # Render page with relevant settings from the authenticator. 56 | html = await self.render_template( 57 | "signup.html", 58 | ask_email=self.authenticator.ask_email_on_signup, 59 | two_factor_auth=self.authenticator.allow_2fa, 60 | recaptcha_key=self.authenticator.recaptcha_key, 61 | tos=self.authenticator.tos, 62 | ) 63 | self.finish(html) 64 | 65 | def get_result_message( 66 | self, 67 | user, 68 | assume_user_is_human, 69 | username_already_taken, 70 | confirmation_matches, 71 | user_is_admin, 72 | ): 73 | """Helper function to discern exactly what message and alert level are 74 | appropriate to display as a response. Called from post() below.""" 75 | 76 | # Error if failed captcha. 77 | if not assume_user_is_human: 78 | alert = "alert-danger" 79 | message = "You failed the reCAPTCHA. Please try again" 80 | # Error if username is taken. 81 | elif username_already_taken: 82 | alert = "alert-danger" 83 | message = ( 84 | "Something went wrong!\nIt appears that this " 85 | "username is already in use. Please try again " 86 | "with a different username." 87 | ) 88 | # Error if confirmation didn't match password. 89 | elif not confirmation_matches: 90 | alert = "alert-danger" 91 | message = "Your password did not match the confirmation. Please try again." 92 | # Error if user creation was not successful. 93 | elif not user: 94 | alert = "alert-danger" 95 | minimum_password_length = self.authenticator.minimum_password_length 96 | # Error if minimum password length is > 0. 97 | if minimum_password_length > 0: 98 | message = ( 99 | "Something went wrong!\nBe sure your username " 100 | "does not contain spaces, commas or slashes, your " 101 | f"password has at least {minimum_password_length} " 102 | "characters and is not too common." 103 | ) 104 | # Error if minimum password length is 0. 105 | else: 106 | message = ( 107 | "Something went wrong!\nBe sure your username " 108 | "does not contain spaces, commas or slashes and your " 109 | "password is not too common." 110 | ) 111 | # If user creation went through & open-signup is enabled, success. 112 | # If user creation went through & the user is an admin, also success. 113 | elif (user is not None) and (self.authenticator.open_signup or user_is_admin): 114 | alert = "alert-success" 115 | message = ( 116 | "The signup was successful! You can now go to " 117 | "the home page and log in to the system." 118 | ) 119 | else: 120 | # Default response if nothing goes wrong. 121 | alert = "alert-info" 122 | message = "Your information has been sent to the admin." 123 | 124 | if (user is not None) and user.login_email_sent: 125 | message = ( 126 | "The signup was successful! Check your email " 127 | "to authorize your access." 128 | ) 129 | 130 | return alert, message 131 | 132 | async def post(self): 133 | """Rendering on POST requests (signup visits with data attached).""" 134 | 135 | # 404 if users aren't allowed to sign up. 136 | if not self.authenticator.enable_signup: 137 | raise web.HTTPError(404) 138 | 139 | if not self.authenticator.recaptcha_key: 140 | # If this option is not enabled, we proceed under 141 | # the assumption that the user is human. 142 | assume_user_is_human = True 143 | else: 144 | # If this option _is_ enabled, we assume the user 145 | # is _not_ human until we know otherwise. 146 | assume_user_is_human = False 147 | 148 | recaptcha_response = self.get_body_argument( 149 | "g-recaptcha-response", strip=True 150 | ) 151 | if recaptcha_response != "": 152 | data = { 153 | "secret": self.authenticator.recaptcha_secret, 154 | "response": recaptcha_response, 155 | } 156 | siteverify_url = "https://www.google.com/recaptcha/api/siteverify" 157 | validation_status = requests.post(siteverify_url, data=data) 158 | 159 | assume_user_is_human = validation_status.json().get("success") 160 | 161 | # Logging result 162 | if assume_user_is_human: 163 | self.authenticator.log.info("Passed reCaptcha") 164 | else: 165 | self.authenticator.log.error("Failed reCaptcha") 166 | 167 | # initialize user_info 168 | user_info = { 169 | "username": self.get_body_argument("username", strip=False), 170 | "password": self.get_body_argument("signup_password", strip=False), 171 | "email": self.get_body_argument("email", "", strip=False), 172 | "has_2fa": bool(self.get_body_argument("2fa", "", strip=False)), 173 | } 174 | username = user_info["username"] 175 | 176 | # summarize info 177 | password = self.get_body_argument("signup_password", strip=False) 178 | confirmation = self.get_body_argument( 179 | "signup_password_confirmation", strip=False 180 | ) 181 | confirmation_matches = password == confirmation 182 | user_is_admin = username in self.authenticator.admin_users 183 | username_already_taken = self.authenticator.user_exists(username) 184 | 185 | # if everything seems ok, create a user 186 | user = None 187 | if assume_user_is_human and not username_already_taken and confirmation_matches: 188 | user = self.authenticator.create_user(**user_info) 189 | 190 | # Call helper function from above for precise alert-level and message. 191 | alert, message = self.get_result_message( 192 | user, 193 | assume_user_is_human, 194 | username_already_taken, 195 | confirmation_matches, 196 | user_is_admin, 197 | ) 198 | 199 | otp_secret, user_2fa = "", "" 200 | if user: 201 | otp_secret = user.otp_secret 202 | user_2fa = user.has_2fa 203 | 204 | html = await self.render_template( 205 | "signup.html", 206 | ask_email=self.authenticator.ask_email_on_signup, 207 | result_message=message, 208 | alert=alert, 209 | two_factor_auth=self.authenticator.allow_2fa, 210 | two_factor_auth_user=user_2fa, 211 | two_factor_auth_value=otp_secret, 212 | recaptcha_key=self.authenticator.recaptcha_key, 213 | tos=self.authenticator.tos, 214 | ) 215 | self.finish(html) 216 | 217 | 218 | class AuthorizationAreaHandler(LocalBase): 219 | """Responsible for rendering the /hub/authorize page.""" 220 | 221 | @admin_users_scope 222 | async def get(self): 223 | html = await self.render_template( 224 | "authorization-area.html", 225 | ask_email=self.authenticator.ask_email_on_signup, 226 | users=self.db.query(UserInfo).all(), 227 | ) 228 | self.finish(html) 229 | 230 | 231 | class ToggleAuthorizationHandler(LocalBase): 232 | """Responsible for the authorize/[someusername] page, 233 | which immediately redirects after toggling the 234 | respective user's authorization status.""" 235 | 236 | @admin_users_scope 237 | async def get(self, slug): 238 | UserInfo.change_authorization(self.db, slug) 239 | self.redirect(self.hub.base_url + "authorize#" + slug) 240 | 241 | 242 | class EmailAuthorizationHandler(LocalBase): 243 | """Responsible for the confirm/[someusername] validation of 244 | cryptographic URLs for the self-serve-approval feature.""" 245 | 246 | async def get(self, slug): 247 | """Called on GET requests. The slug is given in the URL after /confirm/. 248 | It's a long-ish string of letters encoding which user this authorization 249 | link is for and until when it is valid, cryptographically signed by the 250 | secret key given in the configuration file. This is done to make the 251 | approval URL not-reverse-engineer-able.""" 252 | 253 | slug_validation_successful = False 254 | message = "Invalid URL" 255 | 256 | if self.authenticator.allow_self_approval_for: 257 | try: 258 | data = EmailAuthorizationHandler.validate_slug( 259 | slug, self.authenticator.secret_key 260 | ) 261 | slug_validation_successful = True 262 | except ValueError: 263 | pass 264 | 265 | if slug_validation_successful: 266 | username = data["username"] 267 | usr = UserInfo.find(self.db, username) 268 | 269 | if not usr.is_authorized: 270 | UserInfo.change_authorization(self.db, username) 271 | message = f"{username} has been authorized!" 272 | else: 273 | message = f"{username} was already authorized." 274 | 275 | html = await self.render_template( 276 | "my_message.html", 277 | message=message, 278 | ) 279 | self.finish(html) 280 | 281 | # static method so it can be easily tested without initializate the class 282 | @staticmethod 283 | def validate_slug(slug, key): 284 | """This function makes sure the given slug is 285 | not expired and has a valid signature.""" 286 | from .crypto.signing import BadSignature, Signer 287 | 288 | s = Signer(key) 289 | try: 290 | obj = s.unsign_object(slug) 291 | except BadSignature as e: 292 | raise ValueError(e) 293 | 294 | # the following it is not supported in earlier versions of python 295 | # obj["expire"] = datetime.fromisoformat(obj["expire"]) 296 | 297 | # format="%Y-%m-%dT%H:%M:%S.%f" 298 | datestr, timestr = obj["expire"].split("T") 299 | 300 | # before the T 301 | year_month_day = datestr.split("-") 302 | dateobj = date( 303 | int(year_month_day[0]), int(year_month_day[1]), int(year_month_day[2]) 304 | ) 305 | 306 | # after the T 307 | # manually parsing iso-8601 times with a colon in the timezone 308 | # since the strptime does not support it 309 | if timestr[-3] == ":": 310 | timestr = timestr[:-3] + timestr[-2:] 311 | timeobj = datetime.strptime(timestr, "%H:%M:%S.%f%z").timetz() 312 | 313 | obj["expire"] = datetime.combine(dateobj, timeobj) 314 | 315 | if datetime.now(tz.utc) > obj["expire"]: 316 | raise ValueError("The URL has expired") 317 | 318 | return obj 319 | 320 | 321 | class ChangePasswordHandler(LocalBase): 322 | """Responsible for rendering the /hub/change-password page where users can change 323 | their own password. Both on GET requests, when simply navigating to the site, 324 | and on POST requests, with the data to change the password attached.""" 325 | 326 | @web.authenticated 327 | async def get(self): 328 | """Rendering on GET requests ("normal" visits).""" 329 | 330 | user = await self.get_current_user() 331 | html = await self.render_template( 332 | "change-password.html", 333 | user_name=user.name, 334 | ) 335 | self.finish(html) 336 | 337 | @web.authenticated 338 | async def post(self): 339 | """Rendering on POST requests (requests with data attached).""" 340 | 341 | user = await self.get_current_user() 342 | old_password = self.get_body_argument("old_password", strip=False) 343 | new_password = self.get_body_argument("new_password", strip=False) 344 | confirmation = self.get_body_argument("new_password_confirmation", strip=False) 345 | 346 | correct_password_provided = self.authenticator.get_user( 347 | user.name 348 | ).is_valid_password(old_password) 349 | 350 | new_password_matches_confirmation = new_password == confirmation 351 | 352 | if not correct_password_provided: 353 | alert = "alert-danger" 354 | message = "Your current password was incorrect. Please try again." 355 | elif not new_password_matches_confirmation: 356 | alert = "alert-danger" 357 | message = ( 358 | "Your new password didn't match the confirmation. Please try again." 359 | ) 360 | else: 361 | success = self.authenticator.change_password(user.name, new_password) 362 | if success: 363 | alert = "alert-success" 364 | message = "Your password has been changed successfully!" 365 | else: 366 | alert = "alert-danger" 367 | minimum_password_length = self.authenticator.minimum_password_length 368 | # Error if minimum password length is > 0. 369 | if minimum_password_length > 0: 370 | message = ( 371 | "Something went wrong!\n" 372 | "Be sure your new password has at least" 373 | f" {minimum_password_length} characters " 374 | "and is not too common." 375 | ) 376 | # Error if minimum password length is 0. 377 | else: 378 | message = ( 379 | "Something went wrong!\n" 380 | "Be sure your new password is not too common." 381 | ) 382 | 383 | html = await self.render_template( 384 | "change-password.html", 385 | user_name=user.name, 386 | result_message=message, 387 | alert=alert, 388 | ) 389 | self.finish(html) 390 | 391 | 392 | class ChangePasswordAdminHandler(LocalBase): 393 | """Responsible for rendering the /hub/change-password/[someusername] page where 394 | admins can change any user's password. Both on GET requests, when simply 395 | navigating to the site, and on POST requests, with the data to change the 396 | password attached.""" 397 | 398 | @admin_users_scope 399 | async def get(self, user_name): 400 | """Rendering on GET requests ("normal" visits).""" 401 | 402 | if not self.authenticator.user_exists(user_name): 403 | raise web.HTTPError(404) 404 | 405 | html = await self.render_template( 406 | "change-password-admin.html", 407 | user_name=user_name, 408 | ) 409 | self.finish(html) 410 | 411 | @admin_users_scope 412 | async def post(self, user_name): 413 | """Rendering on POST requests (requests with data attached).""" 414 | 415 | new_password = self.get_body_argument("new_password", strip=False) 416 | confirmation = self.get_body_argument("new_password_confirmation", strip=False) 417 | 418 | new_password_matches_confirmation = new_password == confirmation 419 | 420 | if not new_password_matches_confirmation: 421 | alert = "alert-danger" 422 | message = ( 423 | "The new password didn't match the confirmation. Please try again." 424 | ) 425 | else: 426 | success = self.authenticator.change_password(user_name, new_password) 427 | if success: 428 | alert = "alert-success" 429 | message = f"The password for {user_name} has been changed successfully" 430 | else: 431 | alert = "alert-danger" 432 | minimum_password_length = self.authenticator.minimum_password_length 433 | # Error if minimum password length is > 0. 434 | if minimum_password_length > 0: 435 | message = ( 436 | "Something went wrong!\nBe sure the new password " 437 | f"for {user_name} has at least {minimum_password_length} " 438 | "characters and is not too common." 439 | ) 440 | # Error if minimum password length is 0. 441 | else: 442 | message = ( 443 | "Something went wrong!\nBe sure the new password " 444 | f"for {user_name} is not too common." 445 | ) 446 | 447 | html = await self.render_template( 448 | "change-password-admin.html", 449 | user_name=user_name, 450 | result_message=message, 451 | alert=alert, 452 | ) 453 | self.finish(html) 454 | 455 | 456 | class LoginHandler(LoginHandler, LocalBase): 457 | """Responsible for rendering the /hub/login page.""" 458 | 459 | def _render(self, login_error=None, username=None): 460 | """For 'normal' rendering.""" 461 | 462 | return self.render_template( 463 | "native-login.html", 464 | next=url_escape(self.get_argument("next", default="")), 465 | username=username, 466 | login_error=login_error, 467 | custom_html=self.authenticator.custom_html, 468 | login_url=self.settings["login_url"], 469 | enable_signup=self.authenticator.enable_signup, 470 | two_factor_auth=self.authenticator.allow_2fa, 471 | authenticator_login_url=url_concat( 472 | self.authenticator.login_url(self.hub.base_url), 473 | {"next": self.get_argument("next", "")}, 474 | ), 475 | ) 476 | 477 | async def post(self): 478 | """Rendering on POST requests (requests with data attached).""" 479 | 480 | # parse the arguments dict 481 | data = {} 482 | for arg in self.request.arguments: 483 | data[arg] = self.get_argument(arg, strip=False) 484 | 485 | auth_timer = self.statsd.timer("login.authenticate").start() 486 | user = await self.login_user(data) 487 | auth_timer.stop(send=False) 488 | 489 | if user: 490 | # register current user for subsequent requests to user 491 | # (e.g. logging the request) 492 | self._jupyterhub_user = user 493 | self.redirect(self.get_next_url(user)) 494 | else: 495 | # default error mesage on unsuccessful login 496 | error = "Invalid username or password." 497 | 498 | # check is user exists and has correct password, 499 | # and is just not authorised 500 | username = data["username"] 501 | user = self.authenticator.get_user(username) 502 | if user is not None: 503 | if user.is_valid_password(data["password"]) and not user.is_authorized: 504 | error = ( 505 | f"User {username} has not been authorized " 506 | "by an administrator yet." 507 | ) 508 | 509 | html = await self._render(login_error=error, username=username) 510 | self.finish(html) 511 | 512 | 513 | class DiscardHandler(LocalBase): 514 | """Responsible for the /hub/discard/[someusername] page 515 | that immediately redirects after discarding a user 516 | from the database.""" 517 | 518 | @admin_users_scope 519 | async def get(self, user_name): 520 | user = self.authenticator.get_user(user_name) 521 | if user is not None: 522 | if not user.is_authorized: 523 | # Delete user from NativeAuthenticator db table (users_info) 524 | user = type("User", (), {"name": user_name}) 525 | self.authenticator.delete_user(user) 526 | 527 | # Also delete user from jupyterhub registry, if present 528 | if self.users.get(user_name) is not None: 529 | self.users.delete(user_name) 530 | 531 | self.redirect(self.hub.base_url + "authorize") 532 | -------------------------------------------------------------------------------- /nativeauthenticator/nativeauthenticator.py: -------------------------------------------------------------------------------- 1 | import dbm 2 | import os 3 | import re 4 | import smtplib 5 | from datetime import datetime, timedelta 6 | from datetime import timezone as tz 7 | from email.message import EmailMessage 8 | from pathlib import Path 9 | 10 | import bcrypt 11 | from jupyterhub.auth import Authenticator 12 | from sqlalchemy import inspect 13 | from tornado import web 14 | from traitlets import Bool, Dict, Integer, Tuple, Unicode 15 | 16 | from .crypto.signing import Signer 17 | from .handlers import ( 18 | AuthorizationAreaHandler, 19 | ChangePasswordAdminHandler, 20 | ChangePasswordHandler, 21 | DiscardHandler, 22 | EmailAuthorizationHandler, 23 | LoginHandler, 24 | SignUpHandler, 25 | ToggleAuthorizationHandler, 26 | ) 27 | from .orm import UserInfo 28 | 29 | 30 | class NativeAuthenticator(Authenticator): 31 | COMMON_PASSWORDS = None 32 | recaptcha_key = Unicode( 33 | config=True, 34 | help=( 35 | "Your key to enable reCAPTCHA as described at " 36 | "https://developers.google.com/recaptcha/intro" 37 | ), 38 | ).tag(default=None) 39 | 40 | recaptcha_secret = Unicode( 41 | config=True, 42 | help=( 43 | "Your secret to enable reCAPTCHA as described at " 44 | "https://developers.google.com/recaptcha/intro" 45 | ), 46 | ).tag(default=None) 47 | 48 | tos = Unicode( 49 | config=True, 50 | help=("The HTML to present next to the Term of Service " "checkbox"), 51 | ).tag(default=None) 52 | 53 | self_approval_server = Dict( 54 | config=True, 55 | help=( 56 | "SMTP server information as a dictionary of 'url', 'usr'" 57 | "and 'pwd' to use for sending email, e.g." 58 | "self_approval_server={'url': 'smtp.gmail.com', 'usr': 'myself'" 59 | "'pwd': 'mypassword'}" 60 | ), 61 | ).tag(default=None) 62 | 63 | secret_key = Unicode( 64 | config=True, 65 | help=( 66 | "Secret key to cryptographically sign the " 67 | "self-approved URL (if allow_self_approval is utilized)" 68 | ), 69 | ).tag(default="") 70 | 71 | allow_self_approval_for = Unicode( 72 | allow_none=True, 73 | config=True, 74 | help=( 75 | "Use self-service authentication (rather than " 76 | "admin-based authentication) for users whose " 77 | "email match this patter. Note that this forces " 78 | "ask_email_on_signup to be True." 79 | ), 80 | ).tag(default=None) 81 | 82 | self_approval_email = Tuple( 83 | Unicode(), 84 | Unicode(), 85 | Unicode(), 86 | config=True, 87 | default_value=( 88 | "do-not-reply@my-domain.com", 89 | "Welcome to JupyterHub on my-domain", 90 | ( 91 | "Your JupyterHub account on my-domain has been " 92 | "created, but it's inactive.\n" 93 | "If you did not create the account yourself, " 94 | "IGNORE this message:\n" 95 | "somebody is trying to use your email to get an " 96 | "unathorized account!\n" 97 | "If you did create the account yourself, navigate " 98 | "to {approval_url} to activate it.\n" 99 | ), 100 | ), 101 | ) 102 | 103 | check_common_password = Bool( 104 | config=True, 105 | help=( 106 | "Creates a verification of password strength " 107 | "when a new user makes signup" 108 | ), 109 | ).tag(default=False) 110 | 111 | minimum_password_length = Integer( 112 | config=True, 113 | help=("Check if the length of the password is at least this size on " "signup"), 114 | ).tag(default=1) 115 | 116 | allowed_failed_logins = Integer( 117 | config=True, 118 | help=( 119 | "Configures the number of failed attempts a user can have " 120 | "before being blocked." 121 | ), 122 | ).tag(default=0) 123 | 124 | seconds_before_next_try = Integer( 125 | config=True, 126 | help=( 127 | "Configures the number of seconds a user has to wait " 128 | "after being blocked. Default is 600." 129 | ), 130 | ).tag(default=600) 131 | 132 | enable_signup = Bool( 133 | config=True, 134 | default_value=True, 135 | help=("Allows every user to registry a new account"), 136 | ) 137 | 138 | open_signup = Bool( 139 | config=True, 140 | default_value=False, 141 | help=( 142 | "Allows every user that made sign up to automatically log in " 143 | "the system without needing admin authorization" 144 | ), 145 | ) 146 | 147 | ask_email_on_signup = Bool(False, config=True, help="Asks for email on signup") 148 | 149 | import_from_firstuse = Bool( 150 | False, config=True, help="Import users from FirstUse Authenticator database" 151 | ) 152 | 153 | firstuse_db_path = Unicode( 154 | "passwords.dbm", 155 | config=True, 156 | help=""" 157 | Path to store the db file of FirstUse with username / pwd hash in 158 | """, 159 | ) 160 | 161 | delete_firstuse_db_after_import = Bool( 162 | config=True, 163 | default_value=False, 164 | help="Deletes FirstUse Authenticator database after the import", 165 | ) 166 | 167 | allow_2fa = Bool(False, config=True, help="") 168 | 169 | def __init__(self, add_new_table=True, *args, **kwargs): 170 | super().__init__(*args, **kwargs) 171 | 172 | self.login_attempts = dict() 173 | if add_new_table: 174 | self.add_new_table() 175 | 176 | if self.import_from_firstuse: 177 | self.add_data_from_firstuse() 178 | 179 | self.setup_self_approval() 180 | 181 | def setup_self_approval(self): 182 | if self.allow_self_approval_for: 183 | if self.open_signup: 184 | self.log.error("self_approval and open_signup are conflicts!") 185 | self.ask_email_on_signup = True 186 | if len(self.secret_key) < 8: 187 | raise ValueError( 188 | "Secret_key must be a random string of " 189 | "len > 8 when using self_approval" 190 | ) 191 | 192 | def add_new_table(self): 193 | inspector = inspect(self.db.bind) 194 | if "users_info" not in inspector.get_table_names(): 195 | UserInfo.__table__.create(self.db.bind) 196 | 197 | def add_login_attempt(self, username): 198 | if not self.login_attempts.get(username): 199 | self.login_attempts[username] = {"count": 1, "time": datetime.now()} 200 | else: 201 | self.login_attempts[username]["count"] += 1 202 | self.login_attempts[username]["time"] = datetime.now() 203 | 204 | def can_try_to_login_again(self, username): 205 | login_attempts = self.login_attempts.get(username) 206 | if not login_attempts: 207 | return True 208 | 209 | time_last_attempt = datetime.now() - login_attempts["time"] 210 | if time_last_attempt.seconds > self.seconds_before_next_try: 211 | return True 212 | 213 | return False 214 | 215 | def is_blocked(self, username): 216 | logins = self.login_attempts.get(username) 217 | 218 | if not logins or logins["count"] < self.allowed_failed_logins: 219 | return False 220 | 221 | if self.can_try_to_login_again(username): 222 | return False 223 | return True 224 | 225 | def successful_login(self, username): 226 | if self.login_attempts.get(username): 227 | self.login_attempts.pop(username) 228 | 229 | async def authenticate(self, handler, data): 230 | username = self.normalize_username(data["username"]) 231 | password = data["password"] 232 | 233 | user = self.get_user(username) 234 | if not user: 235 | return 236 | 237 | if self.allowed_failed_logins: 238 | if self.is_blocked(username): 239 | return 240 | 241 | validations = [user.is_authorized, user.is_valid_password(password)] 242 | if user.has_2fa: 243 | validations.append(user.is_valid_token(data.get("2fa"))) 244 | 245 | if all(validations): 246 | self.successful_login(username) 247 | return username 248 | 249 | self.add_login_attempt(username) 250 | 251 | def is_password_common(self, password): 252 | common_credentials_file = os.path.join( 253 | os.path.dirname(os.path.abspath(__file__)), "common-credentials.txt" 254 | ) 255 | if not self.COMMON_PASSWORDS: 256 | with open(common_credentials_file) as f: 257 | self.COMMON_PASSWORDS = set(f.read().splitlines()) 258 | return password in self.COMMON_PASSWORDS 259 | 260 | def is_password_strong(self, password): 261 | checks = [len(password) >= self.minimum_password_length] 262 | 263 | if self.check_common_password: 264 | checks.append(not self.is_password_common(password)) 265 | 266 | return all(checks) 267 | 268 | def get_user(self, username): 269 | return UserInfo.find(self.db, self.normalize_username(username)) 270 | 271 | def get_authed_users(self): 272 | try: 273 | allowed = self.allowed_users 274 | except AttributeError: 275 | try: 276 | # Deprecated for jupyterhub >= 1.2 277 | allowed = self.whitelist 278 | except AttributeError: 279 | # Not present at all in jupyterhub < 0.9 280 | allowed = {} 281 | 282 | authed = set() 283 | for info in UserInfo.all_users(self.db): 284 | user = self.get_user(info.username) 285 | if user is not None: 286 | if user.is_authorized: 287 | authed.update(set({info.username})) 288 | 289 | return authed.union(allowed.union(self.admin_users)) 290 | 291 | def user_exists(self, username): 292 | return self.get_user(username) is not None 293 | 294 | def create_user(self, username, password, **kwargs): 295 | username = self.normalize_username(username) 296 | 297 | if self.user_exists(username) or not self.validate_username(username): 298 | return 299 | 300 | if not self.is_password_strong(password): 301 | return 302 | 303 | if not self.enable_signup: 304 | return 305 | 306 | encoded_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) 307 | infos = {"username": username, "password": encoded_password} 308 | infos.update(kwargs) 309 | 310 | # Pre-authorized users (admins, or any users during open signup) 311 | pre_authorized = self.open_signup or username in self.get_authed_users() 312 | 313 | if pre_authorized: 314 | infos.update({"is_authorized": True}) 315 | 316 | try: 317 | user_info = UserInfo(**infos) 318 | except AssertionError: 319 | return 320 | 321 | # Don't send authorization emails to pre-authorized users. 322 | if self.allow_self_approval_for and not pre_authorized: 323 | match = re.match(self.allow_self_approval_for, user_info.email) 324 | if match: 325 | url = self.generate_approval_url(username) 326 | self.send_approval_email(user_info.email, url) 327 | user_info.login_email_sent = True 328 | 329 | self.db.add(user_info) 330 | self.db.commit() 331 | return user_info 332 | 333 | def generate_approval_url(self, username, when=None): 334 | if when is None: 335 | when = datetime.now(tz.utc) + timedelta(minutes=15) 336 | s = Signer(self.secret_key) 337 | u = s.sign_object({"username": username, "expire": when.isoformat()}) 338 | return "/confirm/" + u 339 | 340 | def send_approval_email(self, dest, url): 341 | msg = EmailMessage() 342 | msg["From"] = self.self_approval_email[0] 343 | msg["Subject"] = self.self_approval_email[1] 344 | msg.set_content(self.self_approval_email[2].format(approval_url=url)) 345 | msg["To"] = dest 346 | try: 347 | if self.self_approval_server: 348 | s = smtplib.SMTP_SSL(self.self_approval_server["url"]) 349 | s.login( 350 | self.self_approval_server["usr"], self.self_approval_server["pwd"] 351 | ) 352 | else: 353 | s = smtplib.SMTP("localhost") 354 | s.send_message(msg) 355 | s.quit() 356 | except Exception as e: 357 | self.log.error(e) 358 | raise web.HTTPError( 359 | 503, 360 | reason="Self-authorization email could not " 361 | + "be sent. Please contact the JupyterHub " 362 | + "admin about this.", 363 | ) 364 | 365 | def get_unauthed_amount(self): 366 | unauthed = 0 367 | for info in UserInfo.all_users(self.db): 368 | user = self.get_user(info.username) 369 | if user is not None: 370 | if info.username not in self.get_authed_users(): 371 | unauthed += 1 372 | 373 | return unauthed 374 | 375 | def change_password(self, username, new_password): 376 | user = self.get_user(username) 377 | 378 | criteria = [ 379 | user is not None, 380 | self.is_password_strong(new_password), 381 | ] 382 | if not all(criteria): 383 | return 384 | 385 | user.password = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()) 386 | self.db.commit() 387 | return True 388 | 389 | def validate_username(self, username): 390 | invalid_chars = [",", " ", "/"] 391 | if any((char in username) for char in invalid_chars): 392 | return False 393 | return super().validate_username(username) 394 | 395 | def get_handlers(self, app): 396 | native_handlers = [ 397 | (r"/login", LoginHandler), 398 | (r"/signup", SignUpHandler), 399 | (r"/discard/([^/]*)", DiscardHandler), 400 | (r"/authorize", AuthorizationAreaHandler), 401 | (r"/authorize/([^/]*)", ToggleAuthorizationHandler), 402 | # the following /confirm/ must be like in generate_approval_url() 403 | (r"/confirm/([^/]*)", EmailAuthorizationHandler), 404 | (r"/change-password", ChangePasswordHandler), 405 | (r"/change-password/([^/]+)", ChangePasswordAdminHandler), 406 | ] 407 | return native_handlers 408 | 409 | def delete_user(self, user): 410 | user_info = self.get_user(user.name) 411 | if user_info is not None: 412 | self.db.delete(user_info) 413 | self.db.commit() 414 | return super().delete_user(user) 415 | 416 | def delete_dbm_db(self): 417 | db_path = Path(self.firstuse_db_path) 418 | db_dir = db_path.cwd() 419 | db_name = db_path.name 420 | db_complete_path = str(db_path.absolute()) 421 | 422 | # necessary for BSD implementation of dbm lib 423 | if os.path.exists(os.path.join(db_dir, db_name + ".db")): 424 | os.remove(db_complete_path + ".db") 425 | else: 426 | os.remove(db_complete_path) 427 | 428 | def add_data_from_firstuse(self): 429 | with dbm.open(self.firstuse_db_path, "c", 0o600) as db: 430 | for user in db.keys(): 431 | password = db[user].decode() 432 | new_user = self.create_user(user.decode(), password) 433 | if not new_user: 434 | error = ( 435 | f"User {user} was not created. Check password " 436 | "restrictions or username problems before trying " 437 | "again." 438 | ) 439 | raise ValueError(error) 440 | 441 | if self.delete_firstuse_db_after_import: 442 | self.delete_dbm_db() 443 | -------------------------------------------------------------------------------- /nativeauthenticator/orm.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import re 4 | 5 | import bcrypt 6 | import onetimepass 7 | from jupyterhub.orm import Base 8 | from sqlalchemy import Boolean, Column, Integer, LargeBinary, String 9 | from sqlalchemy.orm import validates 10 | 11 | 12 | class UserInfo(Base): 13 | """ 14 | This class represents the information that NativeAuthenticator persists in 15 | JupyterHub's database. 16 | """ 17 | 18 | __tablename__ = "users_info" 19 | id = Column(Integer, primary_key=True, autoincrement=True) 20 | 21 | # username should be a JupyterHub username, normalized by the Authenticator 22 | # class normalize_username function. 23 | username = Column(String(128), nullable=False) 24 | 25 | # password should be a bcrypt generated string that not only contains a 26 | # hashed password, but also the salt and cost that was used to hash the 27 | # password. Since bcrypt can extract the salt from this concatenation, this 28 | # can be used again during validation as salt. 29 | password = Column(LargeBinary, nullable=False) 30 | 31 | # is_authorized is a boolean to indicate if the user has been authorized, 32 | # either by an admin, or by validating via an email for example. 33 | is_authorized = Column(Boolean, default=False) 34 | 35 | # login_email_sent is boolean to indicate if a self approval email has been 36 | # sent out, as enabled by having a allow_self_approval_for configuration 37 | # set. 38 | login_email_sent = Column(Boolean, default=False) 39 | 40 | # email is a un-encrypted string representing the email 41 | email = Column(String(128)) 42 | 43 | # has_2fa is a boolean that is being set to true if the user declares they 44 | # want to setup 2fa during sign-up. 45 | has_2fa = Column(Boolean, default=False) 46 | 47 | # otp_secret (one-time password secret) is given to a user during setup of 48 | # 2fa. With a shared secret like this, both the user and nativeauthenticator 49 | # are enabled to generate the same one-time password's, which enables them 50 | # to be matched against each other. 51 | otp_secret = Column(String(16)) 52 | 53 | def __init__(self, **kwargs): 54 | super().__init__(**kwargs) 55 | if not self.otp_secret: 56 | self.otp_secret = base64.b32encode(os.urandom(10)).decode("utf-8") 57 | 58 | @classmethod 59 | def find(cls, db, username): 60 | """ 61 | Find a user info record by username. 62 | 63 | Returns None if no user was found. 64 | """ 65 | return db.query(cls).filter(cls.username == username).first() 66 | 67 | @classmethod 68 | def all_users(cls, db): 69 | """ 70 | Returns all available user info records. 71 | """ 72 | return db.query(cls).all() 73 | 74 | @classmethod 75 | def change_authorization(cls, db, username): 76 | """ 77 | Toggles the authorization status of a user info record. 78 | 79 | Returns the user info record. 80 | """ 81 | user = db.query(cls).filter(cls.username == username).first() 82 | user.is_authorized = not user.is_authorized 83 | db.commit() 84 | return user 85 | 86 | def is_valid_password(self, password): 87 | """ 88 | Checks if a provided password hashes to the hash we have stored in 89 | self.password. 90 | 91 | Note that self.password has been set to the return value of calling 92 | bcrypt.hashpw(...) before, that returns a concatenation of the random 93 | salt used and the hashed salt+password combination. So, when we are 94 | passing self.password back to bcrypt.hashpw(...) as a salt, it is smart 95 | enough to extract and use only the salt that was originally used. 96 | """ 97 | return self.password == bcrypt.hashpw(password.encode(), self.password) 98 | 99 | @validates("email") 100 | def validate_email(self, key, address): 101 | """ 102 | Validates any attempt to set the email field of a user info record. 103 | """ 104 | if not address: 105 | return 106 | assert re.match(r"^[A-Za-z0-9\.\+_-]+@[A-Za-z0-9\._-]+\.[a-zA-Z]*$", address) 107 | return address 108 | 109 | def is_valid_token(self, token): 110 | """ 111 | Validates a time-based one-time password (TOTP) as generated by a user's 112 | 2fa application against the TOTP generated locally by the onetimepass 113 | module. Assuming the user generated a TOTP with a common shared one-time 114 | password secret (otp_secret), these passwords should match. 115 | """ 116 | return onetimepass.valid_totp(token, self.otp_secret) 117 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/authorization-area.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block main %} 4 |
5 |

Authorization area

6 | 7 | 8 | 9 | 10 | 11 | {% if ask_email %}{% endif %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for user in users %} 21 | {% if user.is_authorized %} 22 | 23 | 24 | {% if ask_email %}{% endif %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% else %} 32 | 33 | 34 | {% if ask_email %}{% endif %} 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% endif %} 42 | {% endfor %} 43 | 44 |
UsernameEmailHas 2FA?Is authorized?
{{ user.username }}{{ user.email }}{{ user.has_2fa }}YesUnauthorizeChange password
{{ user.username }}{{ user.email }}{{ user.has_2fa }}NoAuthorizeChange passwordDiscard
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/change-password-admin.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block script %} 4 | {{ super() }} 5 | 23 | {% endblock script %} 24 | 25 | {% block main %} 26 |
27 |
28 |

29 | Change password for {{user_name}} 30 |

31 | 32 |

Please enter the new password you want to set for {{user_name}}.

33 | 34 |
35 | 36 |
37 | 38 | 39 | 40 | 41 |
42 |

43 | 44 | 45 |
46 | 47 |
48 |

49 | 50 | 51 |
52 |
53 | 54 | {% if result_message %} 55 | 56 | {% endif %} 57 |
58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/change-password.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block script %} 4 | {{ super() }} 5 | 26 | {% endblock script %} 27 | 28 | {% block main %} 29 |
30 |
31 |

32 | Change password for {{user_name}} 33 |

34 | 35 |

Please enter your current password and the new password you want to set it to. If you have forgotten your password, an admin can reset it for you.

36 | 37 |
38 | 39 |
40 | 41 | 42 | 43 | 44 |
45 |

46 | 47 | 48 |
49 | 50 |
51 |

52 | 53 | 54 |
55 | 56 | 57 |
58 |

59 | 60 | 61 |
62 |
63 | 64 | {% if result_message %} 65 | 66 | {% endif %} 67 |
68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/my_message.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block main %} 4 |
5 |

{{message}}

6 |
7 | 8 |
9 | If you are authorized, you can try to login. 10 |
11 | {% endblock main %} 12 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/native-login.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% if announcement_login %} 4 | {% set announcement = announcement_login %} 5 | {% endif %} 6 | 7 | {% block script %} 8 | {{ super() }} 9 | 30 | {% endblock script %} 31 | 32 | {% block main %} 33 | {% block login %} 34 |
35 |
36 |
37 | Sign In 38 |
39 | 40 |
41 | 45 | 46 | {% if login_error %} 47 | 50 | {% endif %} 51 | 52 | 53 | 54 |

55 | 56 | 57 |
58 | 59 | 60 | 61 | 62 |
63 |

64 | 65 | {% if two_factor_auth %} 66 | 67 | 68 |

69 | {% endif %} 70 | 71 | 72 |

73 | 74 | {% if enable_signup %} 75 |
76 |

77 | Sign up to create a new user. 78 |

79 | {% endif %} 80 |
81 |
82 |
83 | {% endblock login %} 84 | {% endblock main %} 85 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/page.html" %} 2 | 3 | {% block nav_bar_left_items %} 4 | {{ super() }} 5 | 6 | {% if user.admin %} 7 | 8 | {% endif %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /nativeauthenticator/templates/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} 2 | 3 | {% block script %} 4 | {{ super() }} 5 | 40 | 41 | {% if recaptcha_key %} 42 | 43 | {% endif %} 44 | {% endblock script %} 45 | 46 | 47 | {% block main %} 48 | {% block login %} 49 |
50 |
51 |
52 | Sign Up 53 |
54 |
55 | 59 | 60 | {% if alert %} 61 | 73 | {% endif %} 74 | 75 | 76 | 77 |

78 | 79 | {% if ask_email %} 80 | 81 | 82 |

83 | {% endif %} 84 | 85 | 86 |
87 | 88 | 89 | 90 | 91 |
92 |

93 | 94 | 95 |
96 | 97 |
98 |

99 | 100 | {% if two_factor_auth %} 101 | 102 | Setup two factor authentication 103 | 104 |

105 | {% endif %} 106 | 107 | {% if tos %} 108 | 109 | {{tos|safe}} 110 | 111 |

112 | {% endif %} 113 | 114 | {% if recaptcha_key %} 115 |
116 |

117 | {% endif %} 118 | 119 | 120 |

121 | 122 |
123 |

124 | Login with an existing user. 125 |

126 |
127 |
128 |
129 | {% endblock login %} 130 | {% endblock main %} 131 | -------------------------------------------------------------------------------- /nativeauthenticator/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/94fef0dd6cba239687653be4081321854c8469bd/nativeauthenticator/tests/__init__.py -------------------------------------------------------------------------------- /nativeauthenticator/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from jupyterhub.tests.mocking import MockHub 3 | 4 | 5 | @pytest.fixture 6 | def tmpcwd(tmpdir): 7 | tmpdir.chdir() 8 | 9 | 10 | @pytest.fixture 11 | def app(): 12 | hub = MockHub() 13 | hub.init_db() 14 | return hub 15 | -------------------------------------------------------------------------------- /nativeauthenticator/tests/test_authenticator.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import dbm 3 | import os 4 | import time 5 | from datetime import timezone as tz 6 | 7 | import pytest 8 | 9 | from nativeauthenticator import NativeAuthenticator 10 | 11 | from ..handlers import EmailAuthorizationHandler 12 | from ..orm import UserInfo 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "is_admin,open_signup,expected_authorization", 17 | [ 18 | (False, False, False), 19 | (True, False, True), 20 | (False, True, True), 21 | (True, True, True), 22 | ], 23 | ) 24 | async def test_create_user(is_admin, open_signup, expected_authorization, tmpcwd, app): 25 | """Test method create_user for new user and authorization""" 26 | auth = NativeAuthenticator(db=app.db) 27 | 28 | if is_admin: 29 | auth.admin_users = {"johnsnow"} 30 | if open_signup: 31 | auth.open_signup = True 32 | 33 | auth.create_user("johnsnow", "password") 34 | user_info = UserInfo.find(app.db, "johnsnow") 35 | assert user_info.username == "johnsnow" 36 | assert user_info.is_authorized == expected_authorization 37 | 38 | 39 | async def test_create_user_bad_characters(tmpcwd, app): 40 | """Test method create_user with bad characters on username""" 41 | auth = NativeAuthenticator(db=app.db) 42 | assert not auth.create_user("john snow", "password") 43 | assert not auth.create_user("john,snow", "password") 44 | 45 | 46 | async def test_create_user_twice(tmpcwd, app): 47 | """Test if creating users with an existing handle errors.""" 48 | auth = NativeAuthenticator(db=app.db) 49 | 50 | # First creation should succeed. 51 | assert auth.create_user("johnsnow", "password") 52 | 53 | # Creating the same account again should fail. 54 | assert not auth.create_user("johnsnow", "password") 55 | 56 | # Creating a user with same handle but different pw should also fail. 57 | assert not auth.create_user("johnsnow", "adifferentpassword") 58 | 59 | 60 | async def test_get_authed_users(tmpcwd, app): 61 | """Test if get_authed_users returns the proper set of users.""" 62 | auth = NativeAuthenticator(db=app.db) 63 | 64 | auth.admin_users = set() 65 | assert auth.get_authed_users() == set() 66 | 67 | auth.create_user("johnsnow", "password") 68 | assert auth.get_authed_users() == set() 69 | 70 | UserInfo.change_authorization(app.db, "johnsnow") 71 | assert auth.get_authed_users() == set({"johnsnow"}) 72 | 73 | auth.create_user("daenerystargaryen", "anotherpassword") 74 | assert auth.get_authed_users() == set({"johnsnow"}) 75 | 76 | auth.admin_users = set({"daenerystargaryen"}) 77 | assert "johnsnow" in auth.get_authed_users() 78 | assert "daenerystargaryen" in auth.get_authed_users() 79 | 80 | 81 | async def test_get_unauthed_amount(tmpcwd, app): 82 | """Test if get_unauthed_amount returns the proper amount.""" 83 | auth = NativeAuthenticator(db=app.db) 84 | 85 | auth.admin_users = set() 86 | assert auth.get_unauthed_amount() == 0 87 | 88 | auth.create_user("johnsnow", "password") 89 | assert auth.get_unauthed_amount() == 1 90 | 91 | UserInfo.change_authorization(app.db, "johnsnow") 92 | assert auth.get_unauthed_amount() == 0 93 | 94 | auth.create_user("daenerystargaryen", "anotherpassword") 95 | assert auth.get_unauthed_amount() == 1 96 | 97 | auth.create_user("tyrionlannister", "yetanotherpassword") 98 | assert auth.get_unauthed_amount() == 2 99 | 100 | auth.admin_users = set({"daenerystargaryen"}) 101 | assert auth.get_unauthed_amount() == 1 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "password,min_len,expected", 106 | [ 107 | ("qwerty", 1, False), 108 | ("agameofthrones", 1, True), 109 | ("agameofthrones", 15, False), 110 | ("averyveryverylongpassword", 15, True), 111 | ], 112 | ) 113 | async def test_create_user_with_strong_passwords( 114 | password, min_len, expected, tmpcwd, app 115 | ): 116 | """Test if method create_user and strong passwords mesh""" 117 | auth = NativeAuthenticator(db=app.db) 118 | auth.check_common_password = True 119 | auth.minimum_password_length = min_len 120 | user = auth.create_user("johnsnow", password) 121 | assert bool(user) == expected 122 | 123 | 124 | async def test_change_password(tmpcwd, app): 125 | auth = NativeAuthenticator(db=app.db) 126 | user = auth.create_user("johnsnow", "password") 127 | assert user.is_valid_password("password") 128 | auth.change_password("johnsnow", "newpassword") 129 | assert not user.is_valid_password("password") 130 | assert user.is_valid_password("newpassword") 131 | 132 | 133 | async def test_no_change_to_bad_password(tmpcwd, app): 134 | """Test that changing password doesn't bypass password requirements""" 135 | auth = NativeAuthenticator(db=app.db) 136 | auth.check_common_password = True 137 | auth.minimum_password_length = 8 138 | 139 | auth.create_user("johnsnow", "ironwood") 140 | 141 | # Can't change password of nonexistent users. 142 | assert auth.change_password("samwelltarly", "palanquin") is None 143 | assert auth.get_user("johnsnow").is_valid_password("ironwood") 144 | 145 | # Can't change password to something too short. 146 | assert auth.change_password("johnsnow", "mummer") is None 147 | assert auth.get_user("johnsnow").is_valid_password("ironwood") 148 | 149 | # Can't change password to something too common. 150 | assert auth.change_password("johnsnow", "dragon") is None 151 | assert auth.get_user("johnsnow").is_valid_password("ironwood") 152 | 153 | # CAN change password to something fulfilling criteria. 154 | assert auth.change_password("johnsnow", "Daenerys") is not None 155 | assert not auth.get_user("johnsnow").is_valid_password("ironwood") 156 | assert auth.get_user("johnsnow").is_valid_password("Daenerys") 157 | 158 | 159 | @pytest.mark.parametrize( 160 | "enable_signup,expected_success", 161 | [ 162 | (True, True), 163 | (False, False), 164 | ], 165 | ) 166 | async def test_create_user_disable(enable_signup, expected_success, tmpcwd, app): 167 | """Test method get_or_create_user not create user if signup is disabled""" 168 | auth = NativeAuthenticator(db=app.db) 169 | auth.enable_signup = enable_signup 170 | 171 | user = auth.create_user("johnsnow", "password") 172 | 173 | if expected_success: 174 | assert user.username == "johnsnow" 175 | else: 176 | assert not user 177 | 178 | 179 | @pytest.mark.parametrize( 180 | "username,password,authorized,expected", 181 | [ 182 | ("name", "123", False, False), 183 | ("johnsnow", "123", True, False), 184 | ("Snow", "password", True, False), 185 | ("johnsnow", "password", False, False), 186 | ("johnsnow", "password", True, True), 187 | ], 188 | ) 189 | async def test_authentication(username, password, authorized, expected, tmpcwd, app): 190 | """Test if authentication fails with a unexistent user""" 191 | auth = NativeAuthenticator(db=app.db) 192 | auth.create_user("johnsnow", "password") 193 | if authorized: 194 | UserInfo.change_authorization(app.db, "johnsnow") 195 | response = await auth.authenticate( 196 | app, {"username": username, "password": password} 197 | ) 198 | assert bool(response) == expected 199 | 200 | 201 | async def test_handlers(app): 202 | """Test if all handlers are available on the Authenticator""" 203 | auth = NativeAuthenticator(db=app.db) 204 | handlers = auth.get_handlers(app) 205 | assert handlers[0][0] == "/login" 206 | assert handlers[1][0] == "/signup" 207 | assert handlers[2][0] == "/discard/([^/]*)" 208 | assert handlers[3][0] == "/authorize" 209 | assert handlers[4][0] == "/authorize/([^/]*)" 210 | assert handlers[5][0] == "/confirm/([^/]*)" 211 | assert handlers[6][0] == "/change-password" 212 | assert handlers[7][0] == "/change-password/([^/]+)" 213 | 214 | 215 | async def test_add_new_attempt_of_login(tmpcwd, app): 216 | auth = NativeAuthenticator(db=app.db) 217 | 218 | assert not auth.login_attempts 219 | auth.add_login_attempt("username") 220 | assert auth.login_attempts["username"]["count"] == 1 221 | auth.add_login_attempt("username") 222 | assert auth.login_attempts["username"]["count"] == 2 223 | 224 | 225 | async def test_authentication_login_count(tmpcwd, app): 226 | auth = NativeAuthenticator(db=app.db) 227 | infos = {"username": "johnsnow", "password": "password"} 228 | wrong_infos = {"username": "johnsnow", "password": "wrong_password"} 229 | auth.create_user(infos["username"], infos["password"]) 230 | UserInfo.change_authorization(app.db, "johnsnow") 231 | 232 | assert not auth.login_attempts 233 | 234 | await auth.authenticate(app, wrong_infos) 235 | assert auth.login_attempts["johnsnow"]["count"] == 1 236 | 237 | await auth.authenticate(app, wrong_infos) 238 | assert auth.login_attempts["johnsnow"]["count"] == 2 239 | 240 | await auth.authenticate(app, infos) 241 | assert not auth.login_attempts.get("johnsnow") 242 | 243 | 244 | async def test_authentication_with_exceed_atempts_of_login(tmpcwd, app): 245 | auth = NativeAuthenticator(db=app.db) 246 | auth.allowed_failed_logins = 3 247 | auth.secs_before_next_try = 10 248 | 249 | infos = {"username": "johnsnow", "password": "wrongpassword"} 250 | auth.create_user(infos["username"], "password") 251 | UserInfo.change_authorization(app.db, "johnsnow") 252 | 253 | for i in range(3): 254 | response = await auth.authenticate(app, infos) 255 | assert not response 256 | 257 | infos["password"] = "password" 258 | response = await auth.authenticate(app, infos) 259 | assert not response 260 | 261 | time.sleep(12) 262 | response = await auth.authenticate(app, infos) 263 | assert response 264 | 265 | 266 | async def test_get_user(tmpcwd, app): 267 | auth = NativeAuthenticator(db=app.db) 268 | auth.create_user("johnsnow", "password") 269 | 270 | # Getting existing user is successful. 271 | assert auth.get_user("johnsnow") is not None 272 | 273 | # Getting non-existing user fails. 274 | assert auth.get_user("samwelltarly") is None 275 | 276 | 277 | async def test_delete_user(tmpcwd, app): 278 | auth = NativeAuthenticator(db=app.db) 279 | auth.create_user("johnsnow", "password") 280 | 281 | user = type("User", (), {"name": "johnsnow"}) 282 | auth.delete_user(user) 283 | 284 | user_info = UserInfo.find(app.db, "johnsnow") 285 | assert not user_info 286 | 287 | 288 | async def test_import_from_firstuse_dont_delete_db_after(tmpcwd, app): 289 | with dbm.open("passwords.dbm", "c", 0o600) as db: 290 | db["user1"] = "password" 291 | 292 | auth = NativeAuthenticator(db=app.db) 293 | auth.add_data_from_firstuse() 294 | 295 | files = os.listdir() 296 | assert UserInfo.find(app.db, "user1") 297 | assert ("passwords.dbm" in files) or ("passwords.dbm.db" in files) 298 | 299 | 300 | async def test_import_from_firstuse_delete_db_after(tmpcwd, app): 301 | with dbm.open("passwords.dbm", "c", 0o600) as db: 302 | db["user1"] = "password" 303 | 304 | auth = NativeAuthenticator(db=app.db) 305 | auth.delete_firstuse_db_after_import = True 306 | 307 | auth.add_data_from_firstuse() 308 | files = os.listdir() 309 | assert UserInfo.find(app.db, "user1") 310 | assert ("passwords.dbm" not in files) and ("passwords.dbm.db" not in files) 311 | 312 | 313 | @pytest.mark.parametrize( 314 | "user,pwd", 315 | [ 316 | ("user1", "password"), 317 | ("user 1", "somethingelsereallysecure"), 318 | ], 319 | ) 320 | async def test_import_from_firstuse_invalid_password(user, pwd, tmpcwd, app): 321 | with dbm.open("passwords.dbm", "c", 0o600) as db: 322 | db[user] = pwd 323 | 324 | auth = NativeAuthenticator(db=app.db) 325 | auth.check_common_password = True 326 | with pytest.raises(ValueError): 327 | auth.add_data_from_firstuse() 328 | 329 | 330 | async def test_secret_key(tmpcwd, app): 331 | auth = NativeAuthenticator(db=app.db) 332 | auth.ask_email_on_signup = False 333 | auth.allow_self_approval_for = ".*@example.com$" 334 | auth.secret_key = "short" 335 | 336 | with pytest.raises(ValueError): 337 | auth.setup_self_approval() 338 | 339 | auth.secret_key = "very long and kind-of random asdgaisgfjbafksdgasg" 340 | 341 | auth.setup_self_approval() 342 | assert auth.ask_email_on_signup is True 343 | 344 | 345 | async def test_approval_url(tmpcwd, app): 346 | auth = NativeAuthenticator(db=app.db) 347 | auth.allow_self_approval_for = ".*@example.com$" 348 | auth.secret_key = "very long and kind-of random asdgaisgfjbafksdgasg" 349 | auth.setup_self_approval() 350 | 351 | # confirm that a forged slug cannot be used 352 | with pytest.raises(ValueError): 353 | EmailAuthorizationHandler.validate_slug("foo", auth.secret_key) 354 | 355 | # confirm that an expired URL cannot be used 356 | expiration = datetime.datetime.now(tz.utc) - datetime.timedelta(days=2) 357 | url = auth.generate_approval_url("somebody", when=expiration) 358 | slug = url.split("/")[-1] 359 | with pytest.raises(ValueError): 360 | EmailAuthorizationHandler.validate_slug(slug, auth.secret_key) 361 | 362 | # confirm that a non-expired, correctly signed URL can be used 363 | expiration = datetime.datetime.now(tz.utc) + datetime.timedelta(days=2) 364 | url = auth.generate_approval_url("somebody", when=expiration) 365 | slug = url.split("/")[-1] 366 | out = EmailAuthorizationHandler.validate_slug(slug, auth.secret_key) 367 | assert out["username"] == "somebody" 368 | assert out["expire"] == expiration 369 | -------------------------------------------------------------------------------- /nativeauthenticator/tests/test_orm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import StatementError 3 | 4 | from ..orm import UserInfo 5 | 6 | 7 | @pytest.mark.parametrize("email", ["john", "john@john"]) 8 | def test_validate_method_wrong_email(email, tmpcwd, app): 9 | with pytest.raises(AssertionError): 10 | UserInfo(username="john", password=b"pwd", email=email) 11 | 12 | 13 | def test_validate_method_correct_email(tmpcwd, app): 14 | user = UserInfo(username="john", password=b"pwd", email="john@john.com") 15 | app.db.add(user) 16 | app.db.commit() 17 | assert UserInfo.find(app.db, "john") 18 | 19 | 20 | def test_all_users(tmpcwd, app): 21 | assert len(UserInfo.all_users(app.db)) == 0 22 | user = UserInfo( 23 | username="daenerystargaryen", 24 | password=b"yesispeakvalyrian", 25 | email="khaleesi@valyria.com", 26 | ) 27 | app.db.add(user) 28 | app.db.commit() 29 | 30 | assert len(UserInfo.all_users(app.db)) == 1 31 | 32 | 33 | def test_wrong_pwd_type(tmpcwd, app): 34 | with pytest.raises(StatementError): 35 | user = UserInfo(username="john", password="pwd", email="john@john.com") 36 | app.db.add(user) 37 | UserInfo.find(app.db, "john") 38 | -------------------------------------------------------------------------------- /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 | # black is used for autoformatting Python code 13 | # 14 | # ref: https://black.readthedocs.io/en/stable/ 15 | # 16 | [tool.black] 17 | skip-string-normalization = true 18 | target_version = [ 19 | "py39", 20 | "py310", 21 | "py311", 22 | "py312", 23 | ] 24 | 25 | 26 | # isort is used for autoformatting Python code 27 | # 28 | # ref: https://pycqa.github.io/isort/ 29 | # 30 | [tool.isort] 31 | profile = "black" 32 | 33 | 34 | # pytest is used for running Python based tests 35 | # 36 | # ref: https://docs.pytest.org/en/stable/ 37 | # 38 | [tool.pytest.ini_options] 39 | addopts = "--verbose --color=yes --durations=10" 40 | asyncio_mode = "auto" 41 | testpaths = ["nativeauthenticator/tests"] 42 | 43 | 44 | # tbump is used to simplify and standardize the release process when updating 45 | # the version, making a git commit and tag, and pushing changes. 46 | # 47 | # ref: https://github.com/your-tools/tbump#readme 48 | # 49 | [tool.tbump] 50 | github_url = "https://github.com/jupyterhub/nativeauthenticator" 51 | 52 | [tool.tbump.version] 53 | current = "1.3.1.dev" 54 | regex = ''' 55 | (?P\d+) 56 | \. 57 | (?P\d+) 58 | \. 59 | (?P\d+) 60 | (?P
((a|b|rc)\d+)|)
61 |     \.?
62 |     (?P(?<=\.)dev\d*|)
63 | '''
64 | 
65 | [tool.tbump.git]
66 | message_template = "Bump to {new_version}"
67 | tag_template = "{new_version}"
68 | 
69 | [[tool.tbump.file]]
70 | src = "setup.py"
71 | 


--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
 1 | from setuptools import find_packages, setup
 2 | 
 3 | with open("README.md") as fh:
 4 |     long_description = fh.read()
 5 | 
 6 | setup(
 7 |     name="jupyterhub-nativeauthenticator",
 8 |     version="1.3.1.dev",
 9 |     description="JupyterHub Native Authenticator",
10 |     long_description=long_description,
11 |     long_description_content_type="text/markdown",
12 |     url="https://github.com/jupyterhub/nativeauthenticator",
13 |     author="Leticia Portella",
14 |     author_email="leportella@protonmail.com",
15 |     license="3 Clause BSD",
16 |     packages=find_packages(),
17 |     entry_points={
18 |         # Thanks to this, user are able to do:
19 |         #
20 |         #     c.JupyterHub.authenticator_class = "native"
21 |         #
22 |         # ref: https://jupyterhub.readthedocs.io/en/4.0.0/reference/authenticators.html#registering-custom-authenticators-via-entry-points
23 |         #
24 |         "jupyterhub.authenticators": [
25 |             "native = nativeauthenticator:NativeAuthenticator",
26 |         ],
27 |     },
28 |     python_requires=">=3.9",
29 |     install_requires=[
30 |         "jupyterhub>=4.1.6",
31 |         "bcrypt",
32 |         "onetimepass",
33 |     ],
34 |     extras_require={
35 |         "test": [
36 |             "notebook>=6.4.1",
37 |             "pytest",
38 |             "pytest-asyncio",
39 |             "pytest-cov",
40 |         ],
41 |     },
42 |     include_package_data=True,
43 | )
44 | 


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