bazis-users 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. bazis_users-2.3.0/.github/workflows/build-publish.yml +20 -0
  2. bazis_users-2.3.0/.github/workflows/release.yml +74 -0
  3. bazis_users-2.3.0/.github/workflows/tests.yml +99 -0
  4. bazis_users-2.3.0/CLAUDE.md +55 -0
  5. {bazis_users-2.2.0 → bazis_users-2.3.0}/PKG-INFO +25 -17
  6. {bazis_users-2.2.0 → bazis_users-2.3.0}/README.md +20 -13
  7. bazis_users-2.3.0/bazis/contrib/users/_version.py +24 -0
  8. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/conf.py +6 -3
  9. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/middleware.py +8 -3
  10. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/models_abstract.py +4 -2
  11. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/service.py +31 -8
  12. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/PKG-INFO +25 -17
  13. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/SOURCES.txt +8 -0
  14. bazis_users-2.3.0/bazis_users.egg-info/requires.txt +9 -0
  15. bazis_users-2.3.0/bazis_users.egg-info/scm_file_list.json +86 -0
  16. bazis_users-2.3.0/bazis_users.egg-info/scm_version.json +8 -0
  17. bazis_users-2.3.0/docs/releases/2.3.0.md +30 -0
  18. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/settings.py +2 -1
  19. {bazis_users-2.2.0 → bazis_users-2.3.0}/pyproject.toml +4 -3
  20. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/pyproject.toml +1 -1
  21. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/main.py +1 -5
  22. bazis_users-2.3.0/sample/uv.lock +1580 -0
  23. bazis_users-2.3.0/tests/test_tokens.py +135 -0
  24. bazis_users-2.2.0/.github/workflows/build-publish.yml +0 -16
  25. bazis_users-2.2.0/bazis/contrib/users/_version.py +0 -34
  26. bazis_users-2.2.0/bazis_users.egg-info/requires.txt +0 -8
  27. {bazis_users-2.2.0 → bazis_users-2.3.0}/.dockerignore +0 -0
  28. {bazis_users-2.2.0 → bazis_users-2.3.0}/.github/workflows/update-licenses.yml +0 -0
  29. {bazis_users-2.2.0 → bazis_users-2.3.0}/.gitignore +0 -0
  30. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/__init__.py +0 -0
  31. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/admin_abstract.py +0 -0
  32. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/apps.py +0 -0
  33. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.mo +0 -0
  34. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.po +0 -0
  35. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.mo +0 -0
  36. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.po +0 -0
  37. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/0001_initial.py +0 -0
  38. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/0002_user_dt_first_login.py +0 -0
  39. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/__init__.py +0 -0
  40. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/models.py +0 -0
  41. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/router.py +0 -0
  42. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/routes.py +0 -0
  43. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/routes_abstract.py +0 -0
  44. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/schemas.py +0 -0
  45. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/dependency_links.txt +0 -0
  46. {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/top_level.txt +0 -0
  47. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/Makefile +0 -0
  48. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/README.md +0 -0
  49. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_static/pydata-custom.css +0 -0
  50. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_static/readthedocs-custom.css +0 -0
  51. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_templates/custom-class-template.rst +0 -0
  52. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_templates/custom-module-template.rst +0 -0
  53. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/conf.py +0 -0
  54. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/index.rst +0 -0
  55. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/make.bat +0 -0
  56. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/manage.py +0 -0
  57. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/project.env +0 -0
  58. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/requirements.txt +0 -0
  59. {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/structure.rst +0 -0
  60. {bazis_users-2.2.0 → bazis_users-2.3.0}/pytest.ini +0 -0
  61. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/admin.sh +0 -0
  62. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/app.sh +0 -0
  63. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/config/admin.py +0 -0
  64. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/config/app.py +0 -0
  65. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/db.sh +0 -0
  66. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/init.sh +0 -0
  67. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/__init__.py +0 -0
  68. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/admin.py +0 -0
  69. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/0001_initial.py +0 -0
  70. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/0002_alter_childentity_dt_created_and_more.py +0 -0
  71. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/__init__.py +0 -0
  72. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/models.py +0 -0
  73. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/router.py +0 -0
  74. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/routes.py +0 -0
  75. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/manage.py +0 -0
  76. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/project.env +0 -0
  77. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/__init__.py +0 -0
  78. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/router.py +0 -0
  79. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/settings.py +0 -0
  80. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/urls.py +0 -0
  81. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/wsgi.py +0 -0
  82. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/__init__.py +0 -0
  83. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/admin.py +0 -0
  84. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/apps.py +0 -0
  85. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/0001_initial.py +0 -0
  86. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/0002_user_dt_first_login.py +0 -0
  87. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/__init__.py +0 -0
  88. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/models.py +0 -0
  89. {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/router.py +0 -0
  90. {bazis_users-2.2.0 → bazis_users-2.3.0}/setup.cfg +0 -0
  91. {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/__init__.py +0 -0
  92. {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/conftest.py +0 -0
  93. {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/factories.py +0 -0
  94. {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/test_protected.py +0 -0
  95. {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/test_users.py +0 -0
@@ -0,0 +1,20 @@
1
+ name: Build and Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+ # started by the Release workflow on the tag it creates: a tag pushed with the
8
+ # workflow token does not trigger the push event above
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: write
13
+
14
+ jobs:
15
+ build:
16
+ if: startsWith(github.ref, 'refs/tags/v')
17
+ uses: ecofuture-tech/.github/.github/workflows/reusable-build-publish.yml@main
18
+ secrets:
19
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
20
+ TEST_PYPI_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }}
@@ -0,0 +1,74 @@
1
+ name: Release
2
+
3
+ # Creates the release tag vX.Y.Z on the current main commit and starts Build and Publish
4
+ # for it. Run it from the Actions tab (or through the GitHub API) on the main branch.
5
+
6
+ on:
7
+ workflow_dispatch:
8
+ inputs:
9
+ version:
10
+ description: 'Version without the "v" prefix, e.g. 2.3.0 or 2.4.0-rc1'
11
+ required: true
12
+ type: string
13
+
14
+ permissions:
15
+ contents: write
16
+ actions: write
17
+
18
+ concurrency:
19
+ group: release
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ release:
24
+ runs-on: ubuntu-latest
25
+ timeout-minutes: 10
26
+ if: github.ref == 'refs/heads/main'
27
+ env:
28
+ GH_TOKEN: ${{ github.token }}
29
+ VERSION: ${{ inputs.version }}
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ with:
33
+ fetch-depth: 0
34
+
35
+ - name: Validate the version
36
+ run: |
37
+ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)[0-9]+)?$ ]]; then
38
+ echo "::error::Invalid version '$VERSION', expected e.g. 2.3.0 or 2.4.0-rc1"
39
+ exit 1
40
+ fi
41
+ if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
42
+ echo "::error::Tag v$VERSION already exists"
43
+ exit 1
44
+ fi
45
+ LAST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n 1)
46
+ echo "Last release: ${LAST_TAG:-none}, new release: v$VERSION"
47
+ if [ -n "$LAST_TAG" ] && [ "$(printf '%s\n%s\n' "${LAST_TAG#v}" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then
48
+ echo "::error::Version $VERSION is not newer than $LAST_TAG"
49
+ exit 1
50
+ fi
51
+
52
+ - name: Require green tests on this commit
53
+ run: |
54
+ CONCLUSION=$(gh run list --workflow tests.yml --commit "$GITHUB_SHA" --status completed \
55
+ --json conclusion --jq 'map(.conclusion) | first // "none"')
56
+ echo "Tests on $GITHUB_SHA: $CONCLUSION"
57
+ if [ "$CONCLUSION" != "success" ]; then
58
+ echo "::error::The Tests workflow has not succeeded on $GITHUB_SHA ($CONCLUSION)"
59
+ exit 1
60
+ fi
61
+
62
+ - name: Create the tag
63
+ run: |
64
+ git config user.name 'github-actions[bot]'
65
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
66
+ git tag -a "v$VERSION" -m "${{ github.event.repository.name }} $VERSION" "$GITHUB_SHA"
67
+ git push origin "v$VERSION"
68
+
69
+ - name: Start Build and Publish
70
+ run: gh workflow run build-publish.yml --ref "v$VERSION"
71
+
72
+ - name: Summary
73
+ run: |
74
+ echo "Tagged \`v$VERSION\` at \`$GITHUB_SHA\` and started Build and Publish." >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,99 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: tests-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ timeout-minutes: 10
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v6
23
+ - run: uvx ruff check bazis tests
24
+
25
+ tests:
26
+ name: tests (python ${{ matrix.python }}, ${{ matrix.resolution }})
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 30
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ python: ['3.12', '3.13', '3.14']
33
+ resolution: [highest]
34
+ include:
35
+ # the lowest versions allowed by pyproject.toml
36
+ - python: '3.12'
37
+ resolution: lowest-direct
38
+
39
+ services:
40
+ postgres:
41
+ image: postgis/postgis:16-3.4
42
+ env:
43
+ POSTGRES_USER: postgres
44
+ POSTGRES_PASSWORD: postgres
45
+ POSTGRES_DB: bazis
46
+ ports:
47
+ - 5432:5432
48
+ options: >-
49
+ --health-cmd pg_isready
50
+ --health-interval 5s
51
+ --health-timeout 5s
52
+ --health-retries 10
53
+ redis:
54
+ image: redis:7
55
+ ports:
56
+ - 6379:6379
57
+ options: >-
58
+ --health-cmd "redis-cli ping"
59
+ --health-interval 5s
60
+ --health-timeout 5s
61
+ --health-retries 10
62
+ env:
63
+ BS_DEBUG: 'true'
64
+ BS_SECRET_KEY: ci-only-secret-key-that-is-long-enough-0123456789
65
+ BS_DATABASES__DEFAULT__HOST: localhost
66
+ BS_DATABASES__DEFAULT__PORT: '5432'
67
+ BS_DATABASES__DEFAULT__NAME: bazis
68
+ BS_DATABASES__DEFAULT__USER: postgres
69
+ BS_DATABASES__DEFAULT__PASSWORD: postgres
70
+ BS_CACHES__DEFAULT__LOCATION: redis://localhost:6379/1
71
+ BS_MEDIA_ROOT: /tmp/bazis/media
72
+ BS_STATIC_ROOT: /tmp/bazis/static
73
+ BS_WEBAPP_ROOT: /tmp/bazis/webapp
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+
77
+ - name: Install GDAL (required by the PostGIS database backend)
78
+ # a stalled Ubuntu mirror must fail the step quickly instead of hanging the job
79
+ timeout-minutes: 10
80
+ run: |
81
+ APT="sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"
82
+ $APT update
83
+ $APT install -y --no-install-recommends gdal-bin
84
+
85
+ - uses: astral-sh/setup-uv@v6
86
+ with:
87
+ python-version: ${{ matrix.python }}
88
+ activate-environment: true
89
+
90
+ - name: Install dependencies
91
+ run: uv pip install --resolution ${{ matrix.resolution }} -e '.[test]'
92
+
93
+ - name: Check for missing migrations
94
+ working-directory: sample
95
+ run: python manage.py makemigrations --check --dry-run
96
+
97
+ - name: Run tests
98
+ working-directory: sample
99
+ run: python -m pytest ../tests -o addopts="" -p no:cacheprovider
@@ -0,0 +1,55 @@
1
+ # bazis-users
2
+
3
+ User management for Bazis: abstract user models, session JWTs (`UserAbstract.jwt_build`,
4
+ `service.decode_token`), the FastAPI dependencies `get_user_required` / `get_user_optional`,
5
+ the token endpoint and the user context (`UserMixin.CTX_USER_REQUEST`) used by
6
+ bazis-author and bazis-permit.
7
+
8
+ Security: session tokens are signed with `SECRET_KEY`, so only HMAC algorithms are allowed
9
+ and `exp`/`sub` are required. A context variable set for a request must be reset after it
10
+ (WSGI servers reuse threads).
11
+
12
+ The package code is in `bazis/contrib/users`, the sample project used by the tests is in `sample/`,
13
+ the tests are in `tests/`.
14
+
15
+ ## Running the tests
16
+
17
+ The tests need PostgreSQL with PostGIS and Redis (see `.github/workflows/tests.yml`).
18
+ Run them from the `sample` directory:
19
+
20
+ ```bash
21
+ cd sample
22
+ BS_DEBUG=true \
23
+ BS_SECRET_KEY=local-secret-key-that-is-long-enough-0123456789 \
24
+ BS_DATABASES__DEFAULT__HOST=localhost BS_DATABASES__DEFAULT__PORT=5432 \
25
+ BS_DATABASES__DEFAULT__NAME=bazis BS_DATABASES__DEFAULT__USER=postgres \
26
+ BS_DATABASES__DEFAULT__PASSWORD=postgres \
27
+ BS_CACHES__DEFAULT__LOCATION=redis://localhost:6379/1 \
28
+ BS_MEDIA_ROOT=/tmp/bazis/media BS_STATIC_ROOT=/tmp/bazis/static BS_WEBAPP_ROOT=/tmp/bazis/webapp \
29
+ python -m pytest ../tests -o addopts="" -p no:cacheprovider
30
+ ```
31
+
32
+ Lint: `ruff check bazis tests`. CI also runs `python manage.py makemigrations --check
33
+ --dry-run` in `sample`: commit the migrations of model changes, including the sample apps.
34
+
35
+ ## Releasing
36
+
37
+ A release is the tag `vX.Y.Z` on `main`: the Build and Publish workflow builds the package
38
+ (the version comes from the tag through setuptools-scm) and publishes it to PyPI
39
+ (pre-releases `-alphaN`/`-betaN`/`-rcN` go to Test PyPI) and creates the GitHub release.
40
+
41
+ Claude Code sessions cannot push tags. Release through the **Release** workflow instead:
42
+
43
+ 1. Make sure the changes are merged into `main` and the Tests workflow is green on the
44
+ `main` head commit (the Release workflow checks this and refuses otherwise).
45
+ 2. Add the release notes as `docs/releases/X.Y.Z.md` in the change being released.
46
+ 3. Start the workflow `release.yml` on `ref: main` with the input `version: X.Y.Z`
47
+ (GitHub API: `POST /repos/ecofuture-tech/bazis-users/actions/workflows/release.yml/dispatches`;
48
+ with the GitHub MCP tools: `actions_run_trigger`, method `run_workflow`).
49
+ 4. The Release run creates the annotated tag and starts Build and Publish on it. Check
50
+ that both runs succeed and that the version appears on https://pypi.org/project/bazis-users/.
51
+
52
+ Release the Bazis packages in dependency order: a package is tested in CI against the
53
+ versions of its Bazis dependencies published on PyPI. Pick the version by semver:
54
+ breaking changes (settings renamed or required, dependency removed, behavior changed)
55
+ bump the minor version while the project is below 3.0.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-users
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: User management module for Bazis framework.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,10 +17,11 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis
21
- Requires-Dist: python-multipart
20
+ Requires-Dist: bazis>=2.3.0
21
+ Requires-Dist: python-multipart>=0.0.18
22
+ Requires-Dist: PyJWT>=2.10
22
23
  Provides-Extra: test
23
- Requires-Dist: bazis-test-utils; extra == "test"
24
+ Requires-Dist: bazis-test-utils>=2.3.0; extra == "test"
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: ruff; extra == "dev"
26
27
 
@@ -232,26 +233,33 @@ class MyUserRouteSet(UserRouteSet):
232
233
 
233
234
  Services for working with users and tokens.
234
235
 
235
- **Location**: `bazis.contrib.users.services`
236
+ **Location**: `bazis.contrib.users.service`
236
237
 
237
238
  #### get_token_data
238
239
 
239
- Extracts data from JWT token.
240
+ Extracts data from the JWT token. The token is read from the `bazis_auth` query parameter,
241
+ the `Authorization: Bearer` header or the `bazis_auth` cookie (the name is
242
+ `BAZIS_AUTH_COOKIE_NAME`). It must be signed with `SECRET_KEY` using
243
+ `BAZIS_JWT_SESSION_ALG` (`HS256`, `HS384` or `HS512`) and contain `exp` and `sub`
244
+ (the username); otherwise the request fails with 401. Without a token the result is `{}`.
240
245
 
241
246
  ```python
242
- from bazis.contrib.users.services import get_token_data
247
+ from bazis.contrib.users.service import get_token_data
243
248
 
244
- def my_endpoint(token: str = Depends(get_token_data)):
245
- user_id = token.get('user_id')
249
+ def my_endpoint(token: dict = Depends(get_token_data)):
250
+ username = token.get('sub')
246
251
  # Work with token data
247
252
  ```
248
253
 
254
+ `decode_token(token)` verifies a token outside of a request and raises
255
+ `jwt.ExpiredSignatureError` / `jwt.InvalidTokenError`.
256
+
249
257
  #### get_user_from_token
250
258
 
251
259
  Gets user from token.
252
260
 
253
261
  ```python
254
- from bazis.contrib.users.services import get_user_from_token
262
+ from bazis.contrib.users.service import get_user_from_token
255
263
 
256
264
  def my_endpoint(user = Depends(get_user_from_token)):
257
265
  # user - user object or AnonymousUser
@@ -263,7 +271,7 @@ def my_endpoint(user = Depends(get_user_from_token)):
263
271
  Gets user from request (authentication required).
264
272
 
265
273
  ```python
266
- from bazis.contrib.users.services import get_user_required
274
+ from bazis.contrib.users.service import get_user_required
267
275
 
268
276
  def my_endpoint(user = Depends(get_user_required)):
269
277
  # user - authenticated user
@@ -276,7 +284,7 @@ def my_endpoint(user = Depends(get_user_required)):
276
284
  Gets user from request (authentication optional).
277
285
 
278
286
  ```python
279
- from bazis.contrib.users.services import get_user_optional
287
+ from bazis.contrib.users.service import get_user_optional
280
288
 
281
289
  def my_endpoint(user = Depends(get_user_optional)):
282
290
  # user - user or AnonymousUser
@@ -347,7 +355,7 @@ router.register(routes.MyUserRouteSet.as_router())
347
355
  Use `get_user_required` for endpoints that require authentication:
348
356
 
349
357
  ```python
350
- from bazis.contrib.users.services import get_user_required
358
+ from bazis.contrib.users.service import get_user_required
351
359
  from fastapi import APIRouter, Depends
352
360
  from pydantic import BaseModel
353
361
 
@@ -372,7 +380,7 @@ def change_password(
372
380
  Use `get_user_optional` for endpoints available to everyone:
373
381
 
374
382
  ```python
375
- from bazis.contrib.users.services import get_user_optional, get_token_data
383
+ from bazis.contrib.users.service import get_user_optional, get_token_data
376
384
  from fastapi import Depends
377
385
 
378
386
  @router.get('/auth/', response_model=AuthResponse)
@@ -395,14 +403,14 @@ def auth(
395
403
  Getting data from JWT token without loading the user:
396
404
 
397
405
  ```python
398
- from bazis.contrib.users.services import get_token_data
406
+ from bazis.contrib.users.service import get_token_data
399
407
  from fastapi import Depends
400
408
 
401
409
  @router.get('/token-info/')
402
410
  def token_info(token_data: dict = Depends(get_token_data)):
403
411
  # token_data contains decoded data from JWT
404
412
  return {
405
- "user_id": token_data.get("user_id"),
413
+ "username": token_data.get("sub"),
406
414
  "exp": token_data.get("exp"),
407
415
  }
408
416
  ```
@@ -502,7 +510,7 @@ class Task(UserMixin, DtMixin, UuidMixin, JsonApiMixin):
502
510
  **routes.py**:
503
511
  ```python
504
512
  from bazis.contrib.users.routes import UserRouteSet
505
- from bazis.contrib.users.services import get_user_required
513
+ from bazis.contrib.users.service import get_user_required
506
514
  from django.apps import apps
507
515
  from fastapi import APIRouter, Depends
508
516
  from pydantic import BaseModel
@@ -206,26 +206,33 @@ class MyUserRouteSet(UserRouteSet):
206
206
 
207
207
  Services for working with users and tokens.
208
208
 
209
- **Location**: `bazis.contrib.users.services`
209
+ **Location**: `bazis.contrib.users.service`
210
210
 
211
211
  #### get_token_data
212
212
 
213
- Extracts data from JWT token.
213
+ Extracts data from the JWT token. The token is read from the `bazis_auth` query parameter,
214
+ the `Authorization: Bearer` header or the `bazis_auth` cookie (the name is
215
+ `BAZIS_AUTH_COOKIE_NAME`). It must be signed with `SECRET_KEY` using
216
+ `BAZIS_JWT_SESSION_ALG` (`HS256`, `HS384` or `HS512`) and contain `exp` and `sub`
217
+ (the username); otherwise the request fails with 401. Without a token the result is `{}`.
214
218
 
215
219
  ```python
216
- from bazis.contrib.users.services import get_token_data
220
+ from bazis.contrib.users.service import get_token_data
217
221
 
218
- def my_endpoint(token: str = Depends(get_token_data)):
219
- user_id = token.get('user_id')
222
+ def my_endpoint(token: dict = Depends(get_token_data)):
223
+ username = token.get('sub')
220
224
  # Work with token data
221
225
  ```
222
226
 
227
+ `decode_token(token)` verifies a token outside of a request and raises
228
+ `jwt.ExpiredSignatureError` / `jwt.InvalidTokenError`.
229
+
223
230
  #### get_user_from_token
224
231
 
225
232
  Gets user from token.
226
233
 
227
234
  ```python
228
- from bazis.contrib.users.services import get_user_from_token
235
+ from bazis.contrib.users.service import get_user_from_token
229
236
 
230
237
  def my_endpoint(user = Depends(get_user_from_token)):
231
238
  # user - user object or AnonymousUser
@@ -237,7 +244,7 @@ def my_endpoint(user = Depends(get_user_from_token)):
237
244
  Gets user from request (authentication required).
238
245
 
239
246
  ```python
240
- from bazis.contrib.users.services import get_user_required
247
+ from bazis.contrib.users.service import get_user_required
241
248
 
242
249
  def my_endpoint(user = Depends(get_user_required)):
243
250
  # user - authenticated user
@@ -250,7 +257,7 @@ def my_endpoint(user = Depends(get_user_required)):
250
257
  Gets user from request (authentication optional).
251
258
 
252
259
  ```python
253
- from bazis.contrib.users.services import get_user_optional
260
+ from bazis.contrib.users.service import get_user_optional
254
261
 
255
262
  def my_endpoint(user = Depends(get_user_optional)):
256
263
  # user - user or AnonymousUser
@@ -321,7 +328,7 @@ router.register(routes.MyUserRouteSet.as_router())
321
328
  Use `get_user_required` for endpoints that require authentication:
322
329
 
323
330
  ```python
324
- from bazis.contrib.users.services import get_user_required
331
+ from bazis.contrib.users.service import get_user_required
325
332
  from fastapi import APIRouter, Depends
326
333
  from pydantic import BaseModel
327
334
 
@@ -346,7 +353,7 @@ def change_password(
346
353
  Use `get_user_optional` for endpoints available to everyone:
347
354
 
348
355
  ```python
349
- from bazis.contrib.users.services import get_user_optional, get_token_data
356
+ from bazis.contrib.users.service import get_user_optional, get_token_data
350
357
  from fastapi import Depends
351
358
 
352
359
  @router.get('/auth/', response_model=AuthResponse)
@@ -369,14 +376,14 @@ def auth(
369
376
  Getting data from JWT token without loading the user:
370
377
 
371
378
  ```python
372
- from bazis.contrib.users.services import get_token_data
379
+ from bazis.contrib.users.service import get_token_data
373
380
  from fastapi import Depends
374
381
 
375
382
  @router.get('/token-info/')
376
383
  def token_info(token_data: dict = Depends(get_token_data)):
377
384
  # token_data contains decoded data from JWT
378
385
  return {
379
- "user_id": token_data.get("user_id"),
386
+ "username": token_data.get("sub"),
380
387
  "exp": token_data.get("exp"),
381
388
  }
382
389
  ```
@@ -476,7 +483,7 @@ class Task(UserMixin, DtMixin, UuidMixin, JsonApiMixin):
476
483
  **routes.py**:
477
484
  ```python
478
485
  from bazis.contrib.users.routes import UserRouteSet
479
- from bazis.contrib.users.services import get_user_required
486
+ from bazis.contrib.users.service import get_user_required
480
487
  from django.apps import apps
481
488
  from fastapi import APIRouter, Depends
482
489
  from pydantic import BaseModel
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
+
24
+ __commit_id__ = commit_id = 'g5959b6ec7'
@@ -12,12 +12,12 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from typing import Literal
16
+
15
17
  from django.utils.translation import gettext_lazy as _
16
18
 
17
19
  from pydantic import Field
18
20
 
19
- from jose.constants import Algorithms
20
-
21
21
  from bazis.core.utils.schemas import BazisSettings
22
22
 
23
23
 
@@ -34,7 +34,10 @@ class Settings(BazisSettings):
34
34
  )
35
35
  BAZIS_OPENAPI_TOKEN_URL: str = Field('/api/openapi-token/', title=_('OpenAPI token`s endpoint'))
36
36
  BAZIS_AUTH_COOKIE_NAME: str = Field('bazis_auth', title=_('Name of the user cookie'))
37
- BAZIS_JWT_SESSION_ALG: str = Field(Algorithms.HS256, title=_('JWT algorithm'))
37
+ #: session tokens are signed with SECRET_KEY, so only HMAC algorithms are allowed
38
+ BAZIS_JWT_SESSION_ALG: Literal['HS256', 'HS384', 'HS512'] = Field(
39
+ 'HS256', title=_('JWT algorithm')
40
+ )
38
41
  BAZIS_JWT_SESSION_LIFETIME: int = Field(86400, title=_('JWT lifetime'), dynamic=True)
39
42
 
40
43
 
@@ -32,6 +32,11 @@ class UserRequestMiddleware:
32
32
  Processes the incoming request, setting the user in the context if the user is
33
33
  authenticated, and then passes the request to the next middleware or view.
34
34
  """
35
- if request.user and getattr(request.user, 'is_anonymous', None) is False:
36
- UserMixin.CTX_USER_REQUEST.set(request.user)
37
- return self.get_response(request)
35
+ # the context is reset after the response: a WSGI server reuses its threads,
36
+ # so a user left in the context would be seen by the next request of the thread
37
+ user = request.user if getattr(request.user, 'is_anonymous', None) is False else None
38
+ token = UserMixin.CTX_USER_REQUEST.set(user)
39
+ try:
40
+ return self.get_response(request)
41
+ finally:
42
+ UserMixin.CTX_USER_REQUEST.reset(token)
@@ -29,7 +29,7 @@ from django.dispatch import receiver
29
29
  from django.utils.timezone import now
30
30
  from django.utils.translation import gettext_lazy as _
31
31
 
32
- from jose import jwt
32
+ import jwt
33
33
 
34
34
  from bazis.core.models_abstract import InitialBase, JsonApiMixin
35
35
 
@@ -68,9 +68,11 @@ class UserAbstract(AbstractUser, InitialBase):
68
68
  Generates a JSON Web Token (JWT) for the user, optionally including an
69
69
  authentication type.
70
70
  """
71
+ dt_now = now()
71
72
  data = {
72
73
  'sub': self.username,
73
- 'exp': now() + timedelta(seconds=settings.BAZIS_JWT_SESSION_LIFETIME),
74
+ 'iat': dt_now,
75
+ 'exp': dt_now + timedelta(seconds=settings.BAZIS_JWT_SESSION_LIFETIME),
74
76
  }
75
77
  if auth_type:
76
78
  data['auth_type'] = auth_type
@@ -20,8 +20,7 @@ from fastapi.security import OAuth2PasswordBearer
20
20
 
21
21
  from starlette.status import HTTP_401_UNAUTHORIZED
22
22
 
23
- from jose import JWTError, jwt
24
- from jose.exceptions import ExpiredSignatureError
23
+ import jwt
25
24
 
26
25
  from bazis.core.errors import JsonApi401Exception
27
26
 
@@ -32,6 +31,23 @@ User = get_user_model() # noqa: N806
32
31
  AnonymousUser = get_anonymous_user_model()
33
32
 
34
33
 
34
+ def decode_token(token: str) -> dict:
35
+ """
36
+ Decodes and verifies a session JWT built by `UserAbstract.jwt_build`.
37
+ Raises `jwt.ExpiredSignatureError` for an expired token and `jwt.InvalidTokenError`
38
+ for any other invalid token. Tokens without an expiration time or a subject are
39
+ rejected.
40
+ """
41
+ return jwt.decode(
42
+ token,
43
+ settings.SECRET_KEY,
44
+ algorithms=[settings.BAZIS_JWT_SESSION_ALG],
45
+ # iat is informational: checking it would reject tokens issued by a server whose
46
+ # clock is slightly ahead
47
+ options={'require': ['exp', 'sub'], 'verify_iat': False},
48
+ )
49
+
50
+
35
51
  def get_token_data(
36
52
  token_header: str = Depends(
37
53
  OAuth2PasswordBearer(tokenUrl=settings.BAZIS_OPENAPI_TOKEN_URL, auto_error=False)
@@ -40,19 +56,26 @@ def get_token_data(
40
56
  token_cookie: str | None = Cookie(default=None, alias=settings.BAZIS_AUTH_COOKIE_NAME),
41
57
  ) -> dict:
42
58
  """
43
- Decodes the JWT token provided by the client. If the token is missing or
44
- invalid, returns an empty dictionary. Raises a JsonApi401Exception if the token
45
- has expired.
59
+ Decodes the JWT token provided by the client. Returns an empty dictionary if there
60
+ is no token or the token is not a session token (no expiration). Raises
61
+ JsonApi401Exception if the token has expired or is invalid.
46
62
  """
47
63
  token = token_param or token_header or token_cookie
48
64
 
49
65
  if not token:
50
66
  return {}
51
67
  try:
52
- return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.BAZIS_JWT_SESSION_ALG])
53
- except ExpiredSignatureError:
68
+ return decode_token(token)
69
+ except jwt.ExpiredSignatureError:
54
70
  raise JsonApi401Exception(detail=str(_('Token has expired'))) from None
55
- except JWTError:
71
+ except jwt.MissingRequiredClaimError as exc:
72
+ if exc.claim == 'exp':
73
+ # a token signed with SECRET_KEY but without expiration is not a session token
74
+ # (e.g. the token of the authorization store of bazis-authing, which uses the
75
+ # same cookie): it identifies nobody, the request is anonymous
76
+ return {}
77
+ raise JsonApi401Exception(detail=str(_('Token is invalid'))) from None
78
+ except jwt.InvalidTokenError:
56
79
  raise JsonApi401Exception(detail=str(_('Token is invalid'))) from None
57
80
 
58
81