feature-standards-python-auth 0.2.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.
- feature_standards_python_auth-0.2.0/.gitignore +22 -0
- feature_standards_python_auth-0.2.0/LICENSE +21 -0
- feature_standards_python_auth-0.2.0/PACKAGE.md +23 -0
- feature_standards_python_auth-0.2.0/PKG-INFO +40 -0
- feature_standards_python_auth-0.2.0/feature_auth/__init__.py +15 -0
- feature_standards_python_auth-0.2.0/feature_auth/accounts.py +36 -0
- feature_standards_python_auth-0.2.0/feature_auth/core.py +191 -0
- feature_standards_python_auth-0.2.0/feature_auth/http.py +231 -0
- feature_standards_python_auth-0.2.0/feature_auth/memory.py +90 -0
- feature_standards_python_auth-0.2.0/feature_auth/postgres.py +215 -0
- feature_standards_python_auth-0.2.0/feature_auth/service.py +22 -0
- feature_standards_python_auth-0.2.0/pyproject.toml +48 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
node_modules/
|
|
10
|
+
dist/
|
|
11
|
+
test-results/
|
|
12
|
+
playwright-report/
|
|
13
|
+
.DS_Store
|
|
14
|
+
*.log
|
|
15
|
+
coverage.out
|
|
16
|
+
eval-results/
|
|
17
|
+
/implementations/go-net-http/go-net-http
|
|
18
|
+
/examples/consumer-go/consumer-go
|
|
19
|
+
/clients/react/lib/
|
|
20
|
+
/clients/react/*.tgz
|
|
21
|
+
bin/
|
|
22
|
+
target/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jongko54
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Feature Standards Python authentication
|
|
2
|
+
|
|
3
|
+
Session authentication for FastAPI with a reusable application factory and authentication dependency. The implementation follows the same behavioral contracts as the project's Go, Java, Rust, and browser clients.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from feature_auth import create_app, current_user
|
|
9
|
+
|
|
10
|
+
app = create_app()
|
|
11
|
+
|
|
12
|
+
@app.get("/private")
|
|
13
|
+
def private(user: Annotated[dict, Depends(current_user)]):
|
|
14
|
+
return {"user": user}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.12 or later is required. Configuration is read when the application lifespan starts. In-memory mode supports one configured user and process-local sessions. PostgreSQL mode adds shared persistent sessions; the optional account lifecycle profile adds registration, email verification, and password reset.
|
|
18
|
+
|
|
19
|
+
For a loopback-only demo, explicitly set `AUTH_DEMO=true`, `AUTH_COOKIE_SECURE=false`, and `AUTH_ALLOWED_ORIGIN=http://127.0.0.1:5173`. Run Uvicorn with `--no-proxy-headers`. Demo credentials are `demo@example.com` / `correct-horse-battery-staple`; they are not production credentials.
|
|
20
|
+
|
|
21
|
+
Database migrations and seeding are explicit repository tools. Installing or starting this package does not create tables or users. Production HTTPS, proxy policy, secrets, mail delivery, and operational controls require deployment-specific configuration.
|
|
22
|
+
|
|
23
|
+
See the [source and integration guide](https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi), [contracts](https://github.com/jongko54/feature-standards/tree/main/features/auth), and [security policy](https://github.com/jongko54/feature-standards/security/policy). Contract tests are not an independent security audit. MIT licensed.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: feature-standards-python-auth
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Contract-tested session authentication for FastAPI
|
|
5
|
+
Project-URL: Repository, https://github.com/jongko54/feature-standards
|
|
6
|
+
Project-URL: Documentation, https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi
|
|
7
|
+
Project-URL: Issues, https://github.com/jongko54/feature-standards/issues
|
|
8
|
+
Author: jongko54
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: argon2-cffi<26,>=25.1.0
|
|
13
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
14
|
+
Requires-Dist: psycopg[binary]<4,>=3.2
|
|
15
|
+
Requires-Dist: uvicorn<1,>=0.34
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Feature Standards Python authentication
|
|
19
|
+
|
|
20
|
+
Session authentication for FastAPI with a reusable application factory and authentication dependency. The implementation follows the same behavioral contracts as the project's Go, Java, Rust, and browser clients.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from typing import Annotated
|
|
24
|
+
from fastapi import Depends
|
|
25
|
+
from feature_auth import create_app, current_user
|
|
26
|
+
|
|
27
|
+
app = create_app()
|
|
28
|
+
|
|
29
|
+
@app.get("/private")
|
|
30
|
+
def private(user: Annotated[dict, Depends(current_user)]):
|
|
31
|
+
return {"user": user}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Python 3.12 or later is required. Configuration is read when the application lifespan starts. In-memory mode supports one configured user and process-local sessions. PostgreSQL mode adds shared persistent sessions; the optional account lifecycle profile adds registration, email verification, and password reset.
|
|
35
|
+
|
|
36
|
+
For a loopback-only demo, explicitly set `AUTH_DEMO=true`, `AUTH_COOKIE_SECURE=false`, and `AUTH_ALLOWED_ORIGIN=http://127.0.0.1:5173`. Run Uvicorn with `--no-proxy-headers`. Demo credentials are `demo@example.com` / `correct-horse-battery-staple`; they are not production credentials.
|
|
37
|
+
|
|
38
|
+
Database migrations and seeding are explicit repository tools. Installing or starting this package does not create tables or users. Production HTTPS, proxy policy, secrets, mail delivery, and operational controls require deployment-specific configuration.
|
|
39
|
+
|
|
40
|
+
See the [source and integration guide](https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi), [contracts](https://github.com/jongko54/feature-standards/tree/main/features/auth), and [security policy](https://github.com/jongko54/feature-standards/security/policy). Contract tests are not an independent security audit. MIT licensed.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Reusable browser-session authentication for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from .core import AuthError, AuthService, AuthStore, Settings
|
|
4
|
+
from .http import create_app, current_user
|
|
5
|
+
from .memory import AuthState
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AuthError",
|
|
9
|
+
"AuthService",
|
|
10
|
+
"AuthState",
|
|
11
|
+
"AuthStore",
|
|
12
|
+
"Settings",
|
|
13
|
+
"create_app",
|
|
14
|
+
"current_user",
|
|
15
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""v3 account input contract, independent of HTTP and PostgreSQL."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .core import AuthError, normalize_email, valid_email
|
|
6
|
+
|
|
7
|
+
ACCOUNT_ROUTES = {
|
|
8
|
+
"/auth/register": "register",
|
|
9
|
+
"/auth/email-verification/request": "verify-request",
|
|
10
|
+
"/auth/email-verification/confirm": "verify-confirm",
|
|
11
|
+
"/auth/password-reset/request": "reset-request",
|
|
12
|
+
"/auth/password-reset/confirm": "reset-confirm",
|
|
13
|
+
}
|
|
14
|
+
ACCOUNT_FIELDS = {
|
|
15
|
+
"register": {"email", "password"},
|
|
16
|
+
"verify-request": {"email"},
|
|
17
|
+
"verify-confirm": {"token"},
|
|
18
|
+
"reset-request": {"email"},
|
|
19
|
+
"reset-confirm": {"token", "password"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def account_input(action: str, data: dict[str, str]) -> tuple[str, str, str]:
|
|
24
|
+
email = normalize_email(data.get("email", ""))
|
|
25
|
+
token = data.get("token", "")
|
|
26
|
+
password = data.get("password", "")
|
|
27
|
+
if (
|
|
28
|
+
(
|
|
29
|
+
"email" in data
|
|
30
|
+
and (not valid_email(email) or any(ord(c) <= 32 or ord(c) == 127 for c in email))
|
|
31
|
+
)
|
|
32
|
+
or ("token" in data and re.fullmatch(r"[A-Za-z0-9_-]{43}", token) is None)
|
|
33
|
+
or ("password" in data and not (len(password) >= 15 and len(password.encode()) <= 1024))
|
|
34
|
+
):
|
|
35
|
+
raise AuthError(400, "INVALID_REQUEST")
|
|
36
|
+
return email, token, password
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Shared configuration, storage protocol and authentication service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
COOKIE_NAME = "fs_session"
|
|
12
|
+
MAX_BODY_BYTES = 4096
|
|
13
|
+
DEMO_EMAIL = "demo@example.com"
|
|
14
|
+
DEMO_PASSWORD = "correct-horse-battery-staple"
|
|
15
|
+
ASCII_EMAIL_WHITESPACE = "\t\n\v\f\r "
|
|
16
|
+
ASCII_EMAIL_LOWERCASE = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_email(email: str) -> str:
|
|
20
|
+
"""Unicode 테이블과 무관하게 계약의 ASCII 정규화만 수행한다."""
|
|
21
|
+
return email.strip(ASCII_EMAIL_WHITESPACE).translate(ASCII_EMAIL_LOWERCASE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def env_bool(name: str, default: bool) -> bool:
|
|
25
|
+
value = os.environ.get(name, str(default).lower())
|
|
26
|
+
if value not in ("true", "false"):
|
|
27
|
+
raise ValueError(f"{name} must be true or false")
|
|
28
|
+
return value == "true"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def env_positive_int(name: str, default: int) -> int:
|
|
32
|
+
raw = os.environ.get(name, str(default))
|
|
33
|
+
if not raw.isascii() or not raw.isdecimal() or int(raw) <= 0:
|
|
34
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
35
|
+
return int(raw)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def valid_email(email: str) -> bool:
|
|
39
|
+
return "@" in email and 0 < len(email.encode("utf-8")) <= 254
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_loopback(host: str) -> bool:
|
|
43
|
+
if host == "localhost":
|
|
44
|
+
return True
|
|
45
|
+
try:
|
|
46
|
+
return ipaddress.ip_address(host).is_loopback
|
|
47
|
+
except ValueError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Settings:
|
|
53
|
+
demo: bool
|
|
54
|
+
email: str
|
|
55
|
+
password: str = field(repr=False)
|
|
56
|
+
origin: str = "http://127.0.0.1:5173"
|
|
57
|
+
secure_cookie: bool = True
|
|
58
|
+
session_ttl: int = 1800
|
|
59
|
+
rate_limit_max: int = 5
|
|
60
|
+
rate_limit_window: int = 60
|
|
61
|
+
store: str = "memory"
|
|
62
|
+
database_url: str = field(default="", repr=False)
|
|
63
|
+
account_lifecycle: bool = False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_env(cls) -> Settings:
|
|
67
|
+
store = os.environ.get("AUTH_STORE", "memory")
|
|
68
|
+
if store not in ("memory", "postgres"):
|
|
69
|
+
raise ValueError("AUTH_STORE must be memory or postgres")
|
|
70
|
+
account_lifecycle = env_bool("AUTH_ACCOUNT_LIFECYCLE", False)
|
|
71
|
+
if account_lifecycle and store != "postgres":
|
|
72
|
+
raise ValueError("AUTH_ACCOUNT_LIFECYCLE requires postgres storage")
|
|
73
|
+
database_url = os.environ.get("AUTH_DATABASE_URL", "") if store == "postgres" else ""
|
|
74
|
+
if store == "postgres" and not database_url.startswith(("postgresql://", "postgres://")):
|
|
75
|
+
raise ValueError("AUTH_DATABASE_URL must be a PostgreSQL URI")
|
|
76
|
+
demo = env_bool("AUTH_DEMO", False)
|
|
77
|
+
origin = os.environ.get("AUTH_ALLOWED_ORIGIN", "http://127.0.0.1:5173")
|
|
78
|
+
secure = env_bool("AUTH_COOKIE_SECURE", True)
|
|
79
|
+
parsed = urlsplit(origin)
|
|
80
|
+
try:
|
|
81
|
+
port = parsed.port
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
raise ValueError("AUTH_ALLOWED_ORIGIN has an invalid port") from exc
|
|
84
|
+
if (
|
|
85
|
+
parsed.scheme not in ("http", "https")
|
|
86
|
+
or not parsed.hostname
|
|
87
|
+
or parsed.username is not None
|
|
88
|
+
or parsed.password is not None
|
|
89
|
+
or parsed.path
|
|
90
|
+
or parsed.query
|
|
91
|
+
or parsed.fragment
|
|
92
|
+
or origin != f"{parsed.scheme}://{parsed.netloc}"
|
|
93
|
+
or "*" in origin
|
|
94
|
+
or any(char.isspace() for char in origin)
|
|
95
|
+
or (port is not None and not 1 <= port <= 65535)
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("AUTH_ALLOWED_ORIGIN must be an exact HTTP(S) origin")
|
|
98
|
+
if not demo and parsed.scheme != "https":
|
|
99
|
+
raise ValueError("Non-demo mode requires an HTTPS origin")
|
|
100
|
+
if not secure and not (demo and is_loopback(parsed.hostname)):
|
|
101
|
+
raise ValueError("Insecure cookies require demo mode with a loopback origin")
|
|
102
|
+
email, password = "", ""
|
|
103
|
+
if store == "memory":
|
|
104
|
+
email = DEMO_EMAIL if demo else normalize_email(os.environ.get("AUTH_USER_EMAIL", ""))
|
|
105
|
+
password = DEMO_PASSWORD if demo else os.environ.get("AUTH_USER_PASSWORD", "")
|
|
106
|
+
try:
|
|
107
|
+
valid_credentials = valid_email(email) and 1 <= len(password.encode("utf-8")) <= 1024
|
|
108
|
+
except UnicodeError:
|
|
109
|
+
valid_credentials = False
|
|
110
|
+
if store == "memory" and not valid_credentials:
|
|
111
|
+
raise ValueError("A valid AUTH_USER_EMAIL and AUTH_USER_PASSWORD are required")
|
|
112
|
+
env_positive_int("AUTH_PORT", 8000)
|
|
113
|
+
if int(os.environ.get("AUTH_PORT", "8000")) > 65535:
|
|
114
|
+
raise ValueError("AUTH_PORT must be at most 65535")
|
|
115
|
+
return cls(
|
|
116
|
+
store=store,
|
|
117
|
+
database_url=database_url,
|
|
118
|
+
account_lifecycle=account_lifecycle,
|
|
119
|
+
demo=demo,
|
|
120
|
+
email=email,
|
|
121
|
+
password=password,
|
|
122
|
+
origin=origin,
|
|
123
|
+
secure_cookie=secure,
|
|
124
|
+
session_ttl=env_positive_int("AUTH_SESSION_TTL_SECONDS", 1800),
|
|
125
|
+
rate_limit_max=env_positive_int("AUTH_RATE_LIMIT_MAX", 5),
|
|
126
|
+
rate_limit_window=env_positive_int("AUTH_RATE_LIMIT_WINDOW_SECONDS", 60),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Session:
|
|
132
|
+
csrf: str
|
|
133
|
+
expires_at: float
|
|
134
|
+
user: dict[str, str] | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class FailureWindow:
|
|
139
|
+
count: int
|
|
140
|
+
expires_at: float
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AuthError(Exception):
|
|
144
|
+
def __init__(self, status: int, code: str, headers: dict[str, str] | None = None):
|
|
145
|
+
self.status = status
|
|
146
|
+
self.code = code
|
|
147
|
+
self.headers = headers
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AuthStore(Protocol):
|
|
151
|
+
"""Atomic authentication operations; HTTP adapters never handle SQL."""
|
|
152
|
+
|
|
153
|
+
def authenticate(
|
|
154
|
+
self, peer: str, email: str, password: str, old_token: str
|
|
155
|
+
) -> tuple[str, Session]: ...
|
|
156
|
+
def get_session(self, token: str) -> Session | None: ...
|
|
157
|
+
def logout(self, token: str, csrf: str) -> None: ...
|
|
158
|
+
def health(self) -> None: ...
|
|
159
|
+
def close(self) -> None: ...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AuthService:
|
|
163
|
+
def __init__(self, settings: Settings, store: AuthStore):
|
|
164
|
+
self.settings = replace(settings, password="", database_url="")
|
|
165
|
+
self.store = store
|
|
166
|
+
self.profile = (
|
|
167
|
+
"browser-session-v3"
|
|
168
|
+
if settings.account_lifecycle
|
|
169
|
+
else ("browser-session-v2" if settings.store == "postgres" else "browser-session-v1")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def account(self, action: str, peer: str, email: str, token: str, password: str):
|
|
173
|
+
handler = getattr(self.store, "account", None)
|
|
174
|
+
if not self.settings.account_lifecycle or handler is None:
|
|
175
|
+
raise AuthError(404, "NOT_FOUND")
|
|
176
|
+
return handler(action, peer, email, token, password)
|
|
177
|
+
|
|
178
|
+
def authenticate(self, peer: str, email: str, password: str, old_token: str):
|
|
179
|
+
return self.store.authenticate(peer, email, password, old_token)
|
|
180
|
+
|
|
181
|
+
def get_session(self, token: str):
|
|
182
|
+
return self.store.get_session(token)
|
|
183
|
+
|
|
184
|
+
def logout(self, token: str, csrf: str):
|
|
185
|
+
return self.store.logout(token, csrf)
|
|
186
|
+
|
|
187
|
+
def payload(self, session: Session) -> dict:
|
|
188
|
+
return {
|
|
189
|
+
"user": session.user or {"id": "demo-user", "email": self.settings.email},
|
|
190
|
+
"csrfToken": session.csrf,
|
|
191
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""FastAPI routes; authentication storage is injected through the service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, Request
|
|
11
|
+
from fastapi.responses import JSONResponse, Response
|
|
12
|
+
from starlette.concurrency import run_in_threadpool
|
|
13
|
+
from starlette.exceptions import HTTPException
|
|
14
|
+
|
|
15
|
+
from .accounts import ACCOUNT_FIELDS, ACCOUNT_ROUTES, account_input
|
|
16
|
+
from .core import (
|
|
17
|
+
COOKIE_NAME,
|
|
18
|
+
MAX_BODY_BYTES,
|
|
19
|
+
AuthError,
|
|
20
|
+
AuthService,
|
|
21
|
+
AuthStore,
|
|
22
|
+
Settings,
|
|
23
|
+
normalize_email,
|
|
24
|
+
valid_email,
|
|
25
|
+
)
|
|
26
|
+
from .service import build_service
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error_response(status: int, code: str, headers: dict | None = None) -> JSONResponse:
|
|
30
|
+
response_headers = {"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}
|
|
31
|
+
response_headers.update(headers or {})
|
|
32
|
+
return JSONResponse(
|
|
33
|
+
status_code=status,
|
|
34
|
+
content={"error": {"code": code, "message": code.replace("_", " ").lower()}},
|
|
35
|
+
headers=response_headers,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_origin(request: Request, settings: Settings) -> None:
|
|
40
|
+
if request.headers.getlist("origin") != [settings.origin]:
|
|
41
|
+
raise AuthError(403, "ORIGIN_DENIED")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unique_object(pairs: list[tuple[str, object]]) -> dict:
|
|
45
|
+
result = {}
|
|
46
|
+
for key, value in pairs:
|
|
47
|
+
if key in result:
|
|
48
|
+
raise ValueError("Duplicate JSON key")
|
|
49
|
+
result[key] = value
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def invalid_constant(value: str) -> None:
|
|
54
|
+
raise ValueError("Invalid JSON constant")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def read_object(request: Request, fields: set[str]) -> dict[str, str]:
|
|
58
|
+
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
59
|
+
if media_type != "application/json":
|
|
60
|
+
raise AuthError(415, "UNSUPPORTED_MEDIA_TYPE")
|
|
61
|
+
body = bytearray()
|
|
62
|
+
async for chunk in request.stream():
|
|
63
|
+
body.extend(chunk)
|
|
64
|
+
if len(body) > MAX_BODY_BYTES:
|
|
65
|
+
raise AuthError(413, "REQUEST_TOO_LARGE")
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(
|
|
68
|
+
body.decode("utf-8"), object_pairs_hook=unique_object, parse_constant=invalid_constant
|
|
69
|
+
)
|
|
70
|
+
if not isinstance(data, dict) or set(data) != fields:
|
|
71
|
+
raise ValueError("Invalid object")
|
|
72
|
+
if any(not isinstance(value, str) for value in data.values()):
|
|
73
|
+
raise ValueError("Invalid field type")
|
|
74
|
+
for value in data.values():
|
|
75
|
+
value.encode("utf-8")
|
|
76
|
+
return data
|
|
77
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
78
|
+
raise AuthError(400, "INVALID_REQUEST") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def read_credentials(request: Request) -> tuple[str, str]:
|
|
82
|
+
data = await read_object(request, {"email", "password"})
|
|
83
|
+
try:
|
|
84
|
+
email = normalize_email(data["email"])
|
|
85
|
+
password = data["password"]
|
|
86
|
+
if not valid_email(email) or not 1 <= len(password.encode("utf-8")) <= 1024:
|
|
87
|
+
raise ValueError("Invalid field length")
|
|
88
|
+
return email, password
|
|
89
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
90
|
+
raise AuthError(400, "INVALID_REQUEST") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def current_user(request: Request) -> dict[str, str]:
|
|
94
|
+
"""Return the current user, also usable as Depends(current_user)."""
|
|
95
|
+
state: AuthService = request.app.state.auth
|
|
96
|
+
session = state.get_session(request.cookies.get(COOKIE_NAME, ""))
|
|
97
|
+
if session is None:
|
|
98
|
+
raise AuthError(401, "UNAUTHENTICATED")
|
|
99
|
+
return state.payload(session)["user"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_app(
|
|
103
|
+
*,
|
|
104
|
+
settings: Settings | None = None,
|
|
105
|
+
clock: Callable[[], float] = time.monotonic,
|
|
106
|
+
store: AuthStore | None = None,
|
|
107
|
+
) -> FastAPI:
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def lifespan(application: FastAPI):
|
|
110
|
+
application.state.auth = await run_in_threadpool(
|
|
111
|
+
build_service, settings or Settings.from_env(), store, clock
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
yield
|
|
115
|
+
finally:
|
|
116
|
+
await run_in_threadpool(application.state.auth.store.close)
|
|
117
|
+
|
|
118
|
+
application = FastAPI(
|
|
119
|
+
title="Feature Standards Auth",
|
|
120
|
+
lifespan=lifespan,
|
|
121
|
+
docs_url=None,
|
|
122
|
+
redoc_url=None,
|
|
123
|
+
openapi_url=None,
|
|
124
|
+
redirect_slashes=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@application.middleware("http")
|
|
128
|
+
async def auth_headers(request: Request, call_next):
|
|
129
|
+
if (
|
|
130
|
+
request.url.path in ACCOUNT_ROUTES
|
|
131
|
+
and not request.app.state.auth.settings.account_lifecycle
|
|
132
|
+
):
|
|
133
|
+
return error_response(404, "NOT_FOUND")
|
|
134
|
+
response = await call_next(request)
|
|
135
|
+
if request.url.path.startswith("/auth/"):
|
|
136
|
+
response.headers["Cache-Control"] = "no-store"
|
|
137
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
@application.exception_handler(AuthError)
|
|
141
|
+
async def handle_auth_error(request: Request, error: AuthError):
|
|
142
|
+
return error_response(error.status, error.code, error.headers)
|
|
143
|
+
|
|
144
|
+
@application.exception_handler(HTTPException)
|
|
145
|
+
async def handle_http_error(request: Request, error: HTTPException):
|
|
146
|
+
code = {404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED"}.get(error.status_code, "HTTP_ERROR")
|
|
147
|
+
return error_response(error.status_code, code, error.headers)
|
|
148
|
+
|
|
149
|
+
@application.exception_handler(Exception)
|
|
150
|
+
async def handle_internal_error(request: Request, error: Exception):
|
|
151
|
+
return error_response(500, "INTERNAL_ERROR")
|
|
152
|
+
|
|
153
|
+
@application.get("/healthz")
|
|
154
|
+
def healthz(request: Request):
|
|
155
|
+
state = request.app.state.auth
|
|
156
|
+
state.store.health()
|
|
157
|
+
return {"status": "ok", "profile": state.profile, "implementation": "python-fastapi"}
|
|
158
|
+
|
|
159
|
+
@application.post("/auth/login")
|
|
160
|
+
async def login(request: Request):
|
|
161
|
+
state: AuthService = request.app.state.auth
|
|
162
|
+
require_origin(request, state.settings)
|
|
163
|
+
email, password = await read_credentials(request)
|
|
164
|
+
token, session = await run_in_threadpool(
|
|
165
|
+
state.authenticate,
|
|
166
|
+
request.client.host if request.client else "unknown",
|
|
167
|
+
email,
|
|
168
|
+
password,
|
|
169
|
+
request.cookies.get(COOKIE_NAME, ""),
|
|
170
|
+
)
|
|
171
|
+
response = JSONResponse(state.payload(session))
|
|
172
|
+
response.set_cookie(
|
|
173
|
+
COOKIE_NAME,
|
|
174
|
+
token,
|
|
175
|
+
max_age=state.settings.session_ttl,
|
|
176
|
+
path="/",
|
|
177
|
+
secure=state.settings.secure_cookie,
|
|
178
|
+
httponly=True,
|
|
179
|
+
samesite="lax",
|
|
180
|
+
)
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
@application.get("/auth/me")
|
|
184
|
+
def me(request: Request):
|
|
185
|
+
state: AuthService = request.app.state.auth
|
|
186
|
+
session = state.get_session(request.cookies.get(COOKIE_NAME, ""))
|
|
187
|
+
if session is None:
|
|
188
|
+
raise AuthError(401, "UNAUTHENTICATED")
|
|
189
|
+
return state.payload(session)
|
|
190
|
+
|
|
191
|
+
@application.post("/auth/logout")
|
|
192
|
+
def logout(request: Request):
|
|
193
|
+
state: AuthService = request.app.state.auth
|
|
194
|
+
require_origin(request, state.settings)
|
|
195
|
+
csrf_headers = request.headers.getlist("x-csrf-token")
|
|
196
|
+
csrf = csrf_headers[0] if len(csrf_headers) == 1 else ""
|
|
197
|
+
state.logout(request.cookies.get(COOKIE_NAME, ""), csrf)
|
|
198
|
+
response = Response(status_code=204)
|
|
199
|
+
response.delete_cookie(
|
|
200
|
+
COOKIE_NAME,
|
|
201
|
+
path="/",
|
|
202
|
+
secure=state.settings.secure_cookie,
|
|
203
|
+
httponly=True,
|
|
204
|
+
samesite="lax",
|
|
205
|
+
)
|
|
206
|
+
return response
|
|
207
|
+
|
|
208
|
+
def account_endpoint(action: str):
|
|
209
|
+
async def endpoint(request: Request):
|
|
210
|
+
state: AuthService = request.app.state.auth
|
|
211
|
+
require_origin(request, state.settings)
|
|
212
|
+
data = await read_object(request, ACCOUNT_FIELDS[action])
|
|
213
|
+
email, token, password = account_input(action, data)
|
|
214
|
+
await run_in_threadpool(
|
|
215
|
+
state.account,
|
|
216
|
+
action,
|
|
217
|
+
request.client.host if request.client else "unknown",
|
|
218
|
+
email,
|
|
219
|
+
token,
|
|
220
|
+
password,
|
|
221
|
+
)
|
|
222
|
+
if action.endswith("confirm"):
|
|
223
|
+
return Response(status_code=204)
|
|
224
|
+
return JSONResponse({"status": "accepted"}, status_code=202)
|
|
225
|
+
|
|
226
|
+
return endpoint
|
|
227
|
+
|
|
228
|
+
for path, action in ACCOUNT_ROUTES.items():
|
|
229
|
+
application.add_api_route(path, account_endpoint(action), methods=["POST"])
|
|
230
|
+
|
|
231
|
+
return application
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The original process-local v1 adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import math
|
|
7
|
+
import secrets
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from argon2 import PasswordHasher
|
|
13
|
+
from argon2.exceptions import VerifyMismatchError
|
|
14
|
+
|
|
15
|
+
from .core import AuthError, FailureWindow, Session, Settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthState:
|
|
19
|
+
def __init__(self, settings: Settings, clock: Callable[[], float]):
|
|
20
|
+
self.clock = clock
|
|
21
|
+
self.hasher = PasswordHasher()
|
|
22
|
+
self.password_hash = self.hasher.hash(settings.password)
|
|
23
|
+
self.settings = replace(settings, password="")
|
|
24
|
+
self.sessions: dict[str, Session] = {}
|
|
25
|
+
self.failures: dict[str, FailureWindow] = {}
|
|
26
|
+
self.lock = threading.RLock()
|
|
27
|
+
|
|
28
|
+
def _prune(self, now: float) -> None:
|
|
29
|
+
self.sessions = {
|
|
30
|
+
token: session for token, session in self.sessions.items() if session.expires_at > now
|
|
31
|
+
}
|
|
32
|
+
self.failures = {
|
|
33
|
+
peer: window for peer, window in self.failures.items() if window.expires_at > now
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def authenticate(
|
|
37
|
+
self, peer: str, email: str, password: str, old_token: str
|
|
38
|
+
) -> tuple[str, Session]:
|
|
39
|
+
# 해싱을 직렬화해 병렬 로그인 메모리 폭증과 실패 횟수 경합 우회를 막는다.
|
|
40
|
+
with self.lock:
|
|
41
|
+
now = self.clock()
|
|
42
|
+
self._prune(now)
|
|
43
|
+
failure = self.failures.get(peer)
|
|
44
|
+
if failure is not None and failure.count >= self.settings.rate_limit_max:
|
|
45
|
+
retry_after = max(1, math.ceil(failure.expires_at - now))
|
|
46
|
+
raise AuthError(429, "RATE_LIMITED", {"Retry-After": str(retry_after)})
|
|
47
|
+
try:
|
|
48
|
+
password_matches = self.hasher.verify(self.password_hash, password)
|
|
49
|
+
except VerifyMismatchError:
|
|
50
|
+
password_matches = False
|
|
51
|
+
email_matches = hmac.compare_digest(email.encode(), self.settings.email.encode())
|
|
52
|
+
if not password_matches or not email_matches:
|
|
53
|
+
if failure is None:
|
|
54
|
+
self.failures[peer] = FailureWindow(1, now + self.settings.rate_limit_window)
|
|
55
|
+
else:
|
|
56
|
+
failure.count += 1
|
|
57
|
+
raise AuthError(401, "INVALID_CREDENTIALS")
|
|
58
|
+
self.failures.pop(peer, None)
|
|
59
|
+
self.sessions.pop(old_token, None)
|
|
60
|
+
token = secrets.token_urlsafe(32)
|
|
61
|
+
session = Session(secrets.token_urlsafe(32), self.clock() + self.settings.session_ttl)
|
|
62
|
+
self.sessions[token] = session
|
|
63
|
+
return token, session
|
|
64
|
+
|
|
65
|
+
def get_session(self, token: str) -> Session | None:
|
|
66
|
+
with self.lock:
|
|
67
|
+
self._prune(self.clock())
|
|
68
|
+
return self.sessions.get(token)
|
|
69
|
+
|
|
70
|
+
def logout(self, token: str, csrf: str) -> None:
|
|
71
|
+
with self.lock:
|
|
72
|
+
self._prune(self.clock())
|
|
73
|
+
session = self.sessions.get(token)
|
|
74
|
+
if session is not None:
|
|
75
|
+
if not hmac.compare_digest(csrf.encode("utf-8"), session.csrf.encode("utf-8")):
|
|
76
|
+
raise AuthError(403, "CSRF_INVALID")
|
|
77
|
+
del self.sessions[token]
|
|
78
|
+
|
|
79
|
+
def health(self) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self.sessions.clear()
|
|
84
|
+
self.failures.clear()
|
|
85
|
+
|
|
86
|
+
def payload(self, session: Session) -> dict:
|
|
87
|
+
return {
|
|
88
|
+
"user": session.user or {"id": "demo-user", "email": self.settings.email},
|
|
89
|
+
"csrfToken": session.csrf,
|
|
90
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""PostgreSQL adapter. Transactions complete before values reach the HTTP layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
|
|
13
|
+
import psycopg
|
|
14
|
+
from argon2 import PasswordHasher
|
|
15
|
+
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
|
16
|
+
|
|
17
|
+
from .core import AuthError, Session, Settings
|
|
18
|
+
|
|
19
|
+
PHC = re.compile(r"^\$argon2id\$v=19\$m=65536,t=3,p=4\$([A-Za-z0-9+/]{22})\$([A-Za-z0-9+/]{43})$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def valid_phc(value: str) -> bool:
|
|
23
|
+
match = PHC.fullmatch(value)
|
|
24
|
+
if not match:
|
|
25
|
+
return False
|
|
26
|
+
try:
|
|
27
|
+
salt, digest = (
|
|
28
|
+
base64.b64decode(part + "=" * (-len(part) % 4), validate=True)
|
|
29
|
+
for part in match.groups()
|
|
30
|
+
)
|
|
31
|
+
return (
|
|
32
|
+
len(salt) == 16
|
|
33
|
+
and len(digest) == 32
|
|
34
|
+
and base64.b64encode(salt).decode().rstrip("=") == match[1]
|
|
35
|
+
and base64.b64encode(digest).decode().rstrip("=") == match[2]
|
|
36
|
+
)
|
|
37
|
+
except ValueError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def token_hash(token: str) -> bytes:
|
|
42
|
+
return hashlib.sha256(token.encode("utf-8")).digest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PostgresStore:
|
|
46
|
+
def __init__(self, settings: Settings):
|
|
47
|
+
if not settings.database_url.startswith(("postgresql://", "postgres://")):
|
|
48
|
+
raise ValueError("AUTH_DATABASE_URL must be a PostgreSQL URI")
|
|
49
|
+
self._url = settings.database_url
|
|
50
|
+
self.ttl = settings.session_ttl
|
|
51
|
+
self.limit = settings.rate_limit_max
|
|
52
|
+
self.window = settings.rate_limit_window
|
|
53
|
+
self.account_lifecycle = settings.account_lifecycle
|
|
54
|
+
self.hasher = PasswordHasher(
|
|
55
|
+
time_cost=3, memory_cost=65536, parallelism=4, salt_len=16, hash_len=32
|
|
56
|
+
)
|
|
57
|
+
self._dummy = self.hasher.hash(secrets.token_urlsafe(32))
|
|
58
|
+
try:
|
|
59
|
+
self.health()
|
|
60
|
+
except AuthError:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"PostgreSQL authentication storage is unavailable or incompatible"
|
|
63
|
+
) from None
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def _connection(self):
|
|
67
|
+
try:
|
|
68
|
+
# Each operation owns its connection and transaction; no thread shares one.
|
|
69
|
+
with psycopg.connect(
|
|
70
|
+
self._url,
|
|
71
|
+
connect_timeout=3,
|
|
72
|
+
options="-c statement_timeout=10000 -c lock_timeout=8000",
|
|
73
|
+
) as conn:
|
|
74
|
+
yield conn
|
|
75
|
+
except psycopg.Error:
|
|
76
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE") from None
|
|
77
|
+
|
|
78
|
+
def health(self) -> None:
|
|
79
|
+
with self._connection() as conn:
|
|
80
|
+
rows = conn.execute("SELECT version FROM feature_auth.schema_version").fetchall()
|
|
81
|
+
if rows != [(2 if self.account_lifecycle else 1,)]:
|
|
82
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE")
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def account(self, action: str, peer: str, email: str, token: str, password: str) -> None:
|
|
88
|
+
if not self.account_lifecycle:
|
|
89
|
+
raise AuthError(404, "NOT_FOUND")
|
|
90
|
+
with self._connection() as conn:
|
|
91
|
+
outcome = conn.execute(
|
|
92
|
+
"SELECT feature_auth.account_reserve(%s,%s)", (action, peer)
|
|
93
|
+
).fetchone()[0]
|
|
94
|
+
if not isinstance(outcome, int) or outcome < 0:
|
|
95
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE")
|
|
96
|
+
if outcome == 0:
|
|
97
|
+
# Reserve the peer budget in this transaction before allocating Argon2 memory.
|
|
98
|
+
encoded = self.hasher.hash(password) if password else ""
|
|
99
|
+
if action in ("register", "verify-request", "reset-request"):
|
|
100
|
+
token = secrets.token_urlsafe(32)
|
|
101
|
+
outcome = conn.execute(
|
|
102
|
+
"SELECT feature_auth.account_apply(%s,%s,%s,%s,%s)",
|
|
103
|
+
(action, peer, email, token, encoded),
|
|
104
|
+
).fetchone()[0]
|
|
105
|
+
# The connection context commits before any semantic outcome reaches HTTP.
|
|
106
|
+
if not isinstance(outcome, int):
|
|
107
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE")
|
|
108
|
+
if outcome == -1:
|
|
109
|
+
raise AuthError(400, "INVALID_TOKEN")
|
|
110
|
+
if outcome > 0:
|
|
111
|
+
raise AuthError(429, "RATE_LIMITED", {"Retry-After": str(outcome)})
|
|
112
|
+
if outcome != 0:
|
|
113
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE")
|
|
114
|
+
|
|
115
|
+
def authenticate(self, peer: str, email: str, password: str, old_token: str):
|
|
116
|
+
outcome = None
|
|
117
|
+
result = None
|
|
118
|
+
with self._connection() as conn:
|
|
119
|
+
conn.execute(
|
|
120
|
+
"SELECT pg_advisory_xact_lock(20261102, hashtext(host(CAST(%s AS inet))))", (peer,)
|
|
121
|
+
)
|
|
122
|
+
now = conn.execute("SELECT clock_timestamp()").fetchone()[0]
|
|
123
|
+
conn.execute(
|
|
124
|
+
"DELETE FROM feature_auth.login_attempts "
|
|
125
|
+
"WHERE peer = CAST(%s AS inet) AND expires_at <= %s",
|
|
126
|
+
(peer, now),
|
|
127
|
+
)
|
|
128
|
+
attempt = conn.execute(
|
|
129
|
+
"SELECT failures, expires_at FROM feature_auth.login_attempts "
|
|
130
|
+
"WHERE peer = CAST(%s AS inet)",
|
|
131
|
+
(peer,),
|
|
132
|
+
).fetchone()
|
|
133
|
+
if attempt and attempt[0] >= self.limit:
|
|
134
|
+
retry = max(1, math.ceil((attempt[1] - now).total_seconds()))
|
|
135
|
+
outcome = AuthError(429, "RATE_LIMITED", {"Retry-After": str(retry)})
|
|
136
|
+
else:
|
|
137
|
+
user = conn.execute(
|
|
138
|
+
"SELECT id, email, password_hash, enabled "
|
|
139
|
+
"FROM feature_auth.users WHERE email = %s FOR SHARE",
|
|
140
|
+
(email,),
|
|
141
|
+
).fetchone()
|
|
142
|
+
encoded = user[2] if user else self._dummy
|
|
143
|
+
if not valid_phc(encoded):
|
|
144
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE")
|
|
145
|
+
try:
|
|
146
|
+
matches = self.hasher.verify(encoded, password)
|
|
147
|
+
except VerifyMismatchError:
|
|
148
|
+
matches = False
|
|
149
|
+
except (InvalidHashError, VerificationError):
|
|
150
|
+
raise AuthError(503, "STORAGE_UNAVAILABLE") from None
|
|
151
|
+
if not matches or not user or not user[3]:
|
|
152
|
+
conn.execute(
|
|
153
|
+
"""INSERT INTO feature_auth.login_attempts(peer, failures, expires_at)
|
|
154
|
+
VALUES (CAST(%s AS inet), 1, %s + %s * interval '1 second')
|
|
155
|
+
ON CONFLICT (peer) DO UPDATE
|
|
156
|
+
SET failures = feature_auth.login_attempts.failures + 1""",
|
|
157
|
+
(peer, now, self.window),
|
|
158
|
+
)
|
|
159
|
+
outcome = AuthError(401, "INVALID_CREDENTIALS")
|
|
160
|
+
else:
|
|
161
|
+
conn.execute(
|
|
162
|
+
"DELETE FROM feature_auth.login_attempts WHERE peer = CAST(%s AS inet)",
|
|
163
|
+
(peer,),
|
|
164
|
+
)
|
|
165
|
+
conn.execute(
|
|
166
|
+
"DELETE FROM feature_auth.sessions WHERE token_hash = %s",
|
|
167
|
+
(token_hash(old_token),),
|
|
168
|
+
)
|
|
169
|
+
token, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
170
|
+
# Read DB time after hashing so TTL begins at session creation.
|
|
171
|
+
expires = conn.execute(
|
|
172
|
+
"""INSERT INTO feature_auth.sessions
|
|
173
|
+
(token_hash,user_id,csrf_token,created_at,expires_at)
|
|
174
|
+
SELECT %s,%s,%s,t,t + %s * interval '1 second'
|
|
175
|
+
FROM (SELECT clock_timestamp() AS t) AS clock
|
|
176
|
+
RETURNING expires_at""",
|
|
177
|
+
(token_hash(token), user[0], csrf, self.ttl),
|
|
178
|
+
).fetchone()[0]
|
|
179
|
+
result = (
|
|
180
|
+
token,
|
|
181
|
+
Session(csrf, expires.timestamp(), {"id": user[0], "email": user[1]}),
|
|
182
|
+
)
|
|
183
|
+
# Raising inside the transaction would roll back the failed-attempt counter.
|
|
184
|
+
if outcome:
|
|
185
|
+
raise outcome
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def get_session(self, token: str) -> Session | None:
|
|
189
|
+
with self._connection() as conn:
|
|
190
|
+
row = conn.execute(
|
|
191
|
+
"""SELECT s.csrf_token,s.expires_at,u.id,u.email
|
|
192
|
+
FROM feature_auth.sessions s JOIN feature_auth.users u ON u.id=s.user_id
|
|
193
|
+
WHERE s.token_hash=%s AND s.expires_at > clock_timestamp() AND u.enabled""",
|
|
194
|
+
(token_hash(token),),
|
|
195
|
+
).fetchone()
|
|
196
|
+
if row:
|
|
197
|
+
return Session(row[0], row[1].timestamp(), {"id": row[2], "email": row[3]})
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def logout(self, token: str, csrf: str) -> None:
|
|
201
|
+
with self._connection() as conn:
|
|
202
|
+
digest = token_hash(token)
|
|
203
|
+
row = conn.execute(
|
|
204
|
+
"SELECT csrf_token,expires_at FROM feature_auth.sessions "
|
|
205
|
+
"WHERE token_hash=%s FOR UPDATE",
|
|
206
|
+
(digest,),
|
|
207
|
+
).fetchone()
|
|
208
|
+
now = conn.execute("SELECT clock_timestamp()").fetchone()[0]
|
|
209
|
+
if (
|
|
210
|
+
row
|
|
211
|
+
and row[1] > now
|
|
212
|
+
and not hmac.compare_digest(csrf.encode("utf-8"), row[0].encode("utf-8"))
|
|
213
|
+
):
|
|
214
|
+
raise AuthError(403, "CSRF_INVALID")
|
|
215
|
+
conn.execute("DELETE FROM feature_auth.sessions WHERE token_hash=%s", (digest,))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Composition root for the interchangeable store adapters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from .core import AuthService, AuthStore, Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_service(settings: Settings, store: AuthStore | None, clock: Callable[[], float]):
|
|
9
|
+
if settings.store not in ("memory", "postgres"):
|
|
10
|
+
raise ValueError("AUTH_STORE must be memory or postgres")
|
|
11
|
+
if settings.account_lifecycle and settings.store != "postgres":
|
|
12
|
+
raise ValueError("AUTH_ACCOUNT_LIFECYCLE requires postgres storage")
|
|
13
|
+
if store is None:
|
|
14
|
+
if settings.store == "postgres":
|
|
15
|
+
from .postgres import PostgresStore
|
|
16
|
+
|
|
17
|
+
store = PostgresStore(settings)
|
|
18
|
+
else:
|
|
19
|
+
from .memory import AuthState
|
|
20
|
+
|
|
21
|
+
store = AuthState(settings, clock)
|
|
22
|
+
return AuthService(settings, store)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "feature-standards-python-auth"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Contract-tested session authentication for FastAPI"
|
|
5
|
+
readme = "PACKAGE.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{name = "jongko54"}]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"argon2-cffi>=25.1.0,<26",
|
|
12
|
+
"fastapi>=0.115,<1",
|
|
13
|
+
"uvicorn>=0.34,<1",
|
|
14
|
+
"psycopg[binary]>=3.2,<4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/jongko54/feature-standards"
|
|
19
|
+
Documentation = "https://github.com/jongko54/feature-standards/tree/main/implementations/python-fastapi"
|
|
20
|
+
Issues = "https://github.com/jongko54/feature-standards/issues"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"httpx2>=2.16.0,<3",
|
|
25
|
+
"pytest>=8,<10",
|
|
26
|
+
"ruff>=0.12,<1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
pythonpath = ["."]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
line-length = 100
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling>=1.27,<2"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["feature_auth"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = ["feature_auth", "pyproject.toml", "PACKAGE.md", "LICENSE"]
|