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:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Application modules."""
+1
View File
@@ -0,0 +1 @@
"""Admin module."""
+142
View File
@@ -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))
+46
View File
@@ -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}
+206
View File
@@ -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(),
}
+1
View File
@@ -0,0 +1 @@
"""Auth module."""
+43
View File
@@ -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)
+132
View File
@@ -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)
+206
View File
@@ -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."}
+69
View File
@@ -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
+228
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Content module."""
+27
View File
@@ -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),
)
+105
View File
@@ -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)
+56
View File
@@ -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"}
+24
View File
@@ -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
+70
View File
@@ -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
+24
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class AvatarUploadOut(BaseModel):
message: str
+62
View File
@@ -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}
+5
View File
@@ -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"}
+16
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
"""Users module."""
+52
View File
@@ -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")
+164
View File
@@ -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)
+59
View File
@@ -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)
+34
View File
@@ -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)
+29
View File
@@ -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)