svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -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 +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Iterable, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from svc_infra.apf_payments.provider.registry import get_provider_registry
|
|
9
|
+
from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _maybe_register_default_providers(
|
|
15
|
+
register_defaults: bool, adapters: Optional[Iterable[object]]
|
|
16
|
+
):
|
|
17
|
+
reg = get_provider_registry()
|
|
18
|
+
if register_defaults:
|
|
19
|
+
# Try Stripe by default; silently skip if not configured
|
|
20
|
+
try:
|
|
21
|
+
from svc_infra.apf_payments.provider.stripe import StripeAdapter
|
|
22
|
+
|
|
23
|
+
reg.register(StripeAdapter())
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
from svc_infra.apf_payments.provider.aiydan import AiydanAdapter
|
|
28
|
+
|
|
29
|
+
reg.register(AiydanAdapter())
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
if adapters:
|
|
33
|
+
for a in adapters:
|
|
34
|
+
reg.register(a) # must implement ProviderAdapter protocol (name, create_intent, etc.)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_payments(
|
|
38
|
+
app: FastAPI,
|
|
39
|
+
*,
|
|
40
|
+
prefix: str = "/payments",
|
|
41
|
+
register_default_providers: bool = True,
|
|
42
|
+
adapters: Optional[Iterable[object]] = None,
|
|
43
|
+
include_in_docs: bool | None = None, # None = keep your env-based default visibility
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
One-call payments installer.
|
|
47
|
+
|
|
48
|
+
- Registers provider adapters (defaults + any provided).
|
|
49
|
+
- Mounts all Payments routers (user/protected/service/public) under `prefix`.
|
|
50
|
+
- Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
|
|
51
|
+
"""
|
|
52
|
+
_maybe_register_default_providers(register_default_providers, adapters)
|
|
53
|
+
|
|
54
|
+
for r in build_payments_routers(prefix=prefix):
|
|
55
|
+
app.include_router(
|
|
56
|
+
r, include_in_schema=True if include_in_docs is None else bool(include_in_docs)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Store the startup function to be called by lifespan if needed
|
|
60
|
+
async def _payments_startup_check():
|
|
61
|
+
try:
|
|
62
|
+
reg = get_provider_registry()
|
|
63
|
+
adapter = reg.get() # default provider
|
|
64
|
+
# Try a cheap call (Stripe: read account or key balance; we just access .name)
|
|
65
|
+
_ = adapter.name
|
|
66
|
+
except Exception as e:
|
|
67
|
+
# Log loud; don't crash the whole app by default
|
|
68
|
+
logger.warning(f"[payments] Provider adapter not ready: {e}")
|
|
69
|
+
|
|
70
|
+
# Add to app state for potential lifespan usage
|
|
71
|
+
if not hasattr(app.state, "startup_events"):
|
|
72
|
+
app.state.startup_events = []
|
|
73
|
+
app.state.startup_events.append(_payments_startup_check)
|
|
@@ -11,8 +11,9 @@ from svc_infra.api.fastapi.auth.mfa.router import mfa_router
|
|
|
11
11
|
from svc_infra.api.fastapi.auth.routers.account import account_router
|
|
12
12
|
from svc_infra.api.fastapi.auth.routers.apikey_router import apikey_router
|
|
13
13
|
from svc_infra.api.fastapi.auth.routers.oauth_router import oauth_router_with_backend
|
|
14
|
+
from svc_infra.api.fastapi.auth.routers.session_router import build_session_router
|
|
14
15
|
from svc_infra.api.fastapi.db.sql.users import get_fastapi_users
|
|
15
|
-
from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX,
|
|
16
|
+
from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
|
|
16
17
|
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
17
18
|
from svc_infra.db.sql.apikey import bind_apikey_model
|
|
18
19
|
|
|
@@ -72,6 +73,15 @@ def install_user_routers(
|
|
|
72
73
|
include_in_schema=include_in_docs,
|
|
73
74
|
dependencies=[Depends(login_client_gaurd)],
|
|
74
75
|
)
|
|
76
|
+
# Session/device listing & revocation endpoints (AuthSession model)
|
|
77
|
+
# Mounted under the user prefix so final paths become /{user_prefix}/sessions/... (e.g., /users/sessions/me)
|
|
78
|
+
# The router itself has a /sessions prefix.
|
|
79
|
+
app.include_router(
|
|
80
|
+
build_session_router(),
|
|
81
|
+
prefix=user_prefix,
|
|
82
|
+
tags=["Session Management"],
|
|
83
|
+
include_in_schema=include_in_docs,
|
|
84
|
+
)
|
|
75
85
|
app.include_router(
|
|
76
86
|
register_router,
|
|
77
87
|
prefix=user_prefix,
|
|
@@ -114,7 +124,7 @@ def setup_oauth_authentication(
|
|
|
114
124
|
user_model,
|
|
115
125
|
auth_backend,
|
|
116
126
|
settings_obj,
|
|
117
|
-
|
|
127
|
+
auth_prefix: str,
|
|
118
128
|
post_login_redirect: str | None,
|
|
119
129
|
provider_account_model=None,
|
|
120
130
|
auth_policy: AuthPolicy,
|
|
@@ -138,7 +148,7 @@ def setup_oauth_authentication(
|
|
|
138
148
|
# Install oauth prefix routers
|
|
139
149
|
install_oauth_routers(
|
|
140
150
|
app,
|
|
141
|
-
oauth_prefix=
|
|
151
|
+
oauth_prefix=auth_prefix + "/oauth",
|
|
142
152
|
oauth_router_instance=oauth_router_instance,
|
|
143
153
|
include_in_docs=include_in_docs,
|
|
144
154
|
)
|
|
@@ -221,7 +231,7 @@ def install_oauth_routers(
|
|
|
221
231
|
)
|
|
222
232
|
|
|
223
233
|
|
|
224
|
-
def
|
|
234
|
+
def add_auth_users(
|
|
225
235
|
app: FastAPI,
|
|
226
236
|
*,
|
|
227
237
|
user_model,
|
|
@@ -230,7 +240,6 @@ def add_auth(
|
|
|
230
240
|
schema_update,
|
|
231
241
|
post_login_redirect: str | None = None,
|
|
232
242
|
auth_prefix: str = AUTH_PREFIX,
|
|
233
|
-
oauth_prefix: str = OAUTH_PREFIX,
|
|
234
243
|
user_prefix: str = USER_PREFIX,
|
|
235
244
|
enable_password: bool = True,
|
|
236
245
|
enable_oauth: bool = True,
|
|
@@ -308,7 +317,7 @@ def add_auth(
|
|
|
308
317
|
user_model=user_model,
|
|
309
318
|
auth_backend=auth_backend,
|
|
310
319
|
settings_obj=settings_obj,
|
|
311
|
-
|
|
320
|
+
auth_prefix=auth_prefix,
|
|
312
321
|
post_login_redirect=post_login_redirect,
|
|
313
322
|
provider_account_model=provider_account_model,
|
|
314
323
|
auth_policy=policy,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
@@ -11,6 +12,7 @@ from fastapi_users.password import PasswordHelper
|
|
|
11
12
|
from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
|
|
12
13
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
13
14
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
15
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
14
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
15
17
|
|
|
16
18
|
_pwd = PasswordHelper()
|
|
@@ -65,9 +67,12 @@ def auth_session_router(
|
|
|
65
67
|
router = public_router()
|
|
66
68
|
policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
|
|
67
69
|
|
|
70
|
+
from svc_infra.security.lockout import get_lockout_status, record_attempt
|
|
71
|
+
|
|
68
72
|
@router.post("/login", name="auth:jwt.login")
|
|
69
73
|
async def login(
|
|
70
74
|
request: Request,
|
|
75
|
+
session: SqlSessionDep,
|
|
71
76
|
username: str = Form(...),
|
|
72
77
|
password: str = Form(...),
|
|
73
78
|
scope: str = Form(""),
|
|
@@ -75,26 +80,76 @@ def auth_session_router(
|
|
|
75
80
|
client_secret: str | None = Form(None),
|
|
76
81
|
user_manager=Depends(fapi.get_user_manager),
|
|
77
82
|
):
|
|
78
|
-
# 1) lookup user (normalize email)
|
|
79
83
|
strategy = auth_backend.get_strategy()
|
|
80
|
-
|
|
81
84
|
email = username.strip().lower()
|
|
85
|
+
# Compute IP hash for lockout correlation
|
|
86
|
+
client_ip = getattr(request.client, "host", None)
|
|
87
|
+
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None
|
|
88
|
+
|
|
89
|
+
# Pre-check lockout by IP to avoid enumeration
|
|
90
|
+
try:
|
|
91
|
+
status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
|
|
92
|
+
if status_lo.locked and status_lo.next_allowed_at:
|
|
93
|
+
retry = int(
|
|
94
|
+
(status_lo.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
|
|
95
|
+
)
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=429,
|
|
98
|
+
detail="account_locked",
|
|
99
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
100
|
+
)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Lookup user
|
|
82
105
|
user = await user_manager.user_db.get_by_email(email)
|
|
83
106
|
if not user:
|
|
84
107
|
_, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
|
|
108
|
+
try:
|
|
109
|
+
await record_attempt(session, user_id=None, ip_hash=ip_hash, success=False)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
85
112
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
86
113
|
|
|
87
|
-
#
|
|
114
|
+
# Status checks
|
|
88
115
|
if not getattr(user, "is_active", True):
|
|
89
116
|
raise HTTPException(401, "account_disabled")
|
|
90
117
|
|
|
91
118
|
hashed = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
|
|
92
119
|
if not hashed:
|
|
93
|
-
|
|
120
|
+
try:
|
|
121
|
+
await record_attempt(
|
|
122
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
|
|
123
|
+
)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
94
126
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
95
127
|
|
|
128
|
+
# Check lockout for this user + IP before verifying password
|
|
129
|
+
try:
|
|
130
|
+
status_user = await get_lockout_status(
|
|
131
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash
|
|
132
|
+
)
|
|
133
|
+
if status_user.locked and status_user.next_allowed_at:
|
|
134
|
+
retry = int(
|
|
135
|
+
(status_user.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
|
|
136
|
+
)
|
|
137
|
+
raise HTTPException(
|
|
138
|
+
status_code=429,
|
|
139
|
+
detail="account_locked",
|
|
140
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
96
145
|
ok, new_hash = _pwd.verify_and_update(password, hashed)
|
|
97
146
|
if not ok:
|
|
147
|
+
try:
|
|
148
|
+
await record_attempt(
|
|
149
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
98
153
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
99
154
|
|
|
100
155
|
# If the hash needs upgrading, persist it (optional but recommended)
|
|
@@ -106,7 +161,6 @@ def auth_session_router(
|
|
|
106
161
|
try:
|
|
107
162
|
await user_manager.user_db.update(user)
|
|
108
163
|
except Exception:
|
|
109
|
-
# don't block login if updating hash fails; log if you have logging here
|
|
110
164
|
pass
|
|
111
165
|
|
|
112
166
|
if getattr(user, "is_verified") is False:
|
|
@@ -130,6 +184,14 @@ def auth_session_router(
|
|
|
130
184
|
# don’t block login if this write fails
|
|
131
185
|
pass
|
|
132
186
|
|
|
187
|
+
# Record successful attempt (for audit)
|
|
188
|
+
try:
|
|
189
|
+
await record_attempt(
|
|
190
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=True
|
|
191
|
+
)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
133
195
|
# 5) mint token and set cookie
|
|
134
196
|
token = await strategy.write_token(user)
|
|
135
197
|
st = get_auth_settings()
|
|
@@ -17,6 +17,15 @@ from svc_infra.api.fastapi.dual.protected import user_router
|
|
|
17
17
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
18
|
from svc_infra.api.fastapi.dual.router import DualAPIRouter
|
|
19
19
|
|
|
20
|
+
from ...paths.auth import (
|
|
21
|
+
MFA_CONFIRM_PATH,
|
|
22
|
+
MFA_DISABLE_PATH,
|
|
23
|
+
MFA_REGENERATE_RECOVERY_PATH,
|
|
24
|
+
MFA_SEND_CODE_PATH,
|
|
25
|
+
MFA_START_PATH,
|
|
26
|
+
MFA_STATUS_PATH,
|
|
27
|
+
MFA_VERIFY_PATH,
|
|
28
|
+
)
|
|
20
29
|
from .models import (
|
|
21
30
|
EMAIL_OTP_STORE,
|
|
22
31
|
ConfirmSetupIn,
|
|
@@ -45,8 +54,8 @@ def mfa_router(
|
|
|
45
54
|
get_strategy, # from get_fastapi_users()
|
|
46
55
|
fapi: FastAPIUsers,
|
|
47
56
|
) -> APIRouter:
|
|
48
|
-
u = user_router(
|
|
49
|
-
p = public_router(
|
|
57
|
+
u = user_router()
|
|
58
|
+
p = public_router()
|
|
50
59
|
|
|
51
60
|
# Resolve current user via cookie OR bearer, using fastapi-users v10 strategy.read_token(..., user_manager)
|
|
52
61
|
async def _get_user_and_session(
|
|
@@ -77,7 +86,7 @@ def mfa_router(
|
|
|
77
86
|
return db_user, session
|
|
78
87
|
|
|
79
88
|
@u.post(
|
|
80
|
-
|
|
89
|
+
MFA_START_PATH,
|
|
81
90
|
response_model=StartSetupOut,
|
|
82
91
|
)
|
|
83
92
|
async def start_setup(user_sess=Depends(_get_user_and_session)):
|
|
@@ -107,7 +116,7 @@ def mfa_router(
|
|
|
107
116
|
return StartSetupOut(otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri))
|
|
108
117
|
|
|
109
118
|
@u.post(
|
|
110
|
-
|
|
119
|
+
MFA_CONFIRM_PATH,
|
|
111
120
|
response_model=RecoveryCodesOut,
|
|
112
121
|
)
|
|
113
122
|
async def confirm_setup(
|
|
@@ -138,7 +147,7 @@ def mfa_router(
|
|
|
138
147
|
return RecoveryCodesOut(codes=codes)
|
|
139
148
|
|
|
140
149
|
@u.post(
|
|
141
|
-
|
|
150
|
+
MFA_DISABLE_PATH,
|
|
142
151
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
143
152
|
)
|
|
144
153
|
async def disable_mfa(
|
|
@@ -170,7 +179,7 @@ def mfa_router(
|
|
|
170
179
|
await session.commit()
|
|
171
180
|
return JSONResponse(status_code=204, content={})
|
|
172
181
|
|
|
173
|
-
@p.post(
|
|
182
|
+
@p.post(MFA_VERIFY_PATH)
|
|
174
183
|
async def verify_mfa(
|
|
175
184
|
request: Request,
|
|
176
185
|
session: SqlSessionDep,
|
|
@@ -244,7 +253,7 @@ def mfa_router(
|
|
|
244
253
|
return resp
|
|
245
254
|
|
|
246
255
|
@p.post(
|
|
247
|
-
|
|
256
|
+
MFA_SEND_CODE_PATH,
|
|
248
257
|
response_model=SendEmailCodeOut,
|
|
249
258
|
description="Sends a 6-digit email OTP tied to the `pre_token`. Returns a resend cooldown.",
|
|
250
259
|
)
|
|
@@ -302,7 +311,7 @@ def mfa_router(
|
|
|
302
311
|
return SendEmailCodeOut(sent=True, cooldown_seconds=cooldown)
|
|
303
312
|
|
|
304
313
|
@u.get(
|
|
305
|
-
|
|
314
|
+
MFA_STATUS_PATH,
|
|
306
315
|
response_model=MFAStatusOut,
|
|
307
316
|
)
|
|
308
317
|
async def mfa_status(user_sess=Depends(_get_user_and_session)):
|
|
@@ -340,7 +349,7 @@ def mfa_router(
|
|
|
340
349
|
)
|
|
341
350
|
|
|
342
351
|
@u.post(
|
|
343
|
-
|
|
352
|
+
MFA_REGENERATE_RECOVERY_PATH,
|
|
344
353
|
response_model=RecoveryCodesOut,
|
|
345
354
|
)
|
|
346
355
|
async def regenerate_recovery_codes(user_sess=Depends(_get_user_and_session)):
|
|
@@ -6,6 +6,7 @@ from svc_infra.api.fastapi.auth.mfa.models import DisableAccountIn
|
|
|
6
6
|
from svc_infra.api.fastapi.auth.security import Identity
|
|
7
7
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
8
8
|
from svc_infra.api.fastapi.dual.protected import user_router
|
|
9
|
+
from svc_infra.api.fastapi.paths.user import DELETE_ACCOUNT_PATH, DISABLE_ACCOUNT_PATH
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
# ---------- Router ----------
|
|
@@ -13,7 +14,7 @@ def account_router(*, user_model: type) -> APIRouter:
|
|
|
13
14
|
r = user_router()
|
|
14
15
|
|
|
15
16
|
@r.patch(
|
|
16
|
-
|
|
17
|
+
DISABLE_ACCOUNT_PATH,
|
|
17
18
|
response_model=dict,
|
|
18
19
|
description="Get account status (active/disabled)",
|
|
19
20
|
)
|
|
@@ -29,7 +30,7 @@ def account_router(*, user_model: type) -> APIRouter:
|
|
|
29
30
|
return {"ok": True, "status": "disabled"}
|
|
30
31
|
|
|
31
32
|
@r.delete(
|
|
32
|
-
|
|
33
|
+
DELETE_ACCOUNT_PATH,
|
|
33
34
|
status_code=204,
|
|
34
35
|
description="Delete account (soft by default, hard if specified)",
|
|
35
36
|
)
|
|
@@ -12,6 +12,12 @@ from svc_infra.api.fastapi.auth.security import Identity
|
|
|
12
12
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
13
13
|
from svc_infra.api.fastapi.dual.protected import user_router
|
|
14
14
|
from svc_infra.api.fastapi.openapi.responses import CONFLICT, NOT_FOUND
|
|
15
|
+
from svc_infra.api.fastapi.paths.auth import (
|
|
16
|
+
CREATE_KEY_PATH,
|
|
17
|
+
DELETE_KEY_PATH,
|
|
18
|
+
LIST_KEYS_PATH,
|
|
19
|
+
REVOKE_KEY_PATH,
|
|
20
|
+
)
|
|
15
21
|
from svc_infra.db.sql.apikey import get_apikey_model
|
|
16
22
|
|
|
17
23
|
|
|
@@ -39,11 +45,11 @@ def _to_uuid(val):
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def apikey_router():
|
|
42
|
-
r = user_router(
|
|
48
|
+
r = user_router()
|
|
43
49
|
ApiKey = get_apikey_model()
|
|
44
50
|
|
|
45
51
|
@r.post(
|
|
46
|
-
|
|
52
|
+
CREATE_KEY_PATH,
|
|
47
53
|
response_model=ApiKeyOut,
|
|
48
54
|
status_code=201,
|
|
49
55
|
responses={409: CONFLICT},
|
|
@@ -87,7 +93,7 @@ def apikey_router():
|
|
|
87
93
|
)
|
|
88
94
|
|
|
89
95
|
@r.get(
|
|
90
|
-
|
|
96
|
+
LIST_KEYS_PATH,
|
|
91
97
|
response_model=list[ApiKeyOut],
|
|
92
98
|
description="List API keys. Non-superusers see only their own keys.",
|
|
93
99
|
)
|
|
@@ -112,7 +118,7 @@ def apikey_router():
|
|
|
112
118
|
]
|
|
113
119
|
|
|
114
120
|
@r.post(
|
|
115
|
-
|
|
121
|
+
REVOKE_KEY_PATH,
|
|
116
122
|
status_code=204,
|
|
117
123
|
responses={404: NOT_FOUND},
|
|
118
124
|
description="Revoke an API key",
|
|
@@ -131,7 +137,7 @@ def apikey_router():
|
|
|
131
137
|
return # 204
|
|
132
138
|
|
|
133
139
|
@r.delete(
|
|
134
|
-
|
|
140
|
+
DELETE_KEY_PATH,
|
|
135
141
|
status_code=204,
|
|
136
142
|
responses={404: NOT_FOUND},
|
|
137
143
|
description="Delete an API key. If the key is active, you must first revoke it or pass force=true.",
|
|
@@ -23,6 +23,13 @@ from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
|
23
23
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings, parse_redirect_allow_hosts
|
|
24
24
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
25
25
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
26
|
+
from svc_infra.api.fastapi.paths.auth import (
|
|
27
|
+
OAUTH_CALLBACK_PATH,
|
|
28
|
+
OAUTH_LOGIN_PATH,
|
|
29
|
+
OAUTH_REFRESH_PATH,
|
|
30
|
+
)
|
|
31
|
+
from svc_infra.security.models import RefreshToken
|
|
32
|
+
from svc_infra.security.session import issue_session_and_refresh, rotate_session_refresh
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
def _gen_pkce_pair() -> tuple[str, str]:
|
|
@@ -461,9 +468,13 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
|
|
|
461
468
|
|
|
462
469
|
|
|
463
470
|
async def _set_cookie_on_response(
|
|
464
|
-
resp: Response,
|
|
471
|
+
resp: Response,
|
|
472
|
+
auth_backend: AuthenticationBackend,
|
|
473
|
+
user: Any,
|
|
474
|
+
*,
|
|
475
|
+
refresh_raw: str,
|
|
465
476
|
) -> None:
|
|
466
|
-
"""Set authentication
|
|
477
|
+
"""Set authentication (JWT) and refresh cookies on response."""
|
|
467
478
|
st = get_auth_settings()
|
|
468
479
|
strategy = auth_backend.get_strategy()
|
|
469
480
|
jwt_token = await strategy.write_token(user)
|
|
@@ -472,6 +483,7 @@ async def _set_cookie_on_response(
|
|
|
472
483
|
if same_site_lit == "none" and not bool(st.session_cookie_secure):
|
|
473
484
|
raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
|
|
474
485
|
|
|
486
|
+
# Access/Auth cookie (short-lived JWT)
|
|
475
487
|
resp.set_cookie(
|
|
476
488
|
key=_cookie_name(st),
|
|
477
489
|
value=jwt_token,
|
|
@@ -483,6 +495,18 @@ async def _set_cookie_on_response(
|
|
|
483
495
|
path="/",
|
|
484
496
|
)
|
|
485
497
|
|
|
498
|
+
# Refresh cookie (opaque token, longer lived)
|
|
499
|
+
resp.set_cookie(
|
|
500
|
+
key=getattr(st, "session_cookie_name", "svc_session"),
|
|
501
|
+
value=refresh_raw,
|
|
502
|
+
max_age=60 * 60 * 24 * 7, # 7 days default
|
|
503
|
+
httponly=True,
|
|
504
|
+
secure=bool(st.session_cookie_secure),
|
|
505
|
+
samesite=same_site_lit,
|
|
506
|
+
domain=_cookie_domain(st),
|
|
507
|
+
path="/",
|
|
508
|
+
)
|
|
509
|
+
|
|
486
510
|
|
|
487
511
|
def _clean_oauth_session_state(request: Request, provider: str) -> None:
|
|
488
512
|
"""Clean up transient OAuth session state."""
|
|
@@ -538,7 +562,7 @@ def _create_oauth_router(
|
|
|
538
562
|
router = public_router()
|
|
539
563
|
|
|
540
564
|
@router.get(
|
|
541
|
-
|
|
565
|
+
OAUTH_LOGIN_PATH,
|
|
542
566
|
description="Login with OAuth provider",
|
|
543
567
|
)
|
|
544
568
|
async def oauth_login(request: Request, provider: str):
|
|
@@ -571,7 +595,7 @@ def _create_oauth_router(
|
|
|
571
595
|
)
|
|
572
596
|
|
|
573
597
|
@router.get(
|
|
574
|
-
|
|
598
|
+
OAUTH_CALLBACK_PATH,
|
|
575
599
|
name="oauth_callback",
|
|
576
600
|
responses={302: {"description": "Redirect to app (or MFA redirect)."}},
|
|
577
601
|
description="OAuth callback endpoint.",
|
|
@@ -636,9 +660,18 @@ def _create_oauth_router(
|
|
|
636
660
|
user.last_login = datetime.now(timezone.utc)
|
|
637
661
|
await session.commit()
|
|
638
662
|
|
|
639
|
-
# Create
|
|
663
|
+
# Create session + initial refresh token
|
|
664
|
+
raw_refresh, _rt = await issue_session_and_refresh(
|
|
665
|
+
session,
|
|
666
|
+
user_id=user.id,
|
|
667
|
+
tenant_id=getattr(user, "tenant_id", None),
|
|
668
|
+
user_agent=str(request.headers.get("user-agent", ""))[:512],
|
|
669
|
+
ip_hash=None,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Create response with auth + refresh cookies
|
|
640
673
|
resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
|
641
|
-
await _set_cookie_on_response(resp, auth_backend, user)
|
|
674
|
+
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
|
|
642
675
|
|
|
643
676
|
# Clean up session state
|
|
644
677
|
_clean_oauth_session_state(request, provider)
|
|
@@ -653,7 +686,7 @@ def _create_oauth_router(
|
|
|
653
686
|
return resp
|
|
654
687
|
|
|
655
688
|
@router.post(
|
|
656
|
-
|
|
689
|
+
OAUTH_REFRESH_PATH,
|
|
657
690
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
658
691
|
responses={204: {"description": "Cookie refreshed"}},
|
|
659
692
|
description="Refresh authentication token.",
|
|
@@ -662,44 +695,55 @@ def _create_oauth_router(
|
|
|
662
695
|
"""Refresh authentication token."""
|
|
663
696
|
st = get_auth_settings()
|
|
664
697
|
|
|
665
|
-
# Read and validate cookie
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if not
|
|
698
|
+
# Read and validate auth JWT cookie
|
|
699
|
+
name_auth = _cookie_name(st)
|
|
700
|
+
raw_auth = request.cookies.get(name_auth)
|
|
701
|
+
if not raw_auth:
|
|
669
702
|
raise HTTPException(401, "missing_token")
|
|
670
703
|
|
|
671
|
-
# Validate and decode JWT token
|
|
672
|
-
user_id = await _validate_and_decode_jwt_token(
|
|
704
|
+
# Validate and decode JWT token to get user id
|
|
705
|
+
user_id = await _validate_and_decode_jwt_token(raw_auth)
|
|
673
706
|
|
|
674
707
|
# Load user
|
|
675
708
|
user = await session.get(user_model, user_id)
|
|
676
709
|
if not user:
|
|
677
710
|
raise HTTPException(401, "invalid_token")
|
|
678
711
|
|
|
679
|
-
#
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
712
|
+
# Obtain refresh cookie
|
|
713
|
+
refresh_cookie_name = getattr(st, "session_cookie_name", "svc_session")
|
|
714
|
+
raw_refresh = request.cookies.get(refresh_cookie_name)
|
|
715
|
+
if not raw_refresh:
|
|
716
|
+
raise HTTPException(401, "missing_refresh_token")
|
|
717
|
+
|
|
718
|
+
# Lookup refresh token row by hash
|
|
719
|
+
from sqlalchemy import select
|
|
720
|
+
|
|
721
|
+
from svc_infra.security.models import hash_refresh_token
|
|
722
|
+
|
|
723
|
+
token_hash = hash_refresh_token(raw_refresh)
|
|
724
|
+
found: RefreshToken | None = (
|
|
725
|
+
(
|
|
726
|
+
await session.execute(
|
|
727
|
+
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
.scalars()
|
|
731
|
+
.first()
|
|
732
|
+
)
|
|
733
|
+
if (
|
|
734
|
+
not found
|
|
735
|
+
or found.revoked_at
|
|
736
|
+
or (found.expires_at and found.expires_at < datetime.now(timezone.utc))
|
|
737
|
+
):
|
|
738
|
+
raise HTTPException(401, "invalid_refresh_token")
|
|
739
|
+
|
|
740
|
+
# Rotate refresh token
|
|
741
|
+
new_raw, _new_rt = await rotate_session_refresh(session, current=found)
|
|
742
|
+
|
|
743
|
+
# Write response (204) with new cookies
|
|
744
|
+
resp = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
745
|
+
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
|
|
746
|
+
# Policy hook: trigger after successful rotation; suppress hook errors
|
|
703
747
|
if hasattr(policy, "on_token_refresh"):
|
|
704
748
|
try:
|
|
705
749
|
await policy.on_token_refresh(user)
|
|
@@ -708,4 +752,5 @@ def _create_oauth_router(
|
|
|
708
752
|
|
|
709
753
|
return resp
|
|
710
754
|
|
|
755
|
+
# Return router at end of factory
|
|
711
756
|
return router
|