svc-infra 0.1.593__py3-none-any.whl → 0.1.595__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.

Files changed (33) hide show
  1. svc_infra/apf_payments/README.md +26 -0
  2. svc_infra/apf_payments/provider/aiydan.py +28 -2
  3. svc_infra/apf_payments/service.py +113 -20
  4. svc_infra/api/fastapi/apf_payments/router.py +67 -4
  5. svc_infra/api/fastapi/auth/add.py +10 -0
  6. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  7. svc_infra/api/fastapi/auth/routers/oauth_router.py +79 -34
  8. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  9. svc_infra/api/fastapi/auth/settings.py +2 -0
  10. svc_infra/api/fastapi/db/sql/users.py +13 -1
  11. svc_infra/api/fastapi/dependencies/ratelimit.py +66 -0
  12. svc_infra/api/fastapi/middleware/ratelimit.py +26 -11
  13. svc_infra/api/fastapi/middleware/ratelimit_store.py +78 -0
  14. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  15. svc_infra/api/fastapi/setup.py +2 -1
  16. svc_infra/obs/metrics/__init__.py +53 -0
  17. svc_infra/obs/metrics.py +52 -0
  18. svc_infra/security/audit.py +130 -0
  19. svc_infra/security/audit_service.py +73 -0
  20. svc_infra/security/headers.py +39 -0
  21. svc_infra/security/hibp.py +91 -0
  22. svc_infra/security/jwt_rotation.py +53 -0
  23. svc_infra/security/lockout.py +96 -0
  24. svc_infra/security/models.py +245 -0
  25. svc_infra/security/org_invites.py +128 -0
  26. svc_infra/security/passwords.py +77 -0
  27. svc_infra/security/permissions.py +148 -0
  28. svc_infra/security/session.py +98 -0
  29. svc_infra/security/signed_cookies.py +80 -0
  30. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/METADATA +1 -1
  31. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/RECORD +33 -16
  32. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/WHEEL +0 -0
  33. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/entry_points.txt +0 -0
@@ -111,6 +111,32 @@ app = setup_service_api(
111
111
  add_payments(app, prefix="/payments")
112
112
  ```
113
113
 
114
+ **Tenant Context**
115
+
116
+ All payments endpoints require a tenant identifier. The FastAPI router now
117
+ derives it automatically from the authenticated principal:
118
+
119
+ - API key principals → ``principal.api_key.tenant_id``
120
+ - User principals → ``principal.user.tenant_id``
121
+ - Fallbacks: ``X-Tenant-Id`` request header or ``request.state.tenant_id``
122
+
123
+ If you need custom mapping logic (for example, translating API keys to an
124
+ external tenant registry), register an override during startup:
125
+
126
+ ```python
127
+ from svc_infra.api.fastapi.apf_payments.router import set_payments_tenant_resolver
128
+
129
+ async def resolve_tenant(request, identity, header):
130
+ # return a string tenant id, or None to fall back to the defaults
131
+ return "tenant-from-custom-logic"
132
+
133
+ set_payments_tenant_resolver(resolve_tenant)
134
+ ```
135
+
136
+ If no tenant can be derived (and the override also returns ``None``), the
137
+ router responds with ``400 tenant_context_missing`` so callers can supply the
138
+ missing context explicitly.
139
+
114
140
  **Environment-Based Configuration**
115
141
 
116
142
  The `easy_service_app` reads these env vars automatically:
@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
5
5
  from typing import Any, Optional, Sequence, Tuple
6
6
 
7
7
  from svc_infra.apf_payments.schemas import (
8
+ BalanceAmount,
8
9
  BalanceSnapshotOut,
9
10
  CustomerOut,
10
11
  CustomerUpsertIn,
@@ -277,13 +278,38 @@ def _usage_record_to_out(data: dict[str, Any]) -> UsageRecordOut:
277
278
  provider_price_id=(
278
279
  str(data.get("provider_price_id")) if data.get("provider_price_id") else None
279
280
  ),
281
+ action=(str(data.get("action")) if data.get("action") else None),
280
282
  )
281
283
 
282
284
 
283
285
  def _balance_snapshot_to_out(data: dict[str, Any]) -> BalanceSnapshotOut:
286
+ def _normalize(side: Any) -> list[dict[str, Any]]:
287
+ if isinstance(side, list):
288
+ out: list[dict[str, Any]] = []
289
+ for item in side:
290
+ if isinstance(item, dict) and "currency" in item and "amount" in item:
291
+ out.append(
292
+ {
293
+ "currency": str(item["currency"]).upper(),
294
+ "amount": int(item["amount"] or 0),
295
+ }
296
+ )
297
+ return out
298
+ if isinstance(side, dict):
299
+ return [
300
+ {"currency": str(cur).upper(), "amount": int(amt or 0)} for cur, amt in side.items()
301
+ ]
302
+ return []
303
+
284
304
  return BalanceSnapshotOut(
285
- available=data.get("available", {}),
286
- pending=data.get("pending", {}),
305
+ available=[
306
+ BalanceAmount(currency=i["currency"], amount=i["amount"])
307
+ for i in _normalize(data.get("available"))
308
+ ],
309
+ pending=[
310
+ BalanceAmount(currency=i["currency"], amount=i["amount"])
311
+ for i in _normalize(data.get("pending"))
312
+ ],
287
313
  )
288
314
 
289
315
 
@@ -65,9 +65,24 @@ def _default_provider_name() -> str:
65
65
 
66
66
 
67
67
  class PaymentsService:
68
+ """Payments service facade wrapping provider adapters and persisting key rows.
68
69
 
69
- def __init__(self, session: AsyncSession, provider_name: Optional[str] = None):
70
+ NOTE: tenant_id is now required for all persistence operations. This is a breaking
71
+ change; callers must supply a valid tenant scope. (Future: could allow multi-tenant
72
+ mapping via adapter registry.)
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ session: AsyncSession,
78
+ *,
79
+ tenant_id: str,
80
+ provider_name: Optional[str] = None,
81
+ ):
82
+ if not tenant_id:
83
+ raise ValueError("tenant_id is required for PaymentsService")
70
84
  self.session = session
85
+ self.tenant_id = tenant_id
71
86
  self._provider_name = (provider_name or _default_provider_name()).lower()
72
87
  self._adapter = None # resolved on first use
73
88
 
@@ -118,6 +133,7 @@ class PaymentsService:
118
133
  # If your PayCustomer model has additional columns (email/name), include them here.
119
134
  self.session.add(
120
135
  PayCustomer(
136
+ tenant_id=self.tenant_id,
121
137
  provider=out.provider,
122
138
  provider_customer_id=out.provider_customer_id,
123
139
  user_id=data.user_id,
@@ -132,6 +148,7 @@ class PaymentsService:
132
148
  out = await adapter.create_intent(data, user_id=user_id)
133
149
  self.session.add(
134
150
  PayIntent(
151
+ tenant_id=self.tenant_id,
135
152
  provider=out.provider,
136
153
  provider_intent_id=out.provider_intent_id,
137
154
  user_id=user_id,
@@ -167,6 +184,32 @@ class PaymentsService:
167
184
  async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
168
185
  adapter = self._get_adapter()
169
186
  out = await adapter.refund(provider_intent_id, data)
187
+ # Create ledger entry if amount present and not already recorded
188
+ pi = await self.session.scalar(
189
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
190
+ )
191
+ if pi:
192
+ amount = int(data.amount) if data.amount is not None else out.amount
193
+ # Guard against duplicates (same provider_ref + kind)
194
+ existing = await self.session.scalar(
195
+ select(LedgerEntry).where(
196
+ LedgerEntry.provider_ref == provider_intent_id,
197
+ LedgerEntry.kind == "refund",
198
+ )
199
+ )
200
+ if amount > 0 and not existing:
201
+ self.session.add(
202
+ LedgerEntry(
203
+ tenant_id=self.tenant_id,
204
+ provider=pi.provider,
205
+ provider_ref=provider_intent_id,
206
+ user_id=pi.user_id,
207
+ amount=+amount,
208
+ currency=out.currency,
209
+ kind="refund",
210
+ status="posted",
211
+ )
212
+ )
170
213
  return out
171
214
 
172
215
  # --- Webhooks -------------------------------------------------------------
@@ -176,6 +219,7 @@ class PaymentsService:
176
219
  parsed = await adapter.verify_and_parse_webhook(signature, payload)
177
220
  self.session.add(
178
221
  PayEvent(
222
+ tenant_id=self.tenant_id,
179
223
  provider=provider,
180
224
  provider_event_id=parsed["id"],
181
225
  type=parsed.get("type", ""),
@@ -199,6 +243,7 @@ class PaymentsService:
199
243
  intent.status = "succeeded"
200
244
  self.session.add(
201
245
  LedgerEntry(
246
+ tenant_id=self.tenant_id,
202
247
  provider=intent.provider,
203
248
  provider_ref=provider_intent_id,
204
249
  user_id=intent.user_id,
@@ -217,17 +262,27 @@ class PaymentsService:
217
262
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
218
263
  )
219
264
  if intent:
220
- self.session.add(
221
- LedgerEntry(
222
- provider=intent.provider,
223
- provider_ref=charge_obj.get("id"),
224
- user_id=intent.user_id,
225
- amount=+amount,
226
- currency=currency,
227
- kind="capture",
228
- status="posted",
265
+ # Avoid duplicate capture entries
266
+ existing = await self.session.scalar(
267
+ select(LedgerEntry).where(
268
+ LedgerEntry.provider_ref == charge_obj.get("id"),
269
+ LedgerEntry.kind == "capture",
229
270
  )
230
271
  )
272
+ if not existing:
273
+ self.session.add(
274
+ LedgerEntry(
275
+ tenant_id=self.tenant_id,
276
+ provider=intent.provider,
277
+ provider_ref=charge_obj.get("id"),
278
+ user_id=intent.user_id,
279
+ amount=+amount,
280
+ currency=currency,
281
+ kind="capture",
282
+ status="posted",
283
+ )
284
+ )
285
+ intent.captured = True
231
286
 
232
287
  async def _post_refund(self, charge_obj: dict):
233
288
  amount = int(charge_obj.get("amount_refunded") or 0)
@@ -237,22 +292,31 @@ class PaymentsService:
237
292
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
238
293
  )
239
294
  if intent and amount > 0:
240
- self.session.add(
241
- LedgerEntry(
242
- provider=intent.provider,
243
- provider_ref=charge_obj.get("id"),
244
- user_id=intent.user_id,
245
- amount=+amount,
246
- currency=currency,
247
- kind="refund",
248
- status="posted",
295
+ existing = await self.session.scalar(
296
+ select(LedgerEntry).where(
297
+ LedgerEntry.provider_ref == charge_obj.get("id"),
298
+ LedgerEntry.kind == "refund",
249
299
  )
250
300
  )
301
+ if not existing:
302
+ self.session.add(
303
+ LedgerEntry(
304
+ tenant_id=self.tenant_id,
305
+ provider=intent.provider,
306
+ provider_ref=charge_obj.get("id"),
307
+ user_id=intent.user_id,
308
+ amount=+amount,
309
+ currency=currency,
310
+ kind="refund",
311
+ status="posted",
312
+ )
313
+ )
251
314
 
252
315
  async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
253
316
  out = await self._get_adapter().attach_payment_method(data)
254
317
  # Upsert locally for quick listing
255
318
  pm = PayPaymentMethod(
319
+ tenant_id=self.tenant_id,
256
320
  provider=out.provider,
257
321
  provider_customer_id=out.provider_customer_id,
258
322
  provider_method_id=out.provider_method_id,
@@ -283,6 +347,7 @@ class PaymentsService:
283
347
  out = await self._get_adapter().create_product(data)
284
348
  self.session.add(
285
349
  PayProduct(
350
+ tenant_id=self.tenant_id,
286
351
  provider=out.provider,
287
352
  provider_product_id=out.provider_product_id,
288
353
  name=out.name,
@@ -295,6 +360,7 @@ class PaymentsService:
295
360
  out = await self._get_adapter().create_price(data)
296
361
  self.session.add(
297
362
  PayPrice(
363
+ tenant_id=self.tenant_id,
298
364
  provider=out.provider,
299
365
  provider_price_id=out.provider_price_id,
300
366
  provider_product_id=out.provider_product_id,
@@ -312,6 +378,7 @@ class PaymentsService:
312
378
  out = await self._get_adapter().create_subscription(data)
313
379
  self.session.add(
314
380
  PaySubscription(
381
+ tenant_id=self.tenant_id,
315
382
  provider=out.provider,
316
383
  provider_subscription_id=out.provider_subscription_id,
317
384
  provider_price_id=out.provider_price_id,
@@ -340,6 +407,7 @@ class PaymentsService:
340
407
  out = await self._get_adapter().create_invoice(data)
341
408
  self.session.add(
342
409
  PayInvoice(
410
+ tenant_id=self.tenant_id,
343
411
  provider=out.provider,
344
412
  provider_invoice_id=out.provider_invoice_id,
345
413
  provider_customer_id=out.provider_customer_id,
@@ -432,6 +500,27 @@ class PaymentsService:
432
500
  pi.status = out.status
433
501
  if out.status in ("succeeded", "requires_capture"): # Stripe specifics vary
434
502
  pi.captured = True if out.status == "succeeded" else pi.captured
503
+ # Add capture ledger entry if succeeded and not already posted
504
+ if out.status == "succeeded":
505
+ existing = await self.session.scalar(
506
+ select(LedgerEntry).where(
507
+ LedgerEntry.provider_ref == provider_intent_id,
508
+ LedgerEntry.kind == "capture",
509
+ )
510
+ )
511
+ if not existing:
512
+ self.session.add(
513
+ LedgerEntry(
514
+ tenant_id=self.tenant_id,
515
+ provider=pi.provider,
516
+ provider_ref=provider_intent_id,
517
+ user_id=pi.user_id,
518
+ amount=+out.amount,
519
+ currency=out.currency,
520
+ kind="capture",
521
+ status="posted",
522
+ )
523
+ )
435
524
  return out
436
525
 
437
526
  async def list_intents(self, f: IntentListFilter) -> tuple[list[IntentOut], str | None]:
@@ -475,6 +564,7 @@ class PaymentsService:
475
564
  out = await self._get_adapter().create_setup_intent(data)
476
565
  self.session.add(
477
566
  PaySetupIntent(
567
+ tenant_id=self.tenant_id,
478
568
  provider=out.provider,
479
569
  provider_setup_intent_id=out.provider_setup_intent_id,
480
570
  user_id=None,
@@ -510,6 +600,7 @@ class PaymentsService:
510
600
  else:
511
601
  self.session.add(
512
602
  PaySetupIntent(
603
+ tenant_id=self.tenant_id,
513
604
  provider=out.provider,
514
605
  provider_setup_intent_id=out.provider_setup_intent_id,
515
606
  user_id=None,
@@ -549,6 +640,7 @@ class PaymentsService:
549
640
  else:
550
641
  self.session.add(
551
642
  PayDispute(
643
+ tenant_id=self.tenant_id,
552
644
  provider=out.provider,
553
645
  provider_dispute_id=out.provider_dispute_id,
554
646
  provider_charge_id=None, # set if adapter returns it
@@ -594,6 +686,7 @@ class PaymentsService:
594
686
  else:
595
687
  self.session.add(
596
688
  PayPayout(
689
+ tenant_id=self.tenant_id,
597
690
  provider=out.provider,
598
691
  provider_payout_id=out.provider_payout_id,
599
692
  amount=out.amount,
@@ -678,10 +771,10 @@ class PaymentsService:
678
771
  if not row:
679
772
  self.session.add(
680
773
  PayCustomer(
774
+ tenant_id=self.tenant_id,
681
775
  provider=out.provider,
682
776
  provider_customer_id=out.provider_customer_id,
683
777
  user_id=None,
684
- tenant_id="",
685
778
  )
686
779
  )
687
780
  return out
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Literal, Optional, cast
3
+ import inspect
4
+ from typing import Annotated, Awaitable, Callable, Literal, Optional, cast
4
5
 
5
- from fastapi import Body, Depends, Header, Request, Response, status
6
+ from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
6
7
  from starlette.responses import JSONResponse
7
8
 
8
9
  from svc_infra.apf_payments.schemas import (
@@ -47,6 +48,7 @@ from svc_infra.apf_payments.schemas import (
47
48
  WebhookReplayOut,
48
49
  )
49
50
  from svc_infra.apf_payments.service import PaymentsService
51
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
50
52
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
51
53
  from svc_infra.api.fastapi.dual import protected_router, public_router, service_router, user_router
52
54
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
@@ -69,8 +71,69 @@ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "captur
69
71
 
70
72
 
71
73
  # --- deps ---
72
- async def get_service(session: SqlSessionDep) -> PaymentsService:
73
- return PaymentsService(session=session)
74
+ TenantOverrideHook = Callable[
75
+ [Request, Optional[Principal], Optional[str]],
76
+ Awaitable[Optional[str]] | Optional[str],
77
+ ]
78
+
79
+ _tenant_override_hook: TenantOverrideHook | None = None
80
+
81
+
82
+ def set_payments_tenant_resolver(resolver: TenantOverrideHook | None) -> None:
83
+ """Override the default tenant resolution used by the payments router.
84
+
85
+ Projects can call this during startup to plug custom logic (e.g. multi-tenant
86
+ mappings). Passing ``None`` resets to the built-in behavior.
87
+ """
88
+
89
+ global _tenant_override_hook
90
+ _tenant_override_hook = resolver
91
+
92
+
93
+ async def resolve_payments_tenant_id(
94
+ request: Request,
95
+ identity: OptionalIdentity = None,
96
+ tenant_header: Annotated[Optional[str], Header(alias="X-Tenant-Id", default=None)] = None,
97
+ ) -> str:
98
+ """Determine the tenant id for the current request.
99
+
100
+ The default strategy prefers authenticated principals (API keys first, then
101
+ user accounts) and falls back to the ``X-Tenant-Id`` header or ``request.state``.
102
+ Applications may override the behavior via
103
+ :func:`set_payments_tenant_resolver`.
104
+ """
105
+
106
+ if _tenant_override_hook is not None:
107
+ maybe = _tenant_override_hook(request, identity, tenant_header)
108
+ if inspect.isawaitable(maybe): # pragma: no cover - depends on override type
109
+ maybe = await maybe
110
+ if maybe is not None:
111
+ return maybe
112
+
113
+ if identity:
114
+ api_key_tenant = getattr(getattr(identity, "api_key", None), "tenant_id", None)
115
+ if api_key_tenant:
116
+ return api_key_tenant
117
+
118
+ user_tenant = getattr(getattr(identity, "user", None), "tenant_id", None)
119
+ if user_tenant:
120
+ return user_tenant
121
+
122
+ if tenant_header:
123
+ return tenant_header
124
+
125
+ state_tenant = getattr(request.state, "tenant_id", None)
126
+ if state_tenant:
127
+ return state_tenant
128
+
129
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="tenant_context_missing")
130
+
131
+
132
+ PaymentsTenantDep = Annotated[str, Depends(resolve_payments_tenant_id)]
133
+
134
+
135
+ async def get_service(session: SqlSessionDep, tenant_id: PaymentsTenantDep) -> PaymentsService:
136
+ return PaymentsService(session=session, tenant_id=tenant_id)
74
137
 
75
138
 
76
139
  # --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
@@ -11,6 +11,7 @@ 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
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
@@ -73,6 +74,15 @@ def install_user_routers(
73
74
  include_in_schema=include_in_docs,
74
75
  dependencies=[Depends(login_client_gaurd)],
75
76
  )
77
+ # Session/device listing & revocation endpoints (AuthSession model)
78
+ # Mounted under the user prefix so final paths become /{user_prefix}/sessions/... (e.g., /users/sessions/me)
79
+ # The router itself has a /sessions prefix.
80
+ app.include_router(
81
+ build_session_router(),
82
+ prefix=user_prefix,
83
+ tags=["Session Management"],
84
+ include_in_schema=include_in_docs,
85
+ )
76
86
  app.include_router(
77
87
  register_router,
78
88
  prefix=user_prefix,
@@ -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
@@ -65,6 +66,9 @@ def auth_session_router(
65
66
  router = public_router()
66
67
  policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
67
68
 
69
+ from svc_infra.api.fastapi.db.sql import SqlSessionDep
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,
@@ -74,27 +78,78 @@ def auth_session_router(
74
78
  client_id: str | None = Form(None),
75
79
  client_secret: str | None = Form(None),
76
80
  user_manager=Depends(fapi.get_user_manager),
81
+ session: SqlSessionDep = Depends(),
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
- # 2) verify status + password
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
- # No password set (likely OAuth-only account)
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()