Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Compton API application package."""
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
SETTINGS_ENV_KEYS: dict[str, str] = {
|
||||
"enable_rate_limit": "ENABLE_RATE_LIMIT",
|
||||
"enable_docs": "ENABLE_DOCS",
|
||||
"cookie_secure": "COOKIE_SECURE",
|
||||
"jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN",
|
||||
"auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS",
|
||||
"auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES",
|
||||
"cors_origins": "CORS_ORIGINS",
|
||||
"frontend_url": "FRONTEND_URL",
|
||||
"public_base_url": "PUBLIC_BASE_URL",
|
||||
"smtp_host": "SMTP_HOST",
|
||||
"smtp_port": "SMTP_PORT",
|
||||
"smtp_from": "SMTP_FROM",
|
||||
"avatar_max_bytes": "AVATAR_MAX_BYTES",
|
||||
"media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS",
|
||||
"log_level": "LOG_LEVEL",
|
||||
"audit_retention_days": "AUDIT_RETENTION_DAYS",
|
||||
"jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS",
|
||||
}
|
||||
|
||||
MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys())
|
||||
|
||||
|
||||
def _settings_file() -> Path:
|
||||
return Path(settings.compton_settings_path)
|
||||
|
||||
|
||||
def get_settings_values() -> dict[str, Any]:
|
||||
return {key: getattr(settings, key) for key in MANAGED_KEYS}
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
current = getattr(settings, key)
|
||||
if isinstance(current, bool):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
if isinstance(current, int):
|
||||
return int(value)
|
||||
if isinstance(current, list):
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
raise ValueError(f"INVALID_LIST_{key}")
|
||||
return value
|
||||
|
||||
|
||||
def env_locks() -> dict[str, bool]:
|
||||
return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()}
|
||||
|
||||
|
||||
def apply_settings_to_app(values: dict[str, Any]) -> None:
|
||||
for key, value in values.items():
|
||||
if key not in MANAGED_KEYS:
|
||||
continue
|
||||
setattr(settings, key, _coerce_value(key, value))
|
||||
|
||||
|
||||
def read_settings() -> dict[str, Any]:
|
||||
path = _settings_file()
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
payload = json.load(file)
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return {key: payload[key] for key in MANAGED_KEYS if key in payload}
|
||||
|
||||
|
||||
def write_settings(partial: dict[str, Any]) -> dict[str, Any]:
|
||||
locks = env_locks()
|
||||
current = get_settings_values()
|
||||
for key, value in partial.items():
|
||||
if key not in MANAGED_KEYS:
|
||||
continue
|
||||
if locks[key]:
|
||||
continue
|
||||
current[key] = _coerce_value(key, value)
|
||||
|
||||
path = _settings_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
json.dump(current, file, ensure_ascii=False, indent=2)
|
||||
return current
|
||||
|
||||
|
||||
def bootstrap_settings() -> None:
|
||||
apply_settings_to_app(read_settings())
|
||||
|
||||
|
||||
def get_settings_payload() -> dict[str, Any]:
|
||||
locks = env_locks()
|
||||
values = get_settings_values()
|
||||
secrets = {
|
||||
"jwt_access_secret_configured": bool(settings.jwt_access_secret),
|
||||
"jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper),
|
||||
"smtp_password_configured": bool(settings.smtp_password),
|
||||
"s3_secret_key_configured": bool(settings.s3_secret_key),
|
||||
}
|
||||
return {
|
||||
"values": values,
|
||||
"locks": locks,
|
||||
"settings_path": str(_settings_file()),
|
||||
"secrets": secrets,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _audit_path() -> Path:
|
||||
return Path(settings.admin_audit_log_path)
|
||||
|
||||
|
||||
def write_audit_event(
|
||||
action: str,
|
||||
actor_user_id: str,
|
||||
actor_email: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"actor_user_id": actor_user_id,
|
||||
"actor_email": actor_email,
|
||||
"details": details or {},
|
||||
}
|
||||
path = _audit_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as file:
|
||||
file.write(json.dumps(payload, ensure_ascii=False))
|
||||
file.write("\n")
|
||||
|
||||
|
||||
def read_audit_events(limit: int = 200) -> list[dict[str, Any]]:
|
||||
path = _audit_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
tail = lines[-limit:]
|
||||
events: list[dict[str, Any]] = []
|
||||
for line in tail:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return list(reversed(events))
|
||||
@@ -0,0 +1,54 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core.install_secrets import load_install_secrets_to_env
|
||||
|
||||
load_install_secrets_to_env()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes"
|
||||
jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes"
|
||||
jwt_access_ttl_min: int = 15
|
||||
jwt_refresh_ttl_days: int = 30
|
||||
enable_docs: bool = True
|
||||
cookie_secure: bool = False
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
enable_rate_limit: bool = True
|
||||
auth_lockout_attempts: int = 5
|
||||
auth_lockout_minutes: int = 15
|
||||
admin_initial_password: str = "Admin1234"
|
||||
demo_user_password: str = "User1234"
|
||||
demo_ops_password: str = "OpsAdmin1234"
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "noreply@compton.example"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
public_base_url: str = "http://localhost:5173"
|
||||
auth_token_ttl_hours: int = 1
|
||||
email_delivery_mode: str = "smtp"
|
||||
s3_endpoint: str = "http://localhost:9000"
|
||||
s3_access_key: str = "minio"
|
||||
s3_secret_key: str = "minio123"
|
||||
s3_bucket: str = "compton"
|
||||
s3_region: str = "us-east-1"
|
||||
storage_mode: str = "s3"
|
||||
avatar_max_bytes: int = 2 * 1024 * 1024
|
||||
media_url_ttl_seconds: int = 600
|
||||
log_level: str = "INFO"
|
||||
audit_retention_days: int = 90
|
||||
password_denylist_path: str = "data/security/password-denylist.txt"
|
||||
compton_settings_path: str = "data/compton_settings.json"
|
||||
admin_audit_log_path: str = "data/logs/admin-audit.jsonl"
|
||||
server_log_path: str = "data/logs/server.log"
|
||||
enable_test_routes: bool = False
|
||||
app_env: str = "development"
|
||||
trusted_proxy_ips: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import bcrypt
|
||||
from jose import jwt
|
||||
|
||||
def generate_secret_token_urlsafe(length: int = 32) -> str:
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
def generate_secret_token_hex(length: int = 32) -> str:
|
||||
return secrets.token_hex(length)
|
||||
|
||||
|
||||
def generate_install_bundle() -> dict[str, str]:
|
||||
postgres_user = "compton_app"
|
||||
postgres_password = generate_secret_token_urlsafe(32)
|
||||
postgres_db = "compton"
|
||||
minio_root_user = "minio"
|
||||
minio_root_password = generate_secret_token_urlsafe(32)
|
||||
return {
|
||||
"POSTGRES_USER": postgres_user,
|
||||
"POSTGRES_PASSWORD": postgres_password,
|
||||
"POSTGRES_DB": postgres_db,
|
||||
"DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}",
|
||||
"JWT_ACCESS_SECRET": generate_secret_token_hex(32),
|
||||
"JWT_REFRESH_PEPPER": generate_secret_token_hex(32),
|
||||
"S3_ACCESS_KEY": minio_root_user,
|
||||
"S3_SECRET_KEY": minio_root_password,
|
||||
"MINIO_ROOT_USER": minio_root_user,
|
||||
"MINIO_ROOT_PASSWORD": minio_root_password,
|
||||
}
|
||||
|
||||
|
||||
def hash_password(raw_password: str) -> str:
|
||||
return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
|
||||
from app.core.config import settings
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"is_superuser": is_superuser,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
|
||||
"jti": generate_secret_token_hex(16),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
from app.core.config import settings
|
||||
|
||||
return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"])
|
||||
|
||||
|
||||
def generate_refresh_token() -> str:
|
||||
return generate_secret_token_urlsafe(48)
|
||||
|
||||
|
||||
def generate_opaque_token() -> str:
|
||||
return generate_secret_token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_opaque_token(token: str) -> str:
|
||||
return hash_refresh_token(token)
|
||||
|
||||
|
||||
def hash_refresh_token(token: str) -> str:
|
||||
from app.core.config import settings
|
||||
|
||||
return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_signed_media_url(stored_url: str | None) -> str | None:
|
||||
from app.core.config import settings
|
||||
|
||||
if not stored_url:
|
||||
return None
|
||||
if not stored_url.startswith("/api/v1/media/files/"):
|
||||
return stored_url
|
||||
path = stored_url.removeprefix("/api/v1/media/files/")
|
||||
expires = int(time.time()) + settings.media_url_ttl_seconds
|
||||
signature = _sign_media_path(path, expires)
|
||||
query = urlencode({"expires": expires, "sig": signature})
|
||||
return f"/api/v1/media/files/{path}?{query}"
|
||||
|
||||
|
||||
def verify_signed_media(path: str, expires: int, signature: str) -> bool:
|
||||
if expires < int(time.time()):
|
||||
return False
|
||||
expected = _sign_media_path(path, expires)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _sign_media_path(path: str, expires: int) -> str:
|
||||
from app.core.config import settings
|
||||
|
||||
payload = f"{path}:{expires}"
|
||||
return hmac.new(
|
||||
settings.jwt_access_secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_connect_args: dict[str, object] = {}
|
||||
_engine_kwargs: dict[str, object] = {
|
||||
"pool_pre_ping": True,
|
||||
"future": True,
|
||||
"pool_size": 5,
|
||||
"max_overflow": 10,
|
||||
"pool_recycle": 1800,
|
||||
}
|
||||
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
_connect_args["check_same_thread"] = False
|
||||
_engine_kwargs["poolclass"] = StaticPool
|
||||
_engine_kwargs.pop("pool_size", None)
|
||||
_engine_kwargs.pop("max_overflow", None)
|
||||
_engine_kwargs.pop("pool_recycle", None)
|
||||
|
||||
engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def ensure_utc(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
from app.modules.users.repository import get_user_by_id
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
|
||||
user = get_user_by_id(payload["sub"])
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
|
||||
if user.status == "pending":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if user.status == "blocked":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user=Depends(get_current_user)):
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||
return user
|
||||
|
||||
|
||||
def require_superuser(user=Depends(get_current_user)):
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY")
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY")
|
||||
return user
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentEmail:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
template: str
|
||||
|
||||
|
||||
class MemoryMailer:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[SentEmail] = []
|
||||
|
||||
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||
self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.sent.clear()
|
||||
|
||||
def latest_token(self, recipient: str, template: str) -> str | None:
|
||||
for message in reversed(self.sent):
|
||||
if message.to == recipient and message.template == template:
|
||||
for line in message.body.splitlines():
|
||||
if line.startswith("TOKEN:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
def send(self, to: str, subject: str, body: str, template: str) -> None:
|
||||
_ = template
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.smtp_from
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
if settings.smtp_user:
|
||||
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
memory_mailer = MemoryMailer()
|
||||
|
||||
|
||||
def get_mailer():
|
||||
if settings.email_delivery_mode == "memory":
|
||||
return memory_mailer
|
||||
return SmtpMailer()
|
||||
|
||||
|
||||
def send_template_email(to: str, template: str, subject: str, body: str) -> None:
|
||||
get_mailer().send(to=to, subject=subject, body=body, template=template)
|
||||
@@ -0,0 +1,2 @@
|
||||
class DomainError(Exception):
|
||||
"""Base domain error."""
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import ParseResult, urlparse, urlunparse
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.crypto import generate_install_bundle
|
||||
|
||||
INSTALL_SECRETS_DIR = Path("data/secrets")
|
||||
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
||||
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
||||
REQUIRED_KEYS = (
|
||||
"POSTGRES_USER",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_DB",
|
||||
"DATABASE_URL",
|
||||
"JWT_ACCESS_SECRET",
|
||||
"JWT_REFRESH_PEPPER",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"MINIO_ROOT_USER",
|
||||
"MINIO_ROOT_PASSWORD",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallSecretsStatus:
|
||||
initialized: bool
|
||||
locked: bool
|
||||
path: str
|
||||
created: bool
|
||||
|
||||
|
||||
def _parse_env_text(raw: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def _render_env(values: dict[str, str]) -> str:
|
||||
ordered = [f"{key}={values[key]}" for key in sorted(values.keys())]
|
||||
return "\n".join(ordered) + "\n"
|
||||
|
||||
|
||||
def read_install_secrets() -> dict[str, str]:
|
||||
if not INSTALL_SECRETS_FILE.exists():
|
||||
return {}
|
||||
return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_install_secrets(values: dict[str, str]) -> None:
|
||||
INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_meta() -> None:
|
||||
payload = (
|
||||
"{\n"
|
||||
f' "install_id": "{uuid4()}",\n'
|
||||
f' "locked_at": "{datetime.now(UTC).isoformat()}"\n'
|
||||
"}\n"
|
||||
)
|
||||
INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _adopt_from_environment() -> dict[str, str]:
|
||||
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS}
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
if parsed.username:
|
||||
env_values["POSTGRES_USER"] = parsed.username
|
||||
if parsed.password:
|
||||
env_values["POSTGRES_PASSWORD"] = parsed.password
|
||||
if parsed.path and parsed.path != "/":
|
||||
env_values["POSTGRES_DB"] = parsed.path.lstrip("/")
|
||||
return {key: value for key, value in env_values.items() if value}
|
||||
|
||||
|
||||
def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||
"""MinIO root credentials are the S3 access key pair — keep them aligned."""
|
||||
if (
|
||||
values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER")
|
||||
and values.get("MINIO_ROOT_PASSWORD")
|
||||
and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"]
|
||||
):
|
||||
values = dict(values)
|
||||
values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"]
|
||||
return values
|
||||
|
||||
|
||||
def load_install_secrets_to_env() -> None:
|
||||
values = _sync_minio_s3_secrets(read_install_secrets())
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def ensure_install_secrets() -> InstallSecretsStatus:
|
||||
existing = read_install_secrets()
|
||||
if existing:
|
||||
synced = _sync_minio_s3_secrets(existing)
|
||||
if synced != existing:
|
||||
synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true")
|
||||
_write_install_secrets(synced)
|
||||
existing = synced
|
||||
load_install_secrets_to_env()
|
||||
return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False)
|
||||
|
||||
adopted = _adopt_from_environment()
|
||||
generated = generate_install_bundle()
|
||||
values = generated | adopted
|
||||
values["SECRETS_LOCKED"] = "true"
|
||||
_write_install_secrets(values)
|
||||
_write_meta()
|
||||
load_install_secrets_to_env()
|
||||
return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True)
|
||||
|
||||
|
||||
def masked_database_url(database_url: str) -> str:
|
||||
parsed = urlparse(database_url)
|
||||
if not parsed.username:
|
||||
return database_url
|
||||
password = "***" if parsed.password else ""
|
||||
credentials = f"{parsed.username}:{password}" if password else parsed.username
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
netloc = f"{credentials}@{host}"
|
||||
sanitized = ParseResult(
|
||||
scheme=parsed.scheme,
|
||||
netloc=netloc,
|
||||
path=parsed.path,
|
||||
params=parsed.params,
|
||||
query=parsed.query,
|
||||
fragment=parsed.fragment,
|
||||
)
|
||||
return urlunparse(sanitized)
|
||||
|
||||
|
||||
def install_secrets_payload() -> dict:
|
||||
values = read_install_secrets()
|
||||
db = urlparse(values.get("DATABASE_URL", ""))
|
||||
return {
|
||||
"initialized": bool(values),
|
||||
"locked": values.get("SECRETS_LOCKED") == "true",
|
||||
"secrets_path": str(INSTALL_SECRETS_FILE),
|
||||
"database": {
|
||||
"host": db.hostname,
|
||||
"port": db.port,
|
||||
"database": db.path.lstrip("/") if db.path else "",
|
||||
"user": db.username,
|
||||
"password_configured": bool(values.get("POSTGRES_PASSWORD")),
|
||||
},
|
||||
"connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")),
|
||||
"secrets_status": {
|
||||
"jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing",
|
||||
"jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing",
|
||||
"postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing",
|
||||
"s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing",
|
||||
"password_bcrypt_salt": "per_user_in_db",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reveal_install_secret(key: str) -> str:
|
||||
mapping = {
|
||||
"database_password": "POSTGRES_PASSWORD",
|
||||
"jwt_access_secret": "JWT_ACCESS_SECRET",
|
||||
"jwt_refresh_pepper": "JWT_REFRESH_PEPPER",
|
||||
"s3_secret_key": "S3_SECRET_KEY",
|
||||
}
|
||||
env_key = mapping.get(key)
|
||||
if not env_key:
|
||||
raise ValueError("UNSUPPORTED_SECRET_KEY")
|
||||
values = read_install_secrets()
|
||||
if env_key in values:
|
||||
return values[env_key]
|
||||
if env_key == "POSTGRES_PASSWORD":
|
||||
database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||
parsed = urlparse(database_url)
|
||||
return parsed.password or ""
|
||||
return os.getenv(env_key, "")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.core.crypto import build_signed_media_url, verify_signed_media
|
||||
|
||||
__all__ = ["build_signed_media_url", "verify_signed_media"]
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
COMMON_PASSWORDS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"password1",
|
||||
"password123",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"qwerty123",
|
||||
"admin123",
|
||||
"admin1234",
|
||||
"letmein1",
|
||||
"welcome1",
|
||||
"iloveyou1",
|
||||
"sunshine1",
|
||||
"football1",
|
||||
"baseball1",
|
||||
"monkey123",
|
||||
"dragon123",
|
||||
"master123",
|
||||
"trustno1",
|
||||
"passw0rd",
|
||||
"passw0rd1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_denylist() -> set[str]:
|
||||
denylist = set(COMMON_PASSWORDS)
|
||||
path = Path(settings.password_denylist_path)
|
||||
if not path.exists():
|
||||
return denylist
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
candidate = line.strip().lower()
|
||||
if candidate and not candidate.startswith("#"):
|
||||
denylist.add(candidate)
|
||||
return denylist
|
||||
|
||||
|
||||
def is_denied_password(password: str) -> bool:
|
||||
return password.lower() in load_denylist()
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
from app.core.password_denylist import is_denied_password
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long")
|
||||
if is_denied_password(password):
|
||||
raise ValueError("Password is too common")
|
||||
if not re.search(r"[A-Z]", password):
|
||||
raise ValueError("Password must include at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", password):
|
||||
raise ValueError("Password must include at least one lowercase letter")
|
||||
if not re.search(r"\d", password):
|
||||
raise ValueError("Password must include at least one digit")
|
||||
return password
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Redis-first rate limiter with in-memory fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||
_redis_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
_redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
_redis_client.ping()
|
||||
return _redis_client
|
||||
except RedisError:
|
||||
_redis_client = None
|
||||
return None
|
||||
|
||||
|
||||
def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
|
||||
if not settings.enable_rate_limit:
|
||||
return
|
||||
redis_client = get_redis_client()
|
||||
if redis_client is not None:
|
||||
redis_key = f"rl:{key}"
|
||||
try:
|
||||
current = redis_client.incr(redis_key)
|
||||
if current == 1:
|
||||
redis_client.expire(redis_key, window_seconds)
|
||||
if current > limit:
|
||||
ttl = redis_client.ttl(redis_key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except RedisError:
|
||||
pass
|
||||
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(seconds=window_seconds)
|
||||
timestamps = [moment for moment in _buckets[key] if moment > cutoff]
|
||||
if len(timestamps) >= limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(window_seconds)},
|
||||
)
|
||||
timestamps.append(now)
|
||||
_buckets[key] = timestamps
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()}
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
request_ip = request.client.host if request.client else ""
|
||||
if forwarded and request_ip in trusted_proxy_ips:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request_ip
|
||||
return "unknown"
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.core.crypto import (
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
generate_opaque_token,
|
||||
generate_refresh_token,
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_access_token",
|
||||
"decode_access_token",
|
||||
"generate_opaque_token",
|
||||
"generate_refresh_token",
|
||||
"hash_opaque_token",
|
||||
"hash_password",
|
||||
"hash_refresh_token",
|
||||
"verify_password",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_s3_client = None
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
global _s3_client
|
||||
if _s3_client is None:
|
||||
import boto3
|
||||
|
||||
_s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name=settings.s3_region,
|
||||
)
|
||||
return _s3_client
|
||||
|
||||
|
||||
def ensure_bucket() -> None:
|
||||
if settings.storage_mode != "s3":
|
||||
return
|
||||
client = get_s3_client()
|
||||
bucket = settings.s3_bucket
|
||||
try:
|
||||
client.head_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
client.create_bucket(Bucket=bucket)
|
||||
|
||||
|
||||
def upload_object(key: str, body: bytes, content_type: str) -> None:
|
||||
if settings.storage_mode == "memory":
|
||||
memory_store[key] = (body, content_type)
|
||||
return
|
||||
client = get_s3_client()
|
||||
ensure_bucket()
|
||||
client.put_object(
|
||||
Bucket=settings.s3_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
|
||||
def download_object(key: str) -> tuple[bytes, str] | None:
|
||||
if settings.storage_mode == "memory":
|
||||
return memory_store.get(key)
|
||||
client = get_s3_client()
|
||||
try:
|
||||
response = client.get_object(Bucket=settings.s3_bucket, Key=key)
|
||||
body = response["Body"].read()
|
||||
content_type = response.get("ContentType", "application/octet-stream")
|
||||
return body, content_type
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
memory_store: dict[str, tuple[bytes, str]] = {}
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.db.base import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||
from app.modules.content.models import ContentPage
|
||||
from app.modules.users.models import User, UserProfile
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserProfile",
|
||||
"RefreshToken",
|
||||
"PasswordResetToken",
|
||||
"EmailVerificationToken",
|
||||
"ContentPage",
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.modules.content.repository import create_page, get_page_by_slug
|
||||
from app.modules.users.repository import create_user, get_user_by_email
|
||||
|
||||
|
||||
def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str):
|
||||
from app.modules.users import repository
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user:
|
||||
changed = False
|
||||
if user.role != role:
|
||||
user.role = role
|
||||
changed = True
|
||||
if user.is_superuser != is_superuser:
|
||||
user.is_superuser = is_superuser
|
||||
changed = True
|
||||
if user.status != status:
|
||||
user.status = status
|
||||
changed = True
|
||||
if user.email_verified_at is None and status == "active":
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
changed = True
|
||||
if changed:
|
||||
repository.update_user(user)
|
||||
return user
|
||||
|
||||
user = create_user(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
is_superuser=is_superuser,
|
||||
status=status,
|
||||
)
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
repository.update_user(user)
|
||||
return user
|
||||
|
||||
|
||||
def run_seed(include_demo_pages: bool = True) -> None:
|
||||
admin = _ensure_user(
|
||||
email="admin@compton.example",
|
||||
password=settings.admin_initial_password,
|
||||
role="admin",
|
||||
is_superuser=True,
|
||||
status="active",
|
||||
)
|
||||
_ensure_user(
|
||||
email="user@compton.example",
|
||||
password=settings.demo_user_password,
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
_ensure_user(
|
||||
email="ops@compton.example",
|
||||
password=settings.demo_ops_password,
|
||||
role="admin",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
|
||||
if not include_demo_pages:
|
||||
return
|
||||
|
||||
demo_pages = [
|
||||
{
|
||||
"slug": "about",
|
||||
"title": "О бренде",
|
||||
"body": "<p>Compton — платформа Organic Tech.</p>",
|
||||
},
|
||||
{
|
||||
"slug": "privacy",
|
||||
"title": "Политика конфиденциальности",
|
||||
"body": "<p>Мы обрабатываем персональные данные согласно политике.</p>",
|
||||
},
|
||||
{
|
||||
"slug": "terms",
|
||||
"title": "Условия использования",
|
||||
"body": "<p>Используя сервис, вы принимаете условия.</p>",
|
||||
},
|
||||
]
|
||||
|
||||
for page in demo_pages:
|
||||
if get_page_by_slug(page["slug"], include_draft=True):
|
||||
continue
|
||||
create_page(
|
||||
slug=page["slug"],
|
||||
title=page["title"],
|
||||
body=page["body"],
|
||||
status="published",
|
||||
author_id=admin.id,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, or_
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||
|
||||
|
||||
def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]:
|
||||
now = datetime.now(UTC)
|
||||
revoked_cutoff = now - timedelta(days=retention_days)
|
||||
with session_scope() as db:
|
||||
refresh_deleted = db.execute(
|
||||
delete(RefreshToken).where(
|
||||
or_(
|
||||
RefreshToken.expires_at < now,
|
||||
RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff),
|
||||
)
|
||||
)
|
||||
).rowcount or 0
|
||||
reset_deleted = db.execute(
|
||||
delete(PasswordResetToken).where(
|
||||
or_(
|
||||
PasswordResetToken.expires_at < now,
|
||||
PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now),
|
||||
)
|
||||
)
|
||||
).rowcount or 0
|
||||
verify_deleted = db.execute(
|
||||
delete(EmailVerificationToken).where(
|
||||
or_(
|
||||
EmailVerificationToken.expires_at < now,
|
||||
EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now),
|
||||
)
|
||||
)
|
||||
).rowcount or 0
|
||||
return {
|
||||
"refresh_tokens_deleted": int(refresh_deleted),
|
||||
"password_reset_tokens_deleted": int(reset_deleted),
|
||||
"email_verification_tokens_deleted": int(verify_deleted),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.install_secrets import ensure_install_secrets
|
||||
from app.core.config import settings
|
||||
from app.core.app_settings import bootstrap_settings
|
||||
from app.core.storage import ensure_bucket
|
||||
from app.core import database as db_module
|
||||
from app.db.token_cleanup import cleanup_expired_tokens
|
||||
from app.db.base import Base
|
||||
from app.db import models as _models # noqa: F401
|
||||
from app.db.seed import run_seed
|
||||
from app.modules.auth.router import router as auth_router
|
||||
from app.modules.users.router import router as users_router
|
||||
from app.modules.content.router import router as content_router
|
||||
from app.modules.admin.router import router as admin_router
|
||||
from app.modules.media.router import router as media_router
|
||||
from app.modules.test.router import router as test_router
|
||||
|
||||
|
||||
def _assert_production_guards() -> None:
|
||||
if settings.app_env.lower() != "production":
|
||||
return
|
||||
if settings.enable_test_routes:
|
||||
raise RuntimeError("ENABLE_TEST_ROUTES must be false in production")
|
||||
if settings.enable_docs:
|
||||
raise RuntimeError("ENABLE_DOCS must be false in production")
|
||||
if not settings.enable_rate_limit:
|
||||
raise RuntimeError("ENABLE_RATE_LIMIT must be true in production")
|
||||
if not settings.cookie_secure:
|
||||
raise RuntimeError("COOKIE_SECURE must be true in production")
|
||||
if settings.jwt_access_secret.startswith("change-me-"):
|
||||
raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production")
|
||||
if settings.jwt_refresh_pepper.startswith("change-me-"):
|
||||
raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production")
|
||||
parsed = urlparse(settings.database_url)
|
||||
if parsed.username == "user" and parsed.password == "pass":
|
||||
raise RuntimeError("Default database credentials are not allowed in production")
|
||||
if parsed.scheme.startswith("postgresql"):
|
||||
sslmode = parse_qs(parsed.query).get("sslmode", [""])[0]
|
||||
if sslmode != "require":
|
||||
raise RuntimeError("DATABASE_URL must contain sslmode=require in production")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_install_secrets()
|
||||
bootstrap_settings()
|
||||
_assert_production_guards()
|
||||
if settings.enable_test_routes:
|
||||
Base.metadata.create_all(db_module.engine)
|
||||
run_seed()
|
||||
cleanup_expired_tokens()
|
||||
ensure_bucket()
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
title="Compton API",
|
||||
version="1.0.0",
|
||||
docs_url="/api/v1/docs" if settings.enable_docs else None,
|
||||
openapi_url="/api/v1/openapi.json" if settings.enable_docs else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
||||
)
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(users_router, prefix="/api/v1/users", tags=["users"])
|
||||
app.include_router(content_router, prefix="/api/v1/content", tags=["content"])
|
||||
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
if settings.enable_test_routes:
|
||||
app.include_router(test_router, prefix="/api/v1/test", tags=["test"])
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Application modules."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Admin module."""
|
||||
@@ -0,0 +1,142 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.core.dependencies import require_admin, require_superuser
|
||||
from app.modules.admin.schemas import (
|
||||
AdminSettingsPatchIn,
|
||||
AdminRevealSecretIn,
|
||||
AdminUiActivityIn,
|
||||
AdminUserCreateIn,
|
||||
AdminUserPasswordPatchIn,
|
||||
AdminUserPatchIn,
|
||||
)
|
||||
from app.modules.admin.service import (
|
||||
create_admin_user,
|
||||
delete_admin_user,
|
||||
get_admin_settings,
|
||||
get_admin_summary,
|
||||
get_install_secrets,
|
||||
get_diagnostics_report,
|
||||
get_server_log_tail,
|
||||
list_activity_feed,
|
||||
list_admin_users,
|
||||
patch_admin_settings,
|
||||
patch_user,
|
||||
record_ui_activity,
|
||||
reveal_secret,
|
||||
reset_user_password,
|
||||
)
|
||||
from app.modules.analytics.service import get_admin_dashboard_metrics
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users_route(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
_admin=Depends(require_admin),
|
||||
):
|
||||
return list_admin_users(page, limit)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def patch_user_route(
|
||||
user_id: str,
|
||||
payload: AdminUserPatchIn,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
if payload.is_superuser is not None and not admin.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="SUPERUSER_ONLY")
|
||||
try:
|
||||
return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return create_admin_user(
|
||||
admin,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
role=payload.role,
|
||||
is_superuser=payload.is_superuser,
|
||||
status=payload.status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/password")
|
||||
async def reset_user_password_route(
|
||||
user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser)
|
||||
):
|
||||
try:
|
||||
return reset_user_password(admin, user_id, payload.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user_route(user_id: str, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return delete_admin_user(admin, user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary_route(_admin=Depends(require_admin)):
|
||||
return get_admin_summary()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats_route(_admin=Depends(require_admin)):
|
||||
return get_admin_dashboard_metrics()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def admin_settings_route(_admin=Depends(require_superuser)):
|
||||
return get_admin_settings()
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)):
|
||||
return patch_admin_settings(admin, payload.values)
|
||||
|
||||
|
||||
@router.get("/diagnostics/report")
|
||||
async def diagnostics_route(_admin=Depends(require_superuser)):
|
||||
return get_diagnostics_report()
|
||||
|
||||
|
||||
@router.get("/activity-feed")
|
||||
async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)):
|
||||
return list_activity_feed(limit=limit)
|
||||
|
||||
|
||||
@router.post("/ui-activity")
|
||||
async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)):
|
||||
return record_ui_activity(admin, payload.event, payload.meta)
|
||||
|
||||
|
||||
@router.get("/server-log")
|
||||
async def server_log_route(
|
||||
lines: int = Query(default=200, ge=1, le=1000),
|
||||
_admin=Depends(require_superuser),
|
||||
):
|
||||
return get_server_log_tail(lines=lines)
|
||||
|
||||
|
||||
@router.get("/secrets")
|
||||
async def install_secrets_route(_admin=Depends(require_superuser)):
|
||||
return get_install_secrets()
|
||||
|
||||
|
||||
@router.post("/secrets/reveal")
|
||||
async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return reveal_secret(admin, payload.key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class AdminUserPatchIn(BaseModel):
|
||||
role: Literal["user", "admin"] | None = None
|
||||
status: Literal["pending", "active", "blocked"] | None = None
|
||||
is_superuser: bool | None = None
|
||||
|
||||
|
||||
class AdminUserCreateIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
role: Literal["user", "admin"] = "user"
|
||||
is_superuser: bool = False
|
||||
status: Literal["pending", "active", "blocked"] = "active"
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AdminUserPasswordPatchIn(BaseModel):
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AdminSettingsPatchIn(BaseModel):
|
||||
values: dict
|
||||
|
||||
|
||||
class AdminUiActivityIn(BaseModel):
|
||||
event: str
|
||||
meta: dict | None = None
|
||||
|
||||
|
||||
class AdminRevealSecretIn(BaseModel):
|
||||
key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"]
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.install_secrets import read_install_secrets
|
||||
from app.core.password_denylist import load_denylist
|
||||
from app.core.redis import get_redis_client
|
||||
from app.core.database import session_scope
|
||||
from app.modules.auth.models import RefreshToken
|
||||
from app.modules.users import repository
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
def build_security_diagnostics_report() -> dict:
|
||||
redis_client = get_redis_client()
|
||||
db = urlparse(settings.database_url)
|
||||
sslmode = parse_qs(db.query).get("sslmode", [""])[0]
|
||||
install_secrets = read_install_secrets()
|
||||
with session_scope() as session:
|
||||
refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0
|
||||
checks = [
|
||||
{
|
||||
"id": "jwt_access_secret",
|
||||
"status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail",
|
||||
"message": "JWT access secret configured",
|
||||
},
|
||||
{
|
||||
"id": "jwt_refresh_pepper",
|
||||
"status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail",
|
||||
"message": "JWT refresh pepper configured",
|
||||
},
|
||||
{
|
||||
"id": "cookie_secure",
|
||||
"status": "ok" if settings.cookie_secure else "warn",
|
||||
"message": "Refresh cookie Secure flag is enabled",
|
||||
},
|
||||
{
|
||||
"id": "rate_limit_backend",
|
||||
"status": "ok" if redis_client is not None else "warn",
|
||||
"message": "Rate limiter uses Redis backend",
|
||||
},
|
||||
{
|
||||
"id": "password_denylist",
|
||||
"status": "ok" if len(load_denylist()) >= 1000 else "warn",
|
||||
"message": "Password denylist has strong coverage",
|
||||
},
|
||||
{
|
||||
"id": "cors_wildcard",
|
||||
"status": "ok" if "*" not in settings.cors_origins else "fail",
|
||||
"message": "CORS does not include wildcard origin",
|
||||
},
|
||||
{
|
||||
"id": "superuser_exists",
|
||||
"status": "ok" if repository.count_superusers() > 0 else "fail",
|
||||
"message": "At least one superuser exists",
|
||||
},
|
||||
{
|
||||
"id": "docs_production",
|
||||
"status": "warn" if settings.enable_docs else "ok",
|
||||
"message": "API docs are disabled in production",
|
||||
},
|
||||
{
|
||||
"id": "db_default_credentials",
|
||||
"status": "fail" if db.username == "user" and db.password == "pass" else "ok",
|
||||
"message": "Database does not use default credentials",
|
||||
},
|
||||
{
|
||||
"id": "db_localhost_exposed",
|
||||
"status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok",
|
||||
"message": "Production database host is not localhost",
|
||||
},
|
||||
{
|
||||
"id": "db_ssl_mode",
|
||||
"status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn",
|
||||
"message": "Production database URL uses sslmode=require",
|
||||
},
|
||||
{
|
||||
"id": "db_refresh_token_table_size",
|
||||
"status": "warn" if refresh_count > 10000 else "ok",
|
||||
"message": "Refresh token table size is under threshold",
|
||||
},
|
||||
{
|
||||
"id": "install_secrets_initialized",
|
||||
"status": "ok" if bool(install_secrets) else "fail",
|
||||
"message": "Install secrets file is initialized",
|
||||
},
|
||||
{
|
||||
"id": "install_secrets_locked",
|
||||
"status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn",
|
||||
"message": "Install secrets are locked after bootstrap",
|
||||
},
|
||||
]
|
||||
return {"checks": checks}
|
||||
@@ -0,0 +1,206 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
from app.core.config import settings
|
||||
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
||||
from app.core.security import hash_password
|
||||
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
from app.modules.users import repository
|
||||
|
||||
|
||||
def list_admin_users(page: int, limit: int) -> dict:
|
||||
users, total = repository.list_users(page, limit)
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"meta": {"total": total, "page": page, "limit": limit},
|
||||
}
|
||||
|
||||
|
||||
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
|
||||
super_count = repository.count_superusers()
|
||||
role_becomes_admin = target.role if role is None else role
|
||||
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
|
||||
is_losing_super = target.role == "admin" and target.is_superuser and (
|
||||
role_becomes_admin != "admin" or not super_becomes_true
|
||||
)
|
||||
if is_losing_super and super_count <= 1:
|
||||
raise ValueError("LAST_SUPERUSER_PROTECTED")
|
||||
|
||||
|
||||
def patch_user(
|
||||
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
|
||||
) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
if admin_user.id == target_user_id and role and role != "admin":
|
||||
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||
if admin_user.id == target_user_id and is_superuser is False:
|
||||
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||
if admin_user.id == target_user_id and status == "blocked":
|
||||
raise ValueError("SELF_BLOCK_FORBIDDEN")
|
||||
|
||||
admin_count = repository.count_admins()
|
||||
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
|
||||
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||
_ensure_last_superuser_protection(target, role, is_superuser)
|
||||
|
||||
if role:
|
||||
target.role = role
|
||||
if is_superuser is not None:
|
||||
target.is_superuser = bool(is_superuser)
|
||||
if status:
|
||||
target.status = status
|
||||
if status == "blocked":
|
||||
revoke_user_refresh_family(target.id)
|
||||
repository.update_user(target)
|
||||
write_audit_event(
|
||||
action="admin.user.patch",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {
|
||||
"id": target.id,
|
||||
"email": target.email,
|
||||
"role": target.role,
|
||||
"is_superuser": target.is_superuser,
|
||||
"status": target.status,
|
||||
}
|
||||
|
||||
|
||||
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
|
||||
if repository.get_user_by_email(email):
|
||||
raise ValueError("USER_EXISTS")
|
||||
user = repository.create_user(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
is_superuser=is_superuser,
|
||||
status=status,
|
||||
)
|
||||
write_audit_event(
|
||||
action="admin.user.create",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": user.id},
|
||||
)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
}
|
||||
|
||||
|
||||
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
target.password_hash = hash_password(password)
|
||||
repository.update_user(target)
|
||||
revoke_user_refresh_family(target.id)
|
||||
write_audit_event(
|
||||
action="admin.user.reset_password",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def delete_admin_user(admin_user, target_user_id: str) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
if admin_user.id == target.id:
|
||||
raise ValueError("SELF_DELETE_FORBIDDEN")
|
||||
_ensure_last_superuser_protection(target, "user", False)
|
||||
if target.role == "admin" and repository.count_admins() <= 1:
|
||||
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||
repository.delete_user(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
write_audit_event(
|
||||
action="admin.user.delete",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def get_admin_summary() -> dict:
|
||||
return {
|
||||
"users_count": repository.count_users(),
|
||||
"registrations_day": repository.count_users_registered_today(),
|
||||
"admins_count": repository.count_admins(),
|
||||
"superusers_count": repository.count_superusers(),
|
||||
}
|
||||
|
||||
|
||||
def get_admin_settings() -> dict:
|
||||
return get_settings_payload()
|
||||
|
||||
|
||||
def patch_admin_settings(admin_user, values: dict) -> dict:
|
||||
merged = write_settings(values)
|
||||
apply_settings_to_app(merged)
|
||||
write_audit_event(
|
||||
action="admin.settings.patch",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"updated_keys": sorted(values.keys())},
|
||||
)
|
||||
return get_settings_payload()
|
||||
|
||||
|
||||
def get_diagnostics_report() -> dict:
|
||||
return build_security_diagnostics_report()
|
||||
|
||||
|
||||
def list_activity_feed(limit: int = 200) -> dict:
|
||||
return {"events": read_audit_events(limit=limit)}
|
||||
|
||||
|
||||
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
|
||||
write_audit_event(
|
||||
action=f"ui.{event}",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details=meta or {},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def get_server_log_tail(lines: int = 200) -> dict:
|
||||
path = Path(settings.server_log_path)
|
||||
if not path.exists():
|
||||
return {"lines": []}
|
||||
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
|
||||
|
||||
|
||||
def get_install_secrets() -> dict:
|
||||
return install_secrets_payload()
|
||||
|
||||
|
||||
def reveal_secret(admin_user, key: str) -> dict:
|
||||
value = reveal_install_secret(key)
|
||||
write_audit_event(
|
||||
action="admin.secrets.reveal",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"key": key},
|
||||
)
|
||||
return {"key": key, "value": value}
|
||||
@@ -0,0 +1,8 @@
|
||||
from app.modules.users import repository
|
||||
|
||||
|
||||
def get_admin_dashboard_metrics() -> dict:
|
||||
return {
|
||||
"users_count": repository.count_users(),
|
||||
"registrations_day": repository.count_users_registered_today(),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Auth module."""
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class EmailVerificationToken(Base):
|
||||
__tablename__ = "email_verification_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
user_id: str,
|
||||
token_hash: str,
|
||||
family_id: str,
|
||||
expires_at: datetime,
|
||||
) -> RefreshToken:
|
||||
with session_scope() as db:
|
||||
token = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
family_id=family_id,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def get_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def revoke_refresh_token(token_hash: str) -> RefreshToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
token.revoked_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def revoke_user_families(user_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
tokens = db.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
now = datetime.now(UTC)
|
||||
for token in tokens:
|
||||
token.revoked_at = now
|
||||
|
||||
|
||||
def revoke_family_tokens(family_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
tokens = db.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.family_id == family_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
now = datetime.now(UTC)
|
||||
for token in tokens:
|
||||
token.revoked_at = now
|
||||
|
||||
|
||||
def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||
with session_scope() as db:
|
||||
db.execute(
|
||||
delete(EmailVerificationToken).where(
|
||||
EmailVerificationToken.user_id == user_id,
|
||||
EmailVerificationToken.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||
|
||||
|
||||
def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(
|
||||
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||
)
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def mark_email_verification_token_used(token_hash: str) -> None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(
|
||||
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
|
||||
)
|
||||
if token:
|
||||
token.used_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None:
|
||||
with session_scope() as db:
|
||||
db.execute(
|
||||
delete(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user_id,
|
||||
PasswordResetToken.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at))
|
||||
|
||||
|
||||
def get_password_reset_token(token_hash: str) -> PasswordResetToken | None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||
if not token:
|
||||
return None
|
||||
return _detach(db, token)
|
||||
|
||||
|
||||
def mark_password_reset_token_used(token_hash: str) -> None:
|
||||
with session_scope() as db:
|
||||
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash))
|
||||
if token:
|
||||
token.used_at = datetime.now(UTC)
|
||||
@@ -0,0 +1,206 @@
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import check_rate_limit, client_ip
|
||||
from app.modules.auth.schemas import (
|
||||
ForgotPasswordIn,
|
||||
LoginIn,
|
||||
LoginOut,
|
||||
RegisterIn,
|
||||
ResendVerificationIn,
|
||||
ResetPasswordIn,
|
||||
VerifyEmailIn,
|
||||
)
|
||||
from app.modules.auth.service import (
|
||||
forgot_password,
|
||||
login,
|
||||
refresh,
|
||||
register,
|
||||
resend_verification,
|
||||
reset_password,
|
||||
revoke_refresh_token,
|
||||
verify_email_token,
|
||||
)
|
||||
from app.modules.users import repository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _allowed_origins() -> set[str]:
|
||||
origins: set[str] = set()
|
||||
for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins):
|
||||
if not raw:
|
||||
continue
|
||||
normalized = raw.rstrip("/")
|
||||
origins.add(normalized)
|
||||
if "://localhost" in normalized:
|
||||
origins.add(normalized.replace("://localhost", "://127.0.0.1"))
|
||||
if "://127.0.0.1" in normalized:
|
||||
origins.add(normalized.replace("://127.0.0.1", "://localhost"))
|
||||
return origins
|
||||
|
||||
|
||||
def _enforce_origin(request: Request, *, require_header: bool = False) -> None:
|
||||
allowed = _allowed_origins()
|
||||
origin = (request.headers.get("Origin") or "").rstrip("/")
|
||||
referer = request.headers.get("Referer") or ""
|
||||
if require_header and not origin and not referer:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if origin and origin not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
if not origin and referer:
|
||||
if not any(referer.startswith(base) for base in allowed):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN")
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/api/v1/auth",
|
||||
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_route(payload: RegisterIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600)
|
||||
user = register(payload.email, payload.password)
|
||||
_ = user
|
||||
return {"message": "If email is valid, verification has been sent."}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email_route(payload: VerifyEmailIn):
|
||||
try:
|
||||
user = verify_email_token(payload.token)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
_ = user
|
||||
return {"status": "active"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginOut)
|
||||
async def login_route(payload: LoginIn, request: Request, response: Response):
|
||||
_enforce_origin(request)
|
||||
user = repository.get_user_by_email(payload.email)
|
||||
if user:
|
||||
locked_until = ensure_utc(user.locked_until)
|
||||
if locked_until and locked_until > utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="ACCOUNT_TEMPORARILY_LOCKED",
|
||||
)
|
||||
check_rate_limit(
|
||||
f"login:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=5,
|
||||
window_seconds=60,
|
||||
)
|
||||
try:
|
||||
access_token, refresh_token, user = login(payload.email, payload.password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS")
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
|
||||
if detail == "ACCOUNT_TEMPORARILY_LOCKED":
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginOut)
|
||||
async def refresh_route(request: Request, response: Response):
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING")
|
||||
_enforce_origin(request, require_header=True)
|
||||
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
|
||||
try:
|
||||
access_token, new_refresh, user = refresh(refresh_token)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
|
||||
_set_refresh_cookie(response, new_refresh)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"expires_in": settings.jwt_access_ttl_min * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout_route(request: Request, response: Response):
|
||||
_enforce_origin(request, require_header=True)
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if refresh_token:
|
||||
revoke_refresh_token(refresh_token)
|
||||
response.delete_cookie(
|
||||
"refresh_token",
|
||||
path="/api/v1/auth",
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return {"message": "logged_out"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password_route(payload: ForgotPasswordIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"forgot:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
forgot_password(payload.email)
|
||||
return {"message": "If email is registered, reset instructions have been sent."}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password_route(payload: ResetPasswordIn):
|
||||
try:
|
||||
reset_password(payload.token, payload.new_password)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "TOKEN_EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN")
|
||||
return {"message": "password_updated"}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification_route(payload: ResendVerificationIn, request: Request):
|
||||
_enforce_origin(request)
|
||||
check_rate_limit(
|
||||
f"resend:{client_ip(request)}:{payload.email.lower()}",
|
||||
limit=3,
|
||||
window_seconds=3600,
|
||||
)
|
||||
resend_verification(payload.email)
|
||||
return {"message": "If email is registered, verification has been sent."}
|
||||
@@ -0,0 +1,69 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("email", "password", mode="before")
|
||||
@classmethod
|
||||
def strip_whitespace(cls, value: str) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("email", "password", mode="before")
|
||||
@classmethod
|
||||
def strip_whitespace(cls, value: str) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
|
||||
class VerifyEmailIn(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResendVerificationIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AuthUserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
is_superuser: bool
|
||||
status: str
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 900
|
||||
user: AuthUserOut
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.datetime_utils import ensure_utc, utc_now
|
||||
from app.core.email import send_template_email
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
generate_opaque_token,
|
||||
generate_refresh_token,
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.modules.auth import repository as auth_repository
|
||||
from app.modules.users import repository
|
||||
from app.modules.users.models import User
|
||||
|
||||
def _token_expires_at():
|
||||
return utc_now() + timedelta(hours=settings.auth_token_ttl_hours)
|
||||
|
||||
|
||||
def _send_verification_email(user: User, token: str) -> None:
|
||||
verify_url = f"{settings.frontend_url}/verify?token={token}"
|
||||
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
||||
token_line = f"TOKEN:{token}\n" if show_token else ""
|
||||
body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line
|
||||
send_template_email(
|
||||
to=user.email,
|
||||
template="verify_email",
|
||||
subject="Confirm your Compton account",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def _send_password_reset_email(user: User, token: str) -> None:
|
||||
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
|
||||
show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes
|
||||
token_line = f"TOKEN:{token}\n" if show_token else ""
|
||||
body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line
|
||||
send_template_email(
|
||||
to=user.email,
|
||||
template="reset_password",
|
||||
subject="Reset your Compton password",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def _issue_verification_token(user_id: str) -> str:
|
||||
token = generate_opaque_token()
|
||||
auth_repository.create_email_verification_token(
|
||||
user_id=user_id,
|
||||
token_hash=hash_opaque_token(token),
|
||||
expires_at=_token_expires_at(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _issue_password_reset_token(user_id: str) -> str:
|
||||
token = generate_opaque_token()
|
||||
auth_repository.create_password_reset_token(
|
||||
user_id=user_id,
|
||||
token_hash=hash_opaque_token(token),
|
||||
expires_at=_token_expires_at(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def register(email: str, password: str) -> User:
|
||||
existing = repository.get_user_by_email(email)
|
||||
if existing:
|
||||
if existing.status == "pending":
|
||||
token = _issue_verification_token(existing.id)
|
||||
_send_verification_email(existing, token)
|
||||
return existing
|
||||
user = repository.create_user(email=email, password_hash=hash_password(password), status="pending")
|
||||
token = _issue_verification_token(user.id)
|
||||
_send_verification_email(user, token)
|
||||
return user
|
||||
|
||||
|
||||
def verify_email_token(token: str) -> User:
|
||||
token_hash = hash_opaque_token(token)
|
||||
token_row = auth_repository.get_email_verification_token(token_hash)
|
||||
if not token_row or token_row.used_at is not None:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
if ensure_utc(token_row.expires_at) < utc_now():
|
||||
raise ValueError("TOKEN_EXPIRED")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
|
||||
user.status = "active"
|
||||
user.email_verified_at = utc_now()
|
||||
repository.update_user(user)
|
||||
auth_repository.mark_email_verification_token_used(token_hash)
|
||||
return user
|
||||
|
||||
|
||||
def resend_verification(email: str) -> None:
|
||||
user = repository.get_user_by_email(email)
|
||||
if not user or user.status != "pending":
|
||||
return
|
||||
token = _issue_verification_token(user.id)
|
||||
_send_verification_email(user, token)
|
||||
|
||||
|
||||
def forgot_password(email: str) -> None:
|
||||
user = repository.get_user_by_email(email)
|
||||
if not user:
|
||||
return
|
||||
token = _issue_password_reset_token(user.id)
|
||||
_send_password_reset_email(user, token)
|
||||
|
||||
|
||||
def reset_password(token: str, new_password: str) -> None:
|
||||
token_hash = hash_opaque_token(token)
|
||||
token_row = auth_repository.get_password_reset_token(token_hash)
|
||||
if not token_row or token_row.used_at is not None:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
if ensure_utc(token_row.expires_at) < utc_now():
|
||||
raise ValueError("TOKEN_EXPIRED")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise ValueError("INVALID_TOKEN")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
repository.update_user(user)
|
||||
auth_repository.mark_password_reset_token_used(token_hash)
|
||||
revoke_user_refresh_family(user.id)
|
||||
|
||||
|
||||
def _is_locked(user: User) -> bool:
|
||||
locked_until = ensure_utc(user.locked_until)
|
||||
if locked_until and locked_until > utc_now():
|
||||
return True
|
||||
if locked_until and locked_until <= utc_now():
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
repository.update_user(user)
|
||||
return False
|
||||
|
||||
|
||||
def _record_failed_login(user: User) -> None:
|
||||
locked_until = ensure_utc(user.locked_until)
|
||||
if locked_until and locked_until <= utc_now():
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= settings.auth_lockout_attempts:
|
||||
user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes)
|
||||
repository.update_user(user)
|
||||
|
||||
|
||||
def _reset_login_attempts(user: User) -> None:
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
repository.update_user(user)
|
||||
|
||||
|
||||
def login(email: str, password: str) -> tuple[str, str, User]:
|
||||
user = repository.get_user_by_email(email)
|
||||
if user and _is_locked(user):
|
||||
raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED")
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
if user:
|
||||
_record_failed_login(user)
|
||||
raise ValueError("INVALID_CREDENTIALS")
|
||||
if user.status == "pending":
|
||||
raise PermissionError("EMAIL_NOT_VERIFIED")
|
||||
if user.status == "blocked":
|
||||
raise PermissionError("ACCOUNT_BLOCKED")
|
||||
_reset_login_attempts(user)
|
||||
access_token = create_access_token(user.id, user.role, user.is_superuser)
|
||||
refresh_token = issue_refresh_token(user.id)
|
||||
return access_token, refresh_token, user
|
||||
|
||||
|
||||
def issue_refresh_token(user_id: str, family_id: str | None = None) -> str:
|
||||
token = generate_refresh_token()
|
||||
token_hash = hash_refresh_token(token)
|
||||
family = family_id or str(uuid4())
|
||||
auth_repository.create_refresh_token(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
family_id=family,
|
||||
expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def refresh(refresh_token: str) -> tuple[str, str, User]:
|
||||
token_hash = hash_refresh_token(refresh_token)
|
||||
token_row = auth_repository.get_refresh_token(token_hash)
|
||||
if not token_row:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if token_row.revoked_at is not None:
|
||||
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if ensure_utc(token_row.expires_at) < utc_now():
|
||||
raise PermissionError("EXPIRED_REFRESH")
|
||||
|
||||
user = repository.get_user_by_id(token_row.user_id)
|
||||
if not user:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
if user.status in {"pending", "blocked"}:
|
||||
raise PermissionError("INVALID_REFRESH")
|
||||
|
||||
auth_repository.revoke_refresh_token(token_hash)
|
||||
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
|
||||
access = create_access_token(user.id, user.role, user.is_superuser)
|
||||
return access, new_refresh, user
|
||||
|
||||
|
||||
def revoke_refresh_token(refresh_token: str) -> None:
|
||||
token_hash = hash_refresh_token(refresh_token)
|
||||
token_row = auth_repository.revoke_refresh_token(token_hash)
|
||||
if token_row:
|
||||
auth_repository.revoke_family_tokens(token_row.family_id)
|
||||
|
||||
|
||||
def revoke_user_refresh_family(user_id: str) -> None:
|
||||
auth_repository.revoke_user_families(user_id)
|
||||
@@ -0,0 +1 @@
|
||||
"""Content module."""
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ContentPage(Base):
|
||||
__tablename__ = "content_pages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
|
||||
author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.content.models import ContentPage
|
||||
|
||||
ALLOWED_PAGE_STATUSES = {"draft", "published"}
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def list_published_pages() -> list[ContentPage]:
|
||||
with session_scope() as db:
|
||||
pages = list(
|
||||
db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug))
|
||||
)
|
||||
return [_detach(db, page) for page in pages]
|
||||
|
||||
|
||||
def list_all_pages() -> list[ContentPage]:
|
||||
with session_scope() as db:
|
||||
pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug)))
|
||||
return [_detach(db, page) for page in pages]
|
||||
|
||||
|
||||
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||
with session_scope() as db:
|
||||
page = db.scalar(select(ContentPage).where(ContentPage.slug == slug))
|
||||
if not page:
|
||||
return None
|
||||
if include_draft or page.status == "published":
|
||||
return _detach(db, page)
|
||||
return None
|
||||
|
||||
|
||||
def get_page_by_id(page_id: str) -> ContentPage | None:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if not page:
|
||||
return None
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def create_page(
|
||||
slug: str,
|
||||
title: str,
|
||||
body: str,
|
||||
status: str,
|
||||
author_id: str,
|
||||
) -> ContentPage:
|
||||
if status not in ALLOWED_PAGE_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
with session_scope() as db:
|
||||
page = ContentPage(
|
||||
id=str(uuid4()),
|
||||
slug=slug,
|
||||
title=title,
|
||||
body=body,
|
||||
status=status,
|
||||
author_id=author_id,
|
||||
published_at=datetime.now(UTC) if status == "published" else None,
|
||||
)
|
||||
db.add(page)
|
||||
db.flush()
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def update_page(
|
||||
page_id: str,
|
||||
title: str | None,
|
||||
body: str | None,
|
||||
status: str | None,
|
||||
) -> ContentPage:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if not page:
|
||||
raise KeyError(page_id)
|
||||
if title:
|
||||
page.title = title
|
||||
if body:
|
||||
page.body = body
|
||||
if status:
|
||||
if status not in ALLOWED_PAGE_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
page.status = status
|
||||
if status == "published" and page.published_at is None:
|
||||
page.published_at = datetime.now(UTC)
|
||||
page.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def delete_page(page_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if page:
|
||||
db.delete(page)
|
||||
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.dependencies import require_admin
|
||||
from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn
|
||||
from app.modules.content.service import (
|
||||
create_page,
|
||||
delete_page,
|
||||
get_page_by_slug,
|
||||
list_all_pages,
|
||||
list_published_pages,
|
||||
page_exists,
|
||||
page_to_dict,
|
||||
update_page,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/pages")
|
||||
async def list_pages_route():
|
||||
pages = list_published_pages()
|
||||
return {"data": [page_to_dict(page) for page in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/manage/all")
|
||||
async def list_all_pages_route(_admin=Depends(require_admin)):
|
||||
pages = list_all_pages()
|
||||
return {"data": [page_to_dict(page) for page in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/{slug}")
|
||||
async def get_page_route(slug: str):
|
||||
page = get_page_by_slug(slug)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.post("/pages")
|
||||
async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)):
|
||||
page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id)
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)):
|
||||
if not page_exists(page_id):
|
||||
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||
page = update_page(page_id, payload.title, payload.body, payload.status)
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
async def delete_page_route(page_id: str, _admin=Depends(require_admin)):
|
||||
delete_page(page_id)
|
||||
return {"message": "deleted"}
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContentPageIn(BaseModel):
|
||||
slug: str = Field(min_length=2, max_length=120)
|
||||
title: str = Field(min_length=2, max_length=200)
|
||||
body: str
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
|
||||
|
||||
class ContentPagePatchIn(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=2, max_length=200)
|
||||
body: str | None = None
|
||||
status: Literal["draft", "published"] | None = None
|
||||
|
||||
|
||||
class ContentPageOut(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
title: str
|
||||
body: str
|
||||
status: str
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bleach
|
||||
|
||||
from app.modules.content import repository
|
||||
from app.modules.content.models import ContentPage
|
||||
|
||||
ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]
|
||||
|
||||
|
||||
def sanitize_html(raw_html: str) -> str:
|
||||
return bleach.clean(
|
||||
raw_html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes={"a": ["href"], "img": ["src", "alt"]},
|
||||
protocols=["http", "https", "mailto"],
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def page_to_dict(page: ContentPage) -> dict:
|
||||
return {
|
||||
"id": page.id,
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"body": page.body,
|
||||
"status": page.status,
|
||||
"author_id": page.author_id,
|
||||
"published_at": page.published_at.isoformat() if page.published_at else None,
|
||||
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_published_pages() -> list[ContentPage]:
|
||||
return repository.list_published_pages()
|
||||
|
||||
|
||||
def list_all_pages() -> list[ContentPage]:
|
||||
return repository.list_all_pages()
|
||||
|
||||
|
||||
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||
return repository.get_page_by_slug(slug, include_draft=include_draft)
|
||||
|
||||
|
||||
def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage:
|
||||
return repository.create_page(
|
||||
slug=slug,
|
||||
title=title,
|
||||
body=sanitize_html(body),
|
||||
status=status,
|
||||
author_id=author_id,
|
||||
)
|
||||
|
||||
|
||||
def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage:
|
||||
return repository.update_page(
|
||||
page_id,
|
||||
title,
|
||||
sanitize_html(body) if body else None,
|
||||
status,
|
||||
)
|
||||
|
||||
|
||||
def delete_page(page_id: str) -> None:
|
||||
repository.delete_page(page_id)
|
||||
|
||||
|
||||
def page_exists(page_id: str) -> bool:
|
||||
return repository.get_page_by_id(page_id) is not None
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.core.media_signing import verify_signed_media
|
||||
from app.core.storage import download_object
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
async def get_media_file(
|
||||
file_path: str,
|
||||
expires: int = Query(...),
|
||||
sig: str = Query(...),
|
||||
):
|
||||
if not file_path.startswith("avatars/"):
|
||||
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||
if not verify_signed_media(file_path, expires, sig):
|
||||
raise HTTPException(status_code=403, detail="INVALID_SIGNATURE")
|
||||
stored = download_object(file_path)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||
body, content_type = stored
|
||||
return Response(content=body, media_type=content_type)
|
||||
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AvatarUploadOut(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.storage import upload_object
|
||||
|
||||
ALLOWED_MIME = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
FORMAT_TO_MIME = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
"WEBP": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
class AvatarValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]:
|
||||
if len(content) > settings.avatar_max_bytes:
|
||||
raise AvatarValidationError("FILE_TOO_LARGE")
|
||||
if not content:
|
||||
raise AvatarValidationError("INVALID_IMAGE")
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
image.verify()
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
mime = FORMAT_TO_MIME.get(image.format or "")
|
||||
if mime not in ALLOWED_MIME:
|
||||
raise AvatarValidationError("INVALID_MIME")
|
||||
|
||||
buffer = BytesIO()
|
||||
if mime == "image/jpeg":
|
||||
rgb = image.convert("RGB")
|
||||
rgb.save(buffer, format="JPEG", quality=85, optimize=True)
|
||||
elif mime == "image/png":
|
||||
image.save(buffer, format="PNG", optimize=True)
|
||||
else:
|
||||
image.save(buffer, format="WEBP", quality=85, method=6)
|
||||
return mime, buffer.getvalue()
|
||||
except AvatarValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AvatarValidationError("INVALID_IMAGE") from exc
|
||||
|
||||
|
||||
def upload_user_avatar(user_id: str, content: bytes) -> str:
|
||||
mime, processed = validate_and_process_avatar(content)
|
||||
extension = ALLOWED_MIME[mime]
|
||||
key = f"avatars/{user_id}/{uuid4()}{extension}"
|
||||
upload_object(key, processed, mime)
|
||||
return f"/api/v1/media/files/{key}"
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Celery-ready notifications service placeholder.
|
||||
|
||||
MVP keeps SMTP sync path in auth; v1.0 should move to async queue.
|
||||
"""
|
||||
|
||||
|
||||
def enqueue_email(template: str, recipient: str, context: dict) -> dict:
|
||||
return {"queued": True, "template": template, "recipient": recipient, "context": context}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Orders module placeholder (request/booking mode without payment)."""
|
||||
|
||||
|
||||
def create_order_request(user_id: str, items: list[dict]) -> dict:
|
||||
return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"}
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.email import memory_mailer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/emails/latest-token")
|
||||
async def latest_email_token(to: str = Query(...), template: str = Query(...)):
|
||||
if not settings.enable_test_routes or settings.email_delivery_mode != "memory":
|
||||
raise HTTPException(status_code=404, detail="NOT_FOUND")
|
||||
token = memory_mailer.latest_token(to, template)
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND")
|
||||
return {"token": token}
|
||||
@@ -0,0 +1 @@
|
||||
"""Users module."""
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||
is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
profile: Mapped["UserProfile"] = relationship(
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="profile")
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.users.models import User, UserProfile
|
||||
|
||||
ALLOWED_ROLES = {"user", "admin"}
|
||||
ALLOWED_STATUSES = {"pending", "active", "blocked"}
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def create_user(
|
||||
email: str,
|
||||
password_hash: str,
|
||||
role: str = "user",
|
||||
is_superuser: bool = False,
|
||||
status: str = "pending",
|
||||
) -> User:
|
||||
if role not in ALLOWED_ROLES:
|
||||
raise ValueError("INVALID_ROLE")
|
||||
if status not in ALLOWED_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
if is_superuser and role != "admin":
|
||||
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||
with session_scope() as db:
|
||||
user = User(
|
||||
id=str(uuid4()),
|
||||
email=email.lower(),
|
||||
password_hash=password_hash,
|
||||
role=role,
|
||||
is_superuser=is_superuser,
|
||||
status=status,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
profile = UserProfile(user_id=user.id, display_name=email.split("@")[0])
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> User | None:
|
||||
with session_scope() as db:
|
||||
user = db.scalar(select(User).where(User.email == email.lower()))
|
||||
if not user:
|
||||
return None
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> User | None:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
return None
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def update_user(user: User) -> None:
|
||||
if user.role not in ALLOWED_ROLES:
|
||||
raise ValueError("INVALID_ROLE")
|
||||
if user.status not in ALLOWED_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
if user.is_superuser and user.role != "admin":
|
||||
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||
with session_scope() as db:
|
||||
db_user = db.get(User, user.id)
|
||||
if not db_user:
|
||||
return
|
||||
db_user.email = user.email
|
||||
db_user.password_hash = user.password_hash
|
||||
db_user.role = user.role
|
||||
db_user.is_superuser = user.is_superuser
|
||||
db_user.status = user.status
|
||||
db_user.failed_login_attempts = user.failed_login_attempts
|
||||
db_user.locked_until = user.locked_until
|
||||
db_user.email_verified_at = user.email_verified_at
|
||||
db_user.updated_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def list_users(page: int, limit: int) -> tuple[list[User], int]:
|
||||
with session_scope() as db:
|
||||
total = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
users = db.scalars(
|
||||
select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
).all()
|
||||
return [_detach(db, user) for user in users], total
|
||||
|
||||
|
||||
def count_users() -> int:
|
||||
with session_scope() as db:
|
||||
return db.scalar(select(func.count()).select_from(User)) or 0
|
||||
|
||||
|
||||
def count_users_registered_today() -> int:
|
||||
today = datetime.now(UTC).date()
|
||||
with session_scope() as db:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(func.date(User.created_at) == today)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def count_admins() -> int:
|
||||
with session_scope() as db:
|
||||
return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
|
||||
|
||||
|
||||
def count_superusers() -> int:
|
||||
with session_scope() as db:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.role == "admin", User.is_superuser.is_(True))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def delete_user(user_id: str) -> bool:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
return False
|
||||
db.delete(user)
|
||||
return True
|
||||
|
||||
|
||||
def get_profile(user_id: str) -> UserProfile:
|
||||
with session_scope() as db:
|
||||
profile = db.get(UserProfile, user_id)
|
||||
if not profile:
|
||||
raise KeyError(user_id)
|
||||
return _detach(db, profile)
|
||||
|
||||
|
||||
def update_profile(
|
||||
user_id: str,
|
||||
display_name: str | None = None,
|
||||
avatar_url: str | None = None,
|
||||
) -> UserProfile:
|
||||
with session_scope() as db:
|
||||
profile = db.get(UserProfile, user_id)
|
||||
if not profile:
|
||||
raise KeyError(user_id)
|
||||
if display_name is not None:
|
||||
profile.display_name = display_name
|
||||
if avatar_url is not None:
|
||||
profile.avatar_url = avatar_url
|
||||
db.flush()
|
||||
return _detach(db, profile)
|
||||
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.media_signing import build_signed_media_url
|
||||
from app.core.redis import check_rate_limit
|
||||
from app.modules.media.service import AvatarValidationError
|
||||
from app.modules.users.schemas import PasswordChangeIn, UserPatchIn
|
||||
from app.modules.users.service import change_password, get_me, update_me, upload_avatar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _profile_response(data: dict) -> dict:
|
||||
return {
|
||||
"user": {
|
||||
"id": data["user"].id,
|
||||
"email": data["user"].email,
|
||||
"role": data["user"].role,
|
||||
"status": data["user"].status,
|
||||
},
|
||||
"profile": {
|
||||
"display_name": data["profile"].display_name,
|
||||
"avatar_url": build_signed_media_url(data["profile"].avatar_url),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me_route(current_user=Depends(get_current_user)):
|
||||
return _profile_response(get_me(current_user))
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)):
|
||||
return _profile_response(update_me(current_user, payload.display_name))
|
||||
|
||||
|
||||
@router.post("/me/password")
|
||||
async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)):
|
||||
try:
|
||||
change_password(current_user, payload.current_password, payload.new_password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD")
|
||||
return {"message": "password_changed"}
|
||||
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_avatar_route(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600)
|
||||
content = await file.read()
|
||||
try:
|
||||
data = upload_avatar(current_user, content)
|
||||
except AvatarValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _profile_response(data)
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
status: str
|
||||
|
||||
|
||||
class UserProfileOut(BaseModel):
|
||||
display_name: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class UserMeOut(BaseModel):
|
||||
user: UserOut
|
||||
profile: UserProfileOut
|
||||
|
||||
|
||||
class UserPatchIn(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
@@ -0,0 +1,29 @@
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
from app.modules.media.service import upload_user_avatar
|
||||
from app.modules.users import repository
|
||||
from app.modules.users.models import User
|
||||
|
||||
|
||||
def get_me(user: User) -> dict:
|
||||
profile = repository.get_profile(user.id)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def update_me(user: User, display_name: str | None) -> dict:
|
||||
profile = repository.update_profile(user.id, display_name=display_name)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def upload_avatar(user: User, content: bytes) -> dict:
|
||||
avatar_url = upload_user_avatar(user.id, content)
|
||||
profile = repository.update_profile(user.id, avatar_url=avatar_url)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def change_password(user: User, current_password: str, new_password: str) -> None:
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise ValueError("INVALID_CURRENT_PASSWORD")
|
||||
user.password_hash = hash_password(new_password)
|
||||
repository.update_user(user)
|
||||
revoke_user_refresh_family(user.id)
|
||||
@@ -0,0 +1 @@
|
||||
"""Celery worker entrypoint placeholder for v1.0 notifications."""
|
||||
Reference in New Issue
Block a user