svc-infra 0.1.589__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/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- 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 +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- 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 +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- 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 +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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 +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- 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 +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- 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 -56
- 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/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +52 -0
- 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 +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- 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.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Iterable, Optional
|
|
4
|
+
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
|
5
5
|
|
|
6
6
|
from fastapi import FastAPI
|
|
7
7
|
|
|
8
8
|
from svc_infra.apf_payments.provider.registry import get_provider_registry
|
|
9
9
|
from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from svc_infra.apf_payments.provider.base import ProviderAdapter
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
@@ -24,9 +26,17 @@ def _maybe_register_default_providers(
|
|
|
24
26
|
reg.register(StripeAdapter())
|
|
25
27
|
except Exception:
|
|
26
28
|
pass
|
|
29
|
+
try:
|
|
30
|
+
from svc_infra.apf_payments.provider.aiydan import AiydanAdapter
|
|
31
|
+
|
|
32
|
+
reg.register(AiydanAdapter())
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
27
35
|
if adapters:
|
|
28
36
|
for a in adapters:
|
|
29
|
-
reg.register(
|
|
37
|
+
reg.register(
|
|
38
|
+
cast("ProviderAdapter", a)
|
|
39
|
+
) # must implement ProviderAdapter protocol
|
|
30
40
|
|
|
31
41
|
|
|
32
42
|
def add_payments(
|
|
@@ -35,7 +45,8 @@ def add_payments(
|
|
|
35
45
|
prefix: str = "/payments",
|
|
36
46
|
register_default_providers: bool = True,
|
|
37
47
|
adapters: Optional[Iterable[object]] = None,
|
|
38
|
-
include_in_docs: bool
|
|
48
|
+
include_in_docs: bool
|
|
49
|
+
| None = None, # None = keep your env-based default visibility
|
|
39
50
|
) -> None:
|
|
40
51
|
"""
|
|
41
52
|
One-call payments installer.
|
|
@@ -45,14 +56,16 @@ def add_payments(
|
|
|
45
56
|
- Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
|
|
46
57
|
"""
|
|
47
58
|
_maybe_register_default_providers(register_default_providers, adapters)
|
|
48
|
-
add_prefixed_docs(app, prefix=prefix, title="Payments")
|
|
49
59
|
|
|
50
60
|
for r in build_payments_routers(prefix=prefix):
|
|
51
61
|
app.include_router(
|
|
52
|
-
r,
|
|
62
|
+
r,
|
|
63
|
+
include_in_schema=True
|
|
64
|
+
if include_in_docs is None
|
|
65
|
+
else bool(include_in_docs),
|
|
53
66
|
)
|
|
54
67
|
|
|
55
|
-
|
|
68
|
+
# Store the startup function to be called by lifespan if needed
|
|
56
69
|
async def _payments_startup_check():
|
|
57
70
|
try:
|
|
58
71
|
reg = get_provider_registry()
|
|
@@ -60,5 +73,10 @@ def add_payments(
|
|
|
60
73
|
# Try a cheap call (Stripe: read account or key balance; we just access .name)
|
|
61
74
|
_ = adapter.name
|
|
62
75
|
except Exception as e:
|
|
63
|
-
# Log loud; don
|
|
76
|
+
# Log loud; don't crash the whole app by default
|
|
64
77
|
logger.warning(f"[payments] Provider adapter not ready: {e}")
|
|
78
|
+
|
|
79
|
+
# Add to app state for potential lifespan usage
|
|
80
|
+
if not hasattr(app.state, "startup_events"):
|
|
81
|
+
app.state.startup_events = []
|
|
82
|
+
app.state.startup_events.append(_payments_startup_check)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Authentication module for svc-infra.
|
|
2
|
+
|
|
3
|
+
Provides user authentication, authorization, and security primitives.
|
|
4
|
+
|
|
5
|
+
Key exports:
|
|
6
|
+
- add_auth_users: Add authentication routes to FastAPI app
|
|
7
|
+
- Identity, OptionalIdentity: Annotated dependencies for auth
|
|
8
|
+
- RequireUser, RequireRoles, RequireScopes: Authorization guards
|
|
9
|
+
- Principal: Unified identity (user via JWT/cookie or service via API key)
|
|
10
|
+
- AuthSettings, get_auth_settings: Auth configuration
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
# These imports are safe (no circular dependency)
|
|
18
|
+
from .policy import AuthPolicy, DefaultAuthPolicy
|
|
19
|
+
from .security import (
|
|
20
|
+
Identity,
|
|
21
|
+
OptionalIdentity,
|
|
22
|
+
Principal,
|
|
23
|
+
RequireAnyScope,
|
|
24
|
+
RequireIdentity,
|
|
25
|
+
RequireRoles,
|
|
26
|
+
RequireScopes,
|
|
27
|
+
RequireService,
|
|
28
|
+
RequireUser,
|
|
29
|
+
)
|
|
30
|
+
from .settings import AuthSettings, get_auth_settings, JWTSettings, OIDCProvider
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .add import add_auth_users as add_auth_users
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Main setup
|
|
37
|
+
"add_auth_users",
|
|
38
|
+
# Identity/Auth guards
|
|
39
|
+
"Identity",
|
|
40
|
+
"OptionalIdentity",
|
|
41
|
+
"Principal",
|
|
42
|
+
"RequireIdentity",
|
|
43
|
+
"RequireUser",
|
|
44
|
+
"RequireService",
|
|
45
|
+
"RequireRoles",
|
|
46
|
+
"RequireScopes",
|
|
47
|
+
"RequireAnyScope",
|
|
48
|
+
# Policy
|
|
49
|
+
"AuthPolicy",
|
|
50
|
+
"DefaultAuthPolicy",
|
|
51
|
+
# Settings
|
|
52
|
+
"AuthSettings",
|
|
53
|
+
"get_auth_settings",
|
|
54
|
+
"JWTSettings",
|
|
55
|
+
"OIDCProvider",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def __getattr__(name: str):
|
|
60
|
+
"""Lazy import for add_auth_users to avoid circular import."""
|
|
61
|
+
if name == "add_auth_users":
|
|
62
|
+
from .add import add_auth_users
|
|
63
|
+
|
|
64
|
+
return add_auth_users
|
|
65
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -16,7 +16,9 @@ def _is_local_host(host: str) -> bool:
|
|
|
16
16
|
|
|
17
17
|
def _is_https(request: Request) -> bool:
|
|
18
18
|
proto = (
|
|
19
|
-
(request.headers.get("x-forwarded-proto") or request.url.scheme or "")
|
|
19
|
+
(request.headers.get("x-forwarded-proto") or request.url.scheme or "")
|
|
20
|
+
.split(",")[0]
|
|
21
|
+
.strip()
|
|
20
22
|
)
|
|
21
23
|
return proto.lower() == "https"
|
|
22
24
|
|
|
@@ -31,7 +33,9 @@ def compute_cookie_params(request: Request, *, name: str) -> dict:
|
|
|
31
33
|
|
|
32
34
|
explicit_secure = getattr(st, "session_cookie_secure", None)
|
|
33
35
|
secure = (
|
|
34
|
-
bool(explicit_secure)
|
|
36
|
+
bool(explicit_secure)
|
|
37
|
+
if explicit_secure is not None
|
|
38
|
+
else (_is_https(request) or IS_PROD)
|
|
35
39
|
)
|
|
36
40
|
|
|
37
41
|
samesite = str(getattr(st, "session_cookie_samesite", "lax")).lower()
|
|
@@ -11,12 +11,12 @@ 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
|
-
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
17
|
+
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
|
|
17
18
|
from svc_infra.db.sql.apikey import bind_apikey_model
|
|
18
19
|
|
|
19
|
-
from ..docs.scoped import add_prefixed_docs
|
|
20
20
|
from .policy import AuthPolicy, DefaultAuthPolicy
|
|
21
21
|
from .providers import providers_from_settings
|
|
22
22
|
from .settings import get_auth_settings
|
|
@@ -73,6 +73,15 @@ def install_user_routers(
|
|
|
73
73
|
include_in_schema=include_in_docs,
|
|
74
74
|
dependencies=[Depends(login_client_gaurd)],
|
|
75
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
|
+
)
|
|
76
85
|
app.include_router(
|
|
77
86
|
register_router,
|
|
78
87
|
prefix=user_prefix,
|
|
@@ -126,12 +135,14 @@ def setup_oauth_authentication(
|
|
|
126
135
|
if not providers:
|
|
127
136
|
return
|
|
128
137
|
|
|
138
|
+
redirect_url = (
|
|
139
|
+
post_login_redirect or getattr(settings_obj, "post_login_redirect", None) or "/"
|
|
140
|
+
)
|
|
129
141
|
oauth_router_instance = oauth_router_with_backend(
|
|
130
142
|
user_model=user_model,
|
|
131
143
|
auth_backend=auth_backend,
|
|
132
144
|
providers=providers,
|
|
133
|
-
post_login_redirect=
|
|
134
|
-
or getattr(settings_obj, "post_login_redirect", "/"),
|
|
145
|
+
post_login_redirect=redirect_url,
|
|
135
146
|
provider_account_model=provider_account_model,
|
|
136
147
|
auth_policy=auth_policy,
|
|
137
148
|
)
|
|
@@ -257,19 +268,24 @@ def add_auth_users(
|
|
|
257
268
|
)
|
|
258
269
|
|
|
259
270
|
# Make the boot-time strategy and model available to resolvers
|
|
260
|
-
set_auth_state(
|
|
271
|
+
set_auth_state(
|
|
272
|
+
user_model=user_model, get_strategy=get_strategy, auth_prefix=auth_prefix
|
|
273
|
+
)
|
|
261
274
|
|
|
262
275
|
settings_obj = get_auth_settings()
|
|
263
276
|
policy = auth_policy or DefaultAuthPolicy(settings_obj)
|
|
264
277
|
include_in_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
265
278
|
|
|
266
|
-
if not any(m.cls.__name__ == "SessionMiddleware" for m in app.user_middleware):
|
|
279
|
+
if not any(m.cls.__name__ == "SessionMiddleware" for m in app.user_middleware): # type: ignore[attr-defined]
|
|
267
280
|
jwt_block = getattr(settings_obj, "jwt", None)
|
|
268
|
-
|
|
269
|
-
jwt_block.secret.get_secret_value()
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
281
|
+
if jwt_block and getattr(jwt_block, "secret", None):
|
|
282
|
+
secret = jwt_block.secret.get_secret_value()
|
|
283
|
+
else:
|
|
284
|
+
secret = require_secret(
|
|
285
|
+
None,
|
|
286
|
+
"JWT_SECRET (via auth settings jwt.secret for SessionMiddleware)",
|
|
287
|
+
dev_default="dev-only-session-jwt-secret-not-for-production",
|
|
288
|
+
)
|
|
273
289
|
same_site_lit = cast(
|
|
274
290
|
Literal["lax", "strict", "none"],
|
|
275
291
|
str(getattr(settings_obj, "session_cookie_samesite", "lax")).lower(),
|
|
@@ -283,9 +299,6 @@ def add_auth_users(
|
|
|
283
299
|
https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
|
|
284
300
|
)
|
|
285
301
|
|
|
286
|
-
add_prefixed_docs(app, prefix=user_prefix, title="Users")
|
|
287
|
-
add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
|
|
288
|
-
|
|
289
302
|
if enable_password:
|
|
290
303
|
setup_password_authentication(
|
|
291
304
|
app,
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
6
8
|
from fastapi.responses import JSONResponse
|
|
7
9
|
from fastapi_users import FastAPIUsers
|
|
8
|
-
from fastapi_users.authentication import AuthenticationBackend
|
|
10
|
+
from fastapi_users.authentication import AuthenticationBackend, Strategy
|
|
9
11
|
from fastapi_users.password import PasswordHelper
|
|
12
|
+
from starlette.datastructures import FormData
|
|
10
13
|
|
|
11
14
|
from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
|
|
12
15
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
13
16
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
17
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
14
18
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
15
19
|
|
|
16
20
|
_pwd = PasswordHelper()
|
|
@@ -29,28 +33,38 @@ async def login_client_gaurd(request: Request):
|
|
|
29
33
|
|
|
30
34
|
# only enforce on the login endpoint (form-encoded)
|
|
31
35
|
if request.method.upper() == "POST" and request.url.path.endswith("/login"):
|
|
36
|
+
form: FormData | dict[str, Any]
|
|
32
37
|
try:
|
|
33
38
|
form = await request.form()
|
|
34
39
|
except Exception:
|
|
35
40
|
form = {}
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
)
|
|
39
48
|
if not client_id or not client_secret:
|
|
40
49
|
raise HTTPException(
|
|
41
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail="client_credentials_required",
|
|
42
52
|
)
|
|
43
53
|
|
|
44
54
|
# validate against configured clients
|
|
45
55
|
ok = False
|
|
46
56
|
for pc in getattr(st, "password_clients", []) or []:
|
|
47
|
-
if
|
|
57
|
+
if (
|
|
58
|
+
pc.client_id == client_id
|
|
59
|
+
and pc.client_secret.get_secret_value() == client_secret
|
|
60
|
+
):
|
|
48
61
|
ok = True
|
|
49
62
|
break
|
|
50
63
|
|
|
51
64
|
if not ok:
|
|
52
65
|
raise HTTPException(
|
|
53
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
66
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
67
|
+
detail="invalid_client_credentials",
|
|
54
68
|
)
|
|
55
69
|
|
|
56
70
|
|
|
@@ -65,36 +79,103 @@ def auth_session_router(
|
|
|
65
79
|
router = public_router()
|
|
66
80
|
policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
|
|
67
81
|
|
|
82
|
+
from svc_infra.security.lockout import get_lockout_status, record_attempt
|
|
83
|
+
|
|
68
84
|
@router.post("/login", name="auth:jwt.login")
|
|
69
85
|
async def login(
|
|
70
86
|
request: Request,
|
|
87
|
+
session: SqlSessionDep,
|
|
71
88
|
username: str = Form(...),
|
|
72
89
|
password: str = Form(...),
|
|
73
90
|
scope: str = Form(""),
|
|
74
91
|
client_id: str | None = Form(None),
|
|
75
92
|
client_secret: str | None = Form(None),
|
|
93
|
+
strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
|
|
76
94
|
user_manager=Depends(fapi.get_user_manager),
|
|
77
95
|
):
|
|
78
|
-
# 1) lookup user (normalize email)
|
|
79
|
-
strategy = auth_backend.get_strategy()
|
|
80
|
-
|
|
81
96
|
email = username.strip().lower()
|
|
97
|
+
# Compute IP hash for lockout correlation
|
|
98
|
+
client_ip = getattr(request.client, "host", None)
|
|
99
|
+
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None
|
|
100
|
+
|
|
101
|
+
# Pre-check lockout by IP to avoid enumeration
|
|
102
|
+
try:
|
|
103
|
+
status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
|
|
104
|
+
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
|
+
)
|
|
110
|
+
raise HTTPException(
|
|
111
|
+
status_code=429,
|
|
112
|
+
detail="account_locked",
|
|
113
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Lookup user
|
|
82
119
|
user = await user_manager.user_db.get_by_email(email)
|
|
83
120
|
if not user:
|
|
84
121
|
_, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
|
|
122
|
+
try:
|
|
123
|
+
await record_attempt(
|
|
124
|
+
session, user_id=None, ip_hash=ip_hash, success=False
|
|
125
|
+
)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
85
128
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
86
129
|
|
|
87
|
-
#
|
|
130
|
+
# Status checks
|
|
88
131
|
if not getattr(user, "is_active", True):
|
|
89
132
|
raise HTTPException(401, "account_disabled")
|
|
90
133
|
|
|
91
|
-
hashed = getattr(user, "hashed_password", None) or getattr(
|
|
134
|
+
hashed = getattr(user, "hashed_password", None) or getattr(
|
|
135
|
+
user, "password_hash", None
|
|
136
|
+
)
|
|
92
137
|
if not hashed:
|
|
93
|
-
|
|
138
|
+
try:
|
|
139
|
+
await record_attempt(
|
|
140
|
+
session,
|
|
141
|
+
user_id=getattr(user, "id", None),
|
|
142
|
+
ip_hash=ip_hash,
|
|
143
|
+
success=False,
|
|
144
|
+
)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
94
147
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
95
148
|
|
|
149
|
+
# Check lockout for this user + IP before verifying password
|
|
150
|
+
try:
|
|
151
|
+
status_user = await get_lockout_status(
|
|
152
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash
|
|
153
|
+
)
|
|
154
|
+
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
|
+
)
|
|
160
|
+
raise HTTPException(
|
|
161
|
+
status_code=429,
|
|
162
|
+
detail="account_locked",
|
|
163
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
164
|
+
)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
96
168
|
ok, new_hash = _pwd.verify_and_update(password, hashed)
|
|
97
169
|
if not ok:
|
|
170
|
+
try:
|
|
171
|
+
await record_attempt(
|
|
172
|
+
session,
|
|
173
|
+
user_id=getattr(user, "id", None),
|
|
174
|
+
ip_hash=ip_hash,
|
|
175
|
+
success=False,
|
|
176
|
+
)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
98
179
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
99
180
|
|
|
100
181
|
# If the hash needs upgrading, persist it (optional but recommended)
|
|
@@ -106,7 +187,6 @@ def auth_session_router(
|
|
|
106
187
|
try:
|
|
107
188
|
await user_manager.user_db.update(user)
|
|
108
189
|
except Exception:
|
|
109
|
-
# don't block login if updating hash fails; log if you have logging here
|
|
110
190
|
pass
|
|
111
191
|
|
|
112
192
|
if getattr(user, "is_verified") is False:
|
|
@@ -130,6 +210,17 @@ def auth_session_router(
|
|
|
130
210
|
# don’t block login if this write fails
|
|
131
211
|
pass
|
|
132
212
|
|
|
213
|
+
# Record successful attempt (for audit)
|
|
214
|
+
try:
|
|
215
|
+
await record_attempt(
|
|
216
|
+
session,
|
|
217
|
+
user_id=getattr(user, "id", None),
|
|
218
|
+
ip_hash=ip_hash,
|
|
219
|
+
success=True,
|
|
220
|
+
)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
133
224
|
# 5) mint token and set cookie
|
|
134
225
|
token = await strategy.write_token(user)
|
|
135
226
|
st = get_auth_settings()
|
|
@@ -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",
|