python-platform 0.1.1__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.
- python_platform/__init__.py +445 -0
- python_platform/application/__init__.py +6 -0
- python_platform/application/build_spec.py +37 -0
- python_platform/application/builder.py +260 -0
- python_platform/application/composition.py +219 -0
- python_platform/application/runtime.py +546 -0
- python_platform/application/runtime_hooks.py +327 -0
- python_platform/application/state.py +216 -0
- python_platform/application_services/__init__.py +54 -0
- python_platform/application_services/catalog.py +500 -0
- python_platform/application_services/contracts.py +188 -0
- python_platform/application_services/dispatcher.py +423 -0
- python_platform/application_services/errors.py +89 -0
- python_platform/application_services/execution.py +199 -0
- python_platform/application_services/interceptors.py +44 -0
- python_platform/application_services/invocation.py +427 -0
- python_platform/application_services/policies.py +46 -0
- python_platform/application_services/seeding.py +72 -0
- python_platform/application_services/signature.py +49 -0
- python_platform/application_services/validation.py +84 -0
- python_platform/auditing/__init__.py +12 -0
- python_platform/auditing/contracts.py +39 -0
- python_platform/auditing/control.py +48 -0
- python_platform/auditing/sqlalchemy/__init__.py +6 -0
- python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
- python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/auditing/sqlalchemy/models.py +31 -0
- python_platform/auditing/sqlalchemy/module.py +32 -0
- python_platform/auditing/sqlalchemy/store.py +63 -0
- python_platform/authorization/__init__.py +38 -0
- python_platform/authorization/catalog.py +63 -0
- python_platform/authorization/contracts.py +241 -0
- python_platform/authorization/definition_discovery.py +81 -0
- python_platform/authorization/definitions.py +43 -0
- python_platform/authorization/errors.py +40 -0
- python_platform/background_execution/__init__.py +15 -0
- python_platform/background_execution/application.py +41 -0
- python_platform/background_execution/child.py +123 -0
- python_platform/background_execution/contracts.py +39 -0
- python_platform/background_execution/lifecycle.py +22 -0
- python_platform/background_execution/local.py +127 -0
- python_platform/background_execution/locks.py +31 -0
- python_platform/background_execution/management.py +17 -0
- python_platform/background_execution/module.py +10 -0
- python_platform/background_execution/permissions.py +14 -0
- python_platform/background_execution/processes.py +331 -0
- python_platform/background_jobs/__init__.py +31 -0
- python_platform/background_jobs/catalog.py +287 -0
- python_platform/background_jobs/contracts.py +202 -0
- python_platform/background_jobs/errors.py +28 -0
- python_platform/background_jobs/execution.py +201 -0
- python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_platform/background_jobs/pgqueuer/__init__.py +6 -0
- python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
- python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
- python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_platform/background_jobs/pgqueuer/module.py +102 -0
- python_platform/background_jobs/pgqueuer/options.py +70 -0
- python_platform/background_jobs/pgqueuer/runtime.py +479 -0
- python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_platform/background_jobs/pgqueuer/supervision.py +201 -0
- python_platform/background_workers/__init__.py +22 -0
- python_platform/background_workers/catalog.py +96 -0
- python_platform/background_workers/contracts.py +124 -0
- python_platform/background_workers/errors.py +20 -0
- python_platform/background_workers/execution.py +133 -0
- python_platform/background_workers/runtime.py +203 -0
- python_platform/caching/__init__.py +11 -0
- python_platform/caching/catalog.py +73 -0
- python_platform/caching/contracts.py +73 -0
- python_platform/caching/errors.py +20 -0
- python_platform/cli/__init__.py +87 -0
- python_platform/cli/errors.py +5 -0
- python_platform/cli/project.py +69 -0
- python_platform/cli/runtime.py +68 -0
- python_platform/configuration/__init__.py +21 -0
- python_platform/configuration/composition.py +65 -0
- python_platform/configuration/contracts.py +68 -0
- python_platform/configuration/dotenv_source.py +51 -0
- python_platform/configuration/environment_source.py +38 -0
- python_platform/configuration/immutability.py +124 -0
- python_platform/configuration/input_shape.py +58 -0
- python_platform/configuration/merge.py +103 -0
- python_platform/configuration/root.py +215 -0
- python_platform/configuration/sources.py +519 -0
- python_platform/configuration/values.py +427 -0
- python_platform/configuration/yaml_source.py +51 -0
- python_platform/developer_kit/__init__.py +1 -0
- python_platform/developer_kit/generation.py +182 -0
- python_platform/developer_kit/project_metadata.py +45 -0
- python_platform/developer_kit/source.py +53 -0
- python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
- python_platform/developer_kit/wiring.py +85 -0
- python_platform/diagnostics/__init__.py +23 -0
- python_platform/diagnostics/journal.py +174 -0
- python_platform/diagnostics/model.py +83 -0
- python_platform/diagnostics/source.py +25 -0
- python_platform/distributed_lock/__init__.py +6 -0
- python_platform/distributed_lock/contracts.py +20 -0
- python_platform/distributed_lock/options.py +19 -0
- python_platform/domain/__init__.py +13 -0
- python_platform/domain/aggregates.py +64 -0
- python_platform/domain/entities.py +33 -0
- python_platform/domain/errors.py +12 -0
- python_platform/domain/value_objects.py +31 -0
- python_platform/errors/__init__.py +75 -0
- python_platform/errors/base.py +13 -0
- python_platform/errors/business.py +53 -0
- python_platform/errors/configuration.py +119 -0
- python_platform/errors/diagnostics.py +12 -0
- python_platform/errors/lifecycle.py +164 -0
- python_platform/errors/modularity.py +100 -0
- python_platform/errors/services.py +100 -0
- python_platform/events/__init__.py +25 -0
- python_platform/events/aggregate.py +39 -0
- python_platform/events/catalog.py +136 -0
- python_platform/events/contracts.py +39 -0
- python_platform/events/contribution.py +128 -0
- python_platform/events/discovery.py +105 -0
- python_platform/events/errors.py +48 -0
- python_platform/events/runtime.py +192 -0
- python_platform/fastapi/__init__.py +49 -0
- python_platform/fastapi/action.py +175 -0
- python_platform/fastapi/adapter.py +381 -0
- python_platform/fastapi/application_services.py +647 -0
- python_platform/fastapi/background.py +42 -0
- python_platform/fastapi/contracts.py +278 -0
- python_platform/fastapi/errors.py +32 -0
- python_platform/fastapi/filters.py +111 -0
- python_platform/fastapi/health.py +44 -0
- python_platform/fastapi/http.py +55 -0
- python_platform/fastapi/http_router.py +212 -0
- python_platform/fastapi/manual_action.py +105 -0
- python_platform/fastapi/middleware.py +93 -0
- python_platform/fastapi/parameters.py +97 -0
- python_platform/fastapi/realtime/__init__.py +13 -0
- python_platform/fastapi/realtime/authentication.py +181 -0
- python_platform/fastapi/realtime/connection.py +166 -0
- python_platform/fastapi/realtime/module.py +41 -0
- python_platform/fastapi/realtime/options.py +50 -0
- python_platform/fastapi/realtime/runtime.py +423 -0
- python_platform/fastapi/request_context.py +244 -0
- python_platform/fastapi/route_integrity.py +88 -0
- python_platform/fastapi/routing.py +410 -0
- python_platform/fastapi/server.py +181 -0
- python_platform/fastapi/settings.py +40 -0
- python_platform/fastapi/tracing.py +62 -0
- python_platform/fastapi/transfer.py +128 -0
- python_platform/fastapi/upload_limits.py +63 -0
- python_platform/fastapi/uploads.py +94 -0
- python_platform/hosted_services/__init__.py +29 -0
- python_platform/hosted_services/bridge.py +420 -0
- python_platform/hosted_services/catalog.py +259 -0
- python_platform/hosted_services/contracts.py +55 -0
- python_platform/hosted_services/errors.py +53 -0
- python_platform/hosted_services/options.py +17 -0
- python_platform/hosted_services/runtime.py +267 -0
- python_platform/hosted_services/state.py +40 -0
- python_platform/hosting/__init__.py +4 -0
- python_platform/hosting/instance.py +40 -0
- python_platform/identity/__init__.py +73 -0
- python_platform/identity/application.py +287 -0
- python_platform/identity/contracts.py +283 -0
- python_platform/identity/errors.py +28 -0
- python_platform/identity/http_api.py +90 -0
- python_platform/identity/module.py +46 -0
- python_platform/identity/passwords.py +34 -0
- python_platform/identity/permissions.py +14 -0
- python_platform/identity/services.py +108 -0
- python_platform/identity/sqlalchemy/__init__.py +5 -0
- python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
- python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/identity/sqlalchemy/models.py +85 -0
- python_platform/identity/sqlalchemy/module.py +62 -0
- python_platform/identity/sqlalchemy/stores.py +526 -0
- python_platform/identity/tokens.py +106 -0
- python_platform/invocation/__init__.py +3 -0
- python_platform/invocation/callables.py +171 -0
- python_platform/invocation/contracts.py +33 -0
- python_platform/invocation/entries.py +54 -0
- python_platform/invocation/function_runtime.py +48 -0
- python_platform/invocation/interception.py +184 -0
- python_platform/lifecycle/__init__.py +25 -0
- python_platform/lifecycle/composition.py +35 -0
- python_platform/lifecycle/context.py +31 -0
- python_platform/lifecycle/runtime.py +43 -0
- python_platform/lifecycle/state.py +20 -0
- python_platform/modularity/__init__.py +15 -0
- python_platform/modularity/contracts.py +90 -0
- python_platform/modularity/discovery.py +143 -0
- python_platform/modularity/graph.py +334 -0
- python_platform/modularity/registry.py +100 -0
- python_platform/modularity/selection.py +16 -0
- python_platform/notifications/__init__.py +19 -0
- python_platform/notifications/catalog.py +46 -0
- python_platform/notifications/contracts.py +107 -0
- python_platform/observability/__init__.py +5 -0
- python_platform/observability/context.py +43 -0
- python_platform/observability/export.py +73 -0
- python_platform/observability/formatting.py +89 -0
- python_platform/observability/logging.py +118 -0
- python_platform/observability/options.py +45 -0
- python_platform/observability/tracing.py +80 -0
- python_platform/options/__init__.py +19 -0
- python_platform/options/aliases.py +331 -0
- python_platform/options/contribution.py +62 -0
- python_platform/options/immutability.py +48 -0
- python_platform/options/input_keys.py +214 -0
- python_platform/options/issues.py +335 -0
- python_platform/options/models.py +114 -0
- python_platform/options/registry.py +280 -0
- python_platform/options/schema.py +488 -0
- python_platform/options/validation.py +120 -0
- python_platform/py.typed +0 -0
- python_platform/realtime/__init__.py +12 -0
- python_platform/realtime/contracts.py +28 -0
- python_platform/realtime/diagnostics.py +37 -0
- python_platform/realtime/messages.py +113 -0
- python_platform/redis/__init__.py +6 -0
- python_platform/redis/distributed_lock.py +212 -0
- python_platform/redis/lease_lock.py +36 -0
- python_platform/redis/module.py +50 -0
- python_platform/redis/notification_runtime.py +182 -0
- python_platform/redis/notifications.py +25 -0
- python_platform/redis/options.py +35 -0
- python_platform/redis/runtime.py +116 -0
- python_platform/services/__init__.py +39 -0
- python_platform/services/application_bindings.py +221 -0
- python_platform/services/arbitration.py +381 -0
- python_platform/services/binding.py +102 -0
- python_platform/services/contribution.py +438 -0
- python_platform/services/convention.py +292 -0
- python_platform/services/convention_contracts.py +94 -0
- python_platform/services/exposure.py +23 -0
- python_platform/services/framework_provider.py +178 -0
- python_platform/services/native_graph.py +172 -0
- python_platform/services/provider.py +204 -0
- python_platform/services/registration.py +101 -0
- python_platform/services/runtime.py +298 -0
- python_platform/settings/__init__.py +30 -0
- python_platform/settings/application.py +95 -0
- python_platform/settings/binding.py +33 -0
- python_platform/settings/catalog.py +121 -0
- python_platform/settings/changes.py +14 -0
- python_platform/settings/contracts.py +115 -0
- python_platform/settings/definition_discovery.py +71 -0
- python_platform/settings/definitions.py +41 -0
- python_platform/settings/errors.py +22 -0
- python_platform/settings/handlers.py +43 -0
- python_platform/settings/management.py +57 -0
- python_platform/settings/manager.py +89 -0
- python_platform/settings/module.py +7 -0
- python_platform/settings/notifications.py +39 -0
- python_platform/settings/permissions.py +14 -0
- python_platform/settings/provider.py +74 -0
- python_platform/settings/refresh.py +140 -0
- python_platform/settings/refresh_module.py +45 -0
- python_platform/settings/sqlalchemy/__init__.py +5 -0
- python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
- python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/settings/sqlalchemy/models.py +36 -0
- python_platform/settings/sqlalchemy/module.py +36 -0
- python_platform/settings/sqlalchemy/store.py +86 -0
- python_platform/settings/store.py +16 -0
- python_platform/settings/values.py +39 -0
- python_platform/sqlalchemy/__init__.py +51 -0
- python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
- python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_platform/sqlalchemy/auditing.py +48 -0
- python_platform/sqlalchemy/errors.py +85 -0
- python_platform/sqlalchemy/metadata.py +538 -0
- python_platform/sqlalchemy/migration.py +346 -0
- python_platform/sqlalchemy/module.py +48 -0
- python_platform/sqlalchemy/module_migration.py +148 -0
- python_platform/sqlalchemy/options.py +66 -0
- python_platform/sqlalchemy/repository.py +33 -0
- python_platform/sqlalchemy/runtime.py +85 -0
- python_platform/sqlalchemy/session_provider.py +45 -0
- python_platform/sqlalchemy/unit_of_work.py +97 -0
- python_platform/testing/__init__.py +5 -0
- python_platform/testing/runtime.py +112 -0
- python_platform/unit_of_work/__init__.py +14 -0
- python_platform/unit_of_work/contracts.py +223 -0
- python_platform/unit_of_work/errors.py +27 -0
- python_platform/unit_of_work/manager.py +210 -0
- python_platform/unit_of_work/options.py +92 -0
- python_platform-0.1.1.dist-info/METADATA +122 -0
- python_platform-0.1.1.dist-info/RECORD +344 -0
- python_platform-0.1.1.dist-info/WHEEL +4 -0
- python_platform-0.1.1.dist-info/entry_points.txt +9 -0
- python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
- python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Identity repository、request access check 与独立 refresh/login 事务。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from hashlib import blake2b
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
from sqlalchemy.exc import IntegrityError
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from ...authorization import CurrentUser, RolePermissionVersion
|
|
14
|
+
from ...sqlalchemy import PlatformAsyncSession, SqlAlchemySessionProvider
|
|
15
|
+
from ...sqlalchemy.runtime import _SqlAlchemyEngineRegistry
|
|
16
|
+
from ..contracts import (
|
|
17
|
+
IdentityRole,
|
|
18
|
+
IdentityUser,
|
|
19
|
+
RefreshRotationResult,
|
|
20
|
+
RefreshRotationStatus,
|
|
21
|
+
RefreshSession,
|
|
22
|
+
RoleView,
|
|
23
|
+
UserView,
|
|
24
|
+
)
|
|
25
|
+
from ..errors import IdentityConflictError, IdentityResourceNotFoundError
|
|
26
|
+
from ..tokens import REFRESH_ABSOLUTE_LIFETIME, REFRESH_INACTIVITY_LIFETIME
|
|
27
|
+
from .models import (
|
|
28
|
+
RefreshSessionRow,
|
|
29
|
+
RolePermissionRow,
|
|
30
|
+
RoleRow,
|
|
31
|
+
UserRoleRow,
|
|
32
|
+
UserRow,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_REFRESH_FAMILY_LOCK_PERSON = b"pyplat.rfam.v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SqlAlchemyIdentityRepository:
|
|
39
|
+
__slots__ = ("_provider",)
|
|
40
|
+
|
|
41
|
+
def __init__(self, provider: SqlAlchemySessionProvider) -> None:
|
|
42
|
+
self._provider = provider
|
|
43
|
+
|
|
44
|
+
async def find_user_by_normalized_username(
|
|
45
|
+
self,
|
|
46
|
+
normalized_username: str,
|
|
47
|
+
) -> IdentityUser | None:
|
|
48
|
+
async with self._provider.operation("find_user_by_normalized_username"):
|
|
49
|
+
session = await self._provider.get_session()
|
|
50
|
+
statement = select(UserRow).where(UserRow.normalized_username == normalized_username)
|
|
51
|
+
row = await session.scalar(statement)
|
|
52
|
+
return None if row is None else _user_value(row)
|
|
53
|
+
|
|
54
|
+
async def create_user(self, user: IdentityUser) -> None:
|
|
55
|
+
async with self._provider.operation("create_user"):
|
|
56
|
+
session = await self._provider.get_session()
|
|
57
|
+
session.add(
|
|
58
|
+
UserRow(
|
|
59
|
+
user_id=user.user_id,
|
|
60
|
+
username=user.username,
|
|
61
|
+
normalized_username=user.normalized_username,
|
|
62
|
+
password_hash=user.password_hash,
|
|
63
|
+
is_active=user.is_active,
|
|
64
|
+
failed_access_count=user.failed_access_count,
|
|
65
|
+
lockout_end=user.lockout_end,
|
|
66
|
+
permission_version=user.permission_version,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
await self._flush_unique("user")
|
|
70
|
+
|
|
71
|
+
async def list_users(self) -> tuple[UserView, ...]:
|
|
72
|
+
async with self._provider.operation("list_users"):
|
|
73
|
+
session = await self._provider.get_session()
|
|
74
|
+
rows = tuple(
|
|
75
|
+
(await session.scalars(select(UserRow).order_by(UserRow.normalized_username))).all()
|
|
76
|
+
)
|
|
77
|
+
role_rows = (
|
|
78
|
+
await session.execute(
|
|
79
|
+
select(UserRoleRow.user_id, UserRoleRow.role_id).order_by(
|
|
80
|
+
UserRoleRow.user_id, UserRoleRow.role_id
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
).all()
|
|
84
|
+
roles_by_user: dict[UUID, list[UUID]] = {}
|
|
85
|
+
for user_id, role_id in role_rows:
|
|
86
|
+
roles_by_user.setdefault(user_id, []).append(role_id)
|
|
87
|
+
return tuple(
|
|
88
|
+
UserView(
|
|
89
|
+
user_id=row.user_id,
|
|
90
|
+
username=row.username,
|
|
91
|
+
is_active=row.is_active,
|
|
92
|
+
role_ids=tuple(roles_by_user.get(row.user_id, ())),
|
|
93
|
+
)
|
|
94
|
+
for row in rows
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def create_role(self, role: IdentityRole) -> None:
|
|
98
|
+
async with self._provider.operation("create_role"):
|
|
99
|
+
session = await self._provider.get_session()
|
|
100
|
+
session.add(
|
|
101
|
+
RoleRow(
|
|
102
|
+
role_id=role.role_id,
|
|
103
|
+
name=role.name,
|
|
104
|
+
normalized_name=role.normalized_name,
|
|
105
|
+
permission_version=role.permission_version,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
await self._flush_unique("role")
|
|
109
|
+
|
|
110
|
+
async def find_role_by_normalized_name(self, normalized_name: str) -> IdentityRole | None:
|
|
111
|
+
async with self._provider.operation("find_role_by_normalized_name"):
|
|
112
|
+
session = await self._provider.get_session()
|
|
113
|
+
row = await session.scalar(
|
|
114
|
+
select(RoleRow).where(RoleRow.normalized_name == normalized_name)
|
|
115
|
+
)
|
|
116
|
+
return None if row is None else _role_value(row)
|
|
117
|
+
|
|
118
|
+
async def list_roles(self) -> tuple[RoleView, ...]:
|
|
119
|
+
async with self._provider.operation("list_roles"):
|
|
120
|
+
session = await self._provider.get_session()
|
|
121
|
+
roles = tuple(
|
|
122
|
+
(await session.scalars(select(RoleRow).order_by(RoleRow.normalized_name))).all()
|
|
123
|
+
)
|
|
124
|
+
permission_rows = (
|
|
125
|
+
await session.execute(
|
|
126
|
+
select(RolePermissionRow.role_id, RolePermissionRow.permission_name).order_by(
|
|
127
|
+
RolePermissionRow.role_id, RolePermissionRow.permission_name
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
).all()
|
|
131
|
+
permissions_by_role: dict[UUID, list[str]] = {}
|
|
132
|
+
for role_id, permission_name in permission_rows:
|
|
133
|
+
permissions_by_role.setdefault(role_id, []).append(permission_name)
|
|
134
|
+
return tuple(
|
|
135
|
+
RoleView(
|
|
136
|
+
role_id=row.role_id,
|
|
137
|
+
name=row.name,
|
|
138
|
+
permissions=tuple(permissions_by_role.get(row.role_id, ())),
|
|
139
|
+
)
|
|
140
|
+
for row in roles
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def set_user_roles(self, user_id: UUID, role_ids: tuple[UUID, ...]) -> None:
|
|
144
|
+
async with self._provider.operation("set_user_roles"):
|
|
145
|
+
session = await self._provider.get_session()
|
|
146
|
+
user = await self._require_user(user_id, for_update=True)
|
|
147
|
+
requested = frozenset(role_ids)
|
|
148
|
+
roles = tuple(
|
|
149
|
+
(
|
|
150
|
+
await session.scalars(
|
|
151
|
+
select(RoleRow).where(RoleRow.role_id.in_(requested)).with_for_update()
|
|
152
|
+
)
|
|
153
|
+
).all()
|
|
154
|
+
)
|
|
155
|
+
if len(roles) != len(requested):
|
|
156
|
+
raise IdentityResourceNotFoundError(resource="role")
|
|
157
|
+
existing = tuple(
|
|
158
|
+
(
|
|
159
|
+
await session.scalars(
|
|
160
|
+
select(UserRoleRow).where(UserRoleRow.user_id == user_id).with_for_update()
|
|
161
|
+
)
|
|
162
|
+
).all()
|
|
163
|
+
)
|
|
164
|
+
current = frozenset(item.role_id for item in existing)
|
|
165
|
+
if current == requested:
|
|
166
|
+
return
|
|
167
|
+
for item in existing:
|
|
168
|
+
if item.role_id not in requested:
|
|
169
|
+
await session.delete(item)
|
|
170
|
+
for role_id in sorted(requested - current):
|
|
171
|
+
session.add(UserRoleRow(user_id=user_id, role_id=role_id))
|
|
172
|
+
user.permission_version += 1
|
|
173
|
+
|
|
174
|
+
async def set_role_permissions(self, role_id: UUID, permissions: tuple[str, ...]) -> None:
|
|
175
|
+
async with self._provider.operation("set_role_permissions"):
|
|
176
|
+
session = await self._provider.get_session()
|
|
177
|
+
role = await self._require_role(role_id, for_update=True)
|
|
178
|
+
requested = frozenset(permissions)
|
|
179
|
+
existing = tuple(
|
|
180
|
+
(
|
|
181
|
+
await session.scalars(
|
|
182
|
+
select(RolePermissionRow)
|
|
183
|
+
.where(RolePermissionRow.role_id == role_id)
|
|
184
|
+
.with_for_update()
|
|
185
|
+
)
|
|
186
|
+
).all()
|
|
187
|
+
)
|
|
188
|
+
current = frozenset(item.permission_name for item in existing)
|
|
189
|
+
if current == requested:
|
|
190
|
+
return
|
|
191
|
+
for item in existing:
|
|
192
|
+
if item.permission_name not in requested:
|
|
193
|
+
await session.delete(item)
|
|
194
|
+
for permission in sorted(requested - current):
|
|
195
|
+
session.add(RolePermissionRow(role_id=role_id, permission_name=permission))
|
|
196
|
+
role.permission_version += 1
|
|
197
|
+
|
|
198
|
+
async def _require_user(self, user_id: UUID, *, for_update: bool) -> UserRow:
|
|
199
|
+
session = await self._provider.get_session()
|
|
200
|
+
statement = select(UserRow).where(UserRow.user_id == user_id)
|
|
201
|
+
if for_update:
|
|
202
|
+
statement = statement.with_for_update()
|
|
203
|
+
row = await session.scalar(statement)
|
|
204
|
+
if row is None:
|
|
205
|
+
raise IdentityResourceNotFoundError(resource="user")
|
|
206
|
+
return row
|
|
207
|
+
|
|
208
|
+
async def _require_role(self, role_id: UUID, *, for_update: bool) -> RoleRow:
|
|
209
|
+
session = await self._provider.get_session()
|
|
210
|
+
statement = select(RoleRow).where(RoleRow.role_id == role_id)
|
|
211
|
+
if for_update:
|
|
212
|
+
statement = statement.with_for_update()
|
|
213
|
+
row = await session.scalar(statement)
|
|
214
|
+
if row is None:
|
|
215
|
+
raise IdentityResourceNotFoundError(resource="role")
|
|
216
|
+
return row
|
|
217
|
+
|
|
218
|
+
async def _flush_unique(self, resource: str) -> None:
|
|
219
|
+
session = await self._provider.get_session()
|
|
220
|
+
try:
|
|
221
|
+
await session.flush()
|
|
222
|
+
except IntegrityError:
|
|
223
|
+
raise IdentityConflictError(resource=resource) from None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SqlAlchemyIdentityAccessStore:
|
|
227
|
+
__slots__ = ("_engines",)
|
|
228
|
+
|
|
229
|
+
def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
|
|
230
|
+
self._engines = engines
|
|
231
|
+
|
|
232
|
+
async def load_current_user(
|
|
233
|
+
self,
|
|
234
|
+
user_id: UUID,
|
|
235
|
+
session_id: UUID,
|
|
236
|
+
now: datetime,
|
|
237
|
+
) -> CurrentUser | None:
|
|
238
|
+
async with self._session() as session:
|
|
239
|
+
user = await session.scalar(
|
|
240
|
+
select(UserRow).where(UserRow.user_id == user_id, UserRow.is_active.is_(True))
|
|
241
|
+
)
|
|
242
|
+
refresh = await session.scalar(
|
|
243
|
+
select(RefreshSessionRow).where(
|
|
244
|
+
RefreshSessionRow.session_id == session_id,
|
|
245
|
+
RefreshSessionRow.user_id == user_id,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
if user is None or refresh is None or not _session_is_active(refresh, now):
|
|
249
|
+
return None
|
|
250
|
+
role_rows = (
|
|
251
|
+
await session.execute(
|
|
252
|
+
select(RoleRow.role_id, RoleRow.permission_version)
|
|
253
|
+
.join(UserRoleRow, UserRoleRow.role_id == RoleRow.role_id)
|
|
254
|
+
.where(UserRoleRow.user_id == user_id)
|
|
255
|
+
.order_by(RoleRow.role_id)
|
|
256
|
+
)
|
|
257
|
+
).all()
|
|
258
|
+
return CurrentUser(
|
|
259
|
+
user_id=user.user_id,
|
|
260
|
+
session_id=refresh.session_id,
|
|
261
|
+
username=user.username,
|
|
262
|
+
permission_version=user.permission_version,
|
|
263
|
+
role_versions=tuple(
|
|
264
|
+
RolePermissionVersion(role_id=role_id, version=version)
|
|
265
|
+
for role_id, version in role_rows
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def permissions_for_roles(
|
|
270
|
+
self,
|
|
271
|
+
role_versions: tuple[RolePermissionVersion, ...],
|
|
272
|
+
) -> frozenset[str]:
|
|
273
|
+
if not role_versions:
|
|
274
|
+
return frozenset()
|
|
275
|
+
role_ids = tuple(item.role_id for item in role_versions)
|
|
276
|
+
expected = {item.role_id: item.version for item in role_versions}
|
|
277
|
+
async with self._session() as session:
|
|
278
|
+
actual_rows = (
|
|
279
|
+
await session.execute(
|
|
280
|
+
select(RoleRow.role_id, RoleRow.permission_version).where(
|
|
281
|
+
RoleRow.role_id.in_(role_ids)
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
).all()
|
|
285
|
+
actual: dict[UUID, int] = dict((role_id, version) for role_id, version in actual_rows)
|
|
286
|
+
# Cache miss 时仍重新核对版本;并发权限修改不能把旧 allow 写进新版本 key。
|
|
287
|
+
if actual != expected:
|
|
288
|
+
return frozenset()
|
|
289
|
+
permissions = (
|
|
290
|
+
await session.scalars(
|
|
291
|
+
select(RolePermissionRow.permission_name).where(
|
|
292
|
+
RolePermissionRow.role_id.in_(role_ids)
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
).all()
|
|
296
|
+
return frozenset(permissions)
|
|
297
|
+
|
|
298
|
+
def _session(self) -> PlatformAsyncSession:
|
|
299
|
+
return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class SqlAlchemyIdentityLoginStore:
|
|
303
|
+
__slots__ = ("_engines",)
|
|
304
|
+
|
|
305
|
+
def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
|
|
306
|
+
self._engines = engines
|
|
307
|
+
|
|
308
|
+
async def find_user(self, normalized_username: str) -> IdentityUser | None:
|
|
309
|
+
async with self._session() as session:
|
|
310
|
+
row = await session.scalar(
|
|
311
|
+
select(UserRow).where(UserRow.normalized_username == normalized_username)
|
|
312
|
+
)
|
|
313
|
+
return None if row is None else _user_value(row)
|
|
314
|
+
|
|
315
|
+
async def record_failure(
|
|
316
|
+
self,
|
|
317
|
+
user_id: UUID,
|
|
318
|
+
*,
|
|
319
|
+
now: datetime,
|
|
320
|
+
attempt_limit: int,
|
|
321
|
+
lockout_duration_seconds: int,
|
|
322
|
+
) -> None:
|
|
323
|
+
async with self._session() as session, session.begin():
|
|
324
|
+
row = await session.scalar(
|
|
325
|
+
select(UserRow).where(UserRow.user_id == user_id).with_for_update()
|
|
326
|
+
)
|
|
327
|
+
if row is None or not row.is_active:
|
|
328
|
+
return
|
|
329
|
+
count = (
|
|
330
|
+
0
|
|
331
|
+
if row.lockout_end is not None and row.lockout_end <= now
|
|
332
|
+
else row.failed_access_count
|
|
333
|
+
)
|
|
334
|
+
count += 1
|
|
335
|
+
row.failed_access_count = count
|
|
336
|
+
row.lockout_end = (
|
|
337
|
+
now + timedelta(seconds=lockout_duration_seconds)
|
|
338
|
+
if count >= attempt_limit
|
|
339
|
+
else None
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def complete_success(
|
|
343
|
+
self,
|
|
344
|
+
user_id: UUID,
|
|
345
|
+
*,
|
|
346
|
+
expected_password_hash: str,
|
|
347
|
+
updated_password_hash: str | None,
|
|
348
|
+
session: RefreshSession,
|
|
349
|
+
now: datetime,
|
|
350
|
+
) -> bool:
|
|
351
|
+
async with self._session() as database, database.begin():
|
|
352
|
+
row = await database.scalar(
|
|
353
|
+
select(UserRow).where(UserRow.user_id == user_id).with_for_update()
|
|
354
|
+
)
|
|
355
|
+
if (
|
|
356
|
+
row is None
|
|
357
|
+
or not row.is_active
|
|
358
|
+
or row.password_hash != expected_password_hash
|
|
359
|
+
or (row.lockout_end is not None and row.lockout_end > now)
|
|
360
|
+
):
|
|
361
|
+
return False
|
|
362
|
+
row.failed_access_count = 0
|
|
363
|
+
row.lockout_end = None
|
|
364
|
+
if updated_password_hash is not None:
|
|
365
|
+
row.password_hash = updated_password_hash
|
|
366
|
+
database.add(_refresh_row(session))
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
def _session(self) -> PlatformAsyncSession:
|
|
370
|
+
return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class SqlAlchemyRefreshSessionStore:
|
|
374
|
+
__slots__ = ("_engines",)
|
|
375
|
+
|
|
376
|
+
def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
|
|
377
|
+
self._engines = engines
|
|
378
|
+
|
|
379
|
+
async def rotate(
|
|
380
|
+
self,
|
|
381
|
+
token_hash: str,
|
|
382
|
+
*,
|
|
383
|
+
replacement_id: UUID,
|
|
384
|
+
replacement_hash: str,
|
|
385
|
+
now: datetime,
|
|
386
|
+
) -> RefreshRotationResult:
|
|
387
|
+
async with self._session() as session, session.begin():
|
|
388
|
+
family_id = await session.scalar(
|
|
389
|
+
select(RefreshSessionRow.family_id).where(
|
|
390
|
+
RefreshSessionRow.token_hash == token_hash
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
if family_id is None:
|
|
394
|
+
return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
|
|
395
|
+
await _lock_refresh_family(session, family_id)
|
|
396
|
+
current = await session.scalar(
|
|
397
|
+
select(RefreshSessionRow)
|
|
398
|
+
.where(RefreshSessionRow.token_hash == token_hash)
|
|
399
|
+
.with_for_update()
|
|
400
|
+
)
|
|
401
|
+
if current is None:
|
|
402
|
+
return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
|
|
403
|
+
assert current.family_id == family_id
|
|
404
|
+
if current.revoked_at is not None or current.replaced_by_session_id is not None:
|
|
405
|
+
await _revoke_family(session, current.family_id, now=now)
|
|
406
|
+
return RefreshRotationResult(RefreshRotationStatus.REUSED, None)
|
|
407
|
+
if not _session_is_active(current, now):
|
|
408
|
+
await _revoke_family(session, current.family_id, now=now)
|
|
409
|
+
return RefreshRotationResult(RefreshRotationStatus.EXPIRED, None)
|
|
410
|
+
|
|
411
|
+
replacement = RefreshSession(
|
|
412
|
+
session_id=replacement_id,
|
|
413
|
+
user_id=current.user_id,
|
|
414
|
+
family_id=current.family_id,
|
|
415
|
+
token_hash=replacement_hash,
|
|
416
|
+
created_at=now,
|
|
417
|
+
family_created_at=current.family_created_at,
|
|
418
|
+
last_used_at=now,
|
|
419
|
+
)
|
|
420
|
+
current.revoked_at = now
|
|
421
|
+
current.replaced_by_session_id = replacement_id
|
|
422
|
+
current.last_used_at = now
|
|
423
|
+
session.add(_refresh_row(replacement))
|
|
424
|
+
return RefreshRotationResult(RefreshRotationStatus.ROTATED, replacement)
|
|
425
|
+
|
|
426
|
+
async def revoke(self, token_hash: str, *, now: datetime) -> None:
|
|
427
|
+
async with self._session() as session, session.begin():
|
|
428
|
+
family_id = await session.scalar(
|
|
429
|
+
select(RefreshSessionRow.family_id).where(
|
|
430
|
+
RefreshSessionRow.token_hash == token_hash
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
if family_id is None:
|
|
434
|
+
return
|
|
435
|
+
await _lock_refresh_family(session, family_id)
|
|
436
|
+
current = await session.scalar(
|
|
437
|
+
select(RefreshSessionRow)
|
|
438
|
+
.where(RefreshSessionRow.token_hash == token_hash)
|
|
439
|
+
.with_for_update()
|
|
440
|
+
)
|
|
441
|
+
if current is not None:
|
|
442
|
+
assert current.family_id == family_id
|
|
443
|
+
await _revoke_family(session, current.family_id, now=now)
|
|
444
|
+
|
|
445
|
+
def _session(self) -> PlatformAsyncSession:
|
|
446
|
+
return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
async def _revoke_family(
|
|
450
|
+
session: AsyncSession,
|
|
451
|
+
family_id: UUID,
|
|
452
|
+
*,
|
|
453
|
+
now: datetime,
|
|
454
|
+
) -> None:
|
|
455
|
+
rows = tuple(
|
|
456
|
+
(
|
|
457
|
+
await session.scalars(
|
|
458
|
+
select(RefreshSessionRow)
|
|
459
|
+
.where(RefreshSessionRow.family_id == family_id)
|
|
460
|
+
.with_for_update()
|
|
461
|
+
)
|
|
462
|
+
).all()
|
|
463
|
+
)
|
|
464
|
+
for row in rows:
|
|
465
|
+
if row.revoked_at is None:
|
|
466
|
+
row.revoked_at = now
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def _lock_refresh_family(session: AsyncSession, family_id: UUID) -> None:
|
|
470
|
+
# family 没有可锁的稳定 parent row;transaction advisory lock 在同一短事务内提供
|
|
471
|
+
# family -> token/family rows 的统一顺序,并随 commit/rollback 自动释放。
|
|
472
|
+
await session.scalar(select(func.pg_advisory_xact_lock(_refresh_family_lock_key(family_id))))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _refresh_family_lock_key(family_id: UUID) -> int:
|
|
476
|
+
digest = blake2b(
|
|
477
|
+
family_id.bytes,
|
|
478
|
+
digest_size=8,
|
|
479
|
+
person=_REFRESH_FAMILY_LOCK_PERSON,
|
|
480
|
+
).digest()
|
|
481
|
+
return int.from_bytes(digest, byteorder="big", signed=True)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _session_is_active(row: RefreshSessionRow, now: datetime) -> bool:
|
|
485
|
+
return (
|
|
486
|
+
row.revoked_at is None
|
|
487
|
+
and row.replaced_by_session_id is None
|
|
488
|
+
and now - row.last_used_at < REFRESH_INACTIVITY_LIFETIME
|
|
489
|
+
and now - row.family_created_at < REFRESH_ABSOLUTE_LIFETIME
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _user_value(row: UserRow) -> IdentityUser:
|
|
494
|
+
return IdentityUser(
|
|
495
|
+
user_id=row.user_id,
|
|
496
|
+
username=row.username,
|
|
497
|
+
normalized_username=row.normalized_username,
|
|
498
|
+
password_hash=row.password_hash,
|
|
499
|
+
is_active=row.is_active,
|
|
500
|
+
failed_access_count=row.failed_access_count,
|
|
501
|
+
lockout_end=row.lockout_end,
|
|
502
|
+
permission_version=row.permission_version,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _role_value(row: RoleRow) -> IdentityRole:
|
|
507
|
+
return IdentityRole(
|
|
508
|
+
role_id=row.role_id,
|
|
509
|
+
name=row.name,
|
|
510
|
+
normalized_name=row.normalized_name,
|
|
511
|
+
permission_version=row.permission_version,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _refresh_row(value: RefreshSession) -> RefreshSessionRow:
|
|
516
|
+
return RefreshSessionRow(
|
|
517
|
+
session_id=value.session_id,
|
|
518
|
+
user_id=value.user_id,
|
|
519
|
+
family_id=value.family_id,
|
|
520
|
+
token_hash=value.token_hash,
|
|
521
|
+
created_at=value.created_at,
|
|
522
|
+
family_created_at=value.family_created_at,
|
|
523
|
+
last_used_at=value.last_used_at,
|
|
524
|
+
revoked_at=value.revoked_at,
|
|
525
|
+
replaced_by_session_id=value.replaced_by_session_id,
|
|
526
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""固定 HS256、完整 required claims 与 Host-owned keyring 的 JWT boundary。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
|
|
11
|
+
import jwt
|
|
12
|
+
|
|
13
|
+
from ..options import Options
|
|
14
|
+
from .contracts import IdentityOptions
|
|
15
|
+
from .errors import InvalidCredentialsError
|
|
16
|
+
|
|
17
|
+
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)
|
|
18
|
+
REFRESH_INACTIVITY_LIFETIME = timedelta(days=30)
|
|
19
|
+
REFRESH_ABSOLUTE_LIFETIME = timedelta(days=90)
|
|
20
|
+
ACCESS_TOKEN_TYPE = "access"
|
|
21
|
+
BEARER_TOKEN_TYPE = "Bearer"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class AccessTokenClaims:
|
|
26
|
+
user_id: UUID
|
|
27
|
+
session_id: UUID
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JwtTokenService:
|
|
31
|
+
__slots__ = ("_options",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, options: Options[IdentityOptions]) -> None:
|
|
34
|
+
self._options = options.value
|
|
35
|
+
|
|
36
|
+
def issue(self, user_id: UUID, session_id: UUID, *, now: datetime) -> str:
|
|
37
|
+
issued = _as_utc(now)
|
|
38
|
+
payload = {
|
|
39
|
+
"iss": self._options.issuer,
|
|
40
|
+
"aud": self._options.audience,
|
|
41
|
+
"sub": str(user_id),
|
|
42
|
+
"sid": str(session_id),
|
|
43
|
+
"jti": str(uuid4()),
|
|
44
|
+
"iat": issued,
|
|
45
|
+
"nbf": issued,
|
|
46
|
+
"exp": issued + ACCESS_TOKEN_LIFETIME,
|
|
47
|
+
"typ": ACCESS_TOKEN_TYPE,
|
|
48
|
+
}
|
|
49
|
+
key = self._options.keys[self._options.active_kid].get_secret_value()
|
|
50
|
+
return jwt.encode(
|
|
51
|
+
payload,
|
|
52
|
+
key,
|
|
53
|
+
algorithm="HS256",
|
|
54
|
+
headers={"kid": self._options.active_kid, "typ": "JWT"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def verify(self, token: str) -> AccessTokenClaims:
|
|
58
|
+
try:
|
|
59
|
+
header = jwt.get_unverified_header(token)
|
|
60
|
+
if header.get("alg") != "HS256":
|
|
61
|
+
raise InvalidCredentialsError
|
|
62
|
+
kid = header.get("kid")
|
|
63
|
+
if not isinstance(kid, str) or kid not in self._options.keys:
|
|
64
|
+
raise InvalidCredentialsError
|
|
65
|
+
payload = jwt.decode(
|
|
66
|
+
token,
|
|
67
|
+
self._options.keys[kid].get_secret_value(),
|
|
68
|
+
algorithms=["HS256"],
|
|
69
|
+
issuer=self._options.issuer,
|
|
70
|
+
audience=self._options.audience,
|
|
71
|
+
options={
|
|
72
|
+
"require": ["iss", "aud", "sub", "sid", "jti", "iat", "nbf", "exp", "typ"]
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
if payload.get("typ") != ACCESS_TOKEN_TYPE:
|
|
76
|
+
raise InvalidCredentialsError
|
|
77
|
+
user_id = UUID(_required_string_claim(payload, "sub"))
|
|
78
|
+
session_id = UUID(_required_string_claim(payload, "sid"))
|
|
79
|
+
UUID(_required_string_claim(payload, "jti"))
|
|
80
|
+
except InvalidCredentialsError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception:
|
|
83
|
+
raise InvalidCredentialsError from None
|
|
84
|
+
return AccessTokenClaims(user_id=user_id, session_id=session_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def new_refresh_token() -> str:
|
|
88
|
+
# token_urlsafe(32) 以 32 random bytes 提供 256-bit entropy。
|
|
89
|
+
return secrets.token_urlsafe(32)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def hash_refresh_token(token: str) -> str:
|
|
93
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _required_string_claim(payload: dict[str, object], name: str) -> str:
|
|
97
|
+
value = payload.get(name)
|
|
98
|
+
if not isinstance(value, str) or not value:
|
|
99
|
+
raise InvalidCredentialsError
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _as_utc(value: datetime) -> datetime:
|
|
104
|
+
if value.tzinfo is None:
|
|
105
|
+
raise ValueError("security timestamps must be timezone-aware")
|
|
106
|
+
return value.astimezone(UTC)
|