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.
- bazis_users-2.3.0/.github/workflows/build-publish.yml +20 -0
- bazis_users-2.3.0/.github/workflows/release.yml +74 -0
- bazis_users-2.3.0/.github/workflows/tests.yml +99 -0
- bazis_users-2.3.0/CLAUDE.md +55 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/PKG-INFO +25 -17
- {bazis_users-2.2.0 → bazis_users-2.3.0}/README.md +20 -13
- bazis_users-2.3.0/bazis/contrib/users/_version.py +24 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/conf.py +6 -3
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/middleware.py +8 -3
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/models_abstract.py +4 -2
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/service.py +31 -8
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/PKG-INFO +25 -17
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/SOURCES.txt +8 -0
- bazis_users-2.3.0/bazis_users.egg-info/requires.txt +9 -0
- bazis_users-2.3.0/bazis_users.egg-info/scm_file_list.json +86 -0
- bazis_users-2.3.0/bazis_users.egg-info/scm_version.json +8 -0
- bazis_users-2.3.0/docs/releases/2.3.0.md +30 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/settings.py +2 -1
- {bazis_users-2.2.0 → bazis_users-2.3.0}/pyproject.toml +4 -3
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/pyproject.toml +1 -1
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/main.py +1 -5
- bazis_users-2.3.0/sample/uv.lock +1580 -0
- bazis_users-2.3.0/tests/test_tokens.py +135 -0
- bazis_users-2.2.0/.github/workflows/build-publish.yml +0 -16
- bazis_users-2.2.0/bazis/contrib/users/_version.py +0 -34
- bazis_users-2.2.0/bazis_users.egg-info/requires.txt +0 -8
- {bazis_users-2.2.0 → bazis_users-2.3.0}/.dockerignore +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/.github/workflows/update-licenses.yml +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/.gitignore +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/admin_abstract.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/apps.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.mo +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.po +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.mo +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.po +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/0001_initial.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/0002_user_dt_first_login.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/migrations/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/models.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/router.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/routes.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/routes_abstract.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis/contrib/users/schemas.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/dependency_links.txt +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/bazis_users.egg-info/top_level.txt +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/Makefile +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/README.md +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_static/pydata-custom.css +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_static/readthedocs-custom.css +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_templates/custom-class-template.rst +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/_templates/custom-module-template.rst +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/conf.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/index.rst +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/make.bat +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/manage.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/project.env +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/requirements.txt +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/docs/structure.rst +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/pytest.ini +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/admin.sh +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/app.sh +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/config/admin.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/config/app.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/db.sh +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/deploy/init.sh +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/admin.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/0001_initial.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/0002_alter_childentity_dt_created_and_more.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/migrations/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/models.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/router.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/entity/routes.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/manage.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/project.env +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/router.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/settings.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/urls.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/sample/wsgi.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/admin.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/apps.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/0001_initial.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/0002_user_dt_first_login.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/migrations/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/models.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/sample/users/router.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/setup.cfg +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/__init__.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/conftest.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/factories.py +0 -0
- {bazis_users-2.2.0 → bazis_users-2.3.0}/tests/test_protected.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
247
|
+
from bazis.contrib.users.service import get_token_data
|
|
243
248
|
|
|
244
|
-
def my_endpoint(token:
|
|
245
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
220
|
+
from bazis.contrib.users.service import get_token_data
|
|
217
221
|
|
|
218
|
-
def my_endpoint(token:
|
|
219
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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.
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
|