fix(auth): Casdoor JWT verification and 401 semantics; add /auth/login password-grant endpoint
- verify kid from the JWT header (pyjwt decode() returns claims, not the header — the old lookup was always None and rejected every token) - convert JWK entries to crypto keys explicitly (pyjwt 2.13 dropped dict keys) and fetch the JWKS from /.well-known/jwks - 401 for missing/invalid/expired/wrong-issuer/wrong-audience tokens (was 403/400); 403 stays for valid-but-roleless users; 503 when Casdoor is unreachable; new CASDOOR_ISSUER setting separates the in-network endpoint from the browser-facing issuer - dev-header fallback in inventory/pm scopes now requires an explicit X-User-Role header (no auth info at all -> 401) - new POST /api/v1/auth/login: password grant against Casdoor, local JWT verification, auto-provisioning, returns token + profile with the role resolved from the app users table
This commit is contained in:
+76
-34
@@ -1,10 +1,15 @@
|
||||
"""Casdoor JWT authentication — the v2.6 identity source.
|
||||
|
||||
- Verifies the Bearer token against Casdoor's JWKS (issuer = CASDOOR_ENDPOINT).
|
||||
- Verifies the Bearer token against Casdoor's JWKS (/.well-known/jwks).
|
||||
The expected iss claim is CASDOOR_ISSUER (defaults to CASDOOR_ENDPOINT),
|
||||
so the endpoint may be the in-network service URL while the issuer is the
|
||||
browser-facing origin Casdoor advertises.
|
||||
- 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).
|
||||
- 401 for missing/invalid/expired tokens, 403 when the user has no role yet,
|
||||
503 when Casdoor itself is unreachable.
|
||||
- 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.
|
||||
@@ -23,7 +28,11 @@ 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
|
||||
from app.utils.errors import (
|
||||
ForbiddenError,
|
||||
ServiceUnavailableError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("procurement.auth")
|
||||
|
||||
@@ -31,7 +40,7 @@ _JWKS_CACHE: dict | None = None
|
||||
|
||||
|
||||
def _jwks_url() -> str:
|
||||
return settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/api/public_jwks"
|
||||
return settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
|
||||
|
||||
|
||||
async def _get_jwks() -> dict:
|
||||
@@ -40,34 +49,54 @@ async def _get_jwks() -> dict:
|
||||
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()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(_jwks_url())
|
||||
resp.raise_for_status()
|
||||
_JWKS_CACHE = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("JWKS fetch failed (%s): %s", _jwks_url(), exc)
|
||||
raise ServiceUnavailableError("سرویس احراز هویت (Casdoor) در دسترس نیست")
|
||||
return _JWKS_CACHE
|
||||
|
||||
|
||||
def _key_from_jwk(jwk: dict):
|
||||
"""Build a crypto key object from a JWKS entry (pyjwt 2.13 no longer
|
||||
accepts raw JWK dicts in jwt.decode)."""
|
||||
kty = jwk.get("kty")
|
||||
if kty == "RSA":
|
||||
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
|
||||
if kty == "EC":
|
||||
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
|
||||
return jwk
|
||||
|
||||
|
||||
async def _verify_token(token: str) -> dict:
|
||||
"""Validate the Casdoor JWT and return its claims."""
|
||||
"""Validate the Casdoor JWT and return its claims (401 on any failure)."""
|
||||
try:
|
||||
kid = jwt.get_unverified_header(token).get("kid")
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.warning("JWT unreadable: %s", exc)
|
||||
raise UnauthorizedError("توکن نامعتبر است", field="authorization")
|
||||
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
|
||||
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
|
||||
if entry is None:
|
||||
logger.warning("JWT kid %r not found in JWKS", kid)
|
||||
raise UnauthorizedError("امضای توکن نامعتبر است", field="authorization")
|
||||
key = _key_from_jwk(entry)
|
||||
issuer = settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT
|
||||
audience = settings.CASDOOR_AUDIENCE or settings.CASDOOR_CLIENT_ID
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256", "RS384", "RS512"],
|
||||
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.warning("JWT verification failed: %s", exc)
|
||||
raise ValidationError("توکن نامعتبر یا منقضی شده است", field="authorization")
|
||||
raise UnauthorizedError("توکن نامعتبر یا منقضی شده است", field="authorization")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,31 +107,28 @@ class UserContext:
|
||||
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.
|
||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
"""Auto-provision the `users` row from token claims; resolve role + PM project.
|
||||
|
||||
Never raises on a missing token — callers decide their own fallback
|
||||
(e.g. the inventory router's dev headers).
|
||||
First login: creates the row with role NULL (an admin assigns it later).
|
||||
PMs: resolves their single project via projects.pm_user_id.
|
||||
"""
|
||||
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")
|
||||
raise UnauthorizedError("توکن فاقد شناسه کاربر است", 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 ""),
|
||||
first_name=str(
|
||||
claims.get("firstName")
|
||||
or claims.get("firstname")
|
||||
or claims.get("displayName")
|
||||
or claims.get("name")
|
||||
or "کاربر"
|
||||
),
|
||||
last_name=str(claims.get("lastName") or claims.get("lastname") or ""),
|
||||
phone=str(claims.get("phone") or "") or None,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -117,11 +143,27 @@ async def get_optional_user(request: Request, db: AsyncSession) -> UserContext |
|
||||
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
|
||||
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
|
||||
|
||||
Raises UnauthorizedError for a present-but-invalid token; callers decide
|
||||
their own fallback only for the no-token case (e.g. 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)
|
||||
return await context_from_claims(claims, db)
|
||||
|
||||
|
||||
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
|
||||
"""Require a valid Casdoor token (403 when missing or unprovisioned)."""
|
||||
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
|
||||
ctx = await get_optional_user(request, db)
|
||||
if ctx is None:
|
||||
raise ForbiddenError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||
if ctx.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
return ctx
|
||||
|
||||
Reference in New Issue
Block a user