swiftpy-framework 0.1.0__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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/admin/auth.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from swiftpy.core.config import Config, ConfigurationError
|
|
15
|
+
from swiftpy.core.container import get_container
|
|
16
|
+
from swiftpy.core.context import Context
|
|
17
|
+
from swiftpy.http.request import Request
|
|
18
|
+
from swiftpy.http.response import Response
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("swiftpy.admin.auth")
|
|
21
|
+
|
|
22
|
+
ADMIN_COOKIE_NAME = "swiftpy_admin_session"
|
|
23
|
+
DEFAULT_SESSION_MAX_AGE = 86400 # 24 hours
|
|
24
|
+
DEFAULT_MAX_REVOKED_SESSIONS = 10_000
|
|
25
|
+
_CONTEXT_ADMIN_KEY = "__swiftpy_admin_user__"
|
|
26
|
+
_LOCAL_DEV_ENVS = frozenset({"local", "development", "dev", "testing", "test"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_admin_config(explicit: Config | None = None) -> Config | None:
|
|
30
|
+
"""Resolve the active application Config, falling back cleanly to global Config."""
|
|
31
|
+
if explicit is not None:
|
|
32
|
+
return explicit
|
|
33
|
+
try:
|
|
34
|
+
container = get_container()
|
|
35
|
+
if container.has(Config):
|
|
36
|
+
return container.resolve(Config)
|
|
37
|
+
except RuntimeError:
|
|
38
|
+
pass
|
|
39
|
+
try:
|
|
40
|
+
return Config.get_instance()
|
|
41
|
+
except (RuntimeError, ValueError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_dev_debug_mode(cfg: Config | None) -> bool:
|
|
46
|
+
"""Return True only when config explicitly declares a local/dev/test env in debug mode."""
|
|
47
|
+
if cfg is None:
|
|
48
|
+
return False
|
|
49
|
+
raw_env = cfg.get("app.env") or cfg.get("APP_ENV") or cfg.get("env")
|
|
50
|
+
if raw_env is None:
|
|
51
|
+
return False
|
|
52
|
+
app_env = str(raw_env).strip().lower()
|
|
53
|
+
if app_env not in _LOCAL_DEV_ENVS:
|
|
54
|
+
return False
|
|
55
|
+
raw_debug = cfg.get("app.debug")
|
|
56
|
+
if isinstance(raw_debug, bool):
|
|
57
|
+
return raw_debug
|
|
58
|
+
if isinstance(raw_debug, str):
|
|
59
|
+
return raw_debug.strip().lower() in ("true", "1", "yes", "on")
|
|
60
|
+
return bool(raw_debug) if raw_debug is not None else False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize_cookie_path(prefix: str) -> str:
|
|
64
|
+
"""Normalize an admin mount prefix into a clean cookie Path attribute."""
|
|
65
|
+
stripped = prefix.strip("/")
|
|
66
|
+
return f"/{stripped}" if stripped else "/"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _should_use_secure_cookie(
|
|
70
|
+
*,
|
|
71
|
+
request: Request | None = None,
|
|
72
|
+
secure: bool | None = None,
|
|
73
|
+
config: Config | None = None,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""Determine whether the Secure cookie flag should be set."""
|
|
76
|
+
if secure is not None:
|
|
77
|
+
return bool(secure)
|
|
78
|
+
cfg = _resolve_admin_config(config)
|
|
79
|
+
if cfg is not None:
|
|
80
|
+
explicit = cfg.get("admin.cookie_secure")
|
|
81
|
+
if isinstance(explicit, bool):
|
|
82
|
+
return explicit
|
|
83
|
+
if isinstance(explicit, str):
|
|
84
|
+
return explicit.strip().lower() in ("true", "1", "yes", "on")
|
|
85
|
+
if request is not None:
|
|
86
|
+
scheme = str(getattr(request, "scheme", "") or "").lower()
|
|
87
|
+
fwd_proto = str(request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
|
|
88
|
+
if scheme == "https" or fwd_proto == "https":
|
|
89
|
+
return True
|
|
90
|
+
if cfg is not None:
|
|
91
|
+
raw_env = str(cfg.get("app.env") or cfg.get("env") or "").strip().lower()
|
|
92
|
+
app_url = str(cfg.get("app.url") or "").strip().lower()
|
|
93
|
+
if raw_env in ("production", "prod") or app_url.startswith("https://"):
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _b64encode(data: bytes) -> str:
|
|
99
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _b64decode(s: str) -> bytes:
|
|
103
|
+
padding = 4 - (len(s) % 4)
|
|
104
|
+
if padding != 4:
|
|
105
|
+
s += "=" * padding
|
|
106
|
+
return base64.urlsafe_b64decode(s.encode("ascii"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_cookie_header(cookie_header: str | None) -> dict[str, str]:
|
|
110
|
+
"""Parse HTTP Cookie header string into a key-value dictionary."""
|
|
111
|
+
if not cookie_header:
|
|
112
|
+
return {}
|
|
113
|
+
cookies: dict[str, str] = {}
|
|
114
|
+
for part in cookie_header.split(";"):
|
|
115
|
+
part = part.strip()
|
|
116
|
+
if "=" in part:
|
|
117
|
+
k, v = part.split("=", 1)
|
|
118
|
+
cookies[k.strip()] = v.strip()
|
|
119
|
+
return cookies
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AdminUnauthorizedError(Exception):
|
|
123
|
+
"""Raised when an unauthenticated request attempts to access protected admin resources."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, message: str = "Admin authentication required.") -> None:
|
|
126
|
+
super().__init__(message)
|
|
127
|
+
self.message = message
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AdminUser:
|
|
131
|
+
"""Represents an authenticated admin user session."""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
username: str,
|
|
136
|
+
is_admin: bool = True,
|
|
137
|
+
permissions: list[str] | None = None,
|
|
138
|
+
session_id: str | None = None,
|
|
139
|
+
metadata: dict[str, Any] | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
self.username = username
|
|
142
|
+
self.is_admin = is_admin
|
|
143
|
+
self.permissions = ["all"] if permissions is None else list(permissions)
|
|
144
|
+
self.session_id = session_id if session_id is not None else secrets.token_hex(16)
|
|
145
|
+
self.metadata = dict(metadata or {})
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_authenticated(self) -> bool:
|
|
149
|
+
return self.is_admin is True
|
|
150
|
+
|
|
151
|
+
def has_permission(self, permission: str | None = None) -> bool:
|
|
152
|
+
"""Check whether this authenticated admin user holds the requested permission."""
|
|
153
|
+
if self.is_admin is not True:
|
|
154
|
+
return False
|
|
155
|
+
if "all" in self.permissions or "*" in self.permissions:
|
|
156
|
+
return True
|
|
157
|
+
if permission is None:
|
|
158
|
+
return False
|
|
159
|
+
return permission in self.permissions
|
|
160
|
+
|
|
161
|
+
def to_dict(self) -> dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"username": self.username,
|
|
164
|
+
"is_admin": self.is_admin,
|
|
165
|
+
"permissions": self.permissions,
|
|
166
|
+
"session_id": self.session_id,
|
|
167
|
+
"metadata": self.metadata,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_dict(cls, data: dict[str, Any]) -> AdminUser:
|
|
172
|
+
raw_perms = data.get("permissions")
|
|
173
|
+
perms = [str(p) for p in raw_perms] if isinstance(raw_perms, list) else []
|
|
174
|
+
raw_meta = data.get("metadata")
|
|
175
|
+
meta = dict(raw_meta) if isinstance(raw_meta, dict) else {}
|
|
176
|
+
raw_sid = data.get("session_id")
|
|
177
|
+
sid = raw_sid if isinstance(raw_sid, str) else ""
|
|
178
|
+
return cls(
|
|
179
|
+
username=str(data.get("username", "")),
|
|
180
|
+
is_admin=data.get("is_admin") is True,
|
|
181
|
+
permissions=perms,
|
|
182
|
+
session_id=sid,
|
|
183
|
+
metadata=meta,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def __repr__(self) -> str:
|
|
187
|
+
return f"<AdminUser username={self.username!r} is_admin={self.is_admin}>"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AdminSessionStore:
|
|
191
|
+
"""
|
|
192
|
+
Session storage and token issuance manager for admin authentication.
|
|
193
|
+
Signs sessions using HMAC-SHA256 to ensure tamper-proof, stateless verification
|
|
194
|
+
with bounded TTL-pruned in-memory revocation tracking.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
secret_key: str | None = None,
|
|
200
|
+
config: Config | None = None,
|
|
201
|
+
max_revoked_sessions: int = DEFAULT_MAX_REVOKED_SESSIONS,
|
|
202
|
+
) -> None:
|
|
203
|
+
self._config = config
|
|
204
|
+
self._secret_key = secret_key
|
|
205
|
+
self._ephemeral_secret: str | None = None
|
|
206
|
+
self._max_revoked_sessions = max(1, max_revoked_sessions)
|
|
207
|
+
self._revoked_sessions: dict[str, float] = {}
|
|
208
|
+
|
|
209
|
+
def get_secret_key(self) -> str:
|
|
210
|
+
if self._secret_key and self._secret_key.strip():
|
|
211
|
+
return self._secret_key
|
|
212
|
+
cfg = _resolve_admin_config(self._config)
|
|
213
|
+
key = cfg.get("admin.secret_key") or cfg.get("app.key") if cfg is not None else None
|
|
214
|
+
if key and str(key).strip():
|
|
215
|
+
return str(key)
|
|
216
|
+
if _is_dev_debug_mode(cfg):
|
|
217
|
+
if self._ephemeral_secret is None:
|
|
218
|
+
logger.warning(
|
|
219
|
+
"Admin session secret ('admin.secret_key' or 'app.key') is not configured; "
|
|
220
|
+
"generating an ephemeral key for local debug mode."
|
|
221
|
+
)
|
|
222
|
+
self._ephemeral_secret = secrets.token_hex(32)
|
|
223
|
+
return self._ephemeral_secret
|
|
224
|
+
raise ConfigurationError(
|
|
225
|
+
"Admin session secret key ('admin.secret_key' or 'app.key') is required "
|
|
226
|
+
"outside local debug mode."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _prune_revoked_sessions(self, now: float | None = None) -> None:
|
|
230
|
+
ts = time.time() if now is None else now
|
|
231
|
+
expired = [sid for sid, exp_ts in self._revoked_sessions.items() if exp_ts <= ts]
|
|
232
|
+
for sid in expired:
|
|
233
|
+
self._revoked_sessions.pop(sid, None)
|
|
234
|
+
while len(self._revoked_sessions) > self._max_revoked_sessions:
|
|
235
|
+
oldest_sid = next(iter(self._revoked_sessions))
|
|
236
|
+
self._revoked_sessions.pop(oldest_sid, None)
|
|
237
|
+
|
|
238
|
+
def create_token(self, user: AdminUser, max_age: int = DEFAULT_SESSION_MAX_AGE) -> str:
|
|
239
|
+
"""Create a signed, base64-encoded session token."""
|
|
240
|
+
now = int(time.time())
|
|
241
|
+
payload = {
|
|
242
|
+
"user": user.to_dict(),
|
|
243
|
+
"exp": now + max_age,
|
|
244
|
+
"iat": now,
|
|
245
|
+
}
|
|
246
|
+
payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
247
|
+
payload_b64 = _b64encode(payload_bytes)
|
|
248
|
+
|
|
249
|
+
key = self.get_secret_key().encode("utf-8")
|
|
250
|
+
sig = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
|
|
251
|
+
sig_b64 = _b64encode(sig)
|
|
252
|
+
|
|
253
|
+
return f"{payload_b64}.{sig_b64}"
|
|
254
|
+
|
|
255
|
+
def verify_token(self, token: str) -> AdminUser | None:
|
|
256
|
+
"""Verify signature, expiration, admin status, and session ID of a token."""
|
|
257
|
+
if not token or "." not in token:
|
|
258
|
+
return None
|
|
259
|
+
parts = token.split(".", 1)
|
|
260
|
+
if len(parts) != 2:
|
|
261
|
+
return None
|
|
262
|
+
payload_b64, sig_b64 = parts
|
|
263
|
+
|
|
264
|
+
key = self.get_secret_key().encode("utf-8")
|
|
265
|
+
expected_sig = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
|
|
266
|
+
try:
|
|
267
|
+
provided_sig = _b64decode(sig_b64)
|
|
268
|
+
except (binascii.Error, ValueError):
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
if not hmac.compare_digest(expected_sig, provided_sig):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
payload_bytes = _b64decode(payload_b64)
|
|
276
|
+
data = json.loads(payload_bytes.decode("utf-8"))
|
|
277
|
+
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
if not isinstance(data, dict):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
# Check expiration
|
|
284
|
+
exp = data.get("exp")
|
|
285
|
+
if not isinstance(exp, (int, float)) or time.time() > exp:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
user_data = data.get("user")
|
|
289
|
+
if not isinstance(user_data, dict):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
raw_username = user_data.get("username")
|
|
293
|
+
raw_sid = user_data.get("session_id")
|
|
294
|
+
if not isinstance(raw_username, str) or not raw_username.strip():
|
|
295
|
+
return None
|
|
296
|
+
if not isinstance(raw_sid, str) or not raw_sid.strip():
|
|
297
|
+
return None
|
|
298
|
+
if user_data.get("is_admin") is not True:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
user = AdminUser.from_dict(user_data)
|
|
302
|
+
self._prune_revoked_sessions()
|
|
303
|
+
if user.session_id in self._revoked_sessions:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
return user
|
|
307
|
+
|
|
308
|
+
def revoke_session(self, session_id: str, ttl: int | None = None) -> None:
|
|
309
|
+
"""Revoke an active admin session ID with TTL pruning and bounded retention."""
|
|
310
|
+
if not session_id:
|
|
311
|
+
return
|
|
312
|
+
now = time.time()
|
|
313
|
+
ttl_sec = DEFAULT_SESSION_MAX_AGE if ttl is None else ttl
|
|
314
|
+
self._revoked_sessions[session_id] = now + ttl_sec
|
|
315
|
+
self._prune_revoked_sessions(now)
|
|
316
|
+
|
|
317
|
+
def generate_csrf_token(self, user: AdminUser) -> str:
|
|
318
|
+
"""Generate a cryptographically signed CSRF token bound to the admin session."""
|
|
319
|
+
salt = secrets.token_hex(8)
|
|
320
|
+
msg = f"{user.session_id}:{salt}".encode()
|
|
321
|
+
key = self.get_secret_key().encode()
|
|
322
|
+
sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
|
|
323
|
+
return f"{salt}:{sig}"
|
|
324
|
+
|
|
325
|
+
def verify_csrf_token(self, token: str | None, user: AdminUser) -> bool:
|
|
326
|
+
"""Verify that a CSRF token is authentic and bound to the current admin session."""
|
|
327
|
+
if not token or ":" not in token:
|
|
328
|
+
return False
|
|
329
|
+
parts = token.split(":", 1)
|
|
330
|
+
if len(parts) != 2:
|
|
331
|
+
return False
|
|
332
|
+
salt, provided_sig = parts
|
|
333
|
+
msg = f"{user.session_id}:{salt}".encode()
|
|
334
|
+
key = self.get_secret_key().encode()
|
|
335
|
+
expected_sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
|
|
336
|
+
return hmac.compare_digest(expected_sig, provided_sig)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class AdminAuthenticator:
|
|
340
|
+
"""Validates admin credentials with pluggable custom authenticators."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, config: Config | None = None) -> None:
|
|
343
|
+
self._config = config
|
|
344
|
+
self._custom_auth: Callable[[str, str], bool | AdminUser | None] | None = None
|
|
345
|
+
|
|
346
|
+
def set_authenticator(self, auth_func: Callable[[str, str], bool | AdminUser | None]) -> None:
|
|
347
|
+
"""Register a custom authentication callback: func(username, password) -> bool | AdminUser | None."""
|
|
348
|
+
self._custom_auth = auth_func
|
|
349
|
+
|
|
350
|
+
def authenticate(self, username: str, password: str) -> AdminUser | None:
|
|
351
|
+
"""Verify credentials against custom authenticator or configured defaults."""
|
|
352
|
+
if self._custom_auth is not None:
|
|
353
|
+
res = self._custom_auth(username, password)
|
|
354
|
+
if isinstance(res, AdminUser):
|
|
355
|
+
return res if res.is_admin is True else None
|
|
356
|
+
if res is True:
|
|
357
|
+
return AdminUser(username=username)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
# Resolve configured admin credentials
|
|
361
|
+
cfg = _resolve_admin_config(self._config)
|
|
362
|
+
raw_user = cfg.get("admin.username") if cfg is not None else None
|
|
363
|
+
raw_pass = cfg.get("admin.password") if cfg is not None else None
|
|
364
|
+
|
|
365
|
+
has_custom_user = bool(raw_user and str(raw_user).strip())
|
|
366
|
+
has_custom_pass = bool(raw_pass and str(raw_pass))
|
|
367
|
+
is_default_pair = (
|
|
368
|
+
(not has_custom_user or str(raw_user) == "admin")
|
|
369
|
+
and (not has_custom_pass or str(raw_pass) == "admin123")
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
(not has_custom_user or not has_custom_pass or is_default_pair)
|
|
374
|
+
and not _is_dev_debug_mode(cfg)
|
|
375
|
+
):
|
|
376
|
+
raise ConfigurationError(
|
|
377
|
+
"Default administrative credentials ('admin'/'admin123') are strictly prohibited in production mode "
|
|
378
|
+
"and non-debug environments. Set ADMIN_USERNAME and ADMIN_PASSWORD or configure a custom admin authenticator."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
expected_user = str(raw_user) if has_custom_user else "admin"
|
|
382
|
+
expected_pass = str(raw_pass) if has_custom_pass else "admin123"
|
|
383
|
+
|
|
384
|
+
# Constant-time comparison
|
|
385
|
+
user_ok = hmac.compare_digest(username.strip(), expected_user.strip())
|
|
386
|
+
pass_ok = hmac.compare_digest(password, expected_pass)
|
|
387
|
+
|
|
388
|
+
if user_ok and pass_ok:
|
|
389
|
+
return AdminUser(username=username)
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Global singleton instances
|
|
394
|
+
_admin_session_store = AdminSessionStore()
|
|
395
|
+
_admin_authenticator = AdminAuthenticator()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_admin_session_store() -> AdminSessionStore:
|
|
399
|
+
return _admin_session_store
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_admin_authenticator() -> AdminAuthenticator:
|
|
403
|
+
return _admin_authenticator
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def create_admin_session(
|
|
407
|
+
response: Response,
|
|
408
|
+
user: AdminUser,
|
|
409
|
+
max_age: int = DEFAULT_SESSION_MAX_AGE,
|
|
410
|
+
*,
|
|
411
|
+
prefix: str = "/admin",
|
|
412
|
+
request: Request | None = None,
|
|
413
|
+
secure: bool | None = None,
|
|
414
|
+
) -> str:
|
|
415
|
+
"""Create a signed admin session token and attach it as a Set-Cookie header."""
|
|
416
|
+
store = get_admin_session_store()
|
|
417
|
+
token = store.create_token(user, max_age=max_age)
|
|
418
|
+
cookie_path = _normalize_cookie_path(prefix)
|
|
419
|
+
cookie_parts = [
|
|
420
|
+
f"{ADMIN_COOKIE_NAME}={token}",
|
|
421
|
+
f"Path={cookie_path}",
|
|
422
|
+
f"Max-Age={max_age}",
|
|
423
|
+
"HttpOnly",
|
|
424
|
+
"SameSite=Lax",
|
|
425
|
+
]
|
|
426
|
+
if _should_use_secure_cookie(request=request, secure=secure):
|
|
427
|
+
cookie_parts.append("Secure")
|
|
428
|
+
response.set_cookie("; ".join(cookie_parts))
|
|
429
|
+
return token
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def clear_admin_session(
|
|
433
|
+
response: Response,
|
|
434
|
+
request: Request | None = None,
|
|
435
|
+
*,
|
|
436
|
+
prefix: str = "/admin",
|
|
437
|
+
secure: bool | None = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Clear the admin session cookie and revoke the session in store."""
|
|
440
|
+
if request is not None:
|
|
441
|
+
user = get_current_admin(request)
|
|
442
|
+
if user is not None:
|
|
443
|
+
store = get_admin_session_store()
|
|
444
|
+
store.revoke_session(user.session_id)
|
|
445
|
+
Context.set(_CONTEXT_ADMIN_KEY, None)
|
|
446
|
+
if hasattr(request, "state"):
|
|
447
|
+
request.state.pop("admin_user", None)
|
|
448
|
+
|
|
449
|
+
cookie_path = _normalize_cookie_path(prefix)
|
|
450
|
+
cookie_parts = [
|
|
451
|
+
f"{ADMIN_COOKIE_NAME}=",
|
|
452
|
+
f"Path={cookie_path}",
|
|
453
|
+
"Max-Age=0",
|
|
454
|
+
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
|
455
|
+
"HttpOnly",
|
|
456
|
+
"SameSite=Lax",
|
|
457
|
+
]
|
|
458
|
+
if _should_use_secure_cookie(request=request, secure=secure):
|
|
459
|
+
cookie_parts.append("Secure")
|
|
460
|
+
response.set_cookie("; ".join(cookie_parts))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def get_current_admin(request: Request) -> AdminUser | None:
|
|
464
|
+
"""
|
|
465
|
+
Extract and verify the admin session from request cookies.
|
|
466
|
+
Attaches authenticated AdminUser to request.state and task Context.
|
|
467
|
+
"""
|
|
468
|
+
# Check if already cached on this request
|
|
469
|
+
cached = getattr(request, "state", {}).get("admin_user")
|
|
470
|
+
if isinstance(cached, AdminUser):
|
|
471
|
+
return cached if cached.is_admin is True else None
|
|
472
|
+
|
|
473
|
+
cookie_header = request.headers.get("cookie")
|
|
474
|
+
cookies = parse_cookie_header(cookie_header)
|
|
475
|
+
token = cookies.get(ADMIN_COOKIE_NAME)
|
|
476
|
+
if not token:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
store = get_admin_session_store()
|
|
480
|
+
admin_user = store.verify_token(token)
|
|
481
|
+
if admin_user is not None and admin_user.is_admin is True:
|
|
482
|
+
request.state["admin_user"] = admin_user
|
|
483
|
+
Context.set(_CONTEXT_ADMIN_KEY, admin_user.to_dict())
|
|
484
|
+
return admin_user
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def require_admin(request: Request) -> AdminUser:
|
|
489
|
+
"""
|
|
490
|
+
Enforce authenticated admin session.
|
|
491
|
+
Returns the authenticated AdminUser or raises AdminUnauthorizedError.
|
|
492
|
+
"""
|
|
493
|
+
user = get_current_admin(request)
|
|
494
|
+
if user is None or user.is_admin is not True:
|
|
495
|
+
raise AdminUnauthorizedError("Admin session required. Please sign in.")
|
|
496
|
+
return user
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def current_admin() -> AdminUser | None:
|
|
500
|
+
"""Retrieve the current admin user from task-scoped Context."""
|
|
501
|
+
raw = Context.get(_CONTEXT_ADMIN_KEY)
|
|
502
|
+
if isinstance(raw, dict):
|
|
503
|
+
user = AdminUser.from_dict(raw)
|
|
504
|
+
return user if user.is_admin is True else None
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
__all__ = [
|
|
509
|
+
"ADMIN_COOKIE_NAME",
|
|
510
|
+
"AdminAuthenticator",
|
|
511
|
+
"AdminSessionStore",
|
|
512
|
+
"AdminUnauthorizedError",
|
|
513
|
+
"AdminUser",
|
|
514
|
+
"clear_admin_session",
|
|
515
|
+
"create_admin_session",
|
|
516
|
+
"current_admin",
|
|
517
|
+
"get_admin_authenticator",
|
|
518
|
+
"get_admin_session_store",
|
|
519
|
+
"get_current_admin",
|
|
520
|
+
"parse_cookie_header",
|
|
521
|
+
"require_admin",
|
|
522
|
+
]
|