PyEVP 0.1.0__py3-none-any.whl
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.
- pyevp/__init__.py +47 -0
- pyevp/__main__.py +28 -0
- pyevp/_email.py +65 -0
- pyevp/_httpsig.py +284 -0
- pyevp/_jose.py +145 -0
- pyevp/_sf.py +406 -0
- pyevp/adapters/__init__.py +4 -0
- pyevp/adapters/_doh.py +92 -0
- pyevp/adapters/_fetch.py +86 -0
- pyevp/adapters/_http.py +38 -0
- pyevp/adapters/dnspython.py +79 -0
- pyevp/adapters/doh.py +130 -0
- pyevp/adapters/httpx.py +147 -0
- pyevp/adapters/urllib.py +170 -0
- pyevp/cache.py +83 -0
- pyevp/cli/__init__.py +348 -0
- pyevp/contrib/__init__.py +4 -0
- pyevp/contrib/django/__init__.py +306 -0
- pyevp/contrib/django/apps.py +17 -0
- pyevp/contrib/django/issuer.py +314 -0
- pyevp/contrib/django/migrations/0001_initial.py +17 -0
- pyevp/contrib/django/migrations/__init__.py +0 -0
- pyevp/contrib/django/models.py +14 -0
- pyevp/contrib/django/templatetags/__init__.py +0 -0
- pyevp/contrib/django/templatetags/pyevp.py +32 -0
- pyevp/core.py +321 -0
- pyevp/diagnostics.py +182 -0
- pyevp/discovery.py +144 -0
- pyevp/errors.py +80 -0
- pyevp/issuer/__init__.py +39 -0
- pyevp/issuer/core.py +413 -0
- pyevp/issuer/errors.py +99 -0
- pyevp/issuer/fedcm.py +44 -0
- pyevp/issuer/keys.py +140 -0
- pyevp/issuer/profile.py +96 -0
- pyevp/nonce.py +25 -0
- pyevp/observability.py +89 -0
- pyevp/ports.py +54 -0
- pyevp/profile.py +153 -0
- pyevp/py.typed +0 -0
- pyevp/replay.py +69 -0
- pyevp/testing.py +343 -0
- pyevp/token.py +135 -0
- pyevp/types.py +37 -0
- pyevp/verifier.py +486 -0
- pyevp-0.1.0.dist-info/METADATA +171 -0
- pyevp-0.1.0.dist-info/RECORD +50 -0
- pyevp-0.1.0.dist-info/WHEEL +4 -0
- pyevp-0.1.0.dist-info/entry_points.txt +3 -0
- pyevp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Running an EVP issuer in Django.
|
|
2
|
+
|
|
3
|
+
:class:`IssuerSite` serves everything Chrome needs from an issuer, with the
|
|
4
|
+
logged-in Django user deciding which addresses get tokens::
|
|
5
|
+
|
|
6
|
+
# urls.py, on the issuer's origin
|
|
7
|
+
from pyevp.contrib.django.issuer import IssuerSite
|
|
8
|
+
|
|
9
|
+
class Site(IssuerSite):
|
|
10
|
+
def user_emails(self, request):
|
|
11
|
+
... # addresses whose mail the user receives
|
|
12
|
+
|
|
13
|
+
evp = Site(issuer) # a pyevp.issuer.Issuer
|
|
14
|
+
urlpatterns = [path("", include(evp.urls)), ...]
|
|
15
|
+
|
|
16
|
+
Subclass it to say which addresses a user may get tokens for
|
|
17
|
+
(:meth:`IssuerSite.user_emails`, required) and, optionally, to choose the issuer
|
|
18
|
+
per request (:meth:`IssuerSite.get_issuer`). Each endpoint is also a view of
|
|
19
|
+
its own, for mounting it somewhere else::
|
|
20
|
+
|
|
21
|
+
path("fedcm/accounts", AccountsView.as_view(site=evp))
|
|
22
|
+
|
|
23
|
+
The session cookie must be ``SameSite=None; Secure``, because Chrome's requests
|
|
24
|
+
to the issuer are cross-site; a system check warns otherwise. Add
|
|
25
|
+
:class:`LoginStatusMiddleware` so that Chrome knows when users are signed in.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import weakref
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
from typing import Any, ClassVar
|
|
33
|
+
from urllib.parse import urlsplit
|
|
34
|
+
|
|
35
|
+
from django.conf import settings
|
|
36
|
+
from django.core import checks
|
|
37
|
+
from django.core.exceptions import ImproperlyConfigured, RequestDataTooBig
|
|
38
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
39
|
+
from django.shortcuts import resolve_url
|
|
40
|
+
from django.urls import URLPattern, get_resolver, path
|
|
41
|
+
from django.views import View
|
|
42
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
43
|
+
|
|
44
|
+
from pyevp.issuer import (
|
|
45
|
+
FEDCM_FETCH_DEST,
|
|
46
|
+
IssuanceError,
|
|
47
|
+
IssuanceErrorCode,
|
|
48
|
+
IssuanceResponse,
|
|
49
|
+
Issuer,
|
|
50
|
+
accounts_document,
|
|
51
|
+
is_valid_email,
|
|
52
|
+
web_identity_document,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AccountsView",
|
|
57
|
+
"IssuanceView",
|
|
58
|
+
"IssuerSite",
|
|
59
|
+
"JWKSView",
|
|
60
|
+
"LoginStatusMiddleware",
|
|
61
|
+
"MetadataView",
|
|
62
|
+
"WebIdentityView",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
_MAX_BODY = 16 * 1024
|
|
66
|
+
_sites: weakref.WeakSet[IssuerSite] = weakref.WeakSet()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _overrides(cls: type, name: str) -> bool:
|
|
70
|
+
return getattr(cls, name) is not getattr(IssuerSite, name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IssuerSite:
|
|
74
|
+
"""The issuer's endpoints, bound to one :class:`~pyevp.issuer.Issuer` or chosen per request.
|
|
75
|
+
|
|
76
|
+
:param issuer: the issuer to serve. Leave it out and override :meth:`get_issuer`
|
|
77
|
+
when it depends on the request, e.g. on the host.
|
|
78
|
+
:param login_url: where Chrome sends users who are not signed in; defaults to
|
|
79
|
+
``settings.LOGIN_URL``. A path is taken relative to the issuer's origin.
|
|
80
|
+
|
|
81
|
+
:attr:`urls` must be included at the root of the issuer's origin, and the paths
|
|
82
|
+
below must match the issuer's ``issuance_endpoint`` and ``jwks_uri``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
metadata_path: ClassVar[str] = ".well-known/email-verification"
|
|
86
|
+
jwks_path: ClassVar[str] = "email-verification/jwks"
|
|
87
|
+
issuance_path: ClassVar[str] = "email-verification/issuance"
|
|
88
|
+
accounts_path: ClassVar[str] = "fedcm/accounts"
|
|
89
|
+
web_identity_path: ClassVar[str] = ".well-known/web-identity"
|
|
90
|
+
|
|
91
|
+
def __init__(self, issuer: Issuer | None = None, *, login_url: str | None = None) -> None:
|
|
92
|
+
if issuer is not None:
|
|
93
|
+
for url, route in (
|
|
94
|
+
(issuer.issuance_endpoint, self.issuance_path),
|
|
95
|
+
(issuer.jwks_uri, self.jwks_path),
|
|
96
|
+
):
|
|
97
|
+
if urlsplit(url).path != "/" + route:
|
|
98
|
+
raise ImproperlyConfigured(f"{url} is not served at /{route}")
|
|
99
|
+
self.issuer = issuer
|
|
100
|
+
self.login_url = login_url
|
|
101
|
+
if not _overrides(type(self), "user_emails"):
|
|
102
|
+
raise ImproperlyConfigured(
|
|
103
|
+
"subclass IssuerSite and override user_emails() with the addresses whose "
|
|
104
|
+
"mail the signed-in user receives"
|
|
105
|
+
)
|
|
106
|
+
_sites.add(self)
|
|
107
|
+
|
|
108
|
+
# --- hooks ---
|
|
109
|
+
|
|
110
|
+
def get_issuer(self, request: HttpRequest) -> Issuer:
|
|
111
|
+
"""The issuer answering ``request``."""
|
|
112
|
+
if self.issuer is None:
|
|
113
|
+
raise ImproperlyConfigured("pass an Issuer to IssuerSite or override get_issuer")
|
|
114
|
+
return self.issuer
|
|
115
|
+
|
|
116
|
+
def user_emails(self, request: HttpRequest) -> Sequence[str]:
|
|
117
|
+
"""Addresses the signed-in user may get tokens for; empty without a session.
|
|
118
|
+
|
|
119
|
+
Override this. A token tells relying parties that the user controls the address, so
|
|
120
|
+
return only addresses whose mail the user actually receives, for example the
|
|
121
|
+
verified addresses of django-allauth, or what your mail server delivers to the
|
|
122
|
+
user's mailbox. A user model's email field is not that unless your sign-up flow
|
|
123
|
+
verified it, which is why there is no default.
|
|
124
|
+
"""
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
|
|
127
|
+
def owns(self, request: HttpRequest, email: str) -> bool:
|
|
128
|
+
"""Whether the signed-in user controls ``email``, compared case-insensitively.
|
|
129
|
+
|
|
130
|
+
Addresses EVP cannot carry are ignored: lowercasing a non-ASCII one can turn it into
|
|
131
|
+
someone else's (``\\u212aate@`` with a KELVIN SIGN becomes ``kate@``).
|
|
132
|
+
"""
|
|
133
|
+
return email.lower() in {e.lower() for e in self.user_emails(request) if is_valid_email(e)}
|
|
134
|
+
|
|
135
|
+
def get_login_url(self, request: HttpRequest) -> str:
|
|
136
|
+
url = resolve_url(self.login_url or settings.LOGIN_URL)
|
|
137
|
+
return self.get_issuer(request).issuer + url if url.startswith("/") else url
|
|
138
|
+
|
|
139
|
+
# --- URLs ---
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def urls(self) -> list[URLPattern]:
|
|
143
|
+
return [
|
|
144
|
+
path(self.metadata_path, MetadataView.as_view(site=self)),
|
|
145
|
+
path(self.jwks_path, JWKSView.as_view(site=self)),
|
|
146
|
+
path(self.issuance_path, IssuanceView.as_view(site=self)),
|
|
147
|
+
path(self.accounts_path, AccountsView.as_view(site=self)),
|
|
148
|
+
path(self.web_identity_path, WebIdentityView.as_view(site=self)),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _IssuerView(View):
|
|
153
|
+
site: IssuerSite | None = None
|
|
154
|
+
|
|
155
|
+
def _site(self) -> IssuerSite:
|
|
156
|
+
if self.site is None:
|
|
157
|
+
raise ImproperlyConfigured(f"{type(self).__name__}.as_view() needs site=")
|
|
158
|
+
return self.site
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MetadataView(_IssuerView):
|
|
162
|
+
"""``/.well-known/email-verification``."""
|
|
163
|
+
|
|
164
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
165
|
+
return JsonResponse(self._site().get_issuer(request).metadata_document())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class JWKSView(_IssuerView):
|
|
169
|
+
"""The issuer's ``jwks_uri``."""
|
|
170
|
+
|
|
171
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
172
|
+
return JsonResponse(self._site().get_issuer(request).jwks_document())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class IssuanceView(_IssuerView):
|
|
176
|
+
"""The issuer's ``issuance_endpoint``: an EVT for a user who controls the address.
|
|
177
|
+
|
|
178
|
+
Exempt from CSRF protection and from ``ATOMIC_REQUESTS``; use a synchronous
|
|
179
|
+
replay guard such as :class:`~pyevp.contrib.django.EVPReplayGuard`.
|
|
180
|
+
Put per-IP rate limiting in front of it.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
http_method_names = ["post"] # noqa: RUF012
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def as_view(cls, **initkwargs: Any) -> Callable[..., HttpResponse]:
|
|
187
|
+
view = super().as_view(**initkwargs)
|
|
188
|
+
# A forged cross-site POST cannot pass: Sec-Fetch-Dest: email-verification can only
|
|
189
|
+
# come from the browser itself, and a JSON body needs a CORS preflight.
|
|
190
|
+
view = csrf_exempt(view)
|
|
191
|
+
# The replay guard must commit its record on its own; see EVPReplayGuard.
|
|
192
|
+
view._non_atomic_requests = set(settings.DATABASES)
|
|
193
|
+
return view
|
|
194
|
+
|
|
195
|
+
def post(self, request: HttpRequest) -> HttpResponse:
|
|
196
|
+
site = self._site()
|
|
197
|
+
issuer = site.get_issuer(request)
|
|
198
|
+
try:
|
|
199
|
+
body = _read_body(request)
|
|
200
|
+
parsed = issuer.parse_request(
|
|
201
|
+
method=request.method or "", headers=request.headers.items(), body=body
|
|
202
|
+
)
|
|
203
|
+
# One answer for every way this can fail, so responses do not reveal accounts.
|
|
204
|
+
if not site.owns(request, parsed.email):
|
|
205
|
+
raise IssuanceError.authentication_required()
|
|
206
|
+
result = issuer.success_response(issuer.issue(parsed))
|
|
207
|
+
except IssuanceError as exc:
|
|
208
|
+
result = exc.to_response()
|
|
209
|
+
return _to_http(result)
|
|
210
|
+
|
|
211
|
+
def http_method_not_allowed(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
212
|
+
error = IssuanceError(IssuanceErrorCode.INVALID_REQUEST, f"method {request.method}")
|
|
213
|
+
return _to_http(error.to_response())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AccountsView(_IssuerView):
|
|
217
|
+
"""The FedCM accounts endpoint Chrome checks before asking for a token."""
|
|
218
|
+
|
|
219
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
220
|
+
if request.headers.get("Sec-Fetch-Dest") != FEDCM_FETCH_DEST:
|
|
221
|
+
return JsonResponse({"error": "not a FedCM request"}, status=400)
|
|
222
|
+
emails = [e for e in self._site().user_emails(request) if is_valid_email(e)]
|
|
223
|
+
if not emails:
|
|
224
|
+
return JsonResponse({"accounts": []}, status=401)
|
|
225
|
+
return JsonResponse(accounts_document(emails))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class WebIdentityView(_IssuerView):
|
|
229
|
+
"""``/.well-known/web-identity``, which Chrome reads on the issuer's registrable domain."""
|
|
230
|
+
|
|
231
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
232
|
+
site = self._site()
|
|
233
|
+
document = web_identity_document(
|
|
234
|
+
accounts_endpoint=f"{site.get_issuer(request).issuer}/{site.accounts_path}",
|
|
235
|
+
login_url=site.get_login_url(request),
|
|
236
|
+
)
|
|
237
|
+
return JsonResponse(document)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class LoginStatusMiddleware:
|
|
241
|
+
"""Tells Chrome whether the user is signed in (FedCM Login Status API).
|
|
242
|
+
|
|
243
|
+
Adds ``Set-Login: logged-in`` or ``logged-out`` to page responses, so that
|
|
244
|
+
Chrome does not skip the issuer for a signed-in user. Place it after
|
|
245
|
+
``AuthenticationMiddleware``.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
|
249
|
+
self.get_response = get_response
|
|
250
|
+
|
|
251
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
252
|
+
response = self.get_response(request)
|
|
253
|
+
user = getattr(request, "user", None)
|
|
254
|
+
if (
|
|
255
|
+
user is not None
|
|
256
|
+
and request.headers.get("Sec-Fetch-Dest") == "document"
|
|
257
|
+
and "Set-Login" not in response
|
|
258
|
+
):
|
|
259
|
+
response["Set-Login"] = "logged-in" if user.is_authenticated else "logged-out"
|
|
260
|
+
return response
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_body(request: HttpRequest) -> bytes:
|
|
264
|
+
# Refuse large bodies before reading them, whatever DATA_UPLOAD_MAX_MEMORY_SIZE says.
|
|
265
|
+
try:
|
|
266
|
+
too_large = int(request.META.get("CONTENT_LENGTH") or 0) > _MAX_BODY
|
|
267
|
+
body = b"" if too_large else request.body
|
|
268
|
+
except (ValueError, RequestDataTooBig):
|
|
269
|
+
too_large = True
|
|
270
|
+
if too_large or len(body) > _MAX_BODY:
|
|
271
|
+
raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, "body too large")
|
|
272
|
+
return body
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _to_http(result: IssuanceResponse) -> HttpResponse:
|
|
276
|
+
return HttpResponse(result.body, status=result.status, headers=result.headers)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_PER_RESPONSE = (
|
|
280
|
+
" If your session middleware sets the cookie's attributes per response instead, "
|
|
281
|
+
"add this check to SILENCED_SYSTEM_CHECKS."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def check_session_cookie(**kwargs: Any) -> list[checks.CheckMessage]:
|
|
286
|
+
"""Warn when Chrome's cross-site requests to an :class:`IssuerSite` would lack the session.
|
|
287
|
+
|
|
288
|
+
Registered as a deployment check (``manage.py check --deploy``). It reads the
|
|
289
|
+
settings, so it cannot see attributes a middleware sets per response.
|
|
290
|
+
"""
|
|
291
|
+
if getattr(settings, "ROOT_URLCONF", None):
|
|
292
|
+
get_resolver().url_patterns # noqa: B018 (importing the URLconf creates the sites)
|
|
293
|
+
if not _sites:
|
|
294
|
+
return []
|
|
295
|
+
warnings = []
|
|
296
|
+
if settings.SESSION_COOKIE_SAMESITE != "None":
|
|
297
|
+
warnings.append(
|
|
298
|
+
checks.Warning(
|
|
299
|
+
"SESSION_COOKIE_SAMESITE is not 'None'.",
|
|
300
|
+
hint="Chrome's FedCM and issuance requests to the issuer are cross-site; "
|
|
301
|
+
"without SameSite=None they carry no session and every request fails."
|
|
302
|
+
+ _PER_RESPONSE,
|
|
303
|
+
id="pyevp.W001",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
if not settings.SESSION_COOKIE_SECURE:
|
|
307
|
+
warnings.append(
|
|
308
|
+
checks.Warning(
|
|
309
|
+
"SESSION_COOKIE_SECURE is off.",
|
|
310
|
+
hint="Browsers drop SameSite=None cookies that are not Secure." + _PER_RESPONSE,
|
|
311
|
+
id="pyevp.W002",
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return warnings
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
initial = True
|
|
6
|
+
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
operations = [
|
|
10
|
+
migrations.CreateModel(
|
|
11
|
+
name="UsedToken",
|
|
12
|
+
fields=[
|
|
13
|
+
("key", models.CharField(max_length=64, primary_key=True, serialize=False)),
|
|
14
|
+
("expires_at", models.DateTimeField(db_index=True)),
|
|
15
|
+
],
|
|
16
|
+
),
|
|
17
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UsedToken(models.Model):
|
|
9
|
+
"""A token accepted by :class:`pyevp.contrib.django.EVPReplayGuard`."""
|
|
10
|
+
|
|
11
|
+
key = models.CharField(max_length=64, primary_key=True)
|
|
12
|
+
expires_at = models.DateTimeField(db_index=True)
|
|
13
|
+
|
|
14
|
+
objects: ClassVar[models.Manager] = models.Manager()
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Template tags for EVP forms, loaded with ``{% load pyevp %}``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django import template
|
|
6
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
7
|
+
from django.utils.html import format_html
|
|
8
|
+
from django.utils.safestring import SafeString
|
|
9
|
+
|
|
10
|
+
from pyevp.contrib.django import get_nonce
|
|
11
|
+
|
|
12
|
+
register = template.Library()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register.simple_tag(takes_context=True)
|
|
16
|
+
def evp_token_input(context: template.Context, field: str = "evt") -> SafeString:
|
|
17
|
+
"""Render the hidden input that the browser fills with the token.
|
|
18
|
+
|
|
19
|
+
Put it inside the form, next to ``<input type="email" autocomplete="email">``.
|
|
20
|
+
All tags on a page share one nonce (see :func:`~pyevp.contrib.django.get_nonce`).
|
|
21
|
+
"""
|
|
22
|
+
request = context.get("request")
|
|
23
|
+
if request is None:
|
|
24
|
+
raise ImproperlyConfigured(
|
|
25
|
+
"{% evp_token_input %} needs the request in the template context: enable "
|
|
26
|
+
"django.template.context_processors.request or render with a request."
|
|
27
|
+
)
|
|
28
|
+
return format_html(
|
|
29
|
+
'<input type="hidden" name="{}" autocomplete="email-verification-token" nonce="{}">',
|
|
30
|
+
field,
|
|
31
|
+
get_nonce(request),
|
|
32
|
+
)
|