Checkpoint React frontend migration
This commit is contained in:
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Core configuration and database utilities.
|
||||
"""
|
||||
170
backend/app/core/auth.py
Normal file
170
backend/app/core/auth.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Token-based authentication helpers for website-scoped access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
website_id: Optional[int]
|
||||
role: str
|
||||
wp_user_id: Optional[str] = None
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(raw: str) -> bytes:
|
||||
padding = "=" * (-len(raw) % 4)
|
||||
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
|
||||
|
||||
|
||||
def issue_access_token(
|
||||
website_id: int | None,
|
||||
role: str = "student",
|
||||
wp_user_id: str | None = None,
|
||||
expires_in_seconds: int = 3600,
|
||||
) -> str:
|
||||
payload = {
|
||||
"website_id": int(website_id) if website_id is not None else None,
|
||||
"role": role,
|
||||
"wp_user_id": wp_user_id,
|
||||
"exp": int(time.time()) + int(expires_in_seconds),
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{payload_b64}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AuthContext:
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token format",
|
||||
) from exc
|
||||
|
||||
expected_sig = hmac.new(
|
||||
settings.SECRET_KEY.encode("utf-8"),
|
||||
payload_b64.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
provided_sig = _b64url_decode(sig_b64)
|
||||
if not hmac.compare_digest(provided_sig, expected_sig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token signature",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token payload",
|
||||
) from exc
|
||||
|
||||
exp = int(payload.get("exp", 0))
|
||||
if exp <= int(time.time()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token has expired",
|
||||
)
|
||||
|
||||
website_id = payload.get("website_id")
|
||||
role = payload.get("role")
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing required claims",
|
||||
)
|
||||
if website_id is None and role != "system_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing website scope",
|
||||
)
|
||||
|
||||
return AuthContext(
|
||||
website_id=int(website_id) if website_id is not None else None,
|
||||
role=str(role),
|
||||
wp_user_id=payload.get("wp_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
x_website_id: str | None = Header(None, alias="X-Website-ID"),
|
||||
) -> AuthContext:
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
|
||||
context = decode_access_token(parts[1])
|
||||
|
||||
# If system_admin explicitly sets a website context via header, use it
|
||||
if context.role == "system_admin" and x_website_id and x_website_id.isdigit():
|
||||
context.website_id = int(x_website_id)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def require_website_auth(
|
||||
auth: AuthContext,
|
||||
allowed_roles: set[str] | None = None,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Check if the authenticated user has required roles.
|
||||
Returns the website_id if scoped to a specific website.
|
||||
Returns None if the user is a system_admin with global access and no specific website context.
|
||||
"""
|
||||
if allowed_roles is not None and auth.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this endpoint",
|
||||
)
|
||||
|
||||
if auth.role == "system_admin":
|
||||
if auth.website_id is not None:
|
||||
return auth.website_id
|
||||
return None
|
||||
|
||||
return auth.website_id
|
||||
|
||||
|
||||
def ensure_website_scope_matches(
|
||||
auth_website_id: int | None,
|
||||
payload_website_id: int,
|
||||
) -> None:
|
||||
if auth_website_id is None:
|
||||
return
|
||||
if int(auth_website_id) != int(payload_website_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="website_id in payload must match authenticated website scope",
|
||||
)
|
||||
151
backend/app/core/config.py
Normal file
151
backend/app/core/config.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Application configuration using Pydantic Settings.
|
||||
|
||||
Loads configuration from environment variables with validation.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal, List, Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal",
|
||||
description="PostgreSQL database URL with asyncpg driver",
|
||||
)
|
||||
|
||||
# FastAPI
|
||||
SECRET_KEY: str = Field(
|
||||
default="dev-secret-key-change-in-production",
|
||||
description="Secret key for JWT token signing",
|
||||
)
|
||||
API_V1_STR: str = Field(default="/api/v1", description="API v1 prefix")
|
||||
PROJECT_NAME: str = Field(default="IRT Bank Soal", description="Project name")
|
||||
ENVIRONMENT: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="Environment name"
|
||||
)
|
||||
ENABLE_ADMIN: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin UI and admin-only API routes",
|
||||
)
|
||||
ADMIN_USERNAME: str = Field(
|
||||
default="",
|
||||
description="Admin panel username",
|
||||
)
|
||||
ADMIN_PASSWORD: str = Field(
|
||||
default="",
|
||||
description="Admin panel password (plain env value)",
|
||||
)
|
||||
ADMIN_SESSION_EXPIRE_SECONDS: int = Field(
|
||||
default=3600,
|
||||
description="Admin session lifetime in seconds",
|
||||
)
|
||||
|
||||
# OpenRouter (AI Generation)
|
||||
OPENROUTER_API_KEY: str = Field(
|
||||
default="", description="OpenRouter API key for AI generation"
|
||||
)
|
||||
OPENROUTER_MODEL_QWEN: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="Balanced Qwen model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_CHEAP: str = Field(
|
||||
default="mistralai/mistral-small-2603",
|
||||
description="Low-cost model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_LLAMA: str = Field(
|
||||
default="meta-llama/llama-3.3-70b-instruct",
|
||||
description="Premium Llama model identifier",
|
||||
)
|
||||
OPENROUTER_TIMEOUT: int = Field(default=30, description="OpenRouter API timeout in seconds")
|
||||
OPENROUTER_PROVIDER_ORDER: List[str] = Field(
|
||||
default=["NovitaAI", "AkashML", "Inception"],
|
||||
description="Preferred OpenRouter providers in priority order",
|
||||
)
|
||||
OPENROUTER_ALLOW_PROVIDER_FALLBACKS: bool = Field(
|
||||
default=True,
|
||||
description="Allow OpenRouter to fallback outside preferred providers",
|
||||
)
|
||||
|
||||
# WordPress Integration
|
||||
WORDPRESS_API_URL: str = Field(
|
||||
default="", description="WordPress REST API base URL"
|
||||
)
|
||||
WORDPRESS_AUTH_TOKEN: str = Field(
|
||||
default="", description="WordPress JWT authentication token"
|
||||
)
|
||||
|
||||
# Redis (Celery)
|
||||
REDIS_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Redis connection URL"
|
||||
)
|
||||
CELERY_BROKER_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery broker URL"
|
||||
)
|
||||
CELERY_RESULT_BACKEND: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery result backend URL"
|
||||
)
|
||||
|
||||
# CORS - stored as list, accepts comma-separated string from env
|
||||
ALLOWED_ORIGINS: Annotated[List[str], NoDecode] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
description="List of allowed CORS origins",
|
||||
)
|
||||
|
||||
@field_validator("ALLOWED_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated origins into list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||
return v
|
||||
|
||||
@field_validator("OPENROUTER_PROVIDER_ORDER", mode="before")
|
||||
@classmethod
|
||||
def parse_provider_order(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated OpenRouter provider list into array."""
|
||||
if isinstance(v, str):
|
||||
return [provider.strip() for provider in v.split(",") if provider.strip()]
|
||||
return v
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: Union[Settings, None] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Get application settings instance.
|
||||
|
||||
Returns:
|
||||
Settings: Application settings
|
||||
|
||||
Raises:
|
||||
ValueError: If settings not initialized
|
||||
"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
def init_settings(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize settings with custom instance (useful for testing).
|
||||
|
||||
Args:
|
||||
settings: Settings instance to use
|
||||
"""
|
||||
global _settings
|
||||
_settings = settings
|
||||
121
backend/app/core/rate_limit.py
Normal file
121
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Lightweight in-process rate limiting helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_lock = threading.Lock()
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
_redis_client: Redis | None = None
|
||||
_redis_unavailable = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_unavailable:
|
||||
return None
|
||||
if _redis_client is None:
|
||||
settings = get_settings()
|
||||
if not settings.REDIS_URL:
|
||||
return None
|
||||
_redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _enforce_in_memory_rate_limit(
|
||||
*,
|
||||
key: str,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
cutoff = now - window_seconds
|
||||
|
||||
with _lock:
|
||||
dq = _hits[key]
|
||||
while dq and dq[0] <= cutoff:
|
||||
dq.popleft()
|
||||
if len(dq) >= max_requests:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
)
|
||||
dq.append(now)
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
request: Request,
|
||||
*,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
global _redis_unavailable
|
||||
|
||||
ip = _client_ip(request)
|
||||
key = f"{scope}:{ip}"
|
||||
|
||||
redis = _get_redis_client()
|
||||
if redis is not None:
|
||||
try:
|
||||
current = await redis.incr(key)
|
||||
if current == 1:
|
||||
await redis.expire(key, window_seconds)
|
||||
if current > max_requests:
|
||||
ttl = await redis.ttl(key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_redis_unavailable = True
|
||||
logger.warning("Redis rate limiter unavailable; falling back to memory: %s", exc)
|
||||
|
||||
_enforce_in_memory_rate_limit(
|
||||
key=key,
|
||||
scope=scope,
|
||||
max_requests=max_requests,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def close_rate_limit() -> None:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
return
|
||||
try:
|
||||
await _redis_client.aclose()
|
||||
finally:
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def reset_rate_limit_state() -> None:
|
||||
"""Reset local limiter state for tests."""
|
||||
global _redis_client, _redis_unavailable
|
||||
_redis_client = None
|
||||
_redis_unavailable = False
|
||||
with _lock:
|
||||
_hits.clear()
|
||||
Reference in New Issue
Block a user