supython 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/auth/service.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Auth business logic: signup, password grant, refresh rotation, logout."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import asyncpg
|
|
12
|
+
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
|
13
|
+
|
|
14
|
+
from .. import mail, passwords, tokens
|
|
15
|
+
from ..mailer import EmailMessage
|
|
16
|
+
from ..settings import get_settings
|
|
17
|
+
from .schemas import UserResponse
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthError(Exception):
|
|
23
|
+
def __init__(self, code: str, message: str, status: int = 400) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _row_to_user(row: asyncpg.Record) -> UserResponse:
|
|
31
|
+
return UserResponse(
|
|
32
|
+
id=row["id"],
|
|
33
|
+
email=row["email"],
|
|
34
|
+
created_at=row["created_at"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _sha256(value: str) -> str:
|
|
38
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _audit_log(
|
|
42
|
+
conn: asyncpg.Connection,
|
|
43
|
+
user_id: UUID,
|
|
44
|
+
event: str,
|
|
45
|
+
*,
|
|
46
|
+
ip: str | None = None,
|
|
47
|
+
ua: str | None = None,
|
|
48
|
+
payload: dict[str, Any] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
await conn.execute(
|
|
51
|
+
"""
|
|
52
|
+
insert into auth.audit_log (user_id, event, ip, ua, payload)
|
|
53
|
+
values ($1, $2, $3::inet, $4, $5::jsonb)
|
|
54
|
+
""",
|
|
55
|
+
user_id,
|
|
56
|
+
event,
|
|
57
|
+
ip,
|
|
58
|
+
ua,
|
|
59
|
+
json.dumps(payload or {}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _issue_pair(
|
|
64
|
+
conn: asyncpg.Connection, user: UserResponse
|
|
65
|
+
) -> tuple[str, str, int]:
|
|
66
|
+
access, ttl = tokens.issue_access_token(user.id, user.email)
|
|
67
|
+
refresh = tokens.issue_refresh_token()
|
|
68
|
+
await conn.execute(
|
|
69
|
+
"insert into auth.refresh_tokens (user_id, token) values ($1, $2)",
|
|
70
|
+
user.id,
|
|
71
|
+
refresh,
|
|
72
|
+
)
|
|
73
|
+
return access, refresh, ttl
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _store_one_time_token(
|
|
77
|
+
conn: asyncpg.Connection,
|
|
78
|
+
user_id: UUID,
|
|
79
|
+
token_type: str,
|
|
80
|
+
ttl_seconds: int,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Generate, sha256-hash, and store a one-time token. Returns the raw token."""
|
|
83
|
+
raw = secrets.token_urlsafe(32)
|
|
84
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
|
85
|
+
await conn.execute(
|
|
86
|
+
"""
|
|
87
|
+
insert into auth.one_time_tokens (user_id, type, token_hash, expires_at)
|
|
88
|
+
values ($1, $2, $3, $4)
|
|
89
|
+
""",
|
|
90
|
+
user_id,
|
|
91
|
+
token_type,
|
|
92
|
+
_sha256(raw),
|
|
93
|
+
expires_at,
|
|
94
|
+
)
|
|
95
|
+
return raw
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _verify_one_time_token(
|
|
99
|
+
conn: asyncpg.Connection,
|
|
100
|
+
token: str,
|
|
101
|
+
token_type: str,
|
|
102
|
+
email: str | None = None,
|
|
103
|
+
) -> asyncpg.Record:
|
|
104
|
+
"""Return a valid, unexpired, unused token row. Raises AuthError if not found."""
|
|
105
|
+
token_hash = _sha256(token)
|
|
106
|
+
if email is not None:
|
|
107
|
+
row = await conn.fetchrow(
|
|
108
|
+
"""
|
|
109
|
+
select ott.id as ott_id, u.id, u.email, u.created_at
|
|
110
|
+
from auth.one_time_tokens ott
|
|
111
|
+
join auth.users u on u.id = ott.user_id
|
|
112
|
+
where ott.token_hash = $1
|
|
113
|
+
and ott.type = $2
|
|
114
|
+
and ott.used_at is null
|
|
115
|
+
and ott.expires_at > now()
|
|
116
|
+
and u.email = $3
|
|
117
|
+
""",
|
|
118
|
+
token_hash,
|
|
119
|
+
token_type,
|
|
120
|
+
email,
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
row = await conn.fetchrow(
|
|
124
|
+
"""
|
|
125
|
+
select ott.id as ott_id, u.id, u.email, u.created_at
|
|
126
|
+
from auth.one_time_tokens ott
|
|
127
|
+
join auth.users u on u.id = ott.user_id
|
|
128
|
+
where ott.token_hash = $1
|
|
129
|
+
and ott.type = $2
|
|
130
|
+
and ott.used_at is null
|
|
131
|
+
and ott.expires_at > now()
|
|
132
|
+
""",
|
|
133
|
+
token_hash,
|
|
134
|
+
token_type,
|
|
135
|
+
)
|
|
136
|
+
if not row:
|
|
137
|
+
logger.warning("auth.verify_one_time_token: invalid or expired %s token", token_type)
|
|
138
|
+
raise AuthError("invalid_token", "Token is invalid or expired", 400)
|
|
139
|
+
return row
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def signup(
|
|
143
|
+
conn: asyncpg.Connection, email: str, password: str
|
|
144
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
145
|
+
existing = await conn.fetchval(
|
|
146
|
+
"select 1 from auth.users where email = $1", email
|
|
147
|
+
)
|
|
148
|
+
if existing:
|
|
149
|
+
raise AuthError("email_taken", "Email already registered", 409)
|
|
150
|
+
|
|
151
|
+
pw_hash = passwords.hash_password(password)
|
|
152
|
+
row = await conn.fetchrow(
|
|
153
|
+
"""
|
|
154
|
+
insert into auth.users (email, encrypted_password, email_confirmed_at)
|
|
155
|
+
values ($1, $2, now())
|
|
156
|
+
returning id, email, created_at
|
|
157
|
+
""",
|
|
158
|
+
email,
|
|
159
|
+
pw_hash,
|
|
160
|
+
)
|
|
161
|
+
user = _row_to_user(row)
|
|
162
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
from .. import db as _db
|
|
166
|
+
from ..hooks import build_hook_ctx, fire
|
|
167
|
+
|
|
168
|
+
synth_claims = {"sub": str(user.id), "email": user.email, "role": "authenticated"}
|
|
169
|
+
async with _db.as_role("authenticated", synth_claims) as role_conn:
|
|
170
|
+
ctx = build_hook_ctx(conn=role_conn)
|
|
171
|
+
await fire("signup", user, ctx)
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.warning("hooks: signup hook failed", exc_info=True)
|
|
174
|
+
|
|
175
|
+
return user, access, refresh, ttl
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def password_grant(
|
|
179
|
+
conn: asyncpg.Connection, email: str, password: str
|
|
180
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
181
|
+
row = await conn.fetchrow(
|
|
182
|
+
"""
|
|
183
|
+
select id, email, encrypted_password, created_at
|
|
184
|
+
from auth.users
|
|
185
|
+
where email = $1
|
|
186
|
+
""",
|
|
187
|
+
email,
|
|
188
|
+
)
|
|
189
|
+
if not row or not row["encrypted_password"]:
|
|
190
|
+
logger.warning("auth.password_grant: unknown email %s", email)
|
|
191
|
+
raise AuthError("invalid_credentials", "Invalid email or password", 401)
|
|
192
|
+
if not passwords.verify_password(row["encrypted_password"], password):
|
|
193
|
+
logger.warning("auth.password_grant: bad password for %s", email)
|
|
194
|
+
raise AuthError("invalid_credentials", "Invalid email or password", 401)
|
|
195
|
+
|
|
196
|
+
await conn.execute(
|
|
197
|
+
"update auth.users set last_sign_in_at = now() where id = $1",
|
|
198
|
+
row["id"],
|
|
199
|
+
)
|
|
200
|
+
user = _row_to_user(row)
|
|
201
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
202
|
+
return user, access, refresh, ttl
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def refresh_grant(
|
|
206
|
+
conn: asyncpg.Connection,
|
|
207
|
+
refresh_token: str,
|
|
208
|
+
*,
|
|
209
|
+
ip: str | None = None,
|
|
210
|
+
ua: str | None = None,
|
|
211
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
212
|
+
row = await conn.fetchrow(
|
|
213
|
+
"""
|
|
214
|
+
select rt.id as rt_id,
|
|
215
|
+
rt.revoked as rt_revoked,
|
|
216
|
+
u.id as id,
|
|
217
|
+
u.email as email,
|
|
218
|
+
u.created_at as created_at
|
|
219
|
+
from auth.refresh_tokens rt
|
|
220
|
+
join auth.users u on u.id = rt.user_id
|
|
221
|
+
where rt.token = $1
|
|
222
|
+
""",
|
|
223
|
+
refresh_token,
|
|
224
|
+
)
|
|
225
|
+
if not row:
|
|
226
|
+
logger.warning("auth.refresh_grant: unknown refresh token")
|
|
227
|
+
raise AuthError("invalid_refresh_token", "Refresh token is invalid", 401)
|
|
228
|
+
|
|
229
|
+
if row["rt_revoked"]:
|
|
230
|
+
# A revoked token was presented — this is a reuse attack.
|
|
231
|
+
# Walk the full descendant chain and revoke every live child token,
|
|
232
|
+
# then record the incident before refusing the request.
|
|
233
|
+
async with conn.transaction():
|
|
234
|
+
await conn.execute(
|
|
235
|
+
"""
|
|
236
|
+
with recursive descendants as (
|
|
237
|
+
select id, token
|
|
238
|
+
from auth.refresh_tokens
|
|
239
|
+
where parent = $1
|
|
240
|
+
union all
|
|
241
|
+
select rt.id, rt.token
|
|
242
|
+
from auth.refresh_tokens rt
|
|
243
|
+
join descendants d on rt.parent = d.token
|
|
244
|
+
)
|
|
245
|
+
update auth.refresh_tokens
|
|
246
|
+
set revoked = true
|
|
247
|
+
where id in (select id from descendants)
|
|
248
|
+
""",
|
|
249
|
+
refresh_token,
|
|
250
|
+
)
|
|
251
|
+
await _audit_log(
|
|
252
|
+
conn, row["id"], "refresh_token_reuse",
|
|
253
|
+
ip=ip, ua=ua,
|
|
254
|
+
payload={"token_id": str(row["rt_id"])},
|
|
255
|
+
)
|
|
256
|
+
raise AuthError(
|
|
257
|
+
"token_reuse_detected",
|
|
258
|
+
"Token reuse detected — all sessions have been invalidated",
|
|
259
|
+
401,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
user = _row_to_user(row)
|
|
263
|
+
new_refresh = tokens.issue_refresh_token()
|
|
264
|
+
async with conn.transaction():
|
|
265
|
+
await conn.execute(
|
|
266
|
+
"update auth.refresh_tokens set revoked = true where id = $1",
|
|
267
|
+
row["rt_id"],
|
|
268
|
+
)
|
|
269
|
+
await conn.execute(
|
|
270
|
+
"insert into auth.refresh_tokens (user_id, token, parent) "
|
|
271
|
+
"values ($1, $2, $3)",
|
|
272
|
+
user.id,
|
|
273
|
+
new_refresh,
|
|
274
|
+
refresh_token,
|
|
275
|
+
)
|
|
276
|
+
access, ttl = tokens.issue_access_token(user.id, user.email)
|
|
277
|
+
return user, access, new_refresh, ttl
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def logout(conn: asyncpg.Connection, refresh_token: str) -> None:
|
|
281
|
+
await conn.execute(
|
|
282
|
+
"update auth.refresh_tokens set revoked = true where token = $1",
|
|
283
|
+
refresh_token,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def get_user(
|
|
288
|
+
conn: asyncpg.Connection, user_id: UUID
|
|
289
|
+
) -> UserResponse | None:
|
|
290
|
+
row = await conn.fetchrow(
|
|
291
|
+
"select id, email, created_at from auth.users where id = $1",
|
|
292
|
+
user_id,
|
|
293
|
+
)
|
|
294
|
+
return _row_to_user(row) if row else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def request_recover(
|
|
298
|
+
conn: asyncpg.Connection, email: str
|
|
299
|
+
) -> None:
|
|
300
|
+
row = await conn.fetchrow(
|
|
301
|
+
"select id from auth.users where email = $1", email
|
|
302
|
+
)
|
|
303
|
+
if not row:
|
|
304
|
+
return
|
|
305
|
+
s = get_settings()
|
|
306
|
+
async with conn.transaction():
|
|
307
|
+
raw = await _store_one_time_token(conn, row["id"], "recover", s.recover_token_ttl)
|
|
308
|
+
await mail.dispatch(
|
|
309
|
+
conn,
|
|
310
|
+
EmailMessage(
|
|
311
|
+
to=[email],
|
|
312
|
+
subject="Reset your password",
|
|
313
|
+
text=f"Use this token to reset your password: {raw}",
|
|
314
|
+
),
|
|
315
|
+
job_name="send_auth_email",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def verify_recover(
|
|
320
|
+
conn: asyncpg.Connection,
|
|
321
|
+
email: str,
|
|
322
|
+
token: str,
|
|
323
|
+
new_password: str,
|
|
324
|
+
*,
|
|
325
|
+
ip: str | None = None,
|
|
326
|
+
ua: str | None = None,
|
|
327
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
328
|
+
row = await _verify_one_time_token(conn, token, "recover", email)
|
|
329
|
+
pw_hash = passwords.hash_password(new_password)
|
|
330
|
+
user = _row_to_user(row)
|
|
331
|
+
async with conn.transaction():
|
|
332
|
+
await conn.execute(
|
|
333
|
+
"update auth.one_time_tokens set used_at = now() where id = $1",
|
|
334
|
+
row["ott_id"],
|
|
335
|
+
)
|
|
336
|
+
await conn.execute(
|
|
337
|
+
"update auth.users set encrypted_password = $1 where id = $2",
|
|
338
|
+
pw_hash,
|
|
339
|
+
row["id"],
|
|
340
|
+
)
|
|
341
|
+
await _audit_log(
|
|
342
|
+
conn, user.id, "password_change",
|
|
343
|
+
ip=ip, ua=ua,
|
|
344
|
+
payload={"via": "recover"},
|
|
345
|
+
)
|
|
346
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
347
|
+
return user, access, refresh, ttl
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def request_magic_link(
|
|
351
|
+
conn: asyncpg.Connection, email: str
|
|
352
|
+
) -> None:
|
|
353
|
+
row = await conn.fetchrow(
|
|
354
|
+
"select id from auth.users where email = $1", email
|
|
355
|
+
)
|
|
356
|
+
if not row:
|
|
357
|
+
return
|
|
358
|
+
s = get_settings()
|
|
359
|
+
async with conn.transaction():
|
|
360
|
+
raw = await _store_one_time_token(conn, row["id"], "magic_link", s.magic_link_token_ttl)
|
|
361
|
+
verify_url = f"{s.site_url}/auth/v1/magiclink/verify?token={raw}"
|
|
362
|
+
await mail.dispatch(
|
|
363
|
+
conn,
|
|
364
|
+
EmailMessage(
|
|
365
|
+
to=[email],
|
|
366
|
+
subject="Sign in to your account",
|
|
367
|
+
text=f"Click the link to sign in: {verify_url}",
|
|
368
|
+
),
|
|
369
|
+
job_name="send_auth_email",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def verify_magic_link(
|
|
374
|
+
conn: asyncpg.Connection, token: str
|
|
375
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
376
|
+
row = await _verify_one_time_token(conn, token, "magic_link")
|
|
377
|
+
user = _row_to_user(row)
|
|
378
|
+
async with conn.transaction():
|
|
379
|
+
await conn.execute(
|
|
380
|
+
"update auth.one_time_tokens set used_at = now() where id = $1",
|
|
381
|
+
row["ott_id"],
|
|
382
|
+
)
|
|
383
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
384
|
+
return user, access, refresh, ttl
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def request_otp(
|
|
388
|
+
conn: asyncpg.Connection, email: str
|
|
389
|
+
) -> None:
|
|
390
|
+
row = await conn.fetchrow(
|
|
391
|
+
"select id from auth.users where email = $1", email
|
|
392
|
+
)
|
|
393
|
+
if not row:
|
|
394
|
+
return
|
|
395
|
+
s = get_settings()
|
|
396
|
+
async with conn.transaction():
|
|
397
|
+
raw = await _store_one_time_token(conn, row["id"], "otp", s.otp_token_ttl)
|
|
398
|
+
await mail.dispatch(
|
|
399
|
+
conn,
|
|
400
|
+
EmailMessage(
|
|
401
|
+
to=[email],
|
|
402
|
+
subject="Your one-time password",
|
|
403
|
+
text=f"Your OTP is: {raw}",
|
|
404
|
+
),
|
|
405
|
+
job_name="send_auth_email",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def verify_otp(
|
|
410
|
+
conn: asyncpg.Connection, email: str, token: str
|
|
411
|
+
) -> tuple[UserResponse, str, str, int]:
|
|
412
|
+
row = await _verify_one_time_token(conn, token, "otp", email)
|
|
413
|
+
user = _row_to_user(row)
|
|
414
|
+
async with conn.transaction():
|
|
415
|
+
await conn.execute(
|
|
416
|
+
"update auth.one_time_tokens set used_at = now() where id = $1",
|
|
417
|
+
row["ott_id"],
|
|
418
|
+
)
|
|
419
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
420
|
+
return user, access, refresh, ttl
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
# OAuth
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
s = get_settings()
|
|
427
|
+
|
|
428
|
+
_OAUTH_STATE_MAX_AGE = s.oauth_state_max_age # seconds — state cookie lifetime
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _state_signer() -> URLSafeTimedSerializer:
|
|
432
|
+
from ..secretset import load_signing_secret
|
|
433
|
+
|
|
434
|
+
manifest_secret = load_signing_secret("oauth_state")
|
|
435
|
+
if manifest_secret is not None:
|
|
436
|
+
return URLSafeTimedSerializer(manifest_secret)
|
|
437
|
+
legacy = get_settings().oauth_state_secret
|
|
438
|
+
if legacy is None:
|
|
439
|
+
raise RuntimeError(
|
|
440
|
+
"no OAuth state secret configured; run "
|
|
441
|
+
"`supython secret rotate oauth` or set OAUTH_STATE_SECRET"
|
|
442
|
+
)
|
|
443
|
+
return URLSafeTimedSerializer(legacy)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def oauth_start(provider_name: str, redirect_uri: str) -> str:
|
|
447
|
+
"""Return the provider's authorization URL with a signed, time-limited state."""
|
|
448
|
+
from .providers.registry import get_provider
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
provider = get_provider(provider_name)
|
|
452
|
+
except KeyError as exc:
|
|
453
|
+
raise AuthError("unknown_provider", str(exc), 400) from exc
|
|
454
|
+
|
|
455
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
456
|
+
state = _state_signer().dumps(
|
|
457
|
+
{"redirect_uri": redirect_uri, "p": provider_name, "v": code_verifier}
|
|
458
|
+
)
|
|
459
|
+
return await provider.authorize_url(state, redirect_uri, code_verifier)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def oauth_finish(
|
|
463
|
+
conn: asyncpg.Connection,
|
|
464
|
+
provider_name: str,
|
|
465
|
+
code: str,
|
|
466
|
+
state: str,
|
|
467
|
+
*,
|
|
468
|
+
ip: str | None = None,
|
|
469
|
+
ua: str | None = None,
|
|
470
|
+
) -> tuple[UserResponse, str, str, int, str]:
|
|
471
|
+
"""Exchange an OAuth code for our own token pair.
|
|
472
|
+
|
|
473
|
+
Returns (user, access_token, refresh_token, ttl, redirect_uri) where
|
|
474
|
+
redirect_uri was embedded in the signed state at authorize time.
|
|
475
|
+
"""
|
|
476
|
+
from ..secretset import load_verification_secrets
|
|
477
|
+
from .providers.registry import get_provider
|
|
478
|
+
|
|
479
|
+
secrets_list = load_verification_secrets("oauth_state")
|
|
480
|
+
if not secrets_list:
|
|
481
|
+
legacy = get_settings().oauth_state_secret
|
|
482
|
+
if legacy is None:
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
"no OAuth state secret configured; run "
|
|
485
|
+
"`supython secret rotate oauth` or set OAUTH_STATE_SECRET"
|
|
486
|
+
)
|
|
487
|
+
secrets_list = [(legacy, None)]
|
|
488
|
+
|
|
489
|
+
state_data: dict | None = None
|
|
490
|
+
last_error: Exception | None = None
|
|
491
|
+
for value, _kid in secrets_list:
|
|
492
|
+
signer = URLSafeTimedSerializer(value)
|
|
493
|
+
try:
|
|
494
|
+
state_data = signer.loads(state, max_age=_OAUTH_STATE_MAX_AGE)
|
|
495
|
+
break
|
|
496
|
+
except BadSignature as exc:
|
|
497
|
+
last_error = exc
|
|
498
|
+
continue
|
|
499
|
+
if state_data is None:
|
|
500
|
+
raise AuthError(
|
|
501
|
+
"invalid_state", "OAuth state is invalid or expired", 400
|
|
502
|
+
) from last_error
|
|
503
|
+
|
|
504
|
+
redirect_uri: str = state_data.get("redirect_uri", "")
|
|
505
|
+
code_verifier: str | None = state_data.get("v")
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
provider = get_provider(provider_name)
|
|
509
|
+
except KeyError as exc:
|
|
510
|
+
raise AuthError("unknown_provider", str(exc), 400) from exc
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
profile = await provider.exchange(code, redirect_uri, code_verifier)
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
logger.warning("OAuth exchange failed for %s: %s", provider_name, exc)
|
|
516
|
+
raise AuthError("oauth_exchange_failed", "Provider exchange failed", 400) from exc
|
|
517
|
+
|
|
518
|
+
if not profile.email:
|
|
519
|
+
raise AuthError(
|
|
520
|
+
"no_email",
|
|
521
|
+
"The OAuth provider did not return an email address. "
|
|
522
|
+
"Make sure your account has a public primary email set.",
|
|
523
|
+
400,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
identity_row = await conn.fetchrow(
|
|
527
|
+
"""
|
|
528
|
+
select i.user_id, u.email, u.created_at
|
|
529
|
+
from auth.identities i
|
|
530
|
+
join auth.users u on u.id = i.user_id
|
|
531
|
+
where i.provider = $1 and i.provider_user_id = $2
|
|
532
|
+
""",
|
|
533
|
+
provider_name,
|
|
534
|
+
profile.provider_user_id,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if identity_row:
|
|
538
|
+
user = UserResponse(
|
|
539
|
+
id=identity_row["user_id"],
|
|
540
|
+
email=identity_row["email"],
|
|
541
|
+
created_at=identity_row["created_at"],
|
|
542
|
+
)
|
|
543
|
+
await conn.execute(
|
|
544
|
+
"update auth.users set last_sign_in_at = now() where id = $1",
|
|
545
|
+
user.id,
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
async with conn.transaction():
|
|
549
|
+
user_row = await conn.fetchrow(
|
|
550
|
+
"select id, email, created_at from auth.users where email = $1",
|
|
551
|
+
profile.email,
|
|
552
|
+
)
|
|
553
|
+
if not user_row:
|
|
554
|
+
user_row = await conn.fetchrow(
|
|
555
|
+
"""
|
|
556
|
+
insert into auth.users (email, email_confirmed_at)
|
|
557
|
+
values ($1, now())
|
|
558
|
+
returning id, email, created_at
|
|
559
|
+
""",
|
|
560
|
+
profile.email,
|
|
561
|
+
)
|
|
562
|
+
user = _row_to_user(user_row)
|
|
563
|
+
identity_id = await conn.fetchval(
|
|
564
|
+
"""
|
|
565
|
+
insert into auth.identities
|
|
566
|
+
(user_id, provider, provider_user_id, identity_data)
|
|
567
|
+
values ($1, $2, $3, $4::jsonb)
|
|
568
|
+
on conflict (provider, provider_user_id) do nothing
|
|
569
|
+
returning id
|
|
570
|
+
""",
|
|
571
|
+
user.id,
|
|
572
|
+
provider_name,
|
|
573
|
+
profile.provider_user_id,
|
|
574
|
+
json.dumps(profile.raw),
|
|
575
|
+
)
|
|
576
|
+
if identity_id is not None:
|
|
577
|
+
await _audit_log(
|
|
578
|
+
conn, user.id, "oauth_identity_linked",
|
|
579
|
+
ip=ip, ua=ua,
|
|
580
|
+
payload={
|
|
581
|
+
"provider": provider_name,
|
|
582
|
+
"provider_user_id": profile.provider_user_id,
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
587
|
+
return user, access, refresh, ttl, redirect_uri
|