نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Cross-cutting concerns: configuration, database, security, crypto, errors."""
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Application configuration.
|
||||
|
||||
All external providers default to ``mock`` so the whole stack runs with zero
|
||||
credentials. Real adapters stay behind the ``*_PROVIDER`` switches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# web/apps/api/app/core/config.py -> web/
|
||||
WEB_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
#: Values that are obviously placeholders. Never acceptable in production.
|
||||
_INSECURE_SECRETS = frozenset(
|
||||
{"", "change-me", "changeme", "secret", "test", "dev", "password"}
|
||||
)
|
||||
#: HS256 signs with a 256-bit key; anything shorter weakens the signature.
|
||||
MIN_SECRET_LENGTH = 32
|
||||
|
||||
#: The development CORS allowlist. ``localhost`` and ``127.0.0.1`` are distinct
|
||||
#: browser origins, so both spellings are listed; leaving one out makes the dev
|
||||
#: UI fail with an opaque "Disallowed CORS origin".
|
||||
DEVELOPMENT_CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(WEB_ROOT / ".env", WEB_ROOT / ".env.local"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# --- app -----------------------------------------------------------------
|
||||
app_env: str = "development"
|
||||
app_name: str = "AGY Web"
|
||||
app_url: str = "http://localhost:3000"
|
||||
api_url: str = "http://localhost:8000"
|
||||
#: Both loopback spellings, on purpose. ``localhost`` and ``127.0.0.1`` are
|
||||
#: *different origins* to a browser, so listing only one produces a bare
|
||||
#: "Disallowed CORS origin" with nothing to hint at the cause — the classic
|
||||
#: failure being a dev server started with ``-H 127.0.0.1``. Override with
|
||||
#: ``CORS_ORIGINS``; production must set it explicitly (a wildcard is
|
||||
#: refused outright by the validator below).
|
||||
cors_origins: str = DEVELOPMENT_CORS_ORIGINS
|
||||
|
||||
# --- data stores ---------------------------------------------------------
|
||||
database_url: str = "sqlite+aiosqlite:///./agy_web.db"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# --- security ------------------------------------------------------------
|
||||
jwt_secret: str = "change-me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_minutes: int = 60 * 24
|
||||
encryption_key: str = ""
|
||||
|
||||
# --- AI models (hidden from customers) -----------------------------------
|
||||
ai_default_provider: str = "mock"
|
||||
openai_compatible_base_url: str = ""
|
||||
openai_compatible_api_key: str = ""
|
||||
|
||||
# --- git -----------------------------------------------------------------
|
||||
git_provider: str = "mock"
|
||||
gitea_url: str = ""
|
||||
gitea_internal_url: str = ""
|
||||
gitea_admin_user: str = "root"
|
||||
gitea_admin_token: str = ""
|
||||
|
||||
# --- preview domain routing ----------------------------------------------
|
||||
domain_router: str = "mock"
|
||||
preview_root_domain: str = "msa.artacloud.ir"
|
||||
preview_port_start: int = 8080
|
||||
preview_port_end: int = 8999
|
||||
caddyfile_path: str = "/etc/caddy/Caddyfile"
|
||||
max_subdomains_per_project: int = 5
|
||||
|
||||
# --- sandbox -------------------------------------------------------------
|
||||
sandbox_provider: str = "mock"
|
||||
daytona_api_key: str = ""
|
||||
daytona_api_url: str = ""
|
||||
e2b_api_key: str = ""
|
||||
sandbox_command_timeout_seconds: int = 120
|
||||
sandbox_max_output_bytes: int = 200_000
|
||||
|
||||
# --- payments ------------------------------------------------------------
|
||||
payment_provider: str = "mock"
|
||||
stripe_secret_key: str = ""
|
||||
stripe_webhook_secret: str = ""
|
||||
|
||||
# --- error logging -------------------------------------------------------
|
||||
#: Master switch. When off, failures are still logged to stderr but nothing
|
||||
#: is written to ``error_events``.
|
||||
error_log_enabled: bool = True
|
||||
#: Whether 4xx responses are persisted too. Off by default: a public API
|
||||
#: takes a constant stream of 404s from scanners, and recording them all
|
||||
#: buries the genuine bugs. Turn it on to debug a misbehaving client.
|
||||
#: 5xx responses are recorded regardless of this setting.
|
||||
error_log_client_errors: bool = False
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() in {"production", "prod"}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _refuse_insecure_production_secrets(self) -> Settings:
|
||||
"""Fail closed instead of booting a production API with a weak secret.
|
||||
|
||||
A short or placeholder ``JWT_SECRET`` lets anyone mint a token for any
|
||||
tenant — the single worst failure mode this service has. Development
|
||||
keeps working with the documented default; production must be explicit.
|
||||
"""
|
||||
if not self.is_production:
|
||||
return self
|
||||
problems: list[str] = []
|
||||
if self.jwt_secret.strip().lower() in _INSECURE_SECRETS:
|
||||
problems.append("JWT_SECRET is still the placeholder value")
|
||||
elif len(self.jwt_secret) < MIN_SECRET_LENGTH:
|
||||
problems.append(
|
||||
f"JWT_SECRET is {len(self.jwt_secret)} chars; "
|
||||
f"at least {MIN_SECRET_LENGTH} are required"
|
||||
)
|
||||
if not self.encryption_key.strip():
|
||||
problems.append("ENCRYPTION_KEY is empty; stored credentials cannot be encrypted")
|
||||
if self.cors_origins.strip() == "*":
|
||||
problems.append("CORS_ORIGINS must not be '*' in production")
|
||||
elif self.cors_origins.strip() == DEVELOPMENT_CORS_ORIGINS:
|
||||
problems.append(
|
||||
"CORS_ORIGINS is still the development default; "
|
||||
"set it to the real frontend origin"
|
||||
)
|
||||
if problems:
|
||||
raise ValueError(
|
||||
"Unsafe production configuration: " + "; ".join(problems) + "."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Symmetric encryption for secrets at rest (provider API keys, git credentials).
|
||||
|
||||
Fernet (AES-128-CBC + HMAC) with a key derived from ``ENCRYPTION_KEY``. The
|
||||
derivation is deterministic so a rotated key invalidates stored ciphertexts
|
||||
loudly instead of silently returning garbage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
def _derive_key(raw: str) -> bytes:
|
||||
"""Fernet needs exactly 32 url-safe base64 bytes."""
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _cipher() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
material = settings.encryption_key.strip() or settings.jwt_secret
|
||||
_fernet = Fernet(_derive_key(material))
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _cipher().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
try:
|
||||
return _cipher().decrypt(ciphertext.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise AppError(
|
||||
"رمزنگاری ناموفق بود؛ ممکن است ENCRYPTION_KEY تغییر کرده باشد."
|
||||
) from exc
|
||||
|
||||
|
||||
def mask(secret: str | None, keep: int = 4) -> str:
|
||||
"""Safe display form: ``••••1234``. Never returns the full secret."""
|
||||
if not secret:
|
||||
return ""
|
||||
tail = secret[-keep:] if len(secret) > keep else ""
|
||||
return "•" * 6 + tail
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Async SQLAlchemy engine, session factory and declarative base.
|
||||
|
||||
The ORM lives in ``infrastructure``; ``domain`` never imports this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for every ORM table."""
|
||||
|
||||
|
||||
def _engine_kwargs() -> dict:
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
return {"echo": False, "future": True}
|
||||
return {
|
||||
"echo": False,
|
||||
"future": True,
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
}
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, **_engine_kwargs())
|
||||
|
||||
SessionFactory = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency yielding one transactional session per request."""
|
||||
async with SessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Domain-level errors and their HTTP mapping.
|
||||
|
||||
Domain and application layers raise these; only ``interfaces/http`` knows about
|
||||
status codes. That keeps FastAPI out of the inner layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base class for every expected, user-facing failure."""
|
||||
|
||||
status_code = 400
|
||||
code = "app_error"
|
||||
|
||||
def __init__(self, message: str, *, details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 422
|
||||
code = "validation_error"
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
status_code = 401
|
||||
code = "authentication_error"
|
||||
|
||||
|
||||
class PermissionDeniedError(AppError):
|
||||
status_code = 403
|
||||
code = "permission_denied"
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
code = "not_found"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
code = "conflict"
|
||||
|
||||
|
||||
class QuotaExceededError(AppError):
|
||||
status_code = 402
|
||||
code = "quota_exceeded"
|
||||
|
||||
|
||||
class ProviderUnavailableError(AppError):
|
||||
status_code = 503
|
||||
code = "provider_unavailable"
|
||||
|
||||
|
||||
class ApprovalRequiredError(AppError):
|
||||
"""A dangerous operation was attempted without an approval record."""
|
||||
|
||||
status_code = 409
|
||||
code = "approval_required"
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Scrub secrets out of anything before it is persisted.
|
||||
|
||||
This module exists because of one hard rule: **an error log that stores a
|
||||
password is worse than no error log at all.** Error records are written from
|
||||
inside exception handlers, where the values at hand are exactly the values most
|
||||
likely to carry credentials — request bodies, ``Authorization`` headers, DSNs,
|
||||
provider API keys, decrypted git tokens. Anything that reaches the database must
|
||||
pass through here first.
|
||||
|
||||
Two independent layers, because either one alone leaks:
|
||||
|
||||
1. **Structural** — a key that *means* a secret (``password``, ``api_key``,
|
||||
``refresh_token``, …) has its whole value replaced, whatever the value looks
|
||||
like. This catches secrets in shapes no pattern would recognise.
|
||||
2. **Lexical** — value patterns that are recognisable regardless of their key:
|
||||
PEM blocks, JWTs, ``scheme://user:pass@host`` URLs, ``Bearer`` headers,
|
||||
``sk-…``/``ghp_…``/``AKIA…`` provider keys, and ``password=…`` fragments
|
||||
embedded in a larger string.
|
||||
|
||||
On top of both sits a third net: the **configured secret values themselves**
|
||||
(``JWT_SECRET``, ``ENCRYPTION_KEY``, every ``*_API_KEY``/``*_TOKEN``) are
|
||||
replaced literally wherever they appear. A secret that is only ever compared by
|
||||
pattern will eventually be logged verbatim; a secret that is matched by value
|
||||
cannot be.
|
||||
|
||||
Deliberately dependency-free and framework-free: the recorder, the HTTP layer
|
||||
and the workers all import it, and it must never fail — a redactor that raises
|
||||
inside an exception handler turns a 500 into a hang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
#: What replaces a scrubbed value. Deliberately obvious in a log so a reader can
|
||||
#: tell "we removed this" from "this was empty".
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
#: Longest string we will hand to the regex engine. Error payloads can contain
|
||||
#: multi-megabyte blobs; redaction cost must stay bounded and predictable.
|
||||
MAX_INPUT_CHARS = 64_000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 1 — structural: keys that mean "secret"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Exact key names (after normalisation) whose value is never safe to store.
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"client_secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"auth",
|
||||
"credentials",
|
||||
"credential",
|
||||
"private_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"session",
|
||||
"session_id",
|
||||
"session_token",
|
||||
"cookie",
|
||||
"set_cookie",
|
||||
"jwt",
|
||||
"bearer",
|
||||
"encryption_key",
|
||||
"jwt_secret",
|
||||
"signing_key",
|
||||
"webhook_secret",
|
||||
"signature",
|
||||
}
|
||||
)
|
||||
|
||||
#: Suffixes that make a key sensitive however it is prefixed: ``db_password``,
|
||||
#: ``gitea_admin_token``, ``stripe_secret_key``, ``openai_api_key``.
|
||||
#:
|
||||
#: Suffix — not substring — matching on purpose. ``token_count`` and
|
||||
#: ``input_tokens`` are *usage* fields, and redacting them would silently
|
||||
#: destroy the billing data these same records are meant to help debug.
|
||||
_SENSITIVE_SUFFIXES = (
|
||||
"_password",
|
||||
"_passwd",
|
||||
"_secret",
|
||||
"_secret_key",
|
||||
"_token",
|
||||
"_api_key",
|
||||
"_apikey",
|
||||
"_private_key",
|
||||
"_credential",
|
||||
"_credentials",
|
||||
)
|
||||
|
||||
#: Keys that look sensitive by suffix rule but are known-safe counters.
|
||||
_SAFE_KEYS = frozenset(
|
||||
{
|
||||
"token_count",
|
||||
"token_limit",
|
||||
"tokens",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"max_tokens",
|
||||
"cached_tokens",
|
||||
"secret_count",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALISE = re.compile(r"[^a-z0-9]+")
|
||||
#: Splits camelCase so ``refreshToken`` and ``apiKey`` normalise the same way as
|
||||
#: their snake_case spellings, instead of collapsing to one unguessable word.
|
||||
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
|
||||
|
||||
def _normalise_key(key: str) -> str:
|
||||
"""``X-API-Key`` / ``apiKey`` / ``api-key`` all become ``api_key``."""
|
||||
spaced = _CAMEL_BOUNDARY.sub("_", key.strip())
|
||||
return _NORMALISE.sub("_", spaced.lower()).strip("_")
|
||||
|
||||
|
||||
def is_sensitive_key(key: str) -> bool:
|
||||
"""True when a value stored under ``key`` must be replaced wholesale."""
|
||||
normalised = _normalise_key(key)
|
||||
if not normalised or normalised in _SAFE_KEYS:
|
||||
return False
|
||||
if normalised in _SENSITIVE_KEYS:
|
||||
return True
|
||||
return normalised.endswith(_SENSITIVE_SUFFIXES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 2 — lexical: values that are secrets whatever their key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# PEM / OpenSSH private keys, possibly multi-line.
|
||||
(
|
||||
re.compile(
|
||||
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
"[REDACTED_PRIVATE_KEY]",
|
||||
),
|
||||
# scheme://user:password@host — DSNs and git remotes. Keeps the scheme and
|
||||
# the host so the record stays useful; drops the userinfo.
|
||||
(
|
||||
re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"),
|
||||
r"\g<scheme>[REDACTED]@",
|
||||
),
|
||||
# "Authorization: Bearer eyJ…" and friends. Keeps the scheme.
|
||||
(
|
||||
re.compile(r"(?i)\b(bearer|basic|token|apikey)\s+[A-Za-z0-9\-._~+/=]{8,}"),
|
||||
r"\1 [REDACTED]",
|
||||
),
|
||||
# JSON Web Tokens: three base64url segments, always starting "eyJ".
|
||||
(
|
||||
re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"),
|
||||
"[REDACTED_JWT]",
|
||||
),
|
||||
# Provider key shapes worth naming individually.
|
||||
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bya29\.[0-9A-Za-z_\-]+\b"), "[REDACTED_KEY]"),
|
||||
# "password=hunter2", '"api_key": "…"', "secret: abc" inside a longer text.
|
||||
# The value is a quoted run, a scheme-prefixed token ("Bearer eyJ…"), or a
|
||||
# bare run up to the next delimiter.
|
||||
(
|
||||
re.compile(
|
||||
r"(?i)\b(password|passwd|pwd|secret|token|api[_\-]?key|client[_\-]?secret"
|
||||
r"|access[_\-]?token|refresh[_\-]?token|authorization|private[_\-]?key)"
|
||||
r"\b(\s*[:=]\s*)"
|
||||
r"(\"[^\"]{0,512}\""
|
||||
r"|'[^']{0,512}'"
|
||||
r"|(?:bearer|basic|token|apikey)\s+\S{1,512}"
|
||||
r"|[^\s,;&)\]}\"']{1,512})"
|
||||
),
|
||||
r"\1\2[REDACTED]",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configured_secret_values() -> tuple[str, ...]:
|
||||
"""Literal secrets from settings, longest first.
|
||||
|
||||
Read lazily on every call rather than cached at import: the test suite
|
||||
rewrites ``settings.jwt_secret`` in a fixture, and a stale cache would keep
|
||||
the old value while the new one leaked.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
candidates = (
|
||||
settings.jwt_secret,
|
||||
settings.encryption_key,
|
||||
settings.openai_compatible_api_key,
|
||||
settings.gitea_admin_token,
|
||||
settings.daytona_api_key,
|
||||
settings.e2b_api_key,
|
||||
settings.stripe_secret_key,
|
||||
settings.stripe_webhook_secret,
|
||||
)
|
||||
# Short strings and documented placeholders are not secrets. Replacing
|
||||
# "change-me" would garble messages for no security gain.
|
||||
placeholders = {"change-me", "changeme", "secret", "test", "dev", "password", "none"}
|
||||
usable = {
|
||||
value
|
||||
for value in candidates
|
||||
if isinstance(value, str) and len(value) >= 8 and value.lower() not in placeholders
|
||||
}
|
||||
return tuple(sorted(usable, key=len, reverse=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
"""Scrub secrets from a free-form string. Never raises."""
|
||||
try:
|
||||
text = value if len(value) <= MAX_INPUT_CHARS else value[:MAX_INPUT_CHARS] + "…[truncated]"
|
||||
for secret in _configured_secret_values():
|
||||
if secret in text:
|
||||
text = text.replace(secret, REDACTED)
|
||||
for pattern, replacement in _PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
except Exception: # pragma: no cover - redaction must never break a handler
|
||||
# If a pattern ever explodes, drop the text rather than the request.
|
||||
return REDACTED
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
"""Recursively scrub an arbitrary JSON-ish value. Never raises."""
|
||||
try:
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return f"<{len(bytes(value))} bytes>"
|
||||
if isinstance(value, Mapping):
|
||||
return redact_mapping(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [redact(item) for item in value]
|
||||
return redact_text(str(value))
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return REDACTED
|
||||
|
||||
|
||||
def redact_mapping(data: Mapping[Any, Any] | None) -> dict[str, Any]:
|
||||
"""Scrub a mapping, replacing any sensitive key's value outright."""
|
||||
if not data:
|
||||
return {}
|
||||
try:
|
||||
result: dict[str, Any] = {}
|
||||
for key, item in data.items():
|
||||
name = str(key)
|
||||
result[name] = REDACTED if is_sensitive_key(name) else redact(item)
|
||||
return result
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return {"_redaction_failed": True}
|
||||
|
||||
|
||||
def redact_headers(headers: Mapping[Any, Any] | None) -> dict[str, str]:
|
||||
"""Scrub HTTP headers, keeping the shape of the request for debugging.
|
||||
|
||||
Header names are normalised to lower-case; ``authorization``, ``cookie`` and
|
||||
``x-api-key`` fall out as sensitive through the same key rules as JSON.
|
||||
"""
|
||||
if not headers:
|
||||
return {}
|
||||
try:
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
name = str(key).lower()
|
||||
result[name] = REDACTED if is_sensitive_key(name) else str(redact(value))
|
||||
return result
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return {}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Password hashing and JWT issuance/verification.
|
||||
|
||||
Argon2id for passwords, HS256 JWTs for sessions. No secret ever leaves this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthenticationError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHashError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except (InvalidHashError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def create_access_token(user_id: str, *, extra: dict[str, Any] | None = None) -> str:
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.access_token_ttl_minutes)).timestamp()),
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AuthenticationError("نشست منقضی شده است.") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthenticationError("توکن نامعتبر است.") from exc
|
||||
|
||||
|
||||
def generate_token(nbytes: int = 32) -> str:
|
||||
"""Opaque token for invites / one-time links."""
|
||||
return secrets.token_urlsafe(nbytes)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Invite tokens are stored hashed, never in plaintext."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
Reference in New Issue
Block a user