├── .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 | [](https://pypi.python.org/pypi/jupyterhub-nativeauthenticator)
4 | [](https://native-authenticator.readthedocs.org/en/latest/)
5 | [](https://github.com/jupyterhub/nativeauthenticator/actions)
6 | [](https://codecov.io/github/jupyterhub/nativeauthenticator)
7 |
8 | [](https://github.com/jupyterhub/nativeauthenticator/issues)
9 | [](https://discourse.jupyter.org/c/jupyterhub)
10 | [](https://gitter.im/jupyterhub/jupyterhub)
11 | [](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 | 
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 | 
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 | 
199 |
200 | And login will now require the two factor authentication code as well:
201 |
202 | 
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 | 
51 |
52 | The admin must access the authorization panel and authorize the user so they be able to login:
53 |
54 | 
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 | 
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 | 
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 | 
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 |
Username | 11 | {% if ask_email %}Has 2FA? | 13 |Is authorized? | 14 |15 | | 16 | | 17 | | |
---|---|---|---|---|---|---|
{{ user.username }} | 24 | {% if ask_email %}{{ user.email }} | {% endif %} 25 |{{ user.has_2fa }} | 26 |Yes | 27 |Unauthorize | 28 |Change password | 29 |30 | |
{{ user.username }} | 34 | {% if ask_email %}{{ user.email }} | {% endif %} 35 |{{ user.has_2fa }} | 36 |No | 37 |Authorize | 38 |Change password | 39 |Discard | 40 |
((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 | --------------------------------------------------------------------------------