svc-infra 0.1.706__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
3
|
from starlette.requests import Request
|
|
6
4
|
|
|
7
5
|
from svc_infra.app.env import IS_PROD
|
|
@@ -16,9 +14,7 @@ def _is_local_host(host: str) -> bool:
|
|
|
16
14
|
|
|
17
15
|
def _is_https(request: Request) -> bool:
|
|
18
16
|
proto = (
|
|
19
|
-
(request.headers.get("x-forwarded-proto") or request.url.scheme or "")
|
|
20
|
-
.split(",")[0]
|
|
21
|
-
.strip()
|
|
17
|
+
(request.headers.get("x-forwarded-proto") or request.url.scheme or "").split(",")[0].strip()
|
|
22
18
|
)
|
|
23
19
|
return proto.lower() == "https"
|
|
24
20
|
|
|
@@ -27,15 +23,13 @@ def compute_cookie_params(request: Request, *, name: str) -> dict:
|
|
|
27
23
|
st = get_auth_settings()
|
|
28
24
|
cfg_domain = (getattr(st, "session_cookie_domain", "") or "").strip()
|
|
29
25
|
|
|
30
|
-
domain:
|
|
26
|
+
domain: str | None = None
|
|
31
27
|
if cfg_domain and not _is_local_host(cfg_domain):
|
|
32
28
|
domain = cfg_domain
|
|
33
29
|
|
|
34
30
|
explicit_secure = getattr(st, "session_cookie_secure", None)
|
|
35
31
|
secure = (
|
|
36
|
-
bool(explicit_secure)
|
|
37
|
-
if explicit_secure is not None
|
|
38
|
-
else (_is_https(request) or IS_PROD)
|
|
32
|
+
bool(explicit_secure) if explicit_secure is not None else (_is_https(request) or IS_PROD)
|
|
39
33
|
)
|
|
40
34
|
|
|
41
35
|
samesite = str(getattr(st, "session_cookie_samesite", "lax")).lower()
|
|
@@ -135,9 +135,7 @@ def setup_oauth_authentication(
|
|
|
135
135
|
if not providers:
|
|
136
136
|
return
|
|
137
137
|
|
|
138
|
-
redirect_url = (
|
|
139
|
-
post_login_redirect or getattr(settings_obj, "post_login_redirect", None) or "/"
|
|
140
|
-
)
|
|
138
|
+
redirect_url = post_login_redirect or getattr(settings_obj, "post_login_redirect", None) or "/"
|
|
141
139
|
oauth_router_instance = oauth_router_with_backend(
|
|
142
140
|
user_model=user_model,
|
|
143
141
|
auth_backend=auth_backend,
|
|
@@ -253,7 +251,7 @@ def add_auth_users(
|
|
|
253
251
|
(
|
|
254
252
|
fapi,
|
|
255
253
|
auth_backend,
|
|
256
|
-
|
|
254
|
+
_auth_router,
|
|
257
255
|
users_router,
|
|
258
256
|
get_strategy,
|
|
259
257
|
register_router,
|
|
@@ -268,9 +266,7 @@ def add_auth_users(
|
|
|
268
266
|
)
|
|
269
267
|
|
|
270
268
|
# Make the boot-time strategy and model available to resolvers
|
|
271
|
-
set_auth_state(
|
|
272
|
-
user_model=user_model, get_strategy=get_strategy, auth_prefix=auth_prefix
|
|
273
|
-
)
|
|
269
|
+
set_auth_state(user_model=user_model, get_strategy=get_strategy, auth_prefix=auth_prefix)
|
|
274
270
|
|
|
275
271
|
settings_obj = get_auth_settings()
|
|
276
272
|
policy = auth_policy or DefaultAuthPolicy(settings_obj)
|
|
@@ -287,7 +283,7 @@ def add_auth_users(
|
|
|
287
283
|
dev_default="dev-only-session-jwt-secret-not-for-production",
|
|
288
284
|
)
|
|
289
285
|
same_site_lit = cast(
|
|
290
|
-
Literal[
|
|
286
|
+
"Literal['lax', 'strict', 'none']",
|
|
291
287
|
str(getattr(settings_obj, "session_cookie_samesite", "lax")).lower(),
|
|
292
288
|
)
|
|
293
289
|
app.add_middleware(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
from datetime import
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
@@ -42,9 +42,7 @@ async def login_client_gaurd(request: Request):
|
|
|
42
42
|
client_id_raw = form.get("client_id")
|
|
43
43
|
client_secret_raw = form.get("client_secret")
|
|
44
44
|
client_id = client_id_raw.strip() if isinstance(client_id_raw, str) else ""
|
|
45
|
-
client_secret = (
|
|
46
|
-
client_secret_raw.strip() if isinstance(client_secret_raw, str) else ""
|
|
47
|
-
)
|
|
45
|
+
client_secret = client_secret_raw.strip() if isinstance(client_secret_raw, str) else ""
|
|
48
46
|
if not client_id or not client_secret:
|
|
49
47
|
raise HTTPException(
|
|
50
48
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -54,10 +52,7 @@ async def login_client_gaurd(request: Request):
|
|
|
54
52
|
# validate against configured clients
|
|
55
53
|
ok = False
|
|
56
54
|
for pc in getattr(st, "password_clients", []) or []:
|
|
57
|
-
if (
|
|
58
|
-
pc.client_id == client_id
|
|
59
|
-
and pc.client_secret.get_secret_value() == client_secret
|
|
60
|
-
):
|
|
55
|
+
if pc.client_id == client_id and pc.client_secret.get_secret_value() == client_secret:
|
|
61
56
|
ok = True
|
|
62
57
|
break
|
|
63
58
|
|
|
@@ -102,11 +97,7 @@ def auth_session_router(
|
|
|
102
97
|
try:
|
|
103
98
|
status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
|
|
104
99
|
if status_lo.locked and status_lo.next_allowed_at:
|
|
105
|
-
retry = int(
|
|
106
|
-
(
|
|
107
|
-
status_lo.next_allowed_at - datetime.now(timezone.utc)
|
|
108
|
-
).total_seconds()
|
|
109
|
-
)
|
|
100
|
+
retry = int((status_lo.next_allowed_at - datetime.now(UTC)).total_seconds())
|
|
110
101
|
raise HTTPException(
|
|
111
102
|
status_code=429,
|
|
112
103
|
detail="account_locked",
|
|
@@ -120,9 +111,7 @@ def auth_session_router(
|
|
|
120
111
|
if not user:
|
|
121
112
|
_, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
|
|
122
113
|
try:
|
|
123
|
-
await record_attempt(
|
|
124
|
-
session, user_id=None, ip_hash=ip_hash, success=False
|
|
125
|
-
)
|
|
114
|
+
await record_attempt(session, user_id=None, ip_hash=ip_hash, success=False)
|
|
126
115
|
except Exception:
|
|
127
116
|
pass
|
|
128
117
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
@@ -131,9 +120,7 @@ def auth_session_router(
|
|
|
131
120
|
if not getattr(user, "is_active", True):
|
|
132
121
|
raise HTTPException(401, "account_disabled")
|
|
133
122
|
|
|
134
|
-
hashed = getattr(user, "hashed_password", None) or getattr(
|
|
135
|
-
user, "password_hash", None
|
|
136
|
-
)
|
|
123
|
+
hashed = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
|
|
137
124
|
if not hashed:
|
|
138
125
|
try:
|
|
139
126
|
await record_attempt(
|
|
@@ -152,11 +139,7 @@ def auth_session_router(
|
|
|
152
139
|
session, user_id=getattr(user, "id", None), ip_hash=ip_hash
|
|
153
140
|
)
|
|
154
141
|
if status_user.locked and status_user.next_allowed_at:
|
|
155
|
-
retry = int(
|
|
156
|
-
(
|
|
157
|
-
status_user.next_allowed_at - datetime.now(timezone.utc)
|
|
158
|
-
).total_seconds()
|
|
159
|
-
)
|
|
142
|
+
retry = int((status_user.next_allowed_at - datetime.now(UTC)).total_seconds())
|
|
160
143
|
raise HTTPException(
|
|
161
144
|
status_code=429,
|
|
162
145
|
detail="account_locked",
|
|
@@ -189,7 +172,7 @@ def auth_session_router(
|
|
|
189
172
|
except Exception:
|
|
190
173
|
pass
|
|
191
174
|
|
|
192
|
-
if
|
|
175
|
+
if user.is_verified is False:
|
|
193
176
|
raise HTTPException(400, "LOGIN_USER_NOT_VERIFIED")
|
|
194
177
|
|
|
195
178
|
# 3) MFA policy check (user flag, tenant/global, etc.)
|
|
@@ -204,7 +187,7 @@ def auth_session_router(
|
|
|
204
187
|
|
|
205
188
|
# 4) record last_login for password logins that do NOT require MFA
|
|
206
189
|
try:
|
|
207
|
-
user.last_login = datetime.now(
|
|
190
|
+
user.last_login = datetime.now(UTC)
|
|
208
191
|
await user_manager.user_db.update(user, {"last_login": user.last_login})
|
|
209
192
|
except Exception:
|
|
210
193
|
# don’t block login if this write fails
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from typing import Optional
|
|
3
2
|
|
|
4
3
|
from pydantic import BaseModel
|
|
5
4
|
|
|
6
5
|
# --- Email OTP store (replace with Redis in prod) ---
|
|
7
|
-
EMAIL_OTP_STORE: dict[
|
|
8
|
-
str, dict
|
|
9
|
-
] = {} # key = uid (or jti), value={hash,exp,attempts,next_send}
|
|
6
|
+
EMAIL_OTP_STORE: dict[str, dict] = {} # key = uid (or jti), value={hash,exp,attempts,next_send}
|
|
10
7
|
|
|
11
8
|
|
|
12
9
|
class StartSetupOut(BaseModel):
|
|
@@ -56,9 +53,9 @@ class MFAProof(BaseModel):
|
|
|
56
53
|
|
|
57
54
|
|
|
58
55
|
class DisableAccountIn(BaseModel):
|
|
59
|
-
reason:
|
|
60
|
-
mfa:
|
|
56
|
+
reason: str | None = None
|
|
57
|
+
mfa: MFAProof | None = None
|
|
61
58
|
|
|
62
59
|
|
|
63
60
|
class DeleteAccountIn(BaseModel):
|
|
64
|
-
mfa:
|
|
61
|
+
mfa: MFAProof | None = None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
2
|
|
|
3
3
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
4
4
|
from svc_infra.app.env import require_secret
|
|
@@ -29,9 +29,9 @@ def get_mfa_pre_jwt_writer():
|
|
|
29
29
|
async def write(self, user):
|
|
30
30
|
from fastapi_users.jwt import generate_jwt
|
|
31
31
|
|
|
32
|
-
now = datetime.now(
|
|
32
|
+
now = datetime.now(UTC)
|
|
33
33
|
payload = {
|
|
34
|
-
"sub": str(
|
|
34
|
+
"sub": str(user.id),
|
|
35
35
|
"aud": ["fastapi-users:mfa"],
|
|
36
36
|
"iat": int(now.timestamp()),
|
|
37
37
|
"exp": int(now.timestamp()) + self.lifetime,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
4
|
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
import pyotp
|
|
@@ -80,7 +80,7 @@ def mfa_router(
|
|
|
80
80
|
raise HTTPException(401, "Invalid token")
|
|
81
81
|
|
|
82
82
|
# IMPORTANT: rehydrate into *your* session
|
|
83
|
-
db_user = await cast(Any, session).get(user_model, user.id)
|
|
83
|
+
db_user = await cast("Any", session).get(user_model, user.id)
|
|
84
84
|
if not db_user:
|
|
85
85
|
raise HTTPException(401, "Invalid token")
|
|
86
86
|
|
|
@@ -114,9 +114,7 @@ def mfa_router(
|
|
|
114
114
|
# )).scalar_one()
|
|
115
115
|
# assert fresh_secret == secret
|
|
116
116
|
|
|
117
|
-
return StartSetupOut(
|
|
118
|
-
otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri)
|
|
119
|
-
)
|
|
117
|
+
return StartSetupOut(otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri))
|
|
120
118
|
|
|
121
119
|
@u.post(
|
|
122
120
|
MFA_CONFIRM_PATH,
|
|
@@ -144,7 +142,7 @@ def mfa_router(
|
|
|
144
142
|
|
|
145
143
|
user.mfa_recovery = [_hash(c) for c in codes]
|
|
146
144
|
user.mfa_enabled = True
|
|
147
|
-
user.mfa_confirmed_at = datetime.now(
|
|
145
|
+
user.mfa_confirmed_at = datetime.now(UTC)
|
|
148
146
|
await session.commit()
|
|
149
147
|
|
|
150
148
|
return RecoveryCodesOut(codes=codes)
|
|
@@ -201,7 +199,7 @@ def mfa_router(
|
|
|
201
199
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
202
200
|
|
|
203
201
|
# 2) load user
|
|
204
|
-
user = await cast(Any, session).get(user_model, uid)
|
|
202
|
+
user = await cast("Any", session).get(user_model, uid)
|
|
205
203
|
if not user:
|
|
206
204
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
207
205
|
|
|
@@ -209,9 +207,7 @@ def mfa_router(
|
|
|
209
207
|
if not getattr(user, "is_active", True):
|
|
210
208
|
raise HTTPException(401, "account_disabled")
|
|
211
209
|
|
|
212
|
-
if (not getattr(user, "mfa_enabled", False)) or (
|
|
213
|
-
not getattr(user, "mfa_secret", None)
|
|
214
|
-
):
|
|
210
|
+
if (not getattr(user, "mfa_enabled", False)) or (not getattr(user, "mfa_secret", None)):
|
|
215
211
|
raise HTTPException(401, "MFA not enabled")
|
|
216
212
|
|
|
217
213
|
# 3) verify TOTP or fallback
|
|
@@ -247,15 +243,13 @@ def mfa_router(
|
|
|
247
243
|
raise HTTPException(400, "Invalid code")
|
|
248
244
|
|
|
249
245
|
# NEW: set last_login on successful MFA
|
|
250
|
-
user.last_login = datetime.now(
|
|
246
|
+
user.last_login = datetime.now(UTC)
|
|
251
247
|
await session.commit()
|
|
252
248
|
|
|
253
249
|
# 4) mint normal JWT and set cookie
|
|
254
250
|
token = await strategy.write_token(user)
|
|
255
251
|
resp = JSONResponse({"access_token": token, "token_type": "bearer"})
|
|
256
|
-
cp = compute_cookie_params(
|
|
257
|
-
request, name=st.auth_cookie_name
|
|
258
|
-
) # <-- pass Request here
|
|
252
|
+
cp = compute_cookie_params(request, name=st.auth_cookie_name) # <-- pass Request here
|
|
259
253
|
resp.set_cookie(**cp, value=token)
|
|
260
254
|
return resp
|
|
261
255
|
|
|
@@ -278,7 +272,7 @@ def mfa_router(
|
|
|
278
272
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
279
273
|
|
|
280
274
|
# 1b) Load user to get their email
|
|
281
|
-
user = await cast(Any, session).get(user_model, uid)
|
|
275
|
+
user = await cast("Any", session).get(user_model, uid)
|
|
282
276
|
if not user or not getattr(user, "email", None):
|
|
283
277
|
# (optionally also check user.mfa_enabled here)
|
|
284
278
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
|
-
|
|
3
1
|
from fastapi import Body, Depends, HTTPException, Query
|
|
4
2
|
|
|
5
3
|
from svc_infra.api.fastapi.auth.security import Identity
|
|
@@ -12,9 +10,9 @@ def RequireMFAIfEnabled(body_field: str = "mfa"):
|
|
|
12
10
|
async def _dep(
|
|
13
11
|
p: Identity,
|
|
14
12
|
sess: SqlSessionDep,
|
|
15
|
-
mfa:
|
|
16
|
-
mfa_code:
|
|
17
|
-
mfa_pre_token:
|
|
13
|
+
mfa: MFAProof | None = Body(None, embed=True, alias=body_field),
|
|
14
|
+
mfa_code: str | None = Query(None, alias="mfa_code"),
|
|
15
|
+
mfa_pre_token: str | None = Query(None, alias="mfa_pre_token"),
|
|
18
16
|
):
|
|
19
17
|
proof = mfa or (
|
|
20
18
|
MFAProof(code=mfa_code, pre_token=mfa_pre_token)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import hashlib
|
|
3
3
|
import os
|
|
4
|
+
from datetime import UTC
|
|
4
5
|
|
|
5
6
|
import pyotp
|
|
6
7
|
|
|
@@ -38,6 +39,6 @@ def _hash(s: str) -> str:
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _now_utc_ts() -> int:
|
|
41
|
-
from datetime import datetime
|
|
42
|
+
from datetime import datetime
|
|
42
43
|
|
|
43
|
-
return int(datetime.now(
|
|
44
|
+
return int(datetime.now(UTC).timestamp())
|
|
@@ -73,18 +73,11 @@ async def verify_mfa_for_user(
|
|
|
73
73
|
now = _now_utc_ts()
|
|
74
74
|
if rec:
|
|
75
75
|
attempts_left = rec.get("attempts_left")
|
|
76
|
-
if
|
|
77
|
-
now <= rec["exp"]
|
|
78
|
-
and attempts_left
|
|
79
|
-
and attempts_left > 0
|
|
80
|
-
and rec["hash"] == dig
|
|
81
|
-
):
|
|
76
|
+
if now <= rec["exp"] and attempts_left and attempts_left > 0 and rec["hash"] == dig:
|
|
82
77
|
EMAIL_OTP_STORE.pop(uid, None) # burn on success
|
|
83
78
|
return MFAResult(ok=True, method="email", attempts_left=None)
|
|
84
79
|
# decrement on failure
|
|
85
80
|
rec["attempts_left"] = max(0, (attempts_left or 0) - 1)
|
|
86
|
-
return MFAResult(
|
|
87
|
-
ok=False, method="email", attempts_left=rec["attempts_left"]
|
|
88
|
-
)
|
|
81
|
+
return MFAResult(ok=False, method="email", attempts_left=rec["attempts_left"])
|
|
89
82
|
|
|
90
83
|
return MFAResult(ok=False, method="none", attempts_left=None)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from typing import Any
|
|
1
|
+
from typing import Any
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
def providers_from_settings(settings: Any) ->
|
|
4
|
+
def providers_from_settings(settings: Any) -> dict[str, dict[str, Any]]:
|
|
5
5
|
"""
|
|
6
6
|
Returns a registry of providers:
|
|
7
7
|
{
|
|
@@ -20,7 +20,7 @@ def providers_from_settings(settings: Any) -> Dict[str, Dict[str, Any]]:
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
"""
|
|
23
|
-
reg:
|
|
23
|
+
reg: dict[str, dict[str, Any]] = {}
|
|
24
24
|
|
|
25
25
|
# Google (OIDC)
|
|
26
26
|
if getattr(settings, "google_client_id", None) and getattr(
|
|
@@ -64,9 +64,7 @@ def providers_from_settings(settings: Any) -> Dict[str, Dict[str, Any]]:
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
# LinkedIn (non-OIDC)
|
|
67
|
-
if getattr(settings, "li_client_id", None) and getattr(
|
|
68
|
-
settings, "li_client_secret", None
|
|
69
|
-
):
|
|
67
|
+
if getattr(settings, "li_client_id", None) and getattr(settings, "li_client_secret", None):
|
|
70
68
|
reg["linkedin"] = {
|
|
71
69
|
"kind": "linkedin",
|
|
72
70
|
"authorize_url": "https://www.linkedin.com/oauth/v2/authorization",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import datetime, timedelta
|
|
4
|
-
from typing import Any,
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
from uuid import UUID
|
|
6
6
|
|
|
7
7
|
from fastapi import HTTPException, Query
|
|
@@ -23,21 +23,21 @@ from svc_infra.db.sql.apikey import get_apikey_model
|
|
|
23
23
|
|
|
24
24
|
class ApiKeyCreateIn(BaseModel):
|
|
25
25
|
name: str
|
|
26
|
-
user_id:
|
|
27
|
-
scopes:
|
|
28
|
-
ttl_hours:
|
|
26
|
+
user_id: str | None = None
|
|
27
|
+
scopes: list[str] = Field(default_factory=list)
|
|
28
|
+
ttl_hours: int | None = 24 * 365 # default 1y
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class ApiKeyOut(BaseModel):
|
|
32
32
|
id: str
|
|
33
33
|
name: str
|
|
34
|
-
user_id:
|
|
35
|
-
key:
|
|
34
|
+
user_id: str | None
|
|
35
|
+
key: str | None = None
|
|
36
36
|
key_prefix: str
|
|
37
|
-
scopes:
|
|
37
|
+
scopes: list[str]
|
|
38
38
|
active: bool
|
|
39
|
-
expires_at:
|
|
40
|
-
last_used_at:
|
|
39
|
+
expires_at: datetime | None
|
|
40
|
+
last_used_at: datetime | None
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _to_uuid(val):
|
|
@@ -56,7 +56,7 @@ def apikey_router():
|
|
|
56
56
|
description="Create a new API key. The plaintext key is shown only once, at creation time.",
|
|
57
57
|
)
|
|
58
58
|
async def create_key(sess: SqlSessionDep, payload: ApiKeyCreateIn, p: Identity):
|
|
59
|
-
caller_id: UUID =
|
|
59
|
+
caller_id: UUID = p.user.id
|
|
60
60
|
owner_id: UUID = _to_uuid(payload.user_id) if payload.user_id else caller_id
|
|
61
61
|
|
|
62
62
|
if owner_id != caller_id and not getattr(p.user, "is_superuser", False):
|
|
@@ -64,9 +64,7 @@ def apikey_router():
|
|
|
64
64
|
|
|
65
65
|
plaintext, prefix, hashed = ApiKey.make_secret() # type: ignore[attr-defined]
|
|
66
66
|
expires = (
|
|
67
|
-
(datetime.now(
|
|
68
|
-
if payload.ttl_hours
|
|
69
|
-
else None
|
|
67
|
+
(datetime.now(UTC) + timedelta(hours=payload.ttl_hours)) if payload.ttl_hours else None
|
|
70
68
|
)
|
|
71
69
|
|
|
72
70
|
row = ApiKey(
|
|
@@ -124,11 +122,11 @@ def apikey_router():
|
|
|
124
122
|
description="Revoke an API key",
|
|
125
123
|
)
|
|
126
124
|
async def revoke_key(key_id: str, sess: SqlSessionDep, p: Identity):
|
|
127
|
-
row = await cast(Any, sess).get(ApiKey, key_id)
|
|
125
|
+
row = await cast("Any", sess).get(ApiKey, key_id)
|
|
128
126
|
if not row:
|
|
129
127
|
raise HTTPException(404, "not_found")
|
|
130
128
|
|
|
131
|
-
caller_id: UUID =
|
|
129
|
+
caller_id: UUID = p.user.id
|
|
132
130
|
if not (getattr(p.user, "is_superuser", False) or row.user_id == caller_id):
|
|
133
131
|
raise HTTPException(403, "forbidden")
|
|
134
132
|
|
|
@@ -148,11 +146,11 @@ def apikey_router():
|
|
|
148
146
|
p: Identity,
|
|
149
147
|
force: bool = Query(False, description="Allow deleting an active key if True"),
|
|
150
148
|
):
|
|
151
|
-
row = await cast(Any, sess).get(ApiKey, key_id)
|
|
149
|
+
row = await cast("Any", sess).get(ApiKey, key_id)
|
|
152
150
|
if not row:
|
|
153
151
|
return # 204
|
|
154
152
|
|
|
155
|
-
caller_id: UUID =
|
|
153
|
+
caller_id: UUID = p.user.id
|
|
156
154
|
if not (getattr(p.user, "is_superuser", False) or row.user_id == caller_id):
|
|
157
155
|
raise HTTPException(403, "forbidden")
|
|
158
156
|
|