Mass ruff auto-fix:
- ruff check --fix: 109 issues fixed (F401 unused imports,
I001 unsorted imports, UP037 quoted annotations, SIM105
suppressible exception, RUF100 unused-noqa)
- ruff check --fix --unsafe-fixes: 22 additional issues
- ruff format: 70 files reformatted
- Manual pass: fix 16 misplaced import httpx lines
- Manual pass: fix remaining E402 (import-after-docstring)
Result: 283 errors -> 30 errors.
The remaining 30 are real issues that need manual review:
5 F401 unused-import (likely auto-generated stubs)
5 F821 undefined-name (real bugs in code that references
redis/pydantic/LLMRegistry without imports)
3 BLE001 (the compliance LLM fallback is intentional; the
other two are real)
3 RUF012 mutable-class-default
3 SIM105, 3 SIM117, 2 E722, 2 E741
1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)
Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
"""Pry — Authentication with JWT and API keys, per-key rate limiting."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Try to import JWT library
|
|
try:
|
|
import jwt
|
|
|
|
_has_jwt = True
|
|
except ImportError:
|
|
_has_jwt = False
|
|
|
|
# Configuration
|
|
# JWT secret: prefer gopass (PRY_SECRET_BACKEND=gopass), fall back to env, then to a
|
|
# random ephemeral default. The default is intentionally NOT a fixed string so that
|
|
# an unset JWT_SECRET cannot accidentally sign tokens in a way that survives restart.
|
|
try:
|
|
from secrets_backend import get_secret
|
|
|
|
JWT_SECRET = (
|
|
get_secret("jwt_secret")
|
|
or os.getenv("PRY_JWT_SECRET")
|
|
or ("ephemeral-" + secrets.token_hex(32))
|
|
)
|
|
except ImportError:
|
|
JWT_SECRET = os.getenv("PRY_JWT_SECRET") or ("ephemeral-" + secrets.token_hex(32))
|
|
JWT_ALGORITHM = "HS256"
|
|
JWT_EXPIRY_HOURS = 24
|
|
API_KEY_LENGTH = 32
|
|
DEFAULT_RATE_LIMIT_RPM = 60
|
|
|
|
|
|
class AuthManager:
|
|
"""Manage users, API keys, and rate limiting."""
|
|
|
|
def __init__(self, storage: Any = None):
|
|
self._storage = storage # Will be set when DB is ready
|
|
self._rate_limits: dict[str, dict[str, Any]] = {}
|
|
self._users: dict[str, dict[str, Any]] = {}
|
|
self._api_keys: dict[str, dict[str, Any]] = {}
|
|
|
|
def hash_password(self, password: str, salt: str | None = None) -> tuple[str, str]:
|
|
if salt is None:
|
|
salt = secrets.token_hex(16)
|
|
h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100000)
|
|
return h.hex(), salt
|
|
|
|
def verify_password(self, password: str, hashed: str, salt: str) -> bool:
|
|
h, _ = self.hash_password(password, salt)
|
|
return hmac.compare_digest(h, hashed)
|
|
|
|
def create_user(self, email: str, password: str, role: str = "user") -> dict[str, Any]:
|
|
user_id = secrets.token_hex(12)
|
|
pwd_hash, salt = self.hash_password(password)
|
|
user = {
|
|
"id": user_id,
|
|
"email": email,
|
|
"password_hash": pwd_hash,
|
|
"salt": salt,
|
|
"role": role,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"active": True,
|
|
"api_keys": [],
|
|
}
|
|
self._users[user_id] = user
|
|
return user
|
|
|
|
def create_api_key(
|
|
self, user_id: str, name: str = "default", rate_limit_rpm: int = DEFAULT_RATE_LIMIT_RPM
|
|
) -> str:
|
|
key = "pry_" + secrets.token_urlsafe(API_KEY_LENGTH)
|
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
|
api_key = {
|
|
"key_hash": key_hash,
|
|
"user_id": user_id,
|
|
"name": name,
|
|
"rate_limit_rpm": rate_limit_rpm,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"last_used": None,
|
|
"use_count": 0,
|
|
}
|
|
self._api_keys[key_hash] = api_key
|
|
if user_id in self._users:
|
|
self._users[user_id]["api_keys"].append(key_hash)
|
|
return key
|
|
|
|
def verify_api_key(self, key: str) -> dict[str, Any] | None:
|
|
if not key or not key.startswith("pry_"):
|
|
return None
|
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
|
api_key = self._api_keys.get(key_hash)
|
|
if api_key:
|
|
api_key["last_used"] = datetime.now(UTC).isoformat()
|
|
api_key["use_count"] = api_key.get("use_count", 0) + 1
|
|
return api_key
|
|
|
|
def check_rate_limit(self, api_key: str) -> tuple[bool, int]:
|
|
if not api_key:
|
|
return True, 0
|
|
now = time.time()
|
|
rl = self._rate_limits.setdefault(api_key, {"window_start": now, "count": 0})
|
|
if now - rl["window_start"] > 60:
|
|
rl["window_start"] = now
|
|
rl["count"] = 0
|
|
rl["count"] += 1
|
|
api_key_data = self.verify_api_key(api_key)
|
|
limit = (
|
|
api_key_data.get("rate_limit_rpm", DEFAULT_RATE_LIMIT_RPM)
|
|
if api_key_data
|
|
else DEFAULT_RATE_LIMIT_RPM
|
|
)
|
|
remaining = max(0, limit - rl["count"])
|
|
return rl["count"] <= limit, remaining
|
|
|
|
def create_jwt(self, user_id: str, role: str = "user") -> str:
|
|
if not _has_jwt:
|
|
# Fallback: simple base64 token (NOT for production)
|
|
payload = {"sub": user_id, "role": role, "exp": time.time() + JWT_EXPIRY_HOURS * 3600}
|
|
return "pry_jwt." + base64_encode(json.dumps(payload))
|
|
payload = {
|
|
"sub": user_id,
|
|
"role": role,
|
|
"exp": datetime.now(UTC) + timedelta(hours=JWT_EXPIRY_HOURS),
|
|
}
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
def verify_jwt(self, token: str) -> dict[str, Any] | None:
|
|
if not _has_jwt:
|
|
try:
|
|
if not token.startswith("pry_jwt."):
|
|
return None
|
|
payload = json.loads(base64_decode(token[8:]))
|
|
if payload.get("exp", 0) < time.time():
|
|
return None
|
|
return payload
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
try:
|
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
|
|
|
|
def base64_encode(s: str) -> str:
|
|
import base64
|
|
|
|
return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=")
|
|
|
|
|
|
def base64_decode(s: str) -> str:
|
|
import base64
|
|
|
|
padding = 4 - len(s) % 4
|
|
if padding != 4:
|
|
s += "=" * padding
|
|
return base64.urlsafe_b64decode(s.encode()).decode()
|