terp-cap-auth 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.
- terp_cap_auth-0.1.0/.gitignore +47 -0
- terp_cap_auth-0.1.0/PKG-INFO +9 -0
- terp_cap_auth-0.1.0/escape-hatch-budget.json +3 -0
- terp_cap_auth-0.1.0/pyproject.toml +20 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/__init__.py +91 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/deps.py +156 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/hashing.py +40 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/py.typed +0 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/refresh.py +88 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/router.py +311 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/schemas.py +38 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/throttle.py +132 -0
- terp_cap_auth-0.1.0/src/terp/capabilities/auth/tokens.py +139 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp auth capability — Argon2 password hashing, JWT access tokens, and the get_principal seam.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: pwdlib[argon2]>=0.2
|
|
8
|
+
Requires-Dist: pyjwt>=2.8
|
|
9
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp auth capability — Argon2 password hashing, JWT access tokens, and the get_principal seam."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
"pwdlib[argon2]>=0.2",
|
|
14
|
+
"pyjwt>=2.8",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.auth`.
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
sources = ["src"]
|
|
20
|
+
only-include = ["src/terp/capabilities/auth"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""terp.capabilities.auth — authentication (Argon2 hashing + JWT + ``get_principal``).
|
|
2
|
+
|
|
3
|
+
The first opt-in capability. It fills the kernel's ``get_principal`` seam with a
|
|
4
|
+
real Bearer-JWT provider and supplies password hashing + a login router. It is
|
|
5
|
+
**decoupled from the user store**: the app (or, later, the identity capability)
|
|
6
|
+
supplies an ``authenticate`` callback, so auth never needs to know where users
|
|
7
|
+
live.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from terp.capabilities.auth.deps import (
|
|
13
|
+
TokenValidator,
|
|
14
|
+
build_get_principal,
|
|
15
|
+
build_realtime_validator,
|
|
16
|
+
get_principal,
|
|
17
|
+
tenant_from_bearer,
|
|
18
|
+
)
|
|
19
|
+
from terp.capabilities.auth.hashing import (
|
|
20
|
+
hash_password,
|
|
21
|
+
verify_password,
|
|
22
|
+
verify_password_dummy,
|
|
23
|
+
)
|
|
24
|
+
from terp.capabilities.auth.refresh import (
|
|
25
|
+
RefreshRotation,
|
|
26
|
+
clear_refresh_cookie,
|
|
27
|
+
generate_refresh_token,
|
|
28
|
+
refresh_token_digest,
|
|
29
|
+
set_refresh_cookie,
|
|
30
|
+
)
|
|
31
|
+
from terp.capabilities.auth.router import (
|
|
32
|
+
Authenticator,
|
|
33
|
+
CurrentUserResolver,
|
|
34
|
+
LoginTenantResolver,
|
|
35
|
+
PrincipalResolver,
|
|
36
|
+
RefreshIssuer,
|
|
37
|
+
RefreshRotator,
|
|
38
|
+
TokenRevoker,
|
|
39
|
+
TokenVersionResolver,
|
|
40
|
+
build_login_module,
|
|
41
|
+
build_login_router,
|
|
42
|
+
build_me_module,
|
|
43
|
+
build_me_router,
|
|
44
|
+
)
|
|
45
|
+
from terp.capabilities.auth.schemas import AccessToken, CurrentUser, LoginRequest
|
|
46
|
+
from terp.capabilities.auth.throttle import AccountLockedError, LoginThrottle
|
|
47
|
+
from terp.capabilities.auth.tokens import (
|
|
48
|
+
TOKEN_AUDIENCE,
|
|
49
|
+
TOKEN_ISSUER,
|
|
50
|
+
AccessTokenClaims,
|
|
51
|
+
create_access_token,
|
|
52
|
+
decode_access_token,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AccessToken",
|
|
57
|
+
"AccessTokenClaims",
|
|
58
|
+
"AccountLockedError",
|
|
59
|
+
"Authenticator",
|
|
60
|
+
"CurrentUser",
|
|
61
|
+
"CurrentUserResolver",
|
|
62
|
+
"LoginRequest",
|
|
63
|
+
"LoginTenantResolver",
|
|
64
|
+
"LoginThrottle",
|
|
65
|
+
"PrincipalResolver",
|
|
66
|
+
"RefreshIssuer",
|
|
67
|
+
"RefreshRotation",
|
|
68
|
+
"RefreshRotator",
|
|
69
|
+
"TOKEN_AUDIENCE",
|
|
70
|
+
"TOKEN_ISSUER",
|
|
71
|
+
"TokenRevoker",
|
|
72
|
+
"TokenValidator",
|
|
73
|
+
"TokenVersionResolver",
|
|
74
|
+
"build_get_principal",
|
|
75
|
+
"build_realtime_validator",
|
|
76
|
+
"build_login_module",
|
|
77
|
+
"build_login_router",
|
|
78
|
+
"build_me_module",
|
|
79
|
+
"build_me_router",
|
|
80
|
+
"clear_refresh_cookie",
|
|
81
|
+
"create_access_token",
|
|
82
|
+
"decode_access_token",
|
|
83
|
+
"generate_refresh_token",
|
|
84
|
+
"get_principal",
|
|
85
|
+
"hash_password",
|
|
86
|
+
"refresh_token_digest",
|
|
87
|
+
"set_refresh_cookie",
|
|
88
|
+
"tenant_from_bearer",
|
|
89
|
+
"verify_password",
|
|
90
|
+
"verify_password_dummy",
|
|
91
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The auth ``get_principal`` provider — turns a Bearer JWT into a ``Principal``.
|
|
2
|
+
|
|
3
|
+
This is the implementation that fills the kernel's ``get_principal`` seam: pass
|
|
4
|
+
it as ``create_app(..., principal_provider=get_principal)``. A missing or invalid
|
|
5
|
+
token yields ``None`` (unauthenticated), which the deny-by-default guard turns
|
|
6
|
+
into HTTP 401.
|
|
7
|
+
|
|
8
|
+
Two providers are offered:
|
|
9
|
+
|
|
10
|
+
* :func:`get_principal` — the **stateless** default: it trusts a validly-signed,
|
|
11
|
+
unexpired token for its whole lifetime (no store lookup). Simple and zero
|
|
12
|
+
per-request DB cost, at the price of the access-TTL staleness window.
|
|
13
|
+
* :func:`build_get_principal` — the **revocable** provider (ADR 0031): it runs an
|
|
14
|
+
app-supplied ``TokenValidator`` against the store every request, so a token whose
|
|
15
|
+
user was deactivated, demoted, re-tenanted, password-reset, or logged out is
|
|
16
|
+
rejected mid-session. Auth owns the *seam* and never imports the store; the app
|
|
17
|
+
wires the implementation (e.g. ``IdentityService(...).token_is_current``), exactly
|
|
18
|
+
as it wires ``authenticate`` and ``tenant_resolver``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from fastapi import Request
|
|
27
|
+
from sqlmodel import Session
|
|
28
|
+
from starlette.requests import HTTPConnection
|
|
29
|
+
|
|
30
|
+
from terp.core import (
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
Principal,
|
|
33
|
+
SessionDep,
|
|
34
|
+
mark_token_revocation_provider,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from terp.capabilities.auth.tokens import AccessTokenClaims, decode_access_token
|
|
38
|
+
|
|
39
|
+
_BEARER_PREFIX = "bearer "
|
|
40
|
+
|
|
41
|
+
# The app-wired validity check the revocable provider consults every request: given the
|
|
42
|
+
# request session and the decoded claims, return whether the token is still valid (the
|
|
43
|
+
# subject is active and its token epoch is current). Auth owns the type; the app supplies
|
|
44
|
+
# the implementation, so auth never imports the user store (symmetric with the
|
|
45
|
+
# ``authenticate`` / ``tenant_resolver`` seams).
|
|
46
|
+
TokenValidator = Callable[[Session, AccessTokenClaims], bool]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _bearer_token(connection: HTTPConnection) -> str | None:
|
|
50
|
+
"""Return the raw ``Authorization: Bearer`` token, or ``None`` if absent."""
|
|
51
|
+
header = connection.headers.get("Authorization")
|
|
52
|
+
if not header or not header.lower().startswith(_BEARER_PREFIX):
|
|
53
|
+
return None
|
|
54
|
+
return header[len(_BEARER_PREFIX):].strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_principal(connection: HTTPConnection) -> Principal | None:
|
|
58
|
+
"""Extract the caller's principal from the ``Authorization: Bearer`` header.
|
|
59
|
+
|
|
60
|
+
The stateless provider: it does **no** per-request store lookup, so an already-issued
|
|
61
|
+
token stays valid until it expires. Use :func:`build_get_principal` for prompt
|
|
62
|
+
revocation (deactivate / demote / password-reset / logout taking effect mid-session).
|
|
63
|
+
"""
|
|
64
|
+
token = _bearer_token(connection)
|
|
65
|
+
if token is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
claims = decode_access_token(token)
|
|
69
|
+
except AuthenticationError:
|
|
70
|
+
return None
|
|
71
|
+
return Principal(id=claims.subject, role=claims.role)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_get_principal(
|
|
75
|
+
token_validator: TokenValidator | None = None,
|
|
76
|
+
) -> Callable[..., Principal | None]:
|
|
77
|
+
"""Build a ``get_principal`` seam that re-validates the token against the store.
|
|
78
|
+
|
|
79
|
+
When *token_validator* is supplied, the returned provider rejects (→ ``None`` → 401)
|
|
80
|
+
a token the validator fails — the runtime half of the session-revocation control
|
|
81
|
+
(ADR 0031). The provider depends on the request ``Session`` so the validator can do
|
|
82
|
+
its one indexed lookup (the guard already opens a session per guarded request, so
|
|
83
|
+
there is no extra cost there). The returned provider is **marked** as
|
|
84
|
+
revocation-enforcing (:func:`~terp.core.mark_token_revocation_provider`), so
|
|
85
|
+
``create_app(require_token_revocation=True)`` accepts it and refuses the stateless
|
|
86
|
+
default. With no validator it behaves like :func:`get_principal` (stateless) and is
|
|
87
|
+
left unmarked.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def get_principal(
|
|
91
|
+
connection: HTTPConnection, session: SessionDep
|
|
92
|
+
) -> Principal | None:
|
|
93
|
+
token = _bearer_token(connection)
|
|
94
|
+
if token is None:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
claims = decode_access_token(token)
|
|
98
|
+
except AuthenticationError:
|
|
99
|
+
return None
|
|
100
|
+
if token_validator is not None and not token_validator(session, claims):
|
|
101
|
+
return None
|
|
102
|
+
return Principal(id=claims.subject, role=claims.role)
|
|
103
|
+
|
|
104
|
+
if token_validator is not None:
|
|
105
|
+
mark_token_revocation_provider(get_principal)
|
|
106
|
+
return get_principal
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_realtime_validator() -> Callable[[Principal, str], bool]:
|
|
110
|
+
"""Validate the server-retained bearer behind a realtime connection ticket.
|
|
111
|
+
|
|
112
|
+
Native transports cannot send the Authorization header after the generated
|
|
113
|
+
client mints their one-use ticket. The ticket retains the credential only
|
|
114
|
+
in server-side TTL state; this validator rechecks signature/expiry and that
|
|
115
|
+
its subject+role still match the captured principal. Store-backed epoch /
|
|
116
|
+
active-user revocation is an app concern (it owns the identity store):
|
|
117
|
+
compose this validator inside the realtime capability's
|
|
118
|
+
``principal_validator`` with the app's own fresh-session check.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def validate(principal: Principal, credential: str) -> bool:
|
|
122
|
+
if not credential:
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
claims = decode_access_token(credential)
|
|
126
|
+
except AuthenticationError:
|
|
127
|
+
return False
|
|
128
|
+
return claims.subject == principal.id and claims.role == principal.role
|
|
129
|
+
|
|
130
|
+
return validate
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tenant_from_bearer(request: Request) -> uuid.UUID | None:
|
|
134
|
+
"""Resolve the signed ``tenant`` claim from the request's Bearer token.
|
|
135
|
+
|
|
136
|
+
A ready-made resolver for ``terp.capabilities.tenancy.TenantMiddleware``: the
|
|
137
|
+
tenant is only as trustworthy as the token that carries it. A missing,
|
|
138
|
+
invalid, or tenant-less token resolves to ``None`` — scoped services then fail
|
|
139
|
+
closed rather than leaking another tenant's rows.
|
|
140
|
+
"""
|
|
141
|
+
token = _bearer_token(request)
|
|
142
|
+
if token is None:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
return decode_access_token(token).tenant
|
|
146
|
+
except AuthenticationError:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = [
|
|
151
|
+
"TokenValidator",
|
|
152
|
+
"build_get_principal",
|
|
153
|
+
"build_realtime_validator",
|
|
154
|
+
"get_principal",
|
|
155
|
+
"tenant_from_bearer",
|
|
156
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Argon2 password hashing (recommended defaults)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pwdlib import PasswordHash
|
|
6
|
+
|
|
7
|
+
_password_hash = PasswordHash.recommended()
|
|
8
|
+
|
|
9
|
+
# Lazily-built fixed hash that `verify_password_dummy` burns a verification
|
|
10
|
+
# against, so a refused login costs the same as a real password check.
|
|
11
|
+
_dummy_hash: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hash_password(password: str) -> str:
|
|
15
|
+
"""Hash *password* with Argon2 (per-password salt, recommended parameters)."""
|
|
16
|
+
return _password_hash.hash(password)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
20
|
+
"""Return ``True`` iff *password* matches *password_hash*."""
|
|
21
|
+
return _password_hash.verify(password, password_hash)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify_password_dummy() -> None:
|
|
25
|
+
"""Burn one Argon2 verification against a fixed dummy hash (timing equalization).
|
|
26
|
+
|
|
27
|
+
An authenticate path that refuses *before* verifying — unknown email, inactive
|
|
28
|
+
account, SSO-only user with no local credential — must call this so the refusal
|
|
29
|
+
costs the same as a real password check. Without it, a login attempt against a
|
|
30
|
+
valid email takes ~an Argon2 verify while an invalid one returns in microseconds:
|
|
31
|
+
a remote timing side channel that enumerates registered accounts. The dummy hash
|
|
32
|
+
is built lazily on first use (never at import) and the result is discarded.
|
|
33
|
+
"""
|
|
34
|
+
global _dummy_hash
|
|
35
|
+
if _dummy_hash is None:
|
|
36
|
+
_dummy_hash = _password_hash.hash("terp-timing-equalization-dummy")
|
|
37
|
+
_password_hash.verify("terp-timing-equalization-probe", _dummy_hash)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["hash_password", "verify_password", "verify_password_dummy"]
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Refresh-token mechanics for the auth capability (ADR 0054).
|
|
2
|
+
|
|
3
|
+
Auth owns the *mechanics* of the refresh credential — how a token is generated, how it is
|
|
4
|
+
digested for storage, and how it rides an httpOnly cookie — but **not** where it is stored:
|
|
5
|
+
the row lives in the identity store, reached through the app-wired refresh seams (symmetric
|
|
6
|
+
with ``authenticate`` / ``token_version_resolver``), so auth never imports identity.
|
|
7
|
+
|
|
8
|
+
The refresh token is opaque, 256-bit ``secrets`` randomness (not a JWT — its only meaning is
|
|
9
|
+
"this row is still live", which is what makes it individually revocable). It is stored as a
|
|
10
|
+
**keyed HMAC-SHA256 digest**: the key is derived from the app ``SECRET_KEY`` with domain
|
|
11
|
+
separation, so a database leak alone cannot use or even confirm a token without the app
|
|
12
|
+
secret, while the digest stays deterministic — one indexed lookup on ``/refresh``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import secrets
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from fastapi import Response
|
|
24
|
+
|
|
25
|
+
from terp.core import settings
|
|
26
|
+
|
|
27
|
+
# 32 bytes = 256 bits of entropy; url-safe so it is a valid cookie value verbatim.
|
|
28
|
+
_TOKEN_NBYTES = 32
|
|
29
|
+
# Domain-separates the refresh-digest key from every other use of SECRET_KEY.
|
|
30
|
+
_DIGEST_CONTEXT = b"terp.refresh-token.v1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RefreshRotation:
|
|
35
|
+
"""The outcome of a successful refresh: whose session, and the new token to re-cookie."""
|
|
36
|
+
|
|
37
|
+
user_id: uuid.UUID
|
|
38
|
+
token: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_refresh_token() -> str:
|
|
42
|
+
"""A fresh opaque refresh token — 256 bits of URL-safe randomness."""
|
|
43
|
+
return secrets.token_urlsafe(_TOKEN_NBYTES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def refresh_token_digest(raw_token: str) -> str:
|
|
47
|
+
"""The at-rest digest of *raw_token*: a SECRET_KEY-keyed (peppered) HMAC-SHA256 hex.
|
|
48
|
+
|
|
49
|
+
Keyed so a leaked database of digests is useless without the app secret; deterministic
|
|
50
|
+
so ``/refresh`` looks the row up in one indexed read. The key is derived from
|
|
51
|
+
``SECRET_KEY`` (read at call time, so tests and rotation see the live value) with a
|
|
52
|
+
context prefix, keeping it separate from the access-token signing use of the secret.
|
|
53
|
+
"""
|
|
54
|
+
key = hashlib.sha256(_DIGEST_CONTEXT + b":" + settings.SECRET_KEY.encode()).digest()
|
|
55
|
+
return hmac.new(key, raw_token.encode(), hashlib.sha256).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_refresh_cookie(response: Response, token: str) -> None:
|
|
59
|
+
"""Attach the rotating refresh token as an httpOnly, path-scoped cookie (ADR 0054)."""
|
|
60
|
+
response.set_cookie(
|
|
61
|
+
key=settings.REFRESH_COOKIE_NAME,
|
|
62
|
+
value=token,
|
|
63
|
+
max_age=settings.REFRESH_FAMILY_TTL_SECONDS,
|
|
64
|
+
path=settings.REFRESH_COOKIE_PATH,
|
|
65
|
+
httponly=True,
|
|
66
|
+
secure=settings.refresh_cookie_secure,
|
|
67
|
+
samesite=settings.REFRESH_COOKIE_SAMESITE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_refresh_cookie(response: Response) -> None:
|
|
72
|
+
"""Delete the refresh cookie (logout) — matched on name + path so the browser drops it."""
|
|
73
|
+
response.delete_cookie(
|
|
74
|
+
key=settings.REFRESH_COOKIE_NAME,
|
|
75
|
+
path=settings.REFRESH_COOKIE_PATH,
|
|
76
|
+
httponly=True,
|
|
77
|
+
secure=settings.refresh_cookie_secure,
|
|
78
|
+
samesite=settings.REFRESH_COOKIE_SAMESITE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"RefreshRotation",
|
|
84
|
+
"clear_refresh_cookie",
|
|
85
|
+
"generate_refresh_token",
|
|
86
|
+
"refresh_token_digest",
|
|
87
|
+
"set_refresh_cookie",
|
|
88
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Login + logout router + ``ModuleSpec`` builder for the auth capability.
|
|
2
|
+
|
|
3
|
+
The capability does **not** own a user store (the identity capability does). The
|
|
4
|
+
app supplies an ``authenticate(session, email, password) -> Principal | None``
|
|
5
|
+
callback; auth only checks that the credential resolves to a principal and then issues
|
|
6
|
+
a token. The login route is mounted with ``Policy.public`` so it is reachable without a
|
|
7
|
+
token; the optional logout route revokes the caller's sessions through an app-supplied
|
|
8
|
+
seam (auth does not own the store it must write).
|
|
9
|
+
|
|
10
|
+
Session-management seams (ADR 0031), all optional and app-wired so auth never imports
|
|
11
|
+
the store:
|
|
12
|
+
|
|
13
|
+
* ``token_version_resolver`` signs the subject's **current token epoch** into the issued
|
|
14
|
+
token, so a freshly-minted token is valid against the store (without it, the first
|
|
15
|
+
token issued after any revoking change would be instantly stale);
|
|
16
|
+
* ``revoke_sessions`` bumps the caller's epoch on ``POST /logout`` (mounted only when
|
|
17
|
+
wired); and
|
|
18
|
+
* ``throttle`` is the per-account login lockout (on by default, ADR 0031 / L3).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Depends, Request, Response
|
|
27
|
+
from sqlmodel import Session
|
|
28
|
+
|
|
29
|
+
from terp.core import (
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
ModuleSpec,
|
|
32
|
+
Policy,
|
|
33
|
+
Principal,
|
|
34
|
+
SessionDep,
|
|
35
|
+
get_principal,
|
|
36
|
+
settings,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from terp.capabilities.auth.refresh import (
|
|
40
|
+
RefreshRotation,
|
|
41
|
+
clear_refresh_cookie,
|
|
42
|
+
set_refresh_cookie,
|
|
43
|
+
)
|
|
44
|
+
from terp.capabilities.auth.schemas import AccessToken, CurrentUser, LoginRequest
|
|
45
|
+
from terp.capabilities.auth.throttle import LoginThrottle
|
|
46
|
+
from terp.capabilities.auth.tokens import create_access_token
|
|
47
|
+
|
|
48
|
+
Authenticator = Callable[[Session, str, str], Principal | None]
|
|
49
|
+
LoginTenantResolver = Callable[[Session, Principal], uuid.UUID | None]
|
|
50
|
+
# Resolve the subject's current token epoch so login signs it (see module docstring).
|
|
51
|
+
TokenVersionResolver = Callable[[Session, Principal], int]
|
|
52
|
+
# Revoke (invalidate) a subject's outstanding tokens — the logout write-back, app-wired
|
|
53
|
+
# to the store (e.g. ``UsersService.revoke_sessions``) since auth does not own it.
|
|
54
|
+
TokenRevoker = Callable[[Session, uuid.UUID], None]
|
|
55
|
+
# Refresh-token seams (ADR 0054), app-wired to the identity refresh store so auth never
|
|
56
|
+
# imports it. Issue the first token at login; rotate (single-use + reuse-detect) a presented
|
|
57
|
+
# token; resolve an ACTIVE subject's principal by id (the /refresh analog of authenticate,
|
|
58
|
+
# which needs a password). All three are wired together or not at all.
|
|
59
|
+
RefreshIssuer = Callable[[Session, uuid.UUID], str]
|
|
60
|
+
RefreshRotator = Callable[[Session, str], RefreshRotation | None]
|
|
61
|
+
PrincipalResolver = Callable[[Session, uuid.UUID], Principal | None]
|
|
62
|
+
# Resolve the authenticated caller's own identity for ``GET /me`` — app-wired to the
|
|
63
|
+
# store (e.g. ``IdentityService.current_user``) so auth never imports where users live
|
|
64
|
+
# (symmetric with the authenticate / tenant_resolver / revoke_sessions seams).
|
|
65
|
+
CurrentUserResolver = Callable[[Session, Principal], CurrentUser]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_login_router(
|
|
69
|
+
authenticate: Authenticator,
|
|
70
|
+
*,
|
|
71
|
+
tenant_resolver: LoginTenantResolver | None = None,
|
|
72
|
+
token_version_resolver: TokenVersionResolver | None = None,
|
|
73
|
+
revoke_sessions: TokenRevoker | None = None,
|
|
74
|
+
throttle: LoginThrottle | None = None,
|
|
75
|
+
refresh_issuer: RefreshIssuer | None = None,
|
|
76
|
+
refresh_rotator: RefreshRotator | None = None,
|
|
77
|
+
principal_resolver: PrincipalResolver | None = None,
|
|
78
|
+
require_refresh: bool = False,
|
|
79
|
+
) -> APIRouter:
|
|
80
|
+
"""Build a ``/login`` (+ optional ``/logout`` / ``/refresh``) router via *authenticate*.
|
|
81
|
+
|
|
82
|
+
When *tenant_resolver* is supplied, the authenticated principal is mapped to a
|
|
83
|
+
tenant and that tenant is signed into the token's ``tenant`` claim — so a
|
|
84
|
+
multi-tenant app issues a tenant-bound token through this one seam (symmetric with
|
|
85
|
+
``TenantMiddleware``'s ``resolve_tenant``), without the auth capability needing to
|
|
86
|
+
know how a tenant is stored. *token_version_resolver* likewise signs the subject's
|
|
87
|
+
current token epoch (ADR 0031). *throttle* (default: a fresh on-by-default
|
|
88
|
+
:class:`LoginThrottle`) locks an account after repeated failed logins. When
|
|
89
|
+
*revoke_sessions* is supplied, a ``POST /logout`` route is mounted that bumps the
|
|
90
|
+
caller's epoch (idempotent; an unauthenticated call is a no-op).
|
|
91
|
+
|
|
92
|
+
Refresh-token rotation (ADR 0054) is opt-in and **all-or-nothing**: supply
|
|
93
|
+
*refresh_issuer* (open a family + mint the first token at login), *refresh_rotator*
|
|
94
|
+
(validate + single-use rotate + reuse-detect), and *principal_resolver* (resolve an
|
|
95
|
+
active subject by id) together — plus *revoke_sessions*, so ``/logout`` revokes the
|
|
96
|
+
family server-side and not just the cookie — to mount ``POST /refresh`` and have
|
|
97
|
+
``/login`` / ``/logout`` set / clear the httpOnly refresh cookie. Wiring only some of
|
|
98
|
+
the seams, omitting *revoke_sessions*, or passing *require_refresh* without them,
|
|
99
|
+
raises at construction (fail-closed).
|
|
100
|
+
"""
|
|
101
|
+
router = APIRouter(tags=["auth"])
|
|
102
|
+
active_throttle = throttle if throttle is not None else LoginThrottle()
|
|
103
|
+
|
|
104
|
+
# Refresh rotation is all-or-nothing: a token issued at login that nothing can rotate
|
|
105
|
+
# (or vice versa) is a fail-closed misconfiguration caught here, at construction.
|
|
106
|
+
refresh_enabled = (
|
|
107
|
+
refresh_issuer is not None
|
|
108
|
+
and refresh_rotator is not None
|
|
109
|
+
and principal_resolver is not None
|
|
110
|
+
)
|
|
111
|
+
any_refresh_seam = (
|
|
112
|
+
refresh_issuer is not None
|
|
113
|
+
or refresh_rotator is not None
|
|
114
|
+
or principal_resolver is not None
|
|
115
|
+
)
|
|
116
|
+
if any_refresh_seam and not refresh_enabled:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"refresh-token rotation is half-wired: refresh_issuer, refresh_rotator, and "
|
|
119
|
+
"principal_resolver must be supplied together (ADR 0054)."
|
|
120
|
+
)
|
|
121
|
+
if refresh_enabled and revoke_sessions is None:
|
|
122
|
+
# Without a server-side revoker, /logout could only drop the browser cookie while
|
|
123
|
+
# every token in the family stayed live in the store — a logout that does not log
|
|
124
|
+
# out. Refuse the half-secure shape at construction (fail-closed).
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"refresh-token rotation requires revoke_sessions: without it a logout clears "
|
|
127
|
+
"the cookie but leaves the refresh-token family live server-side (ADR 0054)."
|
|
128
|
+
)
|
|
129
|
+
if require_refresh and not refresh_enabled:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"require_refresh=True but the refresh seams are not wired (ADR 0054)."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _mint_access_token(session: Session, principal: Principal) -> str:
|
|
135
|
+
tenant = tenant_resolver(session, principal) if tenant_resolver is not None else None
|
|
136
|
+
token_version = (
|
|
137
|
+
token_version_resolver(session, principal)
|
|
138
|
+
if token_version_resolver is not None
|
|
139
|
+
else 0
|
|
140
|
+
)
|
|
141
|
+
return create_access_token(
|
|
142
|
+
subject=principal.id,
|
|
143
|
+
role=principal.role,
|
|
144
|
+
tenant=tenant,
|
|
145
|
+
token_version=token_version,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@router.post("/login", response_model=AccessToken)
|
|
149
|
+
def login(
|
|
150
|
+
credentials: LoginRequest, session: SessionDep, response: Response
|
|
151
|
+
) -> AccessToken:
|
|
152
|
+
active_throttle.check(credentials.email)
|
|
153
|
+
principal = authenticate(session, credentials.email, credentials.password)
|
|
154
|
+
if principal is None:
|
|
155
|
+
active_throttle.record_failure(credentials.email)
|
|
156
|
+
raise AuthenticationError()
|
|
157
|
+
active_throttle.record_success(credentials.email)
|
|
158
|
+
token = _mint_access_token(session, principal)
|
|
159
|
+
if refresh_issuer is not None:
|
|
160
|
+
# Open a fresh refresh-token family and set its httpOnly cookie beside the
|
|
161
|
+
# bearer, so the session survives a reload and can outlive the access TTL.
|
|
162
|
+
set_refresh_cookie(response, refresh_issuer(session, principal.id))
|
|
163
|
+
return AccessToken(access_token=token)
|
|
164
|
+
|
|
165
|
+
if refresh_rotator is not None and principal_resolver is not None:
|
|
166
|
+
rotate_token = refresh_rotator
|
|
167
|
+
resolve_principal = principal_resolver
|
|
168
|
+
|
|
169
|
+
@router.post("/refresh", response_model=AccessToken)
|
|
170
|
+
def refresh(
|
|
171
|
+
request: Request, session: SessionDep, response: Response
|
|
172
|
+
) -> AccessToken:
|
|
173
|
+
# The refresh cookie is the credential here (no bearer): rotate it (single-use +
|
|
174
|
+
# reuse-detection), re-check the subject is still active, then mint a fresh
|
|
175
|
+
# access token and set the rotated cookie. Any failure is a clean 401.
|
|
176
|
+
raw = request.cookies.get(settings.REFRESH_COOKIE_NAME)
|
|
177
|
+
if raw is None:
|
|
178
|
+
raise AuthenticationError()
|
|
179
|
+
rotation = rotate_token(session, raw)
|
|
180
|
+
if rotation is None:
|
|
181
|
+
raise AuthenticationError()
|
|
182
|
+
principal = resolve_principal(session, rotation.user_id)
|
|
183
|
+
if principal is None:
|
|
184
|
+
raise AuthenticationError()
|
|
185
|
+
token = _mint_access_token(session, principal)
|
|
186
|
+
set_refresh_cookie(response, rotation.token)
|
|
187
|
+
return AccessToken(access_token=token)
|
|
188
|
+
|
|
189
|
+
if revoke_sessions is not None or refresh_enabled:
|
|
190
|
+
|
|
191
|
+
@router.post("/logout", status_code=204)
|
|
192
|
+
def logout(
|
|
193
|
+
session: SessionDep,
|
|
194
|
+
principal: Principal | None = Depends(get_principal),
|
|
195
|
+
) -> Response:
|
|
196
|
+
# Logout is idempotent: with no (or an already-revoked) token there is nothing
|
|
197
|
+
# to invalidate. Otherwise bump the caller's epoch (the wired revoke also kills
|
|
198
|
+
# the refresh families), and always drop the refresh cookie.
|
|
199
|
+
if principal is not None and revoke_sessions is not None:
|
|
200
|
+
revoke_sessions(session, principal.id)
|
|
201
|
+
response = Response(status_code=204)
|
|
202
|
+
if refresh_enabled:
|
|
203
|
+
clear_refresh_cookie(response)
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
return router
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_login_module(
|
|
210
|
+
authenticate: Authenticator,
|
|
211
|
+
*,
|
|
212
|
+
name: str = "auth",
|
|
213
|
+
tenant_resolver: LoginTenantResolver | None = None,
|
|
214
|
+
token_version_resolver: TokenVersionResolver | None = None,
|
|
215
|
+
revoke_sessions: TokenRevoker | None = None,
|
|
216
|
+
throttle: LoginThrottle | None = None,
|
|
217
|
+
refresh_issuer: RefreshIssuer | None = None,
|
|
218
|
+
refresh_rotator: RefreshRotator | None = None,
|
|
219
|
+
principal_resolver: PrincipalResolver | None = None,
|
|
220
|
+
require_refresh: bool = False,
|
|
221
|
+
) -> ModuleSpec:
|
|
222
|
+
"""Build the auth ``ModuleSpec`` (public login + optional logout / refresh endpoints).
|
|
223
|
+
|
|
224
|
+
When the refresh seams are wired, the configured refresh-cookie path must match where
|
|
225
|
+
this module is mounted (``/api/v1/<name>``) — a path-scoped cookie the browser never
|
|
226
|
+
sends to ``/refresh`` would make refresh silently never work, so the mismatch is
|
|
227
|
+
refused here, at construction (fail-closed).
|
|
228
|
+
"""
|
|
229
|
+
refresh_enabled = (
|
|
230
|
+
refresh_issuer is not None
|
|
231
|
+
and refresh_rotator is not None
|
|
232
|
+
and principal_resolver is not None
|
|
233
|
+
)
|
|
234
|
+
mount_prefix = f"/api/v1/{name}"
|
|
235
|
+
if refresh_enabled and settings.REFRESH_COOKIE_PATH != mount_prefix:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"REFRESH_COOKIE_PATH ({settings.REFRESH_COOKIE_PATH!r}) does not match this "
|
|
238
|
+
f"module's mount prefix ({mount_prefix!r}); the browser would never send the "
|
|
239
|
+
"refresh cookie to /refresh (ADR 0054)."
|
|
240
|
+
)
|
|
241
|
+
return ModuleSpec(
|
|
242
|
+
name=name,
|
|
243
|
+
router=build_login_router(
|
|
244
|
+
authenticate,
|
|
245
|
+
tenant_resolver=tenant_resolver,
|
|
246
|
+
token_version_resolver=token_version_resolver,
|
|
247
|
+
revoke_sessions=revoke_sessions,
|
|
248
|
+
throttle=throttle,
|
|
249
|
+
refresh_issuer=refresh_issuer,
|
|
250
|
+
refresh_rotator=refresh_rotator,
|
|
251
|
+
principal_resolver=principal_resolver,
|
|
252
|
+
require_refresh=require_refresh,
|
|
253
|
+
),
|
|
254
|
+
policy=Policy.public_write(
|
|
255
|
+
reason="authentication endpoints must be reachable without a token"
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_me_router(resolve_current_user: CurrentUserResolver) -> APIRouter:
|
|
261
|
+
"""Build the ``GET /me`` (who-am-I) router via the *resolve_current_user* seam.
|
|
262
|
+
|
|
263
|
+
The route is **self-scoped**: it reports only the authenticated caller's own identity
|
|
264
|
+
(read from ``principal.id``), so it takes no id parameter and cannot be turned into a
|
|
265
|
+
read of another subject. Mounted behind ``Policy.default()`` (any authenticated
|
|
266
|
+
caller), it answers through the wired principal provider — the revocable one in the
|
|
267
|
+
bundled stack — so a deactivated / demoted / re-tenanted token is already rejected and
|
|
268
|
+
the response reflects the live record rather than stale token claims.
|
|
269
|
+
"""
|
|
270
|
+
router = APIRouter(tags=["auth"])
|
|
271
|
+
|
|
272
|
+
@router.get("/", response_model=CurrentUser)
|
|
273
|
+
def me(
|
|
274
|
+
session: SessionDep,
|
|
275
|
+
principal: Principal | None = Depends(get_principal),
|
|
276
|
+
) -> CurrentUser:
|
|
277
|
+
# The module guard authorizes against Policy.default() (rejecting anonymous)
|
|
278
|
+
# before this handler runs; the explicit check keeps the router correct — a clean
|
|
279
|
+
# 401, never an AttributeError — even if it were ever mounted without that guard.
|
|
280
|
+
if principal is None:
|
|
281
|
+
raise AuthenticationError()
|
|
282
|
+
return resolve_current_user(session, principal)
|
|
283
|
+
|
|
284
|
+
return router
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_me_module(
|
|
288
|
+
resolve_current_user: CurrentUserResolver, *, name: str = "me"
|
|
289
|
+
) -> ModuleSpec:
|
|
290
|
+
"""Build the who-am-I ``ModuleSpec`` (``GET /api/v1/me``, any authenticated caller)."""
|
|
291
|
+
return ModuleSpec(
|
|
292
|
+
name=name,
|
|
293
|
+
router=build_me_router(resolve_current_user),
|
|
294
|
+
policy=Policy.default(),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
__all__ = [
|
|
299
|
+
"Authenticator",
|
|
300
|
+
"CurrentUserResolver",
|
|
301
|
+
"LoginTenantResolver",
|
|
302
|
+
"PrincipalResolver",
|
|
303
|
+
"RefreshIssuer",
|
|
304
|
+
"RefreshRotator",
|
|
305
|
+
"TokenRevoker",
|
|
306
|
+
"TokenVersionResolver",
|
|
307
|
+
"build_login_module",
|
|
308
|
+
"build_login_router",
|
|
309
|
+
"build_me_module",
|
|
310
|
+
"build_me_router",
|
|
311
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Auth request/response DTOs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from sqlmodel import Field
|
|
8
|
+
|
|
9
|
+
from terp.core import BaseSchema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LoginRequest(BaseSchema):
|
|
13
|
+
email: str = Field(max_length=320)
|
|
14
|
+
password: str = Field(max_length=256)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccessToken(BaseSchema):
|
|
18
|
+
access_token: str # arch-allow-schemas-exclude-sensitive-fields: the bearer token the login endpoint exists to mint
|
|
19
|
+
token_type: str = "bearer"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CurrentUser(BaseSchema):
|
|
23
|
+
"""The authenticated caller's own identity — the ``/me`` (who-am-I) response.
|
|
24
|
+
|
|
25
|
+
The frontend's session contract pairs this with :class:`AccessToken`. It is the
|
|
26
|
+
server-validated current identity (resolved through the wired principal provider —
|
|
27
|
+
the revocable one in the bundled stack), so it reflects the live store, not just the
|
|
28
|
+
token's claims. The caller's role is on the wire as both the numeric ``role_rank``
|
|
29
|
+
(the comparable primitive, ADR 0004 / 0022) and a human-readable ``role_name``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
id: uuid.UUID
|
|
33
|
+
email: str
|
|
34
|
+
role_rank: int
|
|
35
|
+
role_name: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["AccessToken", "CurrentUser", "LoginRequest"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Per-account login lockout — a fail-closed brute-force throttle (ADR 0031, L3).
|
|
2
|
+
|
|
3
|
+
A login-specific complement to the generic rate limiter: it counts *failed* logins per
|
|
4
|
+
account and locks the account for a window once a threshold is crossed, so credential
|
|
5
|
+
stuffing against a single user is throttled even when the generic rate limit (which
|
|
6
|
+
counts all requests from all callers) would not bite.
|
|
7
|
+
|
|
8
|
+
The lockout state lives in a pluggable :class:`~terp.core.ThrottleStore` (ADR 0036):
|
|
9
|
+
the default :class:`~terp.core.InMemoryThrottleStore` is per app instance / in process —
|
|
10
|
+
unchanged behaviour — while a multi-instance deployment passes the same shared store the
|
|
11
|
+
rate limiter uses, so the lockout is correct across workers. A store error fails closed
|
|
12
|
+
(the account is treated as locked). It is on by default; an app turns it off only with
|
|
13
|
+
an explicit, reason-bearing :meth:`LoginThrottle.disabled`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
from terp.core import AppError, InMemoryThrottleStore, ThrottleStore
|
|
22
|
+
|
|
23
|
+
_logger = logging.getLogger("terp.capabilities.auth.throttle")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AccountLockedError(AppError):
|
|
27
|
+
"""429 — too many failed login attempts; the account is temporarily locked."""
|
|
28
|
+
|
|
29
|
+
status_code = 429
|
|
30
|
+
code = "account_locked"
|
|
31
|
+
default_message = (
|
|
32
|
+
"Too many failed login attempts. This account is temporarily locked; "
|
|
33
|
+
"please wait and try again."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _utc_now() -> datetime.datetime:
|
|
38
|
+
"""UTC ``now`` provider — kept private so tests can monkeypatch the clock."""
|
|
39
|
+
return datetime.datetime.now(datetime.UTC)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoginThrottle:
|
|
43
|
+
"""Per-account failed-login lockout over a pluggable :class:`ThrottleStore`.
|
|
44
|
+
|
|
45
|
+
After *max_attempts* failed logins for one identifier within *window*, the
|
|
46
|
+
identifier is locked for *lockout*; while locked, even a correct credential is
|
|
47
|
+
refused (the login route calls :meth:`check` *before* verifying the password). A
|
|
48
|
+
successful login clears the counter. State is keyed by a normalized identifier
|
|
49
|
+
(trimmed + lower-cased) so case variants of an email cannot dodge the count. With no
|
|
50
|
+
*store* the default per-instance in-memory store is used (clocked off :func:`_utc_now`
|
|
51
|
+
so tests can drive it); a multi-instance app passes a shared store for one correct
|
|
52
|
+
global counter.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
max_attempts: int = 5,
|
|
59
|
+
window: datetime.timedelta = datetime.timedelta(minutes=15),
|
|
60
|
+
lockout: datetime.timedelta = datetime.timedelta(minutes=15),
|
|
61
|
+
store: ThrottleStore | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if max_attempts < 1:
|
|
64
|
+
raise ValueError("LoginThrottle.max_attempts must be >= 1")
|
|
65
|
+
self.enabled = True
|
|
66
|
+
self.disabled_reason = ""
|
|
67
|
+
self._max_attempts = max_attempts
|
|
68
|
+
self._window = int(window.total_seconds())
|
|
69
|
+
self._lockout = int(lockout.total_seconds())
|
|
70
|
+
self._store = store if store is not None else InMemoryThrottleStore(
|
|
71
|
+
clock=lambda: _utc_now().timestamp()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def disabled(cls, *, reason: str) -> LoginThrottle:
|
|
76
|
+
"""An explicitly-disabled throttle (no lockout). *reason* is required (fail-closed).
|
|
77
|
+
|
|
78
|
+
Mirrors ``CorsPolicy.disabled`` / ``AuditPolicy.disabled``: turning a
|
|
79
|
+
secure-by-default control off is a visible, justified act, never a silent
|
|
80
|
+
omission, so an empty reason is refused.
|
|
81
|
+
"""
|
|
82
|
+
if not reason.strip():
|
|
83
|
+
raise ValueError("LoginThrottle.disabled requires a non-empty reason")
|
|
84
|
+
throttle = cls()
|
|
85
|
+
throttle.enabled = False
|
|
86
|
+
throttle.disabled_reason = reason
|
|
87
|
+
return throttle
|
|
88
|
+
|
|
89
|
+
def check(self, identifier: str) -> None:
|
|
90
|
+
"""Raise :class:`AccountLockedError` if *identifier* is currently locked."""
|
|
91
|
+
if not self.enabled:
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
locked = self._store.locked(self._key(identifier)) > 0
|
|
95
|
+
except Exception as exc: # a shared store outage fails closed
|
|
96
|
+
raise AccountLockedError() from exc
|
|
97
|
+
if locked:
|
|
98
|
+
raise AccountLockedError()
|
|
99
|
+
|
|
100
|
+
def record_failure(self, identifier: str) -> None:
|
|
101
|
+
"""Record a failed attempt; lock *identifier* once the threshold is reached."""
|
|
102
|
+
if not self.enabled:
|
|
103
|
+
return
|
|
104
|
+
key = self._key(identifier)
|
|
105
|
+
try:
|
|
106
|
+
count, _ = self._store.hit(key, self._window)
|
|
107
|
+
if count >= self._max_attempts:
|
|
108
|
+
self._store.lock(key, self._lockout)
|
|
109
|
+
except Exception as exc: # a shared store outage fails closed
|
|
110
|
+
raise AccountLockedError() from exc
|
|
111
|
+
|
|
112
|
+
def record_success(self, identifier: str) -> None:
|
|
113
|
+
"""Clear *identifier*'s failure state after a successful login."""
|
|
114
|
+
if not self.enabled:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
self._store.clear(self._key(identifier))
|
|
118
|
+
except Exception as exc: # best-effort cleanup: never block an already-valid login
|
|
119
|
+
_logger.warning("login_throttle_clear_failed", exc_info=exc)
|
|
120
|
+
|
|
121
|
+
def reset(self) -> None:
|
|
122
|
+
"""Clear all tracked state (a test seam; per-instance state otherwise persists)."""
|
|
123
|
+
reset = getattr(self._store, "reset", None)
|
|
124
|
+
if callable(reset):
|
|
125
|
+
reset()
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _key(identifier: str) -> str:
|
|
129
|
+
return f"lt:{identifier.strip().lower()}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["AccountLockedError", "LoginThrottle"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Signed JWT access tokens, keyed on the kernel ``SECRET_KEY``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
|
|
12
|
+
from terp.core import AuthenticationError, Role, Roles, as_role, settings
|
|
13
|
+
|
|
14
|
+
_ALGORITHM = "HS256"
|
|
15
|
+
DEFAULT_ACCESS_TOKEN_TTL = datetime.timedelta(minutes=15)
|
|
16
|
+
|
|
17
|
+
#: The ``iss`` / ``aud`` claims every Terp access token carries and every decode
|
|
18
|
+
#: **requires** (ADR 0076): a JWT minted by any other issuer for any other audience
|
|
19
|
+
#: — even one signed with the same shared secret (another service reusing the key,
|
|
20
|
+
#: an OIDC provider, a future token type) — is refused, never confused for an
|
|
21
|
+
#: access credential.
|
|
22
|
+
TOKEN_ISSUER: Final[str] = "terp.auth"
|
|
23
|
+
TOKEN_AUDIENCE: Final[str] = "terp.api"
|
|
24
|
+
|
|
25
|
+
# Every claim an access token is refused without: the registered pair above plus
|
|
26
|
+
# the identity/lifetime pair — a token missing any of them never authenticates.
|
|
27
|
+
_REQUIRED_CLAIMS: Final[tuple[str, ...]] = ("exp", "iat", "sub", "aud", "iss")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AccessTokenClaims:
|
|
32
|
+
"""The trusted claims carried by a decoded access token."""
|
|
33
|
+
|
|
34
|
+
subject: uuid.UUID
|
|
35
|
+
role: Role
|
|
36
|
+
tenant: uuid.UUID | None = None
|
|
37
|
+
token_version: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_access_token(
|
|
41
|
+
*,
|
|
42
|
+
subject: uuid.UUID,
|
|
43
|
+
role: Role | Roles,
|
|
44
|
+
tenant: uuid.UUID | None = None,
|
|
45
|
+
token_version: int = 0,
|
|
46
|
+
expires_in: datetime.timedelta = DEFAULT_ACCESS_TOKEN_TTL,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Issue a short-lived HS256 access token for *subject* with *role*.
|
|
49
|
+
|
|
50
|
+
*role* may be a typed :class:`~terp.core.Role` or the legacy ``Roles`` enum;
|
|
51
|
+
its name and rank are both signed in, so a consumer-defined role round-trips
|
|
52
|
+
without being coerced to a fixed tier. When *tenant* is given it is signed as
|
|
53
|
+
the ``tenant`` claim, so a tenant-aware app can bind request scope from the
|
|
54
|
+
verified token (see ``terp.capabilities.tenancy.TenantMiddleware``).
|
|
55
|
+
|
|
56
|
+
*token_version* signs the subject's current **token epoch** as the ``tv`` claim
|
|
57
|
+
(ADR 0031). A principal provider wired with a validator rejects the token once the
|
|
58
|
+
stored epoch moves past it, so deactivating, demoting, re-tenanting, resetting the
|
|
59
|
+
password of, or logging out a user invalidates their outstanding tokens at once —
|
|
60
|
+
sign the user's current epoch here (default ``0`` = revocation inactive).
|
|
61
|
+
|
|
62
|
+
Every token also signs the fixed :data:`TOKEN_ISSUER` / :data:`TOKEN_AUDIENCE`
|
|
63
|
+
pair (ADR 0076), which :func:`decode_access_token` requires — scoping the
|
|
64
|
+
credential to this framework's API even under a shared signing key. Signing
|
|
65
|
+
always uses the **current** ``SECRET_KEY`` (never a fallback).
|
|
66
|
+
"""
|
|
67
|
+
typed = as_role(role)
|
|
68
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
69
|
+
payload: dict[str, object] = {
|
|
70
|
+
"sub": str(subject),
|
|
71
|
+
"role": typed.name,
|
|
72
|
+
"rank": typed.rank,
|
|
73
|
+
"tv": token_version,
|
|
74
|
+
"iss": TOKEN_ISSUER,
|
|
75
|
+
"aud": TOKEN_AUDIENCE,
|
|
76
|
+
"iat": now,
|
|
77
|
+
"exp": now + expires_in,
|
|
78
|
+
}
|
|
79
|
+
if tenant is not None:
|
|
80
|
+
payload["tenant"] = str(tenant)
|
|
81
|
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=_ALGORITHM)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _verify(token: str) -> dict:
|
|
85
|
+
"""Verify *token* against the current key, then each rotation fallback.
|
|
86
|
+
|
|
87
|
+
``SECRET_KEY`` verifies first; a **signature** mismatch (and only that) moves
|
|
88
|
+
on to the next ``SECRET_KEY_FALLBACKS`` entry, so an access token issued just
|
|
89
|
+
before a key rotation stays valid through the configured window (ADR 0076).
|
|
90
|
+
Any other verification failure — expired, wrong ``aud``/``iss``, a missing
|
|
91
|
+
required claim — is final and never retried against another key.
|
|
92
|
+
"""
|
|
93
|
+
keys = (settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS)
|
|
94
|
+
rejected: jwt.InvalidSignatureError | None = None
|
|
95
|
+
for key in keys:
|
|
96
|
+
try:
|
|
97
|
+
return jwt.decode(
|
|
98
|
+
token,
|
|
99
|
+
key,
|
|
100
|
+
algorithms=[_ALGORITHM],
|
|
101
|
+
audience=TOKEN_AUDIENCE,
|
|
102
|
+
issuer=TOKEN_ISSUER,
|
|
103
|
+
options={"require": list(_REQUIRED_CLAIMS)},
|
|
104
|
+
)
|
|
105
|
+
except jwt.InvalidSignatureError as exc:
|
|
106
|
+
rejected = exc
|
|
107
|
+
except jwt.PyJWTError as exc:
|
|
108
|
+
raise AuthenticationError() from exc
|
|
109
|
+
raise AuthenticationError() from rejected
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_access_token(token: str) -> AccessTokenClaims:
|
|
113
|
+
"""Verify + decode *token*. Raises :class:`AuthenticationError` if invalid.
|
|
114
|
+
|
|
115
|
+
Verification is fail-closed on the registered claims: the signature (current
|
|
116
|
+
key or a rotation fallback), the ``exp``/``iat`` lifetime, and the exact
|
|
117
|
+
:data:`TOKEN_ISSUER` / :data:`TOKEN_AUDIENCE` pair must all hold.
|
|
118
|
+
"""
|
|
119
|
+
payload = _verify(token)
|
|
120
|
+
try:
|
|
121
|
+
raw_tenant = payload.get("tenant")
|
|
122
|
+
return AccessTokenClaims(
|
|
123
|
+
subject=uuid.UUID(payload["sub"]),
|
|
124
|
+
role=Role(str(payload["role"]), int(payload["rank"])),
|
|
125
|
+
tenant=uuid.UUID(raw_tenant) if raw_tenant is not None else None,
|
|
126
|
+
token_version=int(payload.get("tv", 0)),
|
|
127
|
+
)
|
|
128
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
129
|
+
raise AuthenticationError() from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
"AccessTokenClaims",
|
|
134
|
+
"DEFAULT_ACCESS_TOKEN_TTL",
|
|
135
|
+
"TOKEN_AUDIENCE",
|
|
136
|
+
"TOKEN_ISSUER",
|
|
137
|
+
"create_access_token",
|
|
138
|
+
"decode_access_token",
|
|
139
|
+
]
|