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,42 @@
|
||||
DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
JWT_ACCESS_SECRET=replace-with-32-byte-secret
|
||||
JWT_REFRESH_PEPPER=replace-with-32-byte-pepper
|
||||
JWT_ACCESS_TTL_MIN=15
|
||||
JWT_REFRESH_TTL_DAYS=30
|
||||
ENABLE_DOCS=true
|
||||
COOKIE_SECURE=false
|
||||
ENABLE_RATE_LIMIT=true
|
||||
APP_ENV=development
|
||||
AUTH_LOCKOUT_ATTEMPTS=5
|
||||
AUTH_LOCKOUT_MINUTES=15
|
||||
CORS_ORIGINS=["http://localhost:5173"]
|
||||
TRUSTED_PROXY_IPS=
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@compton.example
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
PUBLIC_BASE_URL=http://localhost:5173
|
||||
AUTH_TOKEN_TTL_HOURS=1
|
||||
EMAIL_DELIVERY_MODE=memory
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=minio
|
||||
S3_SECRET_KEY=minio123
|
||||
S3_BUCKET=compton
|
||||
S3_REGION=us-east-1
|
||||
STORAGE_MODE=s3
|
||||
AVATAR_MAX_BYTES=2097152
|
||||
MEDIA_URL_TTL_SECONDS=600
|
||||
LOG_LEVEL=INFO
|
||||
AUDIT_RETENTION_DAYS=90
|
||||
COMPTON_SETTINGS_PATH=data/compton_settings.json
|
||||
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
|
||||
SERVER_LOG_PATH=data/logs/server.log
|
||||
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
|
||||
ADMIN_INITIAL_PASSWORD=Admin1234
|
||||
DEMO_USER_PASSWORD=User1234
|
||||
DEMO_OPS_PASSWORD=OpsAdmin1234
|
||||
# E2E only, never enable in production.
|
||||
ENABLE_TEST_ROUTES=false
|
||||
@@ -0,0 +1,11 @@
|
||||
DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test
|
||||
REDIS_URL=redis://localhost:6380/0
|
||||
JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum
|
||||
JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min
|
||||
JWT_ACCESS_TTL_MIN=15
|
||||
JWT_REFRESH_TTL_DAYS=30
|
||||
ENABLE_DOCS=true
|
||||
CORS_ORIGINS=["http://localhost:5175"]
|
||||
EMAIL_DELIVERY_MODE=memory
|
||||
STORAGE_MODE=memory
|
||||
ENABLE_TEST_ROUTES=true
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
COPY requirements-dev.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
CMD ["python", "scripts/docker_entrypoint.py"]
|
||||
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -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."""
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"enable_rate_limit": false,
|
||||
"enable_docs": false,
|
||||
"cookie_secure": false,
|
||||
"jwt_access_ttl_min": 15,
|
||||
"auth_lockout_attempts": 5,
|
||||
"auth_lockout_minutes": 15,
|
||||
"cors_origins": [
|
||||
"http://localhost:5173"
|
||||
],
|
||||
"frontend_url": "http://localhost:5173",
|
||||
"public_base_url": "http://localhost:5173",
|
||||
"smtp_host": "localhost",
|
||||
"smtp_port": 1025,
|
||||
"smtp_from": "noreply@compton.example",
|
||||
"avatar_max_bytes": 2097152,
|
||||
"media_url_ttl_seconds": 600,
|
||||
"log_level": "INFO",
|
||||
"audit_retention_days": 90,
|
||||
"jwt_refresh_ttl_days": 30
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{"timestamp": "2026-07-14T09:45:09.679160+00:00", "action": "admin.settings.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T09:45:10.079262+00:00", "action": "admin.user.create", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "2effcf29-377b-417d-a804-d19cb4c9e2a1"}}
|
||||
{"timestamp": "2026-07-14T09:45:11.267271+00:00", "action": "admin.user.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "a508d851-1f17-4397-a272-c67ac65d0b12"}}
|
||||
{"timestamp": "2026-07-14T09:45:24.615324+00:00", "action": "admin.settings.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T09:45:25.017710+00:00", "action": "admin.user.create", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
|
||||
{"timestamp": "2026-07-14T09:45:25.217777+00:00", "action": "admin.user.delete", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}}
|
||||
{"timestamp": "2026-07-14T09:45:25.431192+00:00", "action": "admin.user.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "55c1800f-e445-4eae-b84a-836c27ec88de"}}
|
||||
{"timestamp": "2026-07-14T09:45:43.063910+00:00", "action": "admin.settings.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T09:45:43.471557+00:00", "action": "admin.user.create", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
|
||||
{"timestamp": "2026-07-14T09:45:43.677436+00:00", "action": "admin.user.delete", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}}
|
||||
{"timestamp": "2026-07-14T09:45:43.890842+00:00", "action": "admin.user.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "5094baf7-5c73-4e9d-a1bb-8baa98516303"}}
|
||||
{"timestamp": "2026-07-14T09:46:32.753967+00:00", "action": "admin.settings.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T09:46:33.160994+00:00", "action": "admin.user.create", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
|
||||
{"timestamp": "2026-07-14T09:46:33.371828+00:00", "action": "admin.user.delete", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}}
|
||||
{"timestamp": "2026-07-14T09:46:33.574496+00:00", "action": "admin.user.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "9c93dc2b-b171-486e-ac8b-855a657dd4db"}}
|
||||
{"timestamp": "2026-07-14T10:17:13.813043+00:00", "action": "admin.settings.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:17:15.032768+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "a7f9dad1-652a-4a78-8eb4-559d73633cbb"}}
|
||||
{"timestamp": "2026-07-14T10:17:17.964910+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "14c90906-7e65-4c2f-bc10-3e2c37771f77"}}
|
||||
{"timestamp": "2026-07-14T10:17:21.299378+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "4fb379b6-d751-4b74-8441-dacfc765a1b6"}}
|
||||
{"timestamp": "2026-07-14T10:18:21.413246+00:00", "action": "admin.settings.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:18:21.831392+00:00", "action": "admin.user.create", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
|
||||
{"timestamp": "2026-07-14T10:18:22.043824+00:00", "action": "admin.user.delete", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}}
|
||||
{"timestamp": "2026-07-14T10:18:22.667006+00:00", "action": "admin.secrets.reveal", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
|
||||
{"timestamp": "2026-07-14T10:18:23.044151+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "19bd08e1-1fd2-45d6-9d2e-910b8021060d"}}
|
||||
{"timestamp": "2026-07-14T10:18:25.817132+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3e2fb3bf-a9c3-47e0-9c7d-5b9a4fff37d5"}}
|
||||
{"timestamp": "2026-07-14T10:18:29.042541+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "25c9b2af-b46b-4c38-96e8-ca504e3d05ef"}}
|
||||
{"timestamp": "2026-07-14T10:18:59.773024+00:00", "action": "admin.settings.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:19:00.184666+00:00", "action": "admin.user.create", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
|
||||
{"timestamp": "2026-07-14T10:19:00.387212+00:00", "action": "admin.user.delete", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}}
|
||||
{"timestamp": "2026-07-14T10:19:01.013462+00:00", "action": "admin.secrets.reveal", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
|
||||
{"timestamp": "2026-07-14T10:19:01.232252+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "2a167305-8874-4b4b-9485-2f01dbc239e7"}}
|
||||
{"timestamp": "2026-07-14T10:19:03.987813+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "022de58f-d06e-42a7-bd93-76e1df17848c"}}
|
||||
{"timestamp": "2026-07-14T10:19:07.219981+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "589bffa1-74dd-401a-a21e-93721d7d9c4e"}}
|
||||
{"timestamp": "2026-07-14T10:19:24.488155+00:00", "action": "admin.settings.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:19:24.903805+00:00", "action": "admin.user.create", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
|
||||
{"timestamp": "2026-07-14T10:19:25.101700+00:00", "action": "admin.user.delete", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}}
|
||||
{"timestamp": "2026-07-14T10:19:25.741900+00:00", "action": "admin.secrets.reveal", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
|
||||
{"timestamp": "2026-07-14T10:19:25.941995+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "3c69f5f3-af00-4d5c-85d6-80a65cb7cf08"}}
|
||||
{"timestamp": "2026-07-14T10:19:28.720348+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a4c56a38-9f0a-48dd-80e2-96ee38f1bde1"}}
|
||||
{"timestamp": "2026-07-14T10:19:31.957425+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d91c9691-8277-44f5-8f02-534f6a1b75e4"}}
|
||||
{"timestamp": "2026-07-14T10:20:50.255505+00:00", "action": "admin.settings.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:20:50.666682+00:00", "action": "admin.user.create", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
|
||||
{"timestamp": "2026-07-14T10:20:50.885241+00:00", "action": "admin.user.delete", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}}
|
||||
{"timestamp": "2026-07-14T10:20:51.518235+00:00", "action": "admin.secrets.reveal", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
|
||||
{"timestamp": "2026-07-14T10:20:51.717954+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "81911a53-d750-480b-aca2-a6c79488cf84"}}
|
||||
{"timestamp": "2026-07-14T10:20:54.465598+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "2c535720-06d4-4c30-8d00-36c43419c9bf"}}
|
||||
{"timestamp": "2026-07-14T10:20:57.747476+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "6de83480-a867-4166-97e4-6c5a053ae771"}}
|
||||
{"timestamp": "2026-07-14T10:50:15.053615+00:00", "action": "admin.settings.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}}
|
||||
{"timestamp": "2026-07-14T10:50:15.469330+00:00", "action": "admin.user.create", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
|
||||
{"timestamp": "2026-07-14T10:50:15.684274+00:00", "action": "admin.user.delete", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}}
|
||||
{"timestamp": "2026-07-14T10:50:16.307486+00:00", "action": "admin.secrets.reveal", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"key": "database_password"}}
|
||||
{"timestamp": "2026-07-14T10:50:16.524434+00:00", "action": "admin.user.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "5afc1722-6ef5-4c0e-817a-fd1f4af99188"}}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
# Import models so Alembic can discover metadata.
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401
|
||||
from app.modules.content.models import ContentPage # noqa: F401
|
||||
from app.modules.users.models import User, UserProfile # noqa: F401
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 20260711_0001
|
||||
Revises:
|
||||
Create Date: 2026-07-11 14:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260711_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=16), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("failed_login_attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("idx_users_email", "users", ["email"], unique=True)
|
||||
op.create_index("idx_users_status", "users", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"user_profiles",
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(length=512), nullable=True),
|
||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("family_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False)
|
||||
op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"password_reset_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"email_verification_tokens",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"content_pages",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("author_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["author_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True)
|
||||
op.create_index("idx_content_status", "content_pages", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_content_status", table_name="content_pages")
|
||||
op.drop_index("idx_content_slug", table_name="content_pages")
|
||||
op.drop_table("content_pages")
|
||||
op.drop_table("email_verification_tokens")
|
||||
op.drop_index("idx_password_reset_user", table_name="password_reset_tokens")
|
||||
op.drop_table("password_reset_tokens")
|
||||
op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens")
|
||||
op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens")
|
||||
op.drop_table("refresh_tokens")
|
||||
op.drop_table("user_profiles")
|
||||
op.drop_index("idx_users_status", table_name="users")
|
||||
op.drop_index("idx_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""seed admin and demo content
|
||||
|
||||
Revision ID: 20260711_0002
|
||||
Revises: 20260711_0001
|
||||
Create Date: 2026-07-11 14:05:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260711_0002"
|
||||
down_revision: Union[str, None] = "20260711_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed)
|
||||
# after all schema migrations, including is_superuser (20260714_0003).
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.content.models import ContentPage
|
||||
from app.modules.users.models import User
|
||||
|
||||
with session_scope() as db:
|
||||
db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"])))
|
||||
db.execute(delete(User).where(User.email == "admin@compton.example"))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add is_superuser to users
|
||||
|
||||
Revision ID: 20260714_0003
|
||||
Revises: 20260711_0002
|
||||
Create Date: 2026-07-14 12:45:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0003"
|
||||
down_revision: Union[str, None] = "20260711_0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.alter_column("users", "is_superuser", server_default=None)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = true "
|
||||
"WHERE email = 'admin@compton.example' AND role = 'admin'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "is_superuser")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""db security constraints and token indexes
|
||||
|
||||
Revision ID: 20260714_0004
|
||||
Revises: 20260714_0003
|
||||
Create Date: 2026-07-14 13:30:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0004"
|
||||
down_revision: Union[str, None] = "20260714_0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.create_check_constraint(
|
||||
"ck_users_role_allowed",
|
||||
"users",
|
||||
"role IN ('user', 'admin')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_status_allowed",
|
||||
"users",
|
||||
"status IN ('pending', 'active', 'blocked')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_users_superuser_requires_admin",
|
||||
"users",
|
||||
"(is_superuser = false) OR (role = 'admin')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_content_pages_status_allowed",
|
||||
"content_pages",
|
||||
"status IN ('draft', 'published')",
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False)
|
||||
op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False)
|
||||
op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens")
|
||||
op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens")
|
||||
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "sqlite":
|
||||
op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check")
|
||||
op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check")
|
||||
op.drop_constraint("ck_users_status_allowed", "users", type_="check")
|
||||
op.drop_constraint("ck_users_role_allowed", "users", type_="check")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""backfill superuser flag for seeded admin
|
||||
|
||||
Revision ID: 20260714_0005
|
||||
Revises: 20260714_0004
|
||||
Create Date: 2026-07-14 14:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0005"
|
||||
down_revision: Union[str, None] = "20260714_0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = true "
|
||||
"WHERE email = 'admin@compton.example' AND role = 'admin'"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET is_superuser = false "
|
||||
"WHERE email IN ('ops@compton.example', 'user@compton.example')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,13 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
check_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
@@ -0,0 +1,24 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.32.0
|
||||
sqlalchemy>=2.0.36
|
||||
alembic>=1.13.3
|
||||
psycopg[binary]>=3.2.0
|
||||
asyncpg>=0.30.0
|
||||
redis>=5.1.1
|
||||
python-jose>=3.3.0
|
||||
bcrypt>=4.0.0,<5.0.0
|
||||
pydantic>=2.9.2
|
||||
email-validator>=2.2.0
|
||||
pydantic-settings>=2.6.0
|
||||
httpx>=0.27.2
|
||||
pytest>=8.3.3
|
||||
pytest-asyncio>=0.24.0
|
||||
pytest-cov>=5.0.0
|
||||
mypy>=1.11.2
|
||||
ruff>=0.7.1
|
||||
pip-audit>=2.7.3
|
||||
bandit>=1.7.9
|
||||
bleach>=6.1.0
|
||||
python-multipart>=0.0.12
|
||||
boto3>=1.35.0
|
||||
Pillow>=10.4.0
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.install_secrets import ensure_install_secrets
|
||||
|
||||
|
||||
def main() -> None:
|
||||
status = ensure_install_secrets()
|
||||
if status.created:
|
||||
print(f"Created install secrets at {status.path}")
|
||||
print(
|
||||
"\nIf docker compose was already started without install.env, reset the Postgres volume "
|
||||
"before the next start (local dev only — deletes DB data):\n"
|
||||
" docker compose --profile docker-web down -v\n"
|
||||
" docker compose --profile docker-web up -d --build"
|
||||
)
|
||||
else:
|
||||
print(f"Install secrets already exist at {status.path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.core.install_secrets import ensure_install_secrets
|
||||
from app.core.config import settings
|
||||
|
||||
INITIAL_REVISION = "20260711_0001"
|
||||
SCHEMA_TABLES = (
|
||||
"content_pages",
|
||||
"email_verification_tokens",
|
||||
"password_reset_tokens",
|
||||
"refresh_tokens",
|
||||
"user_profiles",
|
||||
"users",
|
||||
)
|
||||
|
||||
|
||||
def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
|
||||
engine = create_engine(settings.database_url)
|
||||
last_error: Exception | None = None
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
return engine
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
time.sleep(delay_seconds)
|
||||
detail = str(last_error or "unknown error")
|
||||
hint = ""
|
||||
if "password authentication failed" in detail or "does not exist" in detail:
|
||||
hint = (
|
||||
"\n\nPostgres credentials in install.env do not match the existing database volume "
|
||||
"(common if docker compose ran before bootstrap_install.py).\n"
|
||||
"Local dev fix — deletes Docker DB data:\n"
|
||||
" docker compose --profile docker-web down -v\n"
|
||||
" docker compose --profile docker-web up -d --build\n"
|
||||
"See docs/secrets-recovery.md"
|
||||
)
|
||||
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
|
||||
|
||||
|
||||
def current_revision(engine) -> str | None:
|
||||
inspector = inspect(engine)
|
||||
if "alembic_version" not in inspector.get_table_names():
|
||||
return None
|
||||
with engine.connect() as connection:
|
||||
return connection.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _reset_schema(engine) -> None:
|
||||
cascade = " CASCADE" if engine.dialect.name == "postgresql" else ""
|
||||
with engine.begin() as connection:
|
||||
for table in SCHEMA_TABLES:
|
||||
connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}'))
|
||||
connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}'))
|
||||
|
||||
|
||||
def run_migrations(engine) -> None:
|
||||
config = Config("alembic.ini")
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
revision = current_revision(engine)
|
||||
schema_tables = set(SCHEMA_TABLES)
|
||||
existing_schema = tables & schema_tables
|
||||
|
||||
if existing_schema:
|
||||
if schema_tables.issubset(tables):
|
||||
if revision is None:
|
||||
command.stamp(config, INITIAL_REVISION)
|
||||
else:
|
||||
_reset_schema(engine)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ensure_install_secrets()
|
||||
engine = wait_for_database()
|
||||
run_migrations(engine)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
schema = app.openapi()
|
||||
output = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
output.write_text(json.dumps(schema, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Apply Alembic migrations."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[1]
|
||||
os.chdir(API_DIR)
|
||||
sys.path.insert(0, str(API_DIR))
|
||||
|
||||
db_file = API_DIR / ".e2e.sqlite"
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}"
|
||||
os.environ["EMAIL_DELIVERY_MODE"] = "memory"
|
||||
os.environ["STORAGE_MODE"] = "memory"
|
||||
os.environ["ENABLE_RATE_LIMIT"] = "false"
|
||||
os.environ["ENABLE_TEST_ROUTES"] = "true"
|
||||
web_port = os.environ.get("E2E_WEB_PORT", "5175")
|
||||
os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]'
|
||||
|
||||
port = os.environ.get("E2E_API_PORT", "8001")
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port],
|
||||
cwd=API_DIR,
|
||||
check=True,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.core import database as db_module
|
||||
from app.core.email import memory_mailer
|
||||
from app.core.storage import memory_store
|
||||
from app.db.base import Base
|
||||
from app.db.seed import run_seed
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database():
|
||||
Base.metadata.create_all(db_module.engine)
|
||||
run_seed(include_demo_pages=False)
|
||||
yield
|
||||
Base.metadata.drop_all(db_module.engine)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_rate_limits(monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", False)
|
||||
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
|
||||
monkeypatch.setattr(settings, "storage_mode", "memory")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_email_outbox():
|
||||
memory_mailer.clear()
|
||||
yield
|
||||
memory_mailer.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_storage():
|
||||
memory_store.clear()
|
||||
yield
|
||||
memory_store.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
@@ -0,0 +1,38 @@
|
||||
from app.core.media_signing import build_signed_media_url, verify_signed_media
|
||||
from app.core.config import settings
|
||||
import time
|
||||
|
||||
|
||||
def test_build_and_verify_signed_media_url():
|
||||
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
|
||||
assert signed is not None
|
||||
assert "expires=" in signed
|
||||
assert "sig=" in signed
|
||||
|
||||
path = "avatars/user/file.png"
|
||||
query = signed.split("?", 1)[1]
|
||||
params = dict(part.split("=") for part in query.split("&"))
|
||||
assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds
|
||||
assert verify_signed_media(path, int(params["expires"]), params["sig"])
|
||||
|
||||
|
||||
def test_build_signed_media_url_none():
|
||||
assert build_signed_media_url(None) is None
|
||||
|
||||
|
||||
def test_verify_signed_media_rejects_expired_signature():
|
||||
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
|
||||
assert signed is not None
|
||||
path = "avatars/user/file.png"
|
||||
query = signed.split("?", 1)[1]
|
||||
params = dict(part.split("=") for part in query.split("&"))
|
||||
assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"])
|
||||
|
||||
|
||||
def test_verify_signed_media_rejects_tampered_signature():
|
||||
signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png")
|
||||
assert signed is not None
|
||||
path = "avatars/user/file.png"
|
||||
query = signed.split("?", 1)[1]
|
||||
params = dict(part.split("=") for part in query.split("&"))
|
||||
assert not verify_signed_media(path, int(params["expires"]), "invalid")
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.core.media_signing import build_signed_media_url
|
||||
|
||||
|
||||
def test_build_signed_media_url_passthrough():
|
||||
external = "https://cdn.example.com/avatar.png"
|
||||
assert build_signed_media_url(external) == external
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.core.password_denylist import is_denied_password
|
||||
|
||||
|
||||
def test_password_denylist_blocks_common_password():
|
||||
assert is_denied_password("password123")
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.core.storage import ensure_bucket
|
||||
|
||||
|
||||
def test_ensure_bucket_noop_in_memory():
|
||||
ensure_bucket()
|
||||
@@ -0,0 +1,32 @@
|
||||
from app.db.seed import run_seed
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
|
||||
def test_seed_ensures_admin_is_superuser():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
assert admin is not None
|
||||
admin.is_superuser = False
|
||||
from app.modules.users import repository
|
||||
|
||||
repository.update_user(admin)
|
||||
|
||||
run_seed(include_demo_pages=False)
|
||||
|
||||
refreshed = get_user_by_email("admin@compton.example")
|
||||
assert refreshed is not None
|
||||
assert refreshed.is_superuser is True
|
||||
|
||||
|
||||
def test_seed_ensures_ops_is_not_superuser():
|
||||
ops = get_user_by_email("ops@compton.example")
|
||||
assert ops is not None
|
||||
ops.is_superuser = True
|
||||
from app.modules.users import repository
|
||||
|
||||
repository.update_user(ops)
|
||||
|
||||
run_seed(include_demo_pages=False)
|
||||
|
||||
refreshed = get_user_by_email("ops@compton.example")
|
||||
assert refreshed is not None
|
||||
assert refreshed.is_superuser is False
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.db.token_cleanup import cleanup_expired_tokens
|
||||
from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
|
||||
def test_cleanup_expired_tokens_removes_stale_rows():
|
||||
user = get_user_by_email("admin@compton.example")
|
||||
now = datetime.now(UTC)
|
||||
with session_scope() as db:
|
||||
db.add(
|
||||
RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash="f" * 64,
|
||||
family_id="fam-cleanup-1",
|
||||
expires_at=now - timedelta(days=2),
|
||||
revoked_at=now - timedelta(days=2),
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token_hash="e" * 64,
|
||||
expires_at=now - timedelta(days=1),
|
||||
used_at=None,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
EmailVerificationToken(
|
||||
user_id=user.id,
|
||||
token_hash="d" * 64,
|
||||
expires_at=now - timedelta(days=1),
|
||||
used_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
result = cleanup_expired_tokens(retention_days=1)
|
||||
assert result["refresh_tokens_deleted"] >= 1
|
||||
assert result["password_reset_tokens_deleted"] >= 1
|
||||
assert result["email_verification_tokens_deleted"] >= 1
|
||||
@@ -0,0 +1,3 @@
|
||||
def test_health_smoke(client):
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def make_test_png() -> bytes:
|
||||
image = Image.new("RGB", (8, 8), color=(70, 129, 109))
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.email import memory_mailer
|
||||
|
||||
|
||||
def clear_sent_emails() -> None:
|
||||
memory_mailer.clear()
|
||||
|
||||
|
||||
def latest_token(recipient: str, template: str) -> str:
|
||||
token = memory_mailer.latest_token(recipient, template)
|
||||
if not token:
|
||||
raise AssertionError(f"No {template} email sent to {recipient}")
|
||||
return token
|
||||
|
||||
|
||||
def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None:
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": password})
|
||||
token = latest_token(email, "verify_email")
|
||||
response = client.post("/api/v1/auth/verify-email", json={"token": token})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]:
|
||||
register_and_verify(client, email, password)
|
||||
login = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert login.status_code == 200
|
||||
return {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _super_headers(client) -> dict[str, str]:
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@compton.example", "password": "Admin1234"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
return {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_admin_create_user_rejects_weak_password(client):
|
||||
headers = _super_headers(client)
|
||||
response = client.post(
|
||||
"/api/v1/admin/users",
|
||||
headers=headers,
|
||||
json={
|
||||
"email": "weak-pass@example.com",
|
||||
"password": "password",
|
||||
"role": "user",
|
||||
"is_superuser": False,
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,73 @@
|
||||
from app.core.security import hash_password
|
||||
from app.modules.users import repository
|
||||
|
||||
|
||||
def _admin_headers(client):
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@compton.example", "password": "Admin1234"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _plain_admin_headers(client):
|
||||
email = "ops-admin@compton.example"
|
||||
if not repository.get_user_by_email(email):
|
||||
repository.create_user(
|
||||
email=email,
|
||||
password_hash=hash_password("Admin1234"),
|
||||
role="admin",
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"})
|
||||
token = login.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_admin_users_list(client):
|
||||
response = client.get("/api/v1/admin/users", headers=_admin_headers(client))
|
||||
assert response.status_code == 200
|
||||
assert "data" in response.json()
|
||||
|
||||
|
||||
def test_non_superuser_cannot_patch_settings(client):
|
||||
response = client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"values": {"enable_docs": False}},
|
||||
headers=_plain_admin_headers(client),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "SUPERUSER_ONLY"
|
||||
|
||||
|
||||
def test_superuser_can_patch_settings(client):
|
||||
response = client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"values": {"enable_docs": False}},
|
||||
headers=_admin_headers(client),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "values" in payload
|
||||
|
||||
|
||||
def test_superuser_can_create_and_delete_user(client):
|
||||
created = client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={
|
||||
"email": "created-by-admin@compton.example",
|
||||
"password": "StrongPass123A",
|
||||
"role": "user",
|
||||
"is_superuser": False,
|
||||
"status": "active",
|
||||
},
|
||||
headers=_admin_headers(client),
|
||||
)
|
||||
assert created.status_code == 200
|
||||
user_id = created.json()["id"]
|
||||
|
||||
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json()["status"] == "deleted"
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> dict[str, str]:
|
||||
response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert response.status_code == 200
|
||||
return {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_superuser_can_read_install_secrets(client):
|
||||
headers = _login(client, "admin@compton.example", "Admin1234")
|
||||
response = client.get("/api/v1/admin/secrets", headers=headers)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "secrets_status" in payload
|
||||
assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"}
|
||||
assert "POSTGRES_PASSWORD" not in str(payload)
|
||||
|
||||
|
||||
def test_non_superuser_cannot_read_install_secrets(client):
|
||||
headers = _login(client, "ops@compton.example", "OpsAdmin1234")
|
||||
response = client.get("/api/v1/admin/secrets", headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_can_reveal_db_password(client):
|
||||
headers = _login(client, "admin@compton.example", "Admin1234")
|
||||
response = client.post(
|
||||
"/api/v1/admin/secrets/reveal",
|
||||
headers=headers,
|
||||
json={"key": "database_password"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["key"] == "database_password"
|
||||
assert "value" in response.json()
|
||||
@@ -0,0 +1,33 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.modules.admin.service import patch_user
|
||||
from app.modules.users import repository
|
||||
from app.modules.users.repository import create_user, get_user_by_email
|
||||
from app.core.security import hash_password
|
||||
|
||||
|
||||
def test_admin_cannot_self_demote():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
try:
|
||||
patch_user(admin, admin.id, "user", None)
|
||||
assert False, "Expected self-demotion error"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "SELF_DEMOTION_FORBIDDEN"
|
||||
|
||||
|
||||
def test_admin_can_promote_user():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active")
|
||||
result = patch_user(admin, regular.id, "admin", None)
|
||||
assert result["role"] == "admin"
|
||||
|
||||
|
||||
def test_last_admin_protected():
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active")
|
||||
with patch.object(repository, "count_admins", return_value=1):
|
||||
try:
|
||||
patch_user(admin, target.id, "user", None)
|
||||
assert False, "Expected last-admin protection"
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "LAST_ADMIN_PROTECTED"
|
||||
@@ -0,0 +1,42 @@
|
||||
from tests.helpers import latest_token, register_and_verify
|
||||
|
||||
|
||||
def test_health(client):
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_register_and_verify_and_login(client):
|
||||
register_response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert register_response.status_code == 200
|
||||
assert "user_id" not in register_response.json()
|
||||
|
||||
duplicate = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert duplicate.status_code == 200
|
||||
assert "user_id" not in duplicate.json()
|
||||
|
||||
verify_response = client.post(
|
||||
"/api/v1/auth/verify-email",
|
||||
json={"token": latest_token("user@example.com", "verify_email")},
|
||||
)
|
||||
assert verify_response.status_code == 200
|
||||
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "user@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
assert "access_token" in login_response.json()
|
||||
|
||||
|
||||
def test_forgot_password_anti_enumeration(client):
|
||||
response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"})
|
||||
assert response.status_code == 200
|
||||
assert "If email is registered" in response.json()["message"]
|
||||
@@ -0,0 +1,20 @@
|
||||
from tests.helpers import latest_token
|
||||
|
||||
|
||||
def test_verify_email_rejects_invalid_token(client):
|
||||
client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"})
|
||||
response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_resend_verification_sends_new_token(client):
|
||||
client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"})
|
||||
first_token = latest_token("resend@example.com", "verify_email")
|
||||
|
||||
client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"})
|
||||
second_token = latest_token("resend@example.com", "verify_email")
|
||||
assert first_token != second_token
|
||||
|
||||
verify = client.post("/api/v1/auth/verify-email", json={"token": second_token})
|
||||
assert verify.status_code == 200
|
||||
@@ -0,0 +1,19 @@
|
||||
from tests.helpers import latest_token, register_and_verify
|
||||
|
||||
|
||||
def test_auth_brute_force_lockout(client):
|
||||
register_and_verify(client, "locked@example.com")
|
||||
|
||||
for _ in range(5):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "locked@example.com", "password": "WrongPass1"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
locked = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "locked@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert locked.status_code == 429
|
||||
assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED"
|
||||
@@ -0,0 +1,7 @@
|
||||
from app.core.password_policy import validate_password_strength
|
||||
import pytest
|
||||
|
||||
|
||||
def test_password_policy_rejects_denied_password():
|
||||
with pytest.raises(ValueError, match="too common"):
|
||||
validate_password_strength("Password123")
|
||||
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
def test_password_policy_accepts_valid_password():
|
||||
assert validate_password_strength("Valid123") == "Valid123"
|
||||
|
||||
|
||||
def test_password_policy_rejects_weak_password():
|
||||
with pytest.raises(ValueError, match="uppercase letter"):
|
||||
validate_password_strength("valid123")
|
||||
@@ -0,0 +1,22 @@
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def test_login_rate_limit(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "enable_rate_limit", True)
|
||||
|
||||
email = f"missing-{uuid4().hex}@example.com"
|
||||
for _ in range(5):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "Valid123"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
blocked = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "Valid123"},
|
||||
)
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json()["detail"] == "RATE_LIMIT_EXCEEDED"
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.helpers import register_and_verify
|
||||
|
||||
|
||||
def _admin_headers(client) -> dict[str, str]:
|
||||
from app.core.security import create_access_token
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
token = create_access_token(admin.id, admin.role, admin.is_superuser)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_refresh_fails_for_blocked_user(client):
|
||||
register_and_verify(client, "blocked-refresh@example.com")
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "blocked-refresh@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
admin_headers = _admin_headers(client)
|
||||
me = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {login.json()['access_token']}"},
|
||||
)
|
||||
user_id = me.json()["user"]["id"]
|
||||
blocked = client.patch(
|
||||
f"/api/v1/admin/users/{user_id}",
|
||||
headers=admin_headers,
|
||||
json={"status": "blocked"},
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
|
||||
assert refresh.status_code == 401
|
||||
|
||||
|
||||
def test_refresh_requires_origin_header_when_cookie_present(client):
|
||||
register_and_verify(client, "origin-required@example.com")
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "origin-required@example.com", "password": "Valid123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "INVALID_ORIGIN"
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
generate_refresh_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_password_hashing_roundtrip():
|
||||
hashed = hash_password("Strong123")
|
||||
assert verify_password("Strong123", hashed) is True
|
||||
|
||||
|
||||
def test_access_token_encode_decode():
|
||||
token = create_access_token("u1", "user", False)
|
||||
payload = decode_access_token(token)
|
||||
assert payload["sub"] == "u1"
|
||||
assert payload["role"] == "user"
|
||||
assert payload["is_superuser"] is False
|
||||
|
||||
|
||||
def test_refresh_token_hashing():
|
||||
token = generate_refresh_token()
|
||||
assert hash_refresh_token(token) == hash_refresh_token(token)
|
||||
@@ -0,0 +1,45 @@
|
||||
def _admin_headers(client):
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@compton.example", "password": "Admin1234"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_create_and_read_published_page(client):
|
||||
headers = _admin_headers(client)
|
||||
created = client.post(
|
||||
"/api/v1/content/pages",
|
||||
headers=headers,
|
||||
json={"slug": "about", "title": "About", "body": "<p>Hello</p>", "status": "published"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
fetched = client.get("/api/v1/content/pages/about")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["slug"] == "about"
|
||||
|
||||
|
||||
def test_list_all_pages_requires_admin(client):
|
||||
response = client.get("/api/v1/content/pages/manage/all")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_all_pages_includes_drafts(client):
|
||||
headers = _admin_headers(client)
|
||||
created = client.post(
|
||||
"/api/v1/content/pages",
|
||||
headers=headers,
|
||||
json={"slug": "draft-page", "title": "Draft", "body": "<p>Draft</p>", "status": "draft"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
|
||||
listed = client.get("/api/v1/content/pages/manage/all", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
slugs = [page["slug"] for page in listed.json()["data"]]
|
||||
assert "draft-page" in slugs
|
||||
|
||||
public = client.get("/api/v1/content/pages")
|
||||
public_slugs = [page["slug"] for page in public.json()["data"]]
|
||||
assert "draft-page" not in public_slugs
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.content.service import sanitize_html
|
||||
|
||||
|
||||
def test_sanitize_html_strips_javascript_protocol():
|
||||
raw = '<a href="javascript:alert(1)">x</a><img src="javascript:alert(1)" alt="x" />'
|
||||
clean = sanitize_html(raw)
|
||||
assert "javascript:" not in clean
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user