grantor-django 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.
- grantor_django-0.1.0/.gitignore +18 -0
- grantor_django-0.1.0/PKG-INFO +64 -0
- grantor_django-0.1.0/README.md +31 -0
- grantor_django-0.1.0/pyproject.toml +63 -0
- grantor_django-0.1.0/src/grantor_django/__init__.py +29 -0
- grantor_django-0.1.0/src/grantor_django/admin.py +349 -0
- grantor_django-0.1.0/src/grantor_django/apps.py +78 -0
- grantor_django-0.1.0/src/grantor_django/backends.py +240 -0
- grantor_django-0.1.0/src/grantor_django/client.py +44 -0
- grantor_django-0.1.0/src/grantor_django/conf.py +274 -0
- grantor_django-0.1.0/src/grantor_django/drf.py +323 -0
- grantor_django-0.1.0/src/grantor_django/management/__init__.py +0 -0
- grantor_django-0.1.0/src/grantor_django/management/commands/__init__.py +0 -0
- grantor_django-0.1.0/src/grantor_django/management/commands/grantor_break_glass.py +190 -0
- grantor_django-0.1.0/src/grantor_django/py.typed +0 -0
- grantor_django-0.1.0/src/grantor_django/transaction.py +110 -0
- grantor_django-0.1.0/src/grantor_django/urls.py +49 -0
- grantor_django-0.1.0/src/grantor_django/views.py +292 -0
- grantor_django-0.1.0/tests/conftest.py +141 -0
- grantor_django-0.1.0/tests/django_support.py +8 -0
- grantor_django-0.1.0/tests/test_admin.py +249 -0
- grantor_django-0.1.0/tests/test_break_glass.py +228 -0
- grantor_django-0.1.0/tests/test_configuration.py +273 -0
- grantor_django-0.1.0/tests/test_consumer_findings.py +133 -0
- grantor_django-0.1.0/tests/test_drf.py +299 -0
- grantor_django-0.1.0/tests/test_flow.py +210 -0
- grantor_django-0.1.0/tests/test_provisioning.py +65 -0
- grantor_django-0.1.0/tests/test_session_backends.py +51 -0
- grantor_django-0.1.0/tests/test_signout.py +112 -0
- grantor_django-0.1.0/tests/test_soft_delete.py +140 -0
- grantor_django-0.1.0/tests/test_spa_shape.py +93 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: grantor-django
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sign in with Grantor from Django — views, an authentication backend, DRF and the admin.
|
|
5
|
+
Project-URL: Homepage, https://grantor.id
|
|
6
|
+
Project-URL: Documentation, https://docs.grantor.id
|
|
7
|
+
Project-URL: Source, https://github.com/Grantor-id/grantor-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Grantor-id/grantor-python/issues
|
|
9
|
+
Author: Allure Labs
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: django,grantor,oauth2,oidc,openid-connect,sso
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: django<6,>=4.2
|
|
28
|
+
Requires-Dist: grantor<0.2,>=0.1.0
|
|
29
|
+
Provides-Extra: admin
|
|
30
|
+
Provides-Extra: drf
|
|
31
|
+
Requires-Dist: djangorestframework>=3.14; extra == 'drf'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# `grantor-django`
|
|
35
|
+
|
|
36
|
+
Sign in with [Grantor](https://grantor.id) from Django — an OAuth 2.1 /
|
|
37
|
+
OpenID Connect provider.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pip install grantor-django # session sign-in
|
|
41
|
+
pip install grantor-django[drf] # + a DRF API that answers to an access token
|
|
42
|
+
pip install grantor-django[admin] # + a Django admin with no local password
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
INSTALLED_APPS = [..., "grantor_django"]
|
|
47
|
+
|
|
48
|
+
GRANTOR_ISSUER = "https://acme.api.grantor.id"
|
|
49
|
+
GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
|
|
50
|
+
GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
|
|
51
|
+
GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
urlpatterns = [path("auth/", include("grantor_django.urls"))]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every endpoint — authorization, token, JWKS, end-session — is read from the
|
|
59
|
+
issuer's discovery document. The issuer is the only string you configure.
|
|
60
|
+
|
|
61
|
+
Documentation: [docs.grantor.id](https://docs.grantor.id).
|
|
62
|
+
Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
|
|
63
|
+
|
|
64
|
+
MIT. © Allure Labs.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `grantor-django`
|
|
2
|
+
|
|
3
|
+
Sign in with [Grantor](https://grantor.id) from Django — an OAuth 2.1 /
|
|
4
|
+
OpenID Connect provider.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install grantor-django # session sign-in
|
|
8
|
+
pip install grantor-django[drf] # + a DRF API that answers to an access token
|
|
9
|
+
pip install grantor-django[admin] # + a Django admin with no local password
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
INSTALLED_APPS = [..., "grantor_django"]
|
|
14
|
+
|
|
15
|
+
GRANTOR_ISSUER = "https://acme.api.grantor.id"
|
|
16
|
+
GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
|
|
17
|
+
GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
|
|
18
|
+
GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
urlpatterns = [path("auth/", include("grantor_django.urls"))]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every endpoint — authorization, token, JWKS, end-session — is read from the
|
|
26
|
+
issuer's discovery document. The issuer is the only string you configure.
|
|
27
|
+
|
|
28
|
+
Documentation: [docs.grantor.id](https://docs.grantor.id).
|
|
29
|
+
Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
|
|
30
|
+
|
|
31
|
+
MIT. © Allure Labs.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "grantor-django"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sign in with Grantor from Django — views, an authentication backend, DRF and the admin."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Allure Labs" }]
|
|
9
|
+
keywords = ["django", "oauth2", "oidc", "openid-connect", "sso", "grantor"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Environment :: Web Environment",
|
|
13
|
+
"Framework :: Django",
|
|
14
|
+
"Framework :: Django :: 4.2",
|
|
15
|
+
"Framework :: Django :: 5.0",
|
|
16
|
+
"Framework :: Django :: 5.1",
|
|
17
|
+
"Framework :: Django :: 5.2",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
# Lockstep through 0.x: the two packages are cut from one commit and one
|
|
28
|
+
# CI run, so a `grantor-django` can never be installed beside a core it
|
|
29
|
+
# was not tested against. They decouple at 1.0.
|
|
30
|
+
#
|
|
31
|
+
# Written out rather than as `grantor~=0.1`, because `~=` excludes
|
|
32
|
+
# pre-releases: `grantor~=0.1` cannot resolve `grantor 0.1.0a0`, which
|
|
33
|
+
# is the very core this release is cut with, and
|
|
34
|
+
# `pip install grantor-django==0.1.0a0` fails on it. A specifier that
|
|
35
|
+
# names a pre-release admits them. Same lockstep, one that installs.
|
|
36
|
+
"grantor>=0.1.0,<0.2",
|
|
37
|
+
# Capped at the declared support matrix — 4.2 LTS through 5.x. An
|
|
38
|
+
# uncapped range would let a fresh install resolve to a Django this
|
|
39
|
+
# release was never tested against, which is a promise the
|
|
40
|
+
# classifiers above do not make.
|
|
41
|
+
"django>=4.2,<6",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
drf = ["djangorestframework>=3.14"]
|
|
46
|
+
# Empty on purpose, and kept rather than tidied away: the admin needs
|
|
47
|
+
# nothing Django does not already provide, but `pip install
|
|
48
|
+
# grantor-django[admin]` is in the README, and an extra that does not exist
|
|
49
|
+
# makes that line print a warning about a typo the reader did not make.
|
|
50
|
+
admin = []
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://grantor.id"
|
|
54
|
+
Documentation = "https://docs.grantor.id"
|
|
55
|
+
Source = "https://github.com/Grantor-id/grantor-python"
|
|
56
|
+
Issues = "https://github.com/Grantor-id/grantor-python/issues"
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["hatchling"]
|
|
60
|
+
build-backend = "hatchling.build"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/grantor_django"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sign in with Grantor, from Django.
|
|
2
|
+
|
|
3
|
+
INSTALLED_APPS = [..., "grantor_django"]
|
|
4
|
+
|
|
5
|
+
GRANTOR_ISSUER = "https://acme.api.grantor.id"
|
|
6
|
+
GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
|
|
7
|
+
GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
|
|
8
|
+
GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
|
|
9
|
+
|
|
10
|
+
urlpatterns = [path("auth/", include("grantor_django.urls"))]
|
|
11
|
+
|
|
12
|
+
That is the whole configuration. Every endpoint — authorization, token,
|
|
13
|
+
JWKS, end-session — comes from the issuer's discovery document.
|
|
14
|
+
|
|
15
|
+
The dividing rule between this package and ``grantor``, sharp enough for
|
|
16
|
+
review: anything that touches ``settings``, ``request``, ``session``, the
|
|
17
|
+
``User`` model, or returns an ``HttpResponse`` belongs here. Everything else
|
|
18
|
+
belongs in the core, where a framework this package has never heard of can
|
|
19
|
+
compose it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__", "default_app_config"]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
# Django ≥3.2 discovers AppConfig automatically; named here for readers.
|
|
29
|
+
default_app_config = "grantor_django.apps.GrantorDjangoConfig"
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""A Django admin with no local password at all.
|
|
2
|
+
|
|
3
|
+
The third integration shape, and the one with the sharpest security
|
|
4
|
+
argument. The admin is a privileged surface on a public host, so it has no
|
|
5
|
+
local password path: ``ModelBackend`` need not be installed, admin users are
|
|
6
|
+
created with an unusable password, and the only way in is the authorization
|
|
7
|
+
code flow. There is nothing to brute-force and no credential to leak,
|
|
8
|
+
because none exists on this side.
|
|
9
|
+
|
|
10
|
+
Django's login form is **replaced**, not hidden. A hidden form is a form
|
|
11
|
+
somebody finds.
|
|
12
|
+
|
|
13
|
+
from grantor_django.admin import GrantorAdminSite
|
|
14
|
+
|
|
15
|
+
site = GrantorAdminSite(name="admin")
|
|
16
|
+
|
|
17
|
+
GRANTOR_ADMIN_CLIENT_ID = env("GRANTOR_ADMIN_CLIENT_ID")
|
|
18
|
+
GRANTOR_ADMIN_CLIENT_SECRET = env("GRANTOR_ADMIN_CLIENT_SECRET")
|
|
19
|
+
GRANTOR_ADMIN_ROLE = "superadmin"
|
|
20
|
+
|
|
21
|
+
Privilege comes from a Grantor role and is **re-read on every sign-in,
|
|
22
|
+
including off**. Revoking the role at the issuer takes effect the next time
|
|
23
|
+
the person authenticates, rather than whenever somebody remembers to untick
|
|
24
|
+
a box in a database.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import secrets
|
|
31
|
+
from dataclasses import asdict
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from django.conf import settings
|
|
35
|
+
from django.contrib.admin import AdminSite
|
|
36
|
+
from django.contrib.auth import get_user_model
|
|
37
|
+
from django.contrib.auth import login as django_login
|
|
38
|
+
from django.contrib.auth import logout as django_logout
|
|
39
|
+
from django.core import signing
|
|
40
|
+
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
|
|
41
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
42
|
+
from django.urls import path, reverse
|
|
43
|
+
from grantor import GrantorClient, GrantorError, ProtocolError, TokenError, parse_redirect_error
|
|
44
|
+
|
|
45
|
+
from . import conf, transaction
|
|
46
|
+
from .views import relative_path_only
|
|
47
|
+
|
|
48
|
+
__all__ = ["GrantorAdminSite", "user_for_claims", "admin_client", "BREAK_GLASS_SETTING"]
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger("grantor_django.admin")
|
|
51
|
+
|
|
52
|
+
BREAK_GLASS_SETTING = "GRANTOR_ADMIN_BREAK_GLASS"
|
|
53
|
+
|
|
54
|
+
# Its own cookie, so an admin sign-in and an application sign-in in the same
|
|
55
|
+
# browser cannot overwrite each other's transaction.
|
|
56
|
+
_TXN_COOKIE = "grantor_admin_txn"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _required(name: str) -> str:
|
|
60
|
+
value = getattr(settings, name, None)
|
|
61
|
+
if not value or not isinstance(value, str):
|
|
62
|
+
raise ImproperlyConfigured(
|
|
63
|
+
f"{name} must be set to use the Grantor admin. The admin is a separate "
|
|
64
|
+
"OAuth client from the application's: it asks for the `roles` scope and "
|
|
65
|
+
"its own redirect URI, and sharing one client between the two would mean "
|
|
66
|
+
"a token for the application is a token for the admin."
|
|
67
|
+
)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def admin_client() -> GrantorClient:
|
|
72
|
+
"""The admin's own client — deliberately not the application's."""
|
|
73
|
+
return GrantorClient(
|
|
74
|
+
conf.issuer(),
|
|
75
|
+
http=conf.http_client(),
|
|
76
|
+
client_id=_required("GRANTOR_ADMIN_CLIENT_ID"),
|
|
77
|
+
client_secret=_required("GRANTOR_ADMIN_CLIENT_SECRET"),
|
|
78
|
+
# `roles` is the whole point: it is what says whether this person may
|
|
79
|
+
# be here at all.
|
|
80
|
+
scope="openid profile email roles",
|
|
81
|
+
auth_method=conf.get("GRANTOR_AUTH_METHOD"),
|
|
82
|
+
timeout=conf.get("GRANTOR_TIMEOUT"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def user_for_claims(claims: dict[str, Any]) -> Any:
|
|
87
|
+
"""Map a Grantor identity onto an admin user, or refuse.
|
|
88
|
+
|
|
89
|
+
Both flags are set from the role on **every** sign-in, including to
|
|
90
|
+
``False``. A person whose role was revoked at the issuer loses the admin
|
|
91
|
+
the next time they authenticate — and the record here says so, rather
|
|
92
|
+
than keeping a stale ``is_superuser`` that some other surface might one
|
|
93
|
+
day believe.
|
|
94
|
+
"""
|
|
95
|
+
required_role = getattr(settings, "GRANTOR_ADMIN_ROLE", "superadmin")
|
|
96
|
+
roles = claims.get("roles") or []
|
|
97
|
+
holds_it = isinstance(roles, (list, tuple)) and required_role in roles
|
|
98
|
+
|
|
99
|
+
User = get_user_model()
|
|
100
|
+
username_field = getattr(User, "USERNAME_FIELD", "username")
|
|
101
|
+
user = User._default_manager.filter(**{username_field: claims["sub"]}).first()
|
|
102
|
+
|
|
103
|
+
if user is None:
|
|
104
|
+
# A person being refused leaves no record behind. Creating one would
|
|
105
|
+
# let anybody who can reach the issuer populate this table, and a row
|
|
106
|
+
# in a table called `user` on an admin surface reads like an account
|
|
107
|
+
# whether or not its flags say otherwise.
|
|
108
|
+
if not holds_it:
|
|
109
|
+
_refuse(claims, required_role)
|
|
110
|
+
user = User(**{username_field: claims["sub"], "email": claims.get("email", "")})
|
|
111
|
+
# No password path exists on this side, so there is nothing to guess.
|
|
112
|
+
user.set_unusable_password()
|
|
113
|
+
|
|
114
|
+
if claims.get("email"):
|
|
115
|
+
user.email = claims["email"]
|
|
116
|
+
user.is_active = True
|
|
117
|
+
user.is_staff = holds_it
|
|
118
|
+
user.is_superuser = holds_it
|
|
119
|
+
user.save()
|
|
120
|
+
|
|
121
|
+
if not holds_it:
|
|
122
|
+
# An account that already exists is told the truth even as it is
|
|
123
|
+
# turned away: never creating and never updating are different
|
|
124
|
+
# rules, and only the first one is right.
|
|
125
|
+
_refuse(claims, required_role)
|
|
126
|
+
return user
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _refuse(claims: dict[str, Any], required_role: str) -> None:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"grantor admin: refused sub %s — the %s role is required",
|
|
132
|
+
claims.get("sub"),
|
|
133
|
+
required_role,
|
|
134
|
+
)
|
|
135
|
+
raise PermissionDenied(f"the {required_role} role is required to use this admin")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GrantorAdminSite(AdminSite):
|
|
139
|
+
"""An admin whose login view is a redirect to the issuer."""
|
|
140
|
+
|
|
141
|
+
def get_urls(self) -> list[Any]:
|
|
142
|
+
return [
|
|
143
|
+
path("grantor/callback", self.grantor_callback, name="grantor_callback"),
|
|
144
|
+
*super().get_urls(),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def _redirect_uri(self, request: HttpRequest) -> str:
|
|
148
|
+
return request.build_absolute_uri(reverse(f"{self.name}:grantor_callback"))
|
|
149
|
+
|
|
150
|
+
def login(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
|
|
151
|
+
"""No password form. Straight to the issuer.
|
|
152
|
+
|
|
153
|
+
Unless break-glass is on — see :meth:`_break_glass_login`, and read
|
|
154
|
+
its docstring before turning it on.
|
|
155
|
+
"""
|
|
156
|
+
if getattr(settings, BREAK_GLASS_SETTING, False):
|
|
157
|
+
return self._break_glass_login(request, extra_context)
|
|
158
|
+
|
|
159
|
+
# Same guard the session views use. Unvalidated, this sends the
|
|
160
|
+
# browser off-site **after a successful sign-in** — the moment a
|
|
161
|
+
# person is most likely to trust what they are looking at, on the
|
|
162
|
+
# most privileged surface the product has. The library already had
|
|
163
|
+
# `relative_path_only`; this path simply was not using it.
|
|
164
|
+
next_url = relative_path_only(request.GET.get("next"), reverse(f"{self.name}:index"))
|
|
165
|
+
try:
|
|
166
|
+
authorization = admin_client().start_authorization(
|
|
167
|
+
redirect_uri=self._redirect_uri(request)
|
|
168
|
+
)
|
|
169
|
+
except GrantorError as exc:
|
|
170
|
+
raise PermissionDenied("the issuer is unreachable") from exc
|
|
171
|
+
|
|
172
|
+
response = HttpResponseRedirect(authorization.url)
|
|
173
|
+
_issue_txn(
|
|
174
|
+
response,
|
|
175
|
+
transaction.Transaction(
|
|
176
|
+
state=authorization.state,
|
|
177
|
+
code_verifier=authorization.code_verifier,
|
|
178
|
+
nonce=authorization.nonce,
|
|
179
|
+
next_url=next_url,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
def _break_glass_login(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
|
|
185
|
+
"""Django's password form, back, on purpose. **This is dangerous.**
|
|
186
|
+
|
|
187
|
+
It exists because this product has an incident on record where
|
|
188
|
+
enforcing a second factor locked out the only enrolled account, and
|
|
189
|
+
an auth library that ships no way back is repeating it. It is still
|
|
190
|
+
the thing an attacker most wants you to have left on.
|
|
191
|
+
|
|
192
|
+
Turning it on requires two deliberate acts, not one: setting
|
|
193
|
+
``GRANTOR_ADMIN_BREAK_GLASS = True`` **and** giving somebody a usable
|
|
194
|
+
password *and* ``is_staff``, which no normal path in this library
|
|
195
|
+
ever does (``manage.py grantor_break_glass`` is the supported way,
|
|
196
|
+
and it says the same things this docstring does).
|
|
197
|
+
|
|
198
|
+
``GRANTOR_ADMIN_BREAK_GLASS`` is a **Django setting, not an
|
|
199
|
+
environment variable**. A project that keeps configuration in the
|
|
200
|
+
environment has to read it across itself::
|
|
201
|
+
|
|
202
|
+
GRANTOR_ADMIN_BREAK_GLASS = env.bool(
|
|
203
|
+
"GRANTOR_ADMIN_BREAK_GLASS", default=False
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
Setting the variable alone changes nothing this library can see. A
|
|
207
|
+
consumer set it, force-deployed, and the admin went on redirecting
|
|
208
|
+
to the issuer — which reads as the break-glass being broken rather
|
|
209
|
+
than as never having been switched on.
|
|
210
|
+
|
|
211
|
+
And it needs ``ModelBackend`` in ``AUTHENTICATION_BACKENDS``. A
|
|
212
|
+
project following this module's own advice does not install it, and
|
|
213
|
+
then no password authenticates at all.
|
|
214
|
+
|
|
215
|
+
Afterwards, in the audit trail at the issuer, check:
|
|
216
|
+
|
|
217
|
+
* every admin sign-in during the window — break-glass sign-ins do
|
|
218
|
+
**not** appear there, so anything that does was a normal sign-in
|
|
219
|
+
and anything that happened without one was not;
|
|
220
|
+
* the local ``last_login`` of the account you gave a password to;
|
|
221
|
+
* that the password was removed **and ``is_staff`` revoked** and the
|
|
222
|
+
setting turned back off — the step people forget, because by then
|
|
223
|
+
it is working again. A close that leaves a staff row behind leaves
|
|
224
|
+
a standing admin account on a surface whose whole argument is that
|
|
225
|
+
none exists.
|
|
226
|
+
|
|
227
|
+
Note what this path does **not** do: it does not consult the issuer
|
|
228
|
+
at all, so the Grantor role check is bypassed rather than merely
|
|
229
|
+
deferred. That is the point — the role check is what is broken when
|
|
230
|
+
you need this — and it is the reason the window is closed rather
|
|
231
|
+
than left open because it is working.
|
|
232
|
+
"""
|
|
233
|
+
logger.error(
|
|
234
|
+
"grantor admin: BREAK GLASS is enabled — the admin is accepting local "
|
|
235
|
+
"passwords. Turn %s off once you are back in.",
|
|
236
|
+
BREAK_GLASS_SETTING,
|
|
237
|
+
)
|
|
238
|
+
return super().login(request, extra_context)
|
|
239
|
+
|
|
240
|
+
def logout(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
|
|
241
|
+
"""End the local session, then the issuer's.
|
|
242
|
+
|
|
243
|
+
In that order. Somebody who signs out of an admin and is silently
|
|
244
|
+
signed back in by a session they were never shown has been told
|
|
245
|
+
something untrue — and on this surface, that is a privileged session
|
|
246
|
+
they believe is closed.
|
|
247
|
+
"""
|
|
248
|
+
id_token = request.COOKIES.get(conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"), "")
|
|
249
|
+
django_logout(request)
|
|
250
|
+
|
|
251
|
+
destination = reverse(f"{self.name}:index")
|
|
252
|
+
if id_token:
|
|
253
|
+
try:
|
|
254
|
+
destination = admin_client().end_session_url(
|
|
255
|
+
id_token,
|
|
256
|
+
post_logout_redirect_uri=conf.get("GRANTOR_POST_LOGOUT_REDIRECT_URI"),
|
|
257
|
+
)
|
|
258
|
+
except GrantorError:
|
|
259
|
+
logger.warning(
|
|
260
|
+
"grantor admin: could not build the end-session URL; signed out locally"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
response = HttpResponseRedirect(destination)
|
|
264
|
+
response.delete_cookie(conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"), path="/")
|
|
265
|
+
return response
|
|
266
|
+
|
|
267
|
+
def grantor_callback(self, request: HttpRequest) -> HttpResponse:
|
|
268
|
+
refusal = parse_redirect_error(request.GET)
|
|
269
|
+
if refusal:
|
|
270
|
+
# Surfaced rather than retried. A retry loop against a refusal is
|
|
271
|
+
# how somebody ends up watching a redirect that never settles.
|
|
272
|
+
raise PermissionDenied(f"the issuer refused the sign-in: {refusal}")
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
txn = _read_txn(request)
|
|
276
|
+
except transaction.InvalidTransaction as exc:
|
|
277
|
+
raise PermissionDenied("this sign-in expired; start again") from exc
|
|
278
|
+
|
|
279
|
+
if not secrets.compare_digest(request.GET.get("state", ""), txn.state):
|
|
280
|
+
raise PermissionDenied("state mismatch")
|
|
281
|
+
|
|
282
|
+
client = admin_client()
|
|
283
|
+
try:
|
|
284
|
+
tokens = client.exchange_code(
|
|
285
|
+
request.GET.get("code", ""),
|
|
286
|
+
redirect_uri=self._redirect_uri(request),
|
|
287
|
+
code_verifier=txn.code_verifier,
|
|
288
|
+
)
|
|
289
|
+
except ProtocolError as exc:
|
|
290
|
+
# Logged as well as raised. Django renders PermissionDenied as a
|
|
291
|
+
# bare 403 page with no detail — correct for a browser, useless
|
|
292
|
+
# for whoever has to work out why the admin stopped letting
|
|
293
|
+
# people in. The code is safe to log and is the whole diagnosis.
|
|
294
|
+
logger.warning("grantor admin: token exchange refused: %s", exc.code)
|
|
295
|
+
raise PermissionDenied(
|
|
296
|
+
f"the issuer refused the authorization code: {exc.code}"
|
|
297
|
+
) from exc
|
|
298
|
+
except GrantorError as exc:
|
|
299
|
+
raise PermissionDenied("the issuer is unreachable") from exc
|
|
300
|
+
|
|
301
|
+
if not tokens.id_token:
|
|
302
|
+
raise PermissionDenied("the issuer returned no ID token")
|
|
303
|
+
try:
|
|
304
|
+
claims = client.verify_id_token(tokens.id_token, nonce=txn.nonce)
|
|
305
|
+
except TokenError as exc:
|
|
306
|
+
logger.warning("grantor admin: id token rejected: %s", exc.reason)
|
|
307
|
+
raise PermissionDenied(f"the ID token was rejected: {exc.reason}") from exc
|
|
308
|
+
|
|
309
|
+
user = user_for_claims(claims)
|
|
310
|
+
django_login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
|
311
|
+
|
|
312
|
+
response = HttpResponseRedirect(txn.next_url or reverse(f"{self.name}:index"))
|
|
313
|
+
_clear_txn(response)
|
|
314
|
+
response.set_cookie(
|
|
315
|
+
conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"),
|
|
316
|
+
tokens.id_token,
|
|
317
|
+
httponly=True,
|
|
318
|
+
secure=conf.cookie_secure(),
|
|
319
|
+
samesite=conf.cookie_samesite(),
|
|
320
|
+
path="/",
|
|
321
|
+
)
|
|
322
|
+
return response
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _issue_txn(response: HttpResponse, txn: transaction.Transaction) -> None:
|
|
326
|
+
response.set_cookie(
|
|
327
|
+
_TXN_COOKIE,
|
|
328
|
+
signing.dumps(asdict(txn), salt=transaction.SALT),
|
|
329
|
+
max_age=conf.get("GRANTOR_TXN_MAX_AGE"),
|
|
330
|
+
httponly=True,
|
|
331
|
+
secure=conf.cookie_secure(),
|
|
332
|
+
samesite=conf.cookie_samesite(),
|
|
333
|
+
path="/",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read_txn(request: HttpRequest) -> transaction.Transaction:
|
|
338
|
+
raw = request.COOKIES.get(_TXN_COOKIE, "")
|
|
339
|
+
if not raw:
|
|
340
|
+
raise transaction.InvalidTransaction("no transaction cookie")
|
|
341
|
+
try:
|
|
342
|
+
payload = signing.loads(raw, salt=transaction.SALT, max_age=conf.get("GRANTOR_TXN_MAX_AGE"))
|
|
343
|
+
except signing.BadSignature as exc:
|
|
344
|
+
raise transaction.InvalidTransaction("bad or expired transaction") from exc
|
|
345
|
+
return transaction.Transaction(**payload)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _clear_txn(response: HttpResponse) -> None:
|
|
349
|
+
response.delete_cookie(_TXN_COOKIE, path="/")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The app, and the check that refuses to boot on a broken configuration.
|
|
2
|
+
|
|
3
|
+
A missing setting discovered at the first sign-in attempt is discovered by
|
|
4
|
+
the wrong person: somebody trying to log in, looking at an error page, with
|
|
5
|
+
no way to fix it. Discovered at boot it is a deploy that does not start,
|
|
6
|
+
which is the right audience and the right moment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from django.apps import AppConfig
|
|
14
|
+
from django.core.checks import Error, Warning, register
|
|
15
|
+
|
|
16
|
+
__all__ = ["GrantorDjangoConfig"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GrantorDjangoConfig(AppConfig):
|
|
20
|
+
name = "grantor_django"
|
|
21
|
+
verbose_name = "Grantor"
|
|
22
|
+
|
|
23
|
+
def ready(self) -> None:
|
|
24
|
+
register(_check_settings)
|
|
25
|
+
register(_check_subject_queryset)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_settings(app_configs: Any, **kwargs: Any) -> list[Error]:
|
|
29
|
+
from .conf import check_configuration
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
Error(problem, id=f"grantor_django.E{index:03d}")
|
|
33
|
+
for index, problem in enumerate(check_configuration(), start=1)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_subject_queryset(app_configs: Any, **kwargs: Any) -> list[Warning]:
|
|
38
|
+
"""Warn when an explicit queryset replaces the safe default.
|
|
39
|
+
|
|
40
|
+
When ``sub`` lives on a related model, lookups go through **that
|
|
41
|
+
model's** default manager, so a project with soft-deleted rows is
|
|
42
|
+
correct without configuring anything. Setting
|
|
43
|
+
``GRANTOR_USER_QUERYSET`` replaces that route entirely — which is the
|
|
44
|
+
point of the setting, and also the one way left to reintroduce the
|
|
45
|
+
defect it was added to close.
|
|
46
|
+
|
|
47
|
+
Quiet unless both are true: the subject is behind a relation, and that
|
|
48
|
+
relation's manager excludes rows. A project with a stock manager hears
|
|
49
|
+
nothing, so this does not become noise people learn to ignore.
|
|
50
|
+
"""
|
|
51
|
+
from django.db.models import Manager
|
|
52
|
+
|
|
53
|
+
from .backends import _subject_relation
|
|
54
|
+
from .conf import get
|
|
55
|
+
|
|
56
|
+
path = get("GRANTOR_USER_QUERYSET")
|
|
57
|
+
if not path:
|
|
58
|
+
return []
|
|
59
|
+
rel = _subject_relation()
|
|
60
|
+
if rel is None:
|
|
61
|
+
return []
|
|
62
|
+
model = rel.related_model
|
|
63
|
+
manager = model._default_manager
|
|
64
|
+
if type(manager) is Manager and type(manager) is type(model._base_manager):
|
|
65
|
+
return []
|
|
66
|
+
return [
|
|
67
|
+
Warning(
|
|
68
|
+
f"GRANTOR_USER_QUERYSET ({path}) replaces the default lookup, which would "
|
|
69
|
+
f"otherwise go through {model.__name__}.{manager.__class__.__name__} and honour "
|
|
70
|
+
f"its exclusions. Make sure your queryset excludes the same rows — a queryset "
|
|
71
|
+
f"that joins across the relation reads that table directly and skips the manager.",
|
|
72
|
+
hint=(
|
|
73
|
+
"Remove GRANTOR_USER_QUERYSET to use the default route, which honours "
|
|
74
|
+
f"{model.__name__}'s manager, unless your project needs something else."
|
|
75
|
+
),
|
|
76
|
+
id="grantor_django.W001",
|
|
77
|
+
)
|
|
78
|
+
]
|