svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -2,16 +2,19 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
7
8
|
from fastapi.responses import JSONResponse
|
|
8
9
|
from fastapi_users import FastAPIUsers
|
|
9
|
-
from fastapi_users.authentication import AuthenticationBackend
|
|
10
|
+
from fastapi_users.authentication import AuthenticationBackend, Strategy
|
|
10
11
|
from fastapi_users.password import PasswordHelper
|
|
12
|
+
from starlette.datastructures import FormData
|
|
11
13
|
|
|
12
14
|
from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
|
|
13
15
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
14
16
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
17
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
15
18
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
16
19
|
|
|
17
20
|
_pwd = PasswordHelper()
|
|
@@ -30,28 +33,38 @@ async def login_client_gaurd(request: Request):
|
|
|
30
33
|
|
|
31
34
|
# only enforce on the login endpoint (form-encoded)
|
|
32
35
|
if request.method.upper() == "POST" and request.url.path.endswith("/login"):
|
|
36
|
+
form: FormData | dict[str, Any]
|
|
33
37
|
try:
|
|
34
38
|
form = await request.form()
|
|
35
39
|
except Exception:
|
|
36
40
|
form = {}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
client_id_raw = form.get("client_id")
|
|
43
|
+
client_secret_raw = form.get("client_secret")
|
|
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
|
+
)
|
|
40
48
|
if not client_id or not client_secret:
|
|
41
49
|
raise HTTPException(
|
|
42
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail="client_credentials_required",
|
|
43
52
|
)
|
|
44
53
|
|
|
45
54
|
# validate against configured clients
|
|
46
55
|
ok = False
|
|
47
56
|
for pc in getattr(st, "password_clients", []) or []:
|
|
48
|
-
if
|
|
57
|
+
if (
|
|
58
|
+
pc.client_id == client_id
|
|
59
|
+
and pc.client_secret.get_secret_value() == client_secret
|
|
60
|
+
):
|
|
49
61
|
ok = True
|
|
50
62
|
break
|
|
51
63
|
|
|
52
64
|
if not ok:
|
|
53
65
|
raise HTTPException(
|
|
54
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
66
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
67
|
+
detail="invalid_client_credentials",
|
|
55
68
|
)
|
|
56
69
|
|
|
57
70
|
|
|
@@ -66,21 +79,20 @@ def auth_session_router(
|
|
|
66
79
|
router = public_router()
|
|
67
80
|
policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
|
|
68
81
|
|
|
69
|
-
from svc_infra.api.fastapi.db.sql import SqlSessionDep
|
|
70
82
|
from svc_infra.security.lockout import get_lockout_status, record_attempt
|
|
71
83
|
|
|
72
84
|
@router.post("/login", name="auth:jwt.login")
|
|
73
85
|
async def login(
|
|
74
86
|
request: Request,
|
|
87
|
+
session: SqlSessionDep,
|
|
75
88
|
username: str = Form(...),
|
|
76
89
|
password: str = Form(...),
|
|
77
90
|
scope: str = Form(""),
|
|
78
91
|
client_id: str | None = Form(None),
|
|
79
92
|
client_secret: str | None = Form(None),
|
|
93
|
+
strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
|
|
80
94
|
user_manager=Depends(fapi.get_user_manager),
|
|
81
|
-
session: SqlSessionDep = Depends(),
|
|
82
95
|
):
|
|
83
|
-
strategy = auth_backend.get_strategy()
|
|
84
96
|
email = username.strip().lower()
|
|
85
97
|
# Compute IP hash for lockout correlation
|
|
86
98
|
client_ip = getattr(request.client, "host", None)
|
|
@@ -91,7 +103,9 @@ def auth_session_router(
|
|
|
91
103
|
status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
|
|
92
104
|
if status_lo.locked and status_lo.next_allowed_at:
|
|
93
105
|
retry = int(
|
|
94
|
-
(
|
|
106
|
+
(
|
|
107
|
+
status_lo.next_allowed_at - datetime.now(timezone.utc)
|
|
108
|
+
).total_seconds()
|
|
95
109
|
)
|
|
96
110
|
raise HTTPException(
|
|
97
111
|
status_code=429,
|
|
@@ -106,7 +120,9 @@ def auth_session_router(
|
|
|
106
120
|
if not user:
|
|
107
121
|
_, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
|
|
108
122
|
try:
|
|
109
|
-
await record_attempt(
|
|
123
|
+
await record_attempt(
|
|
124
|
+
session, user_id=None, ip_hash=ip_hash, success=False
|
|
125
|
+
)
|
|
110
126
|
except Exception:
|
|
111
127
|
pass
|
|
112
128
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
@@ -115,11 +131,16 @@ def auth_session_router(
|
|
|
115
131
|
if not getattr(user, "is_active", True):
|
|
116
132
|
raise HTTPException(401, "account_disabled")
|
|
117
133
|
|
|
118
|
-
hashed = getattr(user, "hashed_password", None) or getattr(
|
|
134
|
+
hashed = getattr(user, "hashed_password", None) or getattr(
|
|
135
|
+
user, "password_hash", None
|
|
136
|
+
)
|
|
119
137
|
if not hashed:
|
|
120
138
|
try:
|
|
121
139
|
await record_attempt(
|
|
122
|
-
session,
|
|
140
|
+
session,
|
|
141
|
+
user_id=getattr(user, "id", None),
|
|
142
|
+
ip_hash=ip_hash,
|
|
143
|
+
success=False,
|
|
123
144
|
)
|
|
124
145
|
except Exception:
|
|
125
146
|
pass
|
|
@@ -132,7 +153,9 @@ def auth_session_router(
|
|
|
132
153
|
)
|
|
133
154
|
if status_user.locked and status_user.next_allowed_at:
|
|
134
155
|
retry = int(
|
|
135
|
-
(
|
|
156
|
+
(
|
|
157
|
+
status_user.next_allowed_at - datetime.now(timezone.utc)
|
|
158
|
+
).total_seconds()
|
|
136
159
|
)
|
|
137
160
|
raise HTTPException(
|
|
138
161
|
status_code=429,
|
|
@@ -146,7 +169,10 @@ def auth_session_router(
|
|
|
146
169
|
if not ok:
|
|
147
170
|
try:
|
|
148
171
|
await record_attempt(
|
|
149
|
-
session,
|
|
172
|
+
session,
|
|
173
|
+
user_id=getattr(user, "id", None),
|
|
174
|
+
ip_hash=ip_hash,
|
|
175
|
+
success=False,
|
|
150
176
|
)
|
|
151
177
|
except Exception:
|
|
152
178
|
pass
|
|
@@ -187,7 +213,10 @@ def auth_session_router(
|
|
|
187
213
|
# Record successful attempt (for audit)
|
|
188
214
|
try:
|
|
189
215
|
await record_attempt(
|
|
190
|
-
session,
|
|
216
|
+
session,
|
|
217
|
+
user_id=getattr(user, "id", None),
|
|
218
|
+
ip_hash=ip_hash,
|
|
219
|
+
success=True,
|
|
191
220
|
)
|
|
192
221
|
except Exception:
|
|
193
222
|
pass
|
|
@@ -4,7 +4,9 @@ from typing import Optional
|
|
|
4
4
|
from pydantic import BaseModel
|
|
5
5
|
|
|
6
6
|
# --- Email OTP store (replace with Redis in prod) ---
|
|
7
|
-
EMAIL_OTP_STORE: dict[
|
|
7
|
+
EMAIL_OTP_STORE: dict[
|
|
8
|
+
str, dict
|
|
9
|
+
] = {} # key = uid (or jti), value={hash,exp,attempts,next_send}
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class StartSetupOut(BaseModel):
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
from datetime import datetime, timezone
|
|
2
2
|
|
|
3
3
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
4
|
+
from svc_infra.app.env import require_secret
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
def get_mfa_pre_jwt_writer():
|
|
7
8
|
st = get_auth_settings()
|
|
8
9
|
jwt_block = getattr(st, "jwt", None)
|
|
9
10
|
|
|
10
|
-
# Force to plain string
|
|
11
|
-
|
|
12
|
-
jwt_block.secret.get_secret_value()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# Force to plain string - use require_secret to ensure it's set in production
|
|
12
|
+
if jwt_block and getattr(jwt_block, "secret", None):
|
|
13
|
+
secret = jwt_block.secret.get_secret_value()
|
|
14
|
+
else:
|
|
15
|
+
secret = require_secret(
|
|
16
|
+
None,
|
|
17
|
+
"JWT_SECRET (via auth settings jwt.secret for MFA)",
|
|
18
|
+
dev_default="dev-only-mfa-jwt-secret-not-for-production",
|
|
19
|
+
)
|
|
16
20
|
secret = str(secret)
|
|
17
21
|
|
|
18
22
|
lifetime = int(getattr(st, "mfa_pre_token_lifetime_seconds", 300))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, cast
|
|
4
5
|
|
|
5
6
|
import pyotp
|
|
6
7
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
|
@@ -79,7 +80,7 @@ def mfa_router(
|
|
|
79
80
|
raise HTTPException(401, "Invalid token")
|
|
80
81
|
|
|
81
82
|
# IMPORTANT: rehydrate into *your* session
|
|
82
|
-
db_user = await session.get(user_model, user.id)
|
|
83
|
+
db_user = await cast(Any, session).get(user_model, user.id)
|
|
83
84
|
if not db_user:
|
|
84
85
|
raise HTTPException(401, "Invalid token")
|
|
85
86
|
|
|
@@ -113,7 +114,9 @@ def mfa_router(
|
|
|
113
114
|
# )).scalar_one()
|
|
114
115
|
# assert fresh_secret == secret
|
|
115
116
|
|
|
116
|
-
return StartSetupOut(
|
|
117
|
+
return StartSetupOut(
|
|
118
|
+
otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri)
|
|
119
|
+
)
|
|
117
120
|
|
|
118
121
|
@u.post(
|
|
119
122
|
MFA_CONFIRM_PATH,
|
|
@@ -126,7 +129,7 @@ def mfa_router(
|
|
|
126
129
|
|
|
127
130
|
# RELOAD from DB to avoid stale state
|
|
128
131
|
user = (
|
|
129
|
-
await session.execute(select(user_model).where(user_model.id == user.id))
|
|
132
|
+
await session.execute(select(user_model).where(user_model.id == user.id)) # type: ignore[attr-defined]
|
|
130
133
|
).scalar_one()
|
|
131
134
|
|
|
132
135
|
if not getattr(user, "mfa_secret", None):
|
|
@@ -198,7 +201,7 @@ def mfa_router(
|
|
|
198
201
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
199
202
|
|
|
200
203
|
# 2) load user
|
|
201
|
-
user = await session.get(user_model, uid)
|
|
204
|
+
user = await cast(Any, session).get(user_model, uid)
|
|
202
205
|
if not user:
|
|
203
206
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
204
207
|
|
|
@@ -206,7 +209,9 @@ def mfa_router(
|
|
|
206
209
|
if not getattr(user, "is_active", True):
|
|
207
210
|
raise HTTPException(401, "account_disabled")
|
|
208
211
|
|
|
209
|
-
if (not getattr(user, "mfa_enabled", False)) or (
|
|
212
|
+
if (not getattr(user, "mfa_enabled", False)) or (
|
|
213
|
+
not getattr(user, "mfa_secret", None)
|
|
214
|
+
):
|
|
210
215
|
raise HTTPException(401, "MFA not enabled")
|
|
211
216
|
|
|
212
217
|
# 3) verify TOTP or fallback
|
|
@@ -248,7 +253,9 @@ def mfa_router(
|
|
|
248
253
|
# 4) mint normal JWT and set cookie
|
|
249
254
|
token = await strategy.write_token(user)
|
|
250
255
|
resp = JSONResponse({"access_token": token, "token_type": "bearer"})
|
|
251
|
-
cp = compute_cookie_params(
|
|
256
|
+
cp = compute_cookie_params(
|
|
257
|
+
request, name=st.auth_cookie_name
|
|
258
|
+
) # <-- pass Request here
|
|
252
259
|
resp.set_cookie(**cp, value=token)
|
|
253
260
|
return resp
|
|
254
261
|
|
|
@@ -271,7 +278,7 @@ def mfa_router(
|
|
|
271
278
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
272
279
|
|
|
273
280
|
# 1b) Load user to get their email
|
|
274
|
-
user = await session.get(user_model, uid)
|
|
281
|
+
user = await cast(Any, session).get(user_model, uid)
|
|
275
282
|
if not user or not getattr(user, "email", None):
|
|
276
283
|
# (optionally also check user.mfa_enabled here)
|
|
277
284
|
raise HTTPException(401, "Invalid pre-auth token")
|
|
@@ -326,7 +333,7 @@ def mfa_router(
|
|
|
326
333
|
# Email OTP is always offered in your flow at verify-time
|
|
327
334
|
methods.append("email")
|
|
328
335
|
|
|
329
|
-
def _mask(email: str) -> str:
|
|
336
|
+
def _mask(email: str) -> str | None:
|
|
330
337
|
if not email or "@" not in email:
|
|
331
338
|
return None
|
|
332
339
|
name, domain = email.split("@", 1)
|
|
@@ -5,8 +5,7 @@ from fastapi import Body, Depends, HTTPException, Query
|
|
|
5
5
|
from svc_infra.api.fastapi.auth.security import Identity
|
|
6
6
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
7
7
|
|
|
8
|
-
from .
|
|
9
|
-
from .verify import verify_mfa_for_user
|
|
8
|
+
from .verify import MFAProof, verify_mfa_for_user
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def RequireMFAIfEnabled(body_field: str = "mfa"):
|
|
@@ -29,7 +29,8 @@ def _gen_recovery_codes(n: int, length: int) -> list[str]:
|
|
|
29
29
|
def _gen_numeric_code(n: int = 6) -> str:
|
|
30
30
|
import random
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
code = "".join(str(random.randrange(10)) for _ in range(n))
|
|
33
|
+
return code
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
def _hash(s: str) -> str:
|
|
@@ -73,11 +73,18 @@ 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
|
|
76
|
+
if (
|
|
77
|
+
now <= rec["exp"]
|
|
78
|
+
and attempts_left
|
|
79
|
+
and attempts_left > 0
|
|
80
|
+
and rec["hash"] == dig
|
|
81
|
+
):
|
|
77
82
|
EMAIL_OTP_STORE.pop(uid, None) # burn on success
|
|
78
83
|
return MFAResult(ok=True, method="email", attempts_left=None)
|
|
79
84
|
# decrement on failure
|
|
80
85
|
rec["attempts_left"] = max(0, (attempts_left or 0) - 1)
|
|
81
|
-
return MFAResult(
|
|
86
|
+
return MFAResult(
|
|
87
|
+
ok=False, method="email", attempts_left=rec["attempts_left"]
|
|
88
|
+
)
|
|
82
89
|
|
|
83
90
|
return MFAResult(ok=False, method="none", attempts_left=None)
|
|
@@ -64,7 +64,9 @@ 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(
|
|
67
|
+
if getattr(settings, "li_client_id", None) and getattr(
|
|
68
|
+
settings, "li_client_secret", None
|
|
69
|
+
):
|
|
68
70
|
reg["linkedin"] = {
|
|
69
71
|
"kind": "linkedin",
|
|
70
72
|
"authorize_url": "https://www.linkedin.com/oauth/v2/authorization",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timedelta, timezone
|
|
4
|
-
from typing import List, Optional
|
|
4
|
+
from typing import Any, List, Optional, cast
|
|
5
5
|
from uuid import UUID
|
|
6
6
|
|
|
7
7
|
from fastapi import HTTPException, Query
|
|
@@ -62,7 +62,7 @@ def apikey_router():
|
|
|
62
62
|
if owner_id != caller_id and not getattr(p.user, "is_superuser", False):
|
|
63
63
|
raise HTTPException(403, "forbidden")
|
|
64
64
|
|
|
65
|
-
plaintext, prefix, hashed = ApiKey.make_secret()
|
|
65
|
+
plaintext, prefix, hashed = ApiKey.make_secret() # type: ignore[attr-defined]
|
|
66
66
|
expires = (
|
|
67
67
|
(datetime.now(timezone.utc) + timedelta(hours=payload.ttl_hours))
|
|
68
68
|
if payload.ttl_hours
|
|
@@ -98,9 +98,9 @@ def apikey_router():
|
|
|
98
98
|
description="List API keys. Non-superusers see only their own keys.",
|
|
99
99
|
)
|
|
100
100
|
async def list_keys(sess: SqlSessionDep, p: Identity):
|
|
101
|
-
q = select(ApiKey)
|
|
101
|
+
q: Any = select(ApiKey)
|
|
102
102
|
if not getattr(p.user, "is_superuser", False):
|
|
103
|
-
q = q.where(ApiKey.user_id == p.user.id)
|
|
103
|
+
q = q.where(ApiKey.user_id == p.user.id) # type: ignore[attr-defined]
|
|
104
104
|
rows = (await sess.execute(q)).scalars().all()
|
|
105
105
|
return [
|
|
106
106
|
ApiKeyOut(
|
|
@@ -124,7 +124,7 @@ def apikey_router():
|
|
|
124
124
|
description="Revoke an API key",
|
|
125
125
|
)
|
|
126
126
|
async def revoke_key(key_id: str, sess: SqlSessionDep, p: Identity):
|
|
127
|
-
row = await sess.get(ApiKey, key_id)
|
|
127
|
+
row = await cast(Any, sess).get(ApiKey, key_id)
|
|
128
128
|
if not row:
|
|
129
129
|
raise HTTPException(404, "not_found")
|
|
130
130
|
|
|
@@ -148,7 +148,7 @@ def apikey_router():
|
|
|
148
148
|
p: Identity,
|
|
149
149
|
force: bool = Query(False, description="Allow deleting an active key if True"),
|
|
150
150
|
):
|
|
151
|
-
row = await sess.get(ApiKey, key_id)
|
|
151
|
+
row = await cast(Any, sess).get(ApiKey, key_id)
|
|
152
152
|
if not row:
|
|
153
153
|
return # 204
|
|
154
154
|
|