Files
ERP/backend/app/auth.py
T

137 lines
5.0 KiB
Python

"""Casdoor JWT authentication — the v2.6 identity source.
- Verifies the Bearer token against Casdoor's JWKS (issuer = CASDOOR_ENDPOINT).
- Auto-provisions the `users` row from the token claims on first login
(role stays NULL until assigned by an admin / seed).
- Resolves the caller's role (users.role) and, for PMs, their single
project (projects.pm_user_id).
- Dev fallback: when no Authorization header is present, endpoints that
use `get_optional_user` degrade gracefully; the inventory router keeps
its header-based placeholder mode for local testing.
Dependencies (pyjwt[crypto]):
pip install pyjwt[crypto]
"""
import logging
from dataclasses import dataclass
import jwt
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.utils.errors import ForbiddenError, ValidationError
logger = logging.getLogger("procurement.auth")
_JWKS_CACHE: dict | None = None
def _jwks_url() -> str:
return settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/api/public_jwks"
async def _get_jwks() -> dict:
"""Fetch (and cache) Casdoor's public JWKS."""
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
return _JWKS_CACHE
async def _verify_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims."""
jwks = await _get_jwks()
unverified = jwt.decode(token, options={"verify_signature": False})
kid = unverified.get("kid")
key = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if key is None:
raise ValidationError("امضای توکن نامعتبر است", field="authorization")
issuer = settings.CASDOOR_ENDPOINT
audience = settings.CASDOOR_AUDIENCE or settings.CASDOOR_CLIENT_ID
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512"],
issuer=issuer,
audience=audience,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise ValidationError("توکن نامعتبر یا منقضی شده است", field="authorization")
@dataclass
class UserContext:
user: User
role: str | None
project_id: object | None # uuid.UUID | None
display_name: str
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent/invalid.
Never raises on a missing token — callers decide their own fallback
(e.g. the inventory router's dev headers).
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return None
token = auth.split(" ", 1)[1].strip()
if not token:
return None
claims = await _verify_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
if not casdoor_id:
raise ValidationError("توکن فاقد شناسه کاربر است", field="authorization")
# Auto-provision on first login (role stays NULL until assigned).
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None:
user = User(
casdoor_id=casdoor_id,
first_name=str(claims.get("firstname") or claims.get("name") or "کاربر"),
last_name=str(claims.get("lastname") or ""),
phone=str(claims.get("phone") or "") or None,
)
db.add(user)
await db.flush()
project_id = None
if user.role == "pm":
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
project_id = project.id if project else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (403 when missing or unprovisioned)."""
ctx = await get_optional_user(request, db)
if ctx is None:
raise ForbiddenError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
return ctx
def require_role(*roles: str):
"""Dependency factory: require the caller to hold one of the given roles."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role not in roles:
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
return _dep