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:
ali_mohaghegh
2026-08-29 14:43:49 +03:30
parent 06a9a654a5
commit 3cd76d335f
7 changed files with 202 additions and 38 deletions
+2
View File
@@ -1,6 +1,7 @@
"""All API routers, re-exported for main.py assembly."""
from app.routers import (
attribute_definitions,
auth,
brand_materials,
brands,
category_attributes,
@@ -29,6 +30,7 @@ from app.routers import (
)
ROUTERS = [
auth.router,
units.router,
brands.router,
material_categories.router,
+86
View File
@@ -0,0 +1,86 @@
"""Auth router — in-page login against Casdoor (password grant).
POST /auth/login: the frontend sends username/password; this router exchanges
them at Casdoor's OAuth token endpoint (grant_type=password), verifies the
issued JWT locally (JWKS), auto-provisions the `users` row and returns the
token + profile (role resolved from users.role). No browser redirect.
Error mapping (documented in docs/casdoor-auth.md):
- wrong credentials -> 401 UNAUTHORIZED
- Casdoor unreachable -> 503 UPSTREAM_UNAVAILABLE
- Casdoor misconfigured -> 503 UPSTREAM_UNAVAILABLE (details in api logs)
"""
import logging
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import _verify_token, context_from_claims
from app.config import settings
from app.db.engine import get_db
from app.routers.users import _user_project, user_to_dict
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
logger = logging.getLogger("procurement.auth")
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
@router.post("/login")
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Exchange username/password for a Casdoor JWT (OAuth password grant)."""
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
form = {
"grant_type": "password",
"client_id": settings.CASDOOR_CLIENT_ID,
"client_secret": settings.CASDOOR_CLIENT_SECRET,
"username": payload.username.strip(),
"password": payload.password,
"scope": "openid profile email",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, data=form)
except httpx.HTTPError as exc:
logger.error("Casdoor token endpoint unreachable (%s): %s", url, exc)
raise ServiceUnavailableError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
try:
body = resp.json()
except ValueError as exc:
logger.error("Casdoor token endpoint returned non-JSON (status %s)", resp.status_code)
raise ServiceUnavailableError("پاسخ نامعتبر از سرویس احراز هویت") from exc
access_token = body.get("access_token")
if not access_token:
err = body.get("error") or "unknown_error"
if err in ("invalid_username_or_password", "invalid_grant"):
raise UnauthorizedError("نام کاربری یا رمز عبور نادرست است", field="password")
logger.error(
"Casdoor rejected password grant: error=%s description=%s",
err,
body.get("error_description", ""),
)
raise ServiceUnavailableError("سرویس احراز هویت به درستی پیکربندی نشده است")
# Verify the freshly issued token with our own JWKS checks, then resolve
# the app-side user (auto-provision on first login).
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
return {
"data": {
"access_token": access_token,
"token_type": "bearer",
"expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, project),
}
}
+13 -3
View File
@@ -36,7 +36,12 @@ from app.services.inventory_service import (
apply_transaction,
get_warehouse_or_404,
)
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.errors import (
ForbiddenError,
NotFoundError,
UnauthorizedError,
ValidationError,
)
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
@@ -59,7 +64,9 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
# projects.pm_user_id (single PM per project).
if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db)
if user is not None and user.role is not None:
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
if user.role == "procurement_manager":
return Scope(role="admin", project_id=None, actor=user.display_name)
@@ -73,7 +80,10 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
return Scope(role="admin", project_id=None, actor=user.display_name)
# Dev fallback: header-based identity (fork's placeholder mode).
# Dev fallback: header-based identity (fork's placeholder mode) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401.
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
if role == "procurement_manager":
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
+10 -1
View File
@@ -47,7 +47,12 @@ from app.models.procurement import (
)
from app.schemas.procurement import PMReceiptCreate
from app.services.inventory_service import canonical_specs_hash
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.errors import (
ForbiddenError,
NotFoundError,
UnauthorizedError,
ValidationError,
)
from app.utils.serialization import to_iso
router = APIRouter(prefix="/pm", tags=["pm"])
@@ -71,6 +76,8 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
if request.headers.get("authorization"):
ctx: UserContext | None = await get_optional_user(request, db)
if ctx is not None:
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm":
if ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
@@ -79,6 +86,8 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"