django-device-bound-sessions 0.1.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.
- django_device_bound_sessions-0.1.0/.gitignore +25 -0
- django_device_bound_sessions-0.1.0/LICENSE +21 -0
- django_device_bound_sessions-0.1.0/PKG-INFO +247 -0
- django_device_bound_sessions-0.1.0/README.md +229 -0
- django_device_bound_sessions-0.1.0/example/demo/__init__.py +0 -0
- django_device_bound_sessions-0.1.0/example/demo/settings.py +73 -0
- django_device_bound_sessions-0.1.0/example/demo/templates/base.html +19 -0
- django_device_bound_sessions-0.1.0/example/demo/templates/home.html +33 -0
- django_device_bound_sessions-0.1.0/example/demo/templates/registration/login.html +8 -0
- django_device_bound_sessions-0.1.0/example/demo/urls.py +15 -0
- django_device_bound_sessions-0.1.0/example/demo/views.py +49 -0
- django_device_bound_sessions-0.1.0/example/manage.py +9 -0
- django_device_bound_sessions-0.1.0/pyproject.toml +147 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/__init__.py +9 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/api.py +214 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/apps.py +11 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/challenges.py +94 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/checks.py +77 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/conf.py +149 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/cookies.py +69 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/decorators.py +13 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/middleware.py +107 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/proof.py +159 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/py.typed +0 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/signals.py +20 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/structured_fields.py +248 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/testing.py +190 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/urls.py +10 -0
- django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/views.py +147 -0
- django_device_bound_sessions-0.1.0/tests/__init__.py +0 -0
- django_device_bound_sessions-0.1.0/tests/conftest.py +36 -0
- django_device_bound_sessions-0.1.0/tests/settings.py +27 -0
- django_device_bound_sessions-0.1.0/tests/test_checks.py +101 -0
- django_device_bound_sessions-0.1.0/tests/test_engines.py +43 -0
- django_device_bound_sessions-0.1.0/tests/test_flow.py +610 -0
- django_device_bound_sessions-0.1.0/tests/test_internals.py +183 -0
- django_device_bound_sessions-0.1.0/tests/test_proof.py +227 -0
- django_device_bound_sessions-0.1.0/tests/test_structured_fields.py +177 -0
- django_device_bound_sessions-0.1.0/tests/urls.py +51 -0
- django_device_bound_sessions-0.1.0/typecheck/downstream.py +110 -0
- django_device_bound_sessions-0.1.0/uv.lock +949 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Tool caches
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
|
|
17
|
+
# Coverage data and reports
|
|
18
|
+
.coverage
|
|
19
|
+
.coverage.*
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
coverage.json
|
|
23
|
+
|
|
24
|
+
# Django (e.g. the example project's database)
|
|
25
|
+
*.sqlite3
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 0xdade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-device-bound-sessions
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Device Bound Session Credentials (DBSC) for Django
|
|
5
|
+
Author: 0xdade
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 5.2
|
|
10
|
+
Classifier: Framework :: Django :: 6.1
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: django>=5.2
|
|
16
|
+
Requires-Dist: pyjwt[crypto]>=2.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# django-device-bound-sessions
|
|
20
|
+
|
|
21
|
+
[](https://0xdade.github.io/django-device-bound-sessions/coverage/)
|
|
22
|
+
|
|
23
|
+
[Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) (DBSC) for Django. If someone steals a user's session cookie, it stops working on any other device.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
With DBSC, a supporting browser (Chrome) creates a key pair in the device's secure hardware when the server offers registration after a user logs in. The private key never leaves the device. The library adds a second, **short-lived "bound" cookie** alongside Django's normal session cookie. A browser can only get a new bound cookie by signing a server challenge with that key.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
login, then the next page ──► Secure-Session-Registration: (ES256 RS256);path="/dbsc/register/";challenge="…"
|
|
31
|
+
browser ──► POST /dbsc/register/ Secure-Session-Response: <JWT signed by the new key, includes its public JWK>
|
|
32
|
+
server ◄── 200 session config JSON
|
|
33
|
+
Set-Cookie: dbsc_bound=…; Max-Age=600
|
|
34
|
+
Secure-Session-Challenge: "<next challenge>";id="<id>"
|
|
35
|
+
|
|
36
|
+
…bound cookie expires; the browser refreshes it before sending its next request…
|
|
37
|
+
browser ──► POST /dbsc/refresh/ Sec-Secure-Session-Id: "<id>"
|
|
38
|
+
Secure-Session-Response: <JWT over the cached challenge>
|
|
39
|
+
server ◄── 200 Set-Cookie: dbsc_bound=…
|
|
40
|
+
Secure-Session-Challenge: "<next challenge>";id="<id>"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There are two cookies. Django's normal `sessionid` still says who the user is, and stays as long-lived as it always was. `dbsc_bound` is the short-lived one that proves the request comes from the device that registered; only the refresh endpoint issues it.
|
|
44
|
+
|
|
45
|
+
The registration offer is sent once, on the first page after `login()` that isn't a redirect, within `OFFER_MAX_AGE` (60 seconds).
|
|
46
|
+
|
|
47
|
+
Every response that sets a bound cookie also sends the next challenge, so a refresh normally takes one request. Refresh challenges stay valid as long as a bound cookie does (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`). If the browser has no usable challenge, for example because the user was idle for longer than that, the server answers its refresh with a `403` and a fresh challenge, and the browser retries with a proof over it.
|
|
48
|
+
|
|
49
|
+
Once a Django session is bound, the middleware rejects any request that lacks a valid bound cookie (by default it treats the request as anonymous). An attacker holding only `sessionid` has no key, so they can't refresh, and are locked out once the bound cookie expires: within `BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE` seconds (630 by default).
|
|
50
|
+
|
|
51
|
+
Browsers without DBSC support ignore the registration header. Their sessions never become bound and keep working exactly as they do today.
|
|
52
|
+
|
|
53
|
+
There is no database model. The device binding (public key, algorithm, and audit fields) is stored in the Django session itself, so:
|
|
54
|
+
|
|
55
|
+
- it works with any `SESSION_ENGINE` (db, cache, cached_db, file, signed_cookies);
|
|
56
|
+
- it lives exactly as long as the session does. Logging out or flushing the session removes the binding, and the browser's next refresh gets `{"continue": false}`.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
Requires Python 3.12+ and Django 5.2 (LTS) or 6.1+.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install django-device-bound-sessions
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
INSTALLED_APPS = [
|
|
68
|
+
...,
|
|
69
|
+
"django_device_bound_sessions",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
MIDDLEWARE = [
|
|
73
|
+
...,
|
|
74
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
75
|
+
...,
|
|
76
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
77
|
+
"django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware", # after auth
|
|
78
|
+
...,
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# urls.py
|
|
84
|
+
urlpatterns = [
|
|
85
|
+
...,
|
|
86
|
+
path("dbsc/", include("django_device_bound_sessions.urls")),
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That's it: no migrations. Any `django.contrib.auth.login()` call offers DBSC registration on the next non-redirect response.
|
|
91
|
+
|
|
92
|
+
## Using it in views
|
|
93
|
+
|
|
94
|
+
Every request gets `request.dbsc`:
|
|
95
|
+
|
|
96
|
+
| attribute | meaning |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `request.dbsc.binding` | `DeviceBinding` for this session, or `None` |
|
|
99
|
+
| `request.dbsc.session_id` | DBSC session id, or `None` |
|
|
100
|
+
| `request.dbsc.registered` | the session has a binding |
|
|
101
|
+
| `request.dbsc.bound` | request carried a valid bound cookie |
|
|
102
|
+
|
|
103
|
+
A `DeviceBinding` has `session_id`, `algorithm`, `public_key` (JWK), `thumbprint` (RFC 7638), `user_agent`, `created_at`, and `last_refreshed_at`.
|
|
104
|
+
|
|
105
|
+
Helpers:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from django_device_bound_sessions.api import get_binding, offer_registration
|
|
109
|
+
from django_device_bound_sessions.decorators import dbsc_exempt
|
|
110
|
+
|
|
111
|
+
offer_registration(request) # offer DBSC outside of login (e.g. after step-up auth)
|
|
112
|
+
get_binding(request) # the DeviceBinding, also available as request.dbsc.binding
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dbsc_exempt # skip enforcement and offers for this view
|
|
116
|
+
def health(request): ...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To end a binding, end the session (`logout()`, `request.session.flush()`).
|
|
120
|
+
|
|
121
|
+
## Type checking
|
|
122
|
+
|
|
123
|
+
The package ships `py.typed` and is checked with mypy (strict, with django-stubs), pyright (strict), and [ty](https://docs.astral.sh/ty/). You don't have to change how your views are typed. Pick whichever of these fits your code:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from django.http import HttpRequest
|
|
127
|
+
from django_device_bound_sessions.api import DBSCHttpRequest, DBSCRequestMixin, get_dbsc_state
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# 1. Views typed against plain HttpRequest: nothing to change.
|
|
131
|
+
def account(request: HttpRequest) -> HttpResponse:
|
|
132
|
+
state = get_dbsc_state(request) # DBSCState
|
|
133
|
+
if state.binding is not None:
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# 2. Use DBSCHttpRequest to access request.dbsc directly.
|
|
138
|
+
def account(request: DBSCHttpRequest) -> HttpResponse:
|
|
139
|
+
if request.dbsc.bound:
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# 3. Already have your own request type? Add the mixin to it.
|
|
144
|
+
class AppRequest(DBSCRequestMixin, HttpRequest):
|
|
145
|
+
tenant: Tenant
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If the middleware isn't installed, `get_dbsc_state()` raises `ImproperlyConfigured` instead of quietly reporting an unbound session. `get_binding(request)` reads the session directly and works without the middleware.
|
|
149
|
+
|
|
150
|
+
`typecheck/downstream.py` pins these types down for apps using the library. mypy, pyright and ty all check it strictly, so a change that loosens or breaks them fails the check.
|
|
151
|
+
|
|
152
|
+
Annotate your settings with `DBSCConfig` to get misspelled keys and wrong value types flagged:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from django_device_bound_sessions.conf import DBSCConfig
|
|
156
|
+
|
|
157
|
+
DBSC: DBSCConfig = {"BOUND_COOKIE_AGE": 300, "ENFORCEMENT": "anonymous"}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Settings
|
|
161
|
+
|
|
162
|
+
Set these in a `DBSC` dict in your settings:
|
|
163
|
+
|
|
164
|
+
| setting | default | |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `BOUND_COOKIE_NAME` | `"dbsc_bound"` | |
|
|
167
|
+
| `BOUND_COOKIE_AGE` | `600` | seconds; this is how long a stolen bound cookie stays useful |
|
|
168
|
+
| `BOUND_COOKIE_GRACE` | `30` | extra seconds the server accepts after expiry |
|
|
169
|
+
| `BOUND_COOKIE_DOMAIN` / `_PATH` / `_SECURE` / `_HTTPONLY` / `_SAMESITE` | `None` / `"/"` / `True` / `True` / `"Lax"` | |
|
|
170
|
+
| `ALGORITHMS` | `["ES256", "RS256"]` | offered in preference order (`none` is never accepted) |
|
|
171
|
+
| `CHALLENGE_MAX_AGE` | `60` | seconds a registration challenge is valid, from when the offer is sent. Refresh challenges last as long as a bound cookie (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`) |
|
|
172
|
+
| `CACHE_ALIAS` | `"default"` | cache that makes challenges single-use; must be shared by every worker (see Security notes) |
|
|
173
|
+
| `SCOPE_ORIGIN` | `None` | `scope.origin` |
|
|
174
|
+
| `SCOPE_INCLUDE_SITE` | `False` | `scope.include_site` |
|
|
175
|
+
| `SCOPE_SPECIFICATION` | `[]` | e.g. `[{"type": "exclude", "domain": "*", "path": "/static"}]` |
|
|
176
|
+
| `ALLOWED_REFRESH_INITIATORS` | `[]` | |
|
|
177
|
+
| `REGISTRATION_PATH` / `REFRESH_PATH` | `None` | override the URLs (default: reversed from `django_device_bound_sessions.urls`) |
|
|
178
|
+
| `OFFER_ON_LOGIN` | `True` | |
|
|
179
|
+
| `OFFER_MAX_AGE` | `60` | seconds after `login()` that a registration offer can still be sent |
|
|
180
|
+
| `ENFORCEMENT` | `"anonymous"` | `"anonymous"`: treat the request as logged out. `"logout"`: flush the session. `"off"`: check `request.dbsc` yourself |
|
|
181
|
+
|
|
182
|
+
## Security notes
|
|
183
|
+
|
|
184
|
+
- **A binding is permanent for the life of the session.** The first device to register owns the session. Registration is refused (409) on a session that is already bound, so a stolen session cookie can't be used to move the session onto another device. To use a new device, log in again, which creates a new session.
|
|
185
|
+
- **The window before binding is unprotected, and it's kept short.** Until registration completes, whoever holds the session cookie could receive the registration offer. Offers are only sent for `OFFER_MAX_AGE` seconds after login and are used once. DBSC can't protect a session that was stolen before it was bound; like the spec, this library assumes the device wasn't compromised at login time.
|
|
186
|
+
- Challenges are signed with `SECRET_KEY` and expire. Each one is accepted once, enforced through the cache. Registration challenges are tied to the Django session that received them.
|
|
187
|
+
- **The cache must be shared by every worker.** It's the only state the library keeps outside the session: a record of used challenges, so a signed proof can't be replayed. Use a cache that every process and server sees and that has an atomic `add()`, such as Redis, Memcached or Django's database cache, and point `CACHE_ALIAS` at it. Django's default `LocMemCache` is per-process, so with several workers a used proof could be replayed on another worker until its challenge expires. `DummyCache` disables replay protection entirely, because its `add()` always succeeds. A system check (run by `manage.py check`, `runserver` and `migrate`) warns about `LocMemCache` (`django_device_bound_sessions.W001`, which you can silence with `SILENCED_SYSTEM_CHECKS` if you only ever run one process) and reports an error for `DummyCache` (`E002`) or a `CACHE_ALIAS` that isn't in `CACHES` (`E001`).
|
|
188
|
+
- A refresh with an invalid proof is rejected, but the DBSC session stays alive. Otherwise anyone holding a stolen cookie could knock the real device off.
|
|
189
|
+
- A refresh without the matching Django session (the user logged out, the session expired, or the binding was replaced) returns `{"continue": false}`, which tells the browser to stop. It never changes the session it was sent with.
|
|
190
|
+
- DBSC endpoints are CSRF-exempt, because the browser calls them without a token. The signed challenge authenticates them instead. Responses are `no-store` and `X-Frame-Options: DENY`.
|
|
191
|
+
|
|
192
|
+
## Testing your app
|
|
193
|
+
|
|
194
|
+
`django_device_bound_sessions.testing.DBSCBrowser` wraps a Django test `Client` and simulates the browser side of DBSC:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from django_device_bound_sessions.testing import DBSCBrowser
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_private_page(client, user):
|
|
201
|
+
browser = DBSCBrowser(client) # or algorithm="RS256"
|
|
202
|
+
client.force_login(user)
|
|
203
|
+
browser.get("/") # registers automatically
|
|
204
|
+
browser.expire_bound_cookie()
|
|
205
|
+
assert browser.get("/private/").status_code == 200 # refreshes first, like Chrome
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
uv sync
|
|
212
|
+
uv run pre-commit install # ruff lint + format and zizmor on every commit
|
|
213
|
+
uv run pytest
|
|
214
|
+
uv run pytest --cov # with line + branch coverage (fails below 95%)
|
|
215
|
+
uv run mypy
|
|
216
|
+
uv run pyright
|
|
217
|
+
uv run ty check
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
CI (`.github/workflows/ci.yml`) runs on pushes to `main` and on pull requests:
|
|
221
|
+
|
|
222
|
+
- **Tests** on Python 3.12–3.15 with the latest Django 6.1.x, and Python 3.12–3.14 with the latest
|
|
223
|
+
Django 5.2.x (LTS).
|
|
224
|
+
- **Coverage**: branch coverage with a 95% floor. The run summary shows the coverage table, and the full HTML report is attached as the `coverage-report` artifact. A separate workflow (`pages.yml`) publishes the report and the README badge's data to GitHub Pages on pushes to `main`. There's also a run against the lowest versions allowed by `pyproject.toml` (Django 5.2.0, PyJWT 2.10.0).
|
|
225
|
+
- **Pre-commit** on every file: `ruff check` + `ruff format`, and [zizmor](https://docs.zizmor.sh/) for the workflows and Dependabot config. In CI, zizmor also runs its online audits, such as checking that each pinned action SHA is a real commit in that action's repository.
|
|
226
|
+
- **mypy** (strict, with django-stubs), **pyright** (strict on `src/` and `typecheck/`) and **ty**.
|
|
227
|
+
|
|
228
|
+
A final `CI passed` job depends on all of these and succeeds only if they all did. It's the one check to require in branch protection, so changing the matrix never means updating the ruleset.
|
|
229
|
+
|
|
230
|
+
Actions are pinned to commit SHAs. Dependabot or Renovate can keep them up to date.
|
|
231
|
+
|
|
232
|
+
Run `uv run pre-commit run --all-files` to lint and format everything by hand. Ruff isn't a project dependency. It runs only through pre-commit, so the hook's `rev` in
|
|
233
|
+
`.pre-commit-config.yaml` is the single ruff version used locally and in CI.
|
|
234
|
+
|
|
235
|
+
### Demo project
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
uv run example/manage.py migrate
|
|
239
|
+
uv run example/manage.py createsuperuser
|
|
240
|
+
uv run example/manage.py runserver
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Open http://localhost:8000 in Chrome with DBSC enabled and log in. The home page shows the binding status. The demo's bound cookie lives 60 seconds, and an **Expire bound cookie** button forces a refresh, which you can watch in DevTools → Application → Device Bound Sessions.
|
|
244
|
+
|
|
245
|
+
DBSC requires HTTPS, except on `localhost`.
|
|
246
|
+
|
|
247
|
+
Protocol reference: <https://w3c.github.io/webappsec-dbsc/>
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# django-device-bound-sessions
|
|
2
|
+
|
|
3
|
+
[](https://0xdade.github.io/django-device-bound-sessions/coverage/)
|
|
4
|
+
|
|
5
|
+
[Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) (DBSC) for Django. If someone steals a user's session cookie, it stops working on any other device.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
With DBSC, a supporting browser (Chrome) creates a key pair in the device's secure hardware when the server offers registration after a user logs in. The private key never leaves the device. The library adds a second, **short-lived "bound" cookie** alongside Django's normal session cookie. A browser can only get a new bound cookie by signing a server challenge with that key.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
login, then the next page ──► Secure-Session-Registration: (ES256 RS256);path="/dbsc/register/";challenge="…"
|
|
13
|
+
browser ──► POST /dbsc/register/ Secure-Session-Response: <JWT signed by the new key, includes its public JWK>
|
|
14
|
+
server ◄── 200 session config JSON
|
|
15
|
+
Set-Cookie: dbsc_bound=…; Max-Age=600
|
|
16
|
+
Secure-Session-Challenge: "<next challenge>";id="<id>"
|
|
17
|
+
|
|
18
|
+
…bound cookie expires; the browser refreshes it before sending its next request…
|
|
19
|
+
browser ──► POST /dbsc/refresh/ Sec-Secure-Session-Id: "<id>"
|
|
20
|
+
Secure-Session-Response: <JWT over the cached challenge>
|
|
21
|
+
server ◄── 200 Set-Cookie: dbsc_bound=…
|
|
22
|
+
Secure-Session-Challenge: "<next challenge>";id="<id>"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
There are two cookies. Django's normal `sessionid` still says who the user is, and stays as long-lived as it always was. `dbsc_bound` is the short-lived one that proves the request comes from the device that registered; only the refresh endpoint issues it.
|
|
26
|
+
|
|
27
|
+
The registration offer is sent once, on the first page after `login()` that isn't a redirect, within `OFFER_MAX_AGE` (60 seconds).
|
|
28
|
+
|
|
29
|
+
Every response that sets a bound cookie also sends the next challenge, so a refresh normally takes one request. Refresh challenges stay valid as long as a bound cookie does (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`). If the browser has no usable challenge, for example because the user was idle for longer than that, the server answers its refresh with a `403` and a fresh challenge, and the browser retries with a proof over it.
|
|
30
|
+
|
|
31
|
+
Once a Django session is bound, the middleware rejects any request that lacks a valid bound cookie (by default it treats the request as anonymous). An attacker holding only `sessionid` has no key, so they can't refresh, and are locked out once the bound cookie expires: within `BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE` seconds (630 by default).
|
|
32
|
+
|
|
33
|
+
Browsers without DBSC support ignore the registration header. Their sessions never become bound and keep working exactly as they do today.
|
|
34
|
+
|
|
35
|
+
There is no database model. The device binding (public key, algorithm, and audit fields) is stored in the Django session itself, so:
|
|
36
|
+
|
|
37
|
+
- it works with any `SESSION_ENGINE` (db, cache, cached_db, file, signed_cookies);
|
|
38
|
+
- it lives exactly as long as the session does. Logging out or flushing the session removes the binding, and the browser's next refresh gets `{"continue": false}`.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.12+ and Django 5.2 (LTS) or 6.1+.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install django-device-bound-sessions
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
INSTALLED_APPS = [
|
|
50
|
+
...,
|
|
51
|
+
"django_device_bound_sessions",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
MIDDLEWARE = [
|
|
55
|
+
...,
|
|
56
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
57
|
+
...,
|
|
58
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
59
|
+
"django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware", # after auth
|
|
60
|
+
...,
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# urls.py
|
|
66
|
+
urlpatterns = [
|
|
67
|
+
...,
|
|
68
|
+
path("dbsc/", include("django_device_bound_sessions.urls")),
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
That's it: no migrations. Any `django.contrib.auth.login()` call offers DBSC registration on the next non-redirect response.
|
|
73
|
+
|
|
74
|
+
## Using it in views
|
|
75
|
+
|
|
76
|
+
Every request gets `request.dbsc`:
|
|
77
|
+
|
|
78
|
+
| attribute | meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `request.dbsc.binding` | `DeviceBinding` for this session, or `None` |
|
|
81
|
+
| `request.dbsc.session_id` | DBSC session id, or `None` |
|
|
82
|
+
| `request.dbsc.registered` | the session has a binding |
|
|
83
|
+
| `request.dbsc.bound` | request carried a valid bound cookie |
|
|
84
|
+
|
|
85
|
+
A `DeviceBinding` has `session_id`, `algorithm`, `public_key` (JWK), `thumbprint` (RFC 7638), `user_agent`, `created_at`, and `last_refreshed_at`.
|
|
86
|
+
|
|
87
|
+
Helpers:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from django_device_bound_sessions.api import get_binding, offer_registration
|
|
91
|
+
from django_device_bound_sessions.decorators import dbsc_exempt
|
|
92
|
+
|
|
93
|
+
offer_registration(request) # offer DBSC outside of login (e.g. after step-up auth)
|
|
94
|
+
get_binding(request) # the DeviceBinding, also available as request.dbsc.binding
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dbsc_exempt # skip enforcement and offers for this view
|
|
98
|
+
def health(request): ...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
To end a binding, end the session (`logout()`, `request.session.flush()`).
|
|
102
|
+
|
|
103
|
+
## Type checking
|
|
104
|
+
|
|
105
|
+
The package ships `py.typed` and is checked with mypy (strict, with django-stubs), pyright (strict), and [ty](https://docs.astral.sh/ty/). You don't have to change how your views are typed. Pick whichever of these fits your code:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from django.http import HttpRequest
|
|
109
|
+
from django_device_bound_sessions.api import DBSCHttpRequest, DBSCRequestMixin, get_dbsc_state
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# 1. Views typed against plain HttpRequest: nothing to change.
|
|
113
|
+
def account(request: HttpRequest) -> HttpResponse:
|
|
114
|
+
state = get_dbsc_state(request) # DBSCState
|
|
115
|
+
if state.binding is not None:
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# 2. Use DBSCHttpRequest to access request.dbsc directly.
|
|
120
|
+
def account(request: DBSCHttpRequest) -> HttpResponse:
|
|
121
|
+
if request.dbsc.bound:
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# 3. Already have your own request type? Add the mixin to it.
|
|
126
|
+
class AppRequest(DBSCRequestMixin, HttpRequest):
|
|
127
|
+
tenant: Tenant
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If the middleware isn't installed, `get_dbsc_state()` raises `ImproperlyConfigured` instead of quietly reporting an unbound session. `get_binding(request)` reads the session directly and works without the middleware.
|
|
131
|
+
|
|
132
|
+
`typecheck/downstream.py` pins these types down for apps using the library. mypy, pyright and ty all check it strictly, so a change that loosens or breaks them fails the check.
|
|
133
|
+
|
|
134
|
+
Annotate your settings with `DBSCConfig` to get misspelled keys and wrong value types flagged:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from django_device_bound_sessions.conf import DBSCConfig
|
|
138
|
+
|
|
139
|
+
DBSC: DBSCConfig = {"BOUND_COOKIE_AGE": 300, "ENFORCEMENT": "anonymous"}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Settings
|
|
143
|
+
|
|
144
|
+
Set these in a `DBSC` dict in your settings:
|
|
145
|
+
|
|
146
|
+
| setting | default | |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `BOUND_COOKIE_NAME` | `"dbsc_bound"` | |
|
|
149
|
+
| `BOUND_COOKIE_AGE` | `600` | seconds; this is how long a stolen bound cookie stays useful |
|
|
150
|
+
| `BOUND_COOKIE_GRACE` | `30` | extra seconds the server accepts after expiry |
|
|
151
|
+
| `BOUND_COOKIE_DOMAIN` / `_PATH` / `_SECURE` / `_HTTPONLY` / `_SAMESITE` | `None` / `"/"` / `True` / `True` / `"Lax"` | |
|
|
152
|
+
| `ALGORITHMS` | `["ES256", "RS256"]` | offered in preference order (`none` is never accepted) |
|
|
153
|
+
| `CHALLENGE_MAX_AGE` | `60` | seconds a registration challenge is valid, from when the offer is sent. Refresh challenges last as long as a bound cookie (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`) |
|
|
154
|
+
| `CACHE_ALIAS` | `"default"` | cache that makes challenges single-use; must be shared by every worker (see Security notes) |
|
|
155
|
+
| `SCOPE_ORIGIN` | `None` | `scope.origin` |
|
|
156
|
+
| `SCOPE_INCLUDE_SITE` | `False` | `scope.include_site` |
|
|
157
|
+
| `SCOPE_SPECIFICATION` | `[]` | e.g. `[{"type": "exclude", "domain": "*", "path": "/static"}]` |
|
|
158
|
+
| `ALLOWED_REFRESH_INITIATORS` | `[]` | |
|
|
159
|
+
| `REGISTRATION_PATH` / `REFRESH_PATH` | `None` | override the URLs (default: reversed from `django_device_bound_sessions.urls`) |
|
|
160
|
+
| `OFFER_ON_LOGIN` | `True` | |
|
|
161
|
+
| `OFFER_MAX_AGE` | `60` | seconds after `login()` that a registration offer can still be sent |
|
|
162
|
+
| `ENFORCEMENT` | `"anonymous"` | `"anonymous"`: treat the request as logged out. `"logout"`: flush the session. `"off"`: check `request.dbsc` yourself |
|
|
163
|
+
|
|
164
|
+
## Security notes
|
|
165
|
+
|
|
166
|
+
- **A binding is permanent for the life of the session.** The first device to register owns the session. Registration is refused (409) on a session that is already bound, so a stolen session cookie can't be used to move the session onto another device. To use a new device, log in again, which creates a new session.
|
|
167
|
+
- **The window before binding is unprotected, and it's kept short.** Until registration completes, whoever holds the session cookie could receive the registration offer. Offers are only sent for `OFFER_MAX_AGE` seconds after login and are used once. DBSC can't protect a session that was stolen before it was bound; like the spec, this library assumes the device wasn't compromised at login time.
|
|
168
|
+
- Challenges are signed with `SECRET_KEY` and expire. Each one is accepted once, enforced through the cache. Registration challenges are tied to the Django session that received them.
|
|
169
|
+
- **The cache must be shared by every worker.** It's the only state the library keeps outside the session: a record of used challenges, so a signed proof can't be replayed. Use a cache that every process and server sees and that has an atomic `add()`, such as Redis, Memcached or Django's database cache, and point `CACHE_ALIAS` at it. Django's default `LocMemCache` is per-process, so with several workers a used proof could be replayed on another worker until its challenge expires. `DummyCache` disables replay protection entirely, because its `add()` always succeeds. A system check (run by `manage.py check`, `runserver` and `migrate`) warns about `LocMemCache` (`django_device_bound_sessions.W001`, which you can silence with `SILENCED_SYSTEM_CHECKS` if you only ever run one process) and reports an error for `DummyCache` (`E002`) or a `CACHE_ALIAS` that isn't in `CACHES` (`E001`).
|
|
170
|
+
- A refresh with an invalid proof is rejected, but the DBSC session stays alive. Otherwise anyone holding a stolen cookie could knock the real device off.
|
|
171
|
+
- A refresh without the matching Django session (the user logged out, the session expired, or the binding was replaced) returns `{"continue": false}`, which tells the browser to stop. It never changes the session it was sent with.
|
|
172
|
+
- DBSC endpoints are CSRF-exempt, because the browser calls them without a token. The signed challenge authenticates them instead. Responses are `no-store` and `X-Frame-Options: DENY`.
|
|
173
|
+
|
|
174
|
+
## Testing your app
|
|
175
|
+
|
|
176
|
+
`django_device_bound_sessions.testing.DBSCBrowser` wraps a Django test `Client` and simulates the browser side of DBSC:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from django_device_bound_sessions.testing import DBSCBrowser
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_private_page(client, user):
|
|
183
|
+
browser = DBSCBrowser(client) # or algorithm="RS256"
|
|
184
|
+
client.force_login(user)
|
|
185
|
+
browser.get("/") # registers automatically
|
|
186
|
+
browser.expire_bound_cookie()
|
|
187
|
+
assert browser.get("/private/").status_code == 200 # refreshes first, like Chrome
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
uv sync
|
|
194
|
+
uv run pre-commit install # ruff lint + format and zizmor on every commit
|
|
195
|
+
uv run pytest
|
|
196
|
+
uv run pytest --cov # with line + branch coverage (fails below 95%)
|
|
197
|
+
uv run mypy
|
|
198
|
+
uv run pyright
|
|
199
|
+
uv run ty check
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
CI (`.github/workflows/ci.yml`) runs on pushes to `main` and on pull requests:
|
|
203
|
+
|
|
204
|
+
- **Tests** on Python 3.12–3.15 with the latest Django 6.1.x, and Python 3.12–3.14 with the latest
|
|
205
|
+
Django 5.2.x (LTS).
|
|
206
|
+
- **Coverage**: branch coverage with a 95% floor. The run summary shows the coverage table, and the full HTML report is attached as the `coverage-report` artifact. A separate workflow (`pages.yml`) publishes the report and the README badge's data to GitHub Pages on pushes to `main`. There's also a run against the lowest versions allowed by `pyproject.toml` (Django 5.2.0, PyJWT 2.10.0).
|
|
207
|
+
- **Pre-commit** on every file: `ruff check` + `ruff format`, and [zizmor](https://docs.zizmor.sh/) for the workflows and Dependabot config. In CI, zizmor also runs its online audits, such as checking that each pinned action SHA is a real commit in that action's repository.
|
|
208
|
+
- **mypy** (strict, with django-stubs), **pyright** (strict on `src/` and `typecheck/`) and **ty**.
|
|
209
|
+
|
|
210
|
+
A final `CI passed` job depends on all of these and succeeds only if they all did. It's the one check to require in branch protection, so changing the matrix never means updating the ruleset.
|
|
211
|
+
|
|
212
|
+
Actions are pinned to commit SHAs. Dependabot or Renovate can keep them up to date.
|
|
213
|
+
|
|
214
|
+
Run `uv run pre-commit run --all-files` to lint and format everything by hand. Ruff isn't a project dependency. It runs only through pre-commit, so the hook's `rev` in
|
|
215
|
+
`.pre-commit-config.yaml` is the single ruff version used locally and in CI.
|
|
216
|
+
|
|
217
|
+
### Demo project
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv run example/manage.py migrate
|
|
221
|
+
uv run example/manage.py createsuperuser
|
|
222
|
+
uv run example/manage.py runserver
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Open http://localhost:8000 in Chrome with DBSC enabled and log in. The home page shows the binding status. The demo's bound cookie lives 60 seconds, and an **Expire bound cookie** button forces a refresh, which you can watch in DevTools → Application → Device Bound Sessions.
|
|
226
|
+
|
|
227
|
+
DBSC requires HTTPS, except on `localhost`.
|
|
228
|
+
|
|
229
|
+
Protocol reference: <https://w3c.github.io/webappsec-dbsc/>
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Demo project for django-device-bound-sessions. Run with: uv run example/manage.py runserver"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from django_device_bound_sessions.conf import DBSCConfig
|
|
6
|
+
|
|
7
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
8
|
+
|
|
9
|
+
SECRET_KEY = "django-device-bound-sessions-demo-not-secret" # noqa: S105 - demo only, never deploy
|
|
10
|
+
DEBUG = True
|
|
11
|
+
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
|
|
12
|
+
|
|
13
|
+
INSTALLED_APPS = [
|
|
14
|
+
"django.contrib.admin",
|
|
15
|
+
"django.contrib.auth",
|
|
16
|
+
"django.contrib.contenttypes",
|
|
17
|
+
"django.contrib.sessions",
|
|
18
|
+
"django.contrib.messages",
|
|
19
|
+
"django.contrib.staticfiles",
|
|
20
|
+
"django_device_bound_sessions",
|
|
21
|
+
"demo",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
MIDDLEWARE = [
|
|
25
|
+
"django.middleware.security.SecurityMiddleware",
|
|
26
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
27
|
+
"django.middleware.common.CommonMiddleware",
|
|
28
|
+
"django.middleware.csrf.CsrfViewMiddleware",
|
|
29
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
30
|
+
"django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware",
|
|
31
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
32
|
+
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
ROOT_URLCONF = "demo.urls"
|
|
36
|
+
|
|
37
|
+
TEMPLATES = [
|
|
38
|
+
{
|
|
39
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
40
|
+
"APP_DIRS": True,
|
|
41
|
+
"OPTIONS": {
|
|
42
|
+
"context_processors": [
|
|
43
|
+
"django.template.context_processors.request",
|
|
44
|
+
"django.contrib.auth.context_processors.auth",
|
|
45
|
+
"django.contrib.messages.context_processors.messages",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
|
|
52
|
+
|
|
53
|
+
USE_TZ = True
|
|
54
|
+
STATIC_URL = "static/"
|
|
55
|
+
LOGIN_REDIRECT_URL = "/"
|
|
56
|
+
LOGOUT_REDIRECT_URL = "/"
|
|
57
|
+
|
|
58
|
+
DBSC: DBSCConfig = {
|
|
59
|
+
# Short lifetime so you can watch Chrome refresh in DevTools.
|
|
60
|
+
"BOUND_COOKIE_AGE": 60,
|
|
61
|
+
"SCOPE_SPECIFICATION": [{"type": "exclude", "domain": "*", "path": "/static"}],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# The demo runs as a single runserver process, so Django's default per-process
|
|
65
|
+
# LocMemCache is fine as the replay cache. Deployments with several workers
|
|
66
|
+
# need a shared cache (see the README's security notes).
|
|
67
|
+
SILENCED_SYSTEM_CHECKS = ["django_device_bound_sessions.W001"]
|
|
68
|
+
|
|
69
|
+
LOGGING = {
|
|
70
|
+
"version": 1,
|
|
71
|
+
"handlers": {"console": {"class": "logging.StreamHandler"}},
|
|
72
|
+
"loggers": {"django_device_bound_sessions": {"handlers": ["console"], "level": "INFO"}},
|
|
73
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>django-device-bound-sessions demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
9
|
+
.ok { color: #0a7d32; } .bad { color: #b3261e; }
|
|
10
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: .25rem 1rem; }
|
|
11
|
+
dt { font-weight: 600; } dd { margin: 0; overflow-wrap: anywhere; }
|
|
12
|
+
form { display: inline; } button { margin-right: .5rem; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<h1>django-device-bound-sessions demo</h1>
|
|
17
|
+
{% block content %}{% endblock %}
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
{% if user.is_authenticated %}
|
|
4
|
+
<p>Logged in as <strong>{{ user.get_username }}</strong>.</p>
|
|
5
|
+
{% else %}
|
|
6
|
+
<p>Not logged in. <a href="{% url 'login' %}">Log in</a></p>
|
|
7
|
+
{% endif %}
|
|
8
|
+
|
|
9
|
+
<dl>
|
|
10
|
+
<dt>DBSC session</dt>
|
|
11
|
+
<dd>{% if request.dbsc.session_id %}<code>{{ request.dbsc.session_id }}</code>{% else %}none (not registered){% endif %}</dd>
|
|
12
|
+
<dt>Bound cookie</dt>
|
|
13
|
+
<dd>{% if request.dbsc.bound %}<span class="ok">valid</span>{% elif request.dbsc.session_id %}<span class="bad">missing / invalid: request treated as anonymous</span>{% else %}n/a{% endif %}</dd>
|
|
14
|
+
{% if binding %}
|
|
15
|
+
<dt>Algorithm</dt><dd>{{ binding.algorithm }}</dd>
|
|
16
|
+
<dt>Key thumbprint</dt><dd><code>{{ binding.thumbprint }}</code></dd>
|
|
17
|
+
<dt>Registered</dt><dd>{{ binding.created_at }}</dd>
|
|
18
|
+
<dt>Last refresh</dt><dd>{{ binding.last_refreshed_at|default:"never" }}</dd>
|
|
19
|
+
<dt>User agent</dt><dd>{{ binding.user_agent }}</dd>
|
|
20
|
+
{% endif %}
|
|
21
|
+
</dl>
|
|
22
|
+
|
|
23
|
+
{% if user.is_authenticated or request.dbsc.session_id %}
|
|
24
|
+
<p>
|
|
25
|
+
<form method="post" action="{% url 'expire' %}">{% csrf_token %}<button>Expire bound cookie</button></form>
|
|
26
|
+
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button>Log out</button></form>
|
|
27
|
+
</p>
|
|
28
|
+
<p>The bound cookie lives 60 seconds. After it expires (or you expire it), Chrome should
|
|
29
|
+
refresh it by signing a challenge with its device-bound key before the next request reaches
|
|
30
|
+
the server. Watch the <em>Application → Device Bound Sessions</em> panel and the network log in DevTools.</p>
|
|
31
|
+
<p>The same state as JSON: <a href="{% url 'status' %}">/status/</a></p>
|
|
32
|
+
{% endif %}
|
|
33
|
+
{% endblock %}
|