Files
ERP/backend/app/routers/auth.py
T
MMD e17d1f0883 feat(monitoring): observability stack + app instrumentation
- /health/ready (DB+MinIO+Casdoor checks, webhook post-deploy gate),
  /metrics via prometheus-fastapi-instrumentator, Sentry/GlitchTip capture,
  X-Request-ID middleware + JSON access logging (PROCUREMENT_LOG_FORMAT)
- server-side analytics (OpenPanel Track API): login, quote lifecycle,
  receipts — fire-and-forget, disabled without env config
- monitoring/ compose project: GlitchTip(+pg), Prometheus(+alerts),
  Grafana(provisioned ERP dashboard), Loki+Promtail, node/postgres
  exporters, cAdvisor — mem-limited (~2.2GB), loopback admin UIs,
  separate from app deploys; erp_shared network for scraping
- frontend: @openpanel/web + @sentry/react (build-arg gated, no-op when
  unset), funnel events (login, wizard steps, quote submitted)
- compose: api healthcheck, observability env wiring, .env.example keys
2026-09-18 22:18:02 +03:30

96 lines
3.7 KiB
Python

"""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_projects, user_to_dict
from app.services import analytics
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
@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)
projects = await _user_projects(db, ctx.user)
await analytics.track(
"login_success",
{"username": payload.username, "role": ctx.role},
profile_id=ctx.user.casdoor_id,
)
await analytics.identify(
ctx.user.casdoor_id,
{"name": ctx.display_name, "role": ctx.role, "username": payload.username},
)
return {
"data": {
"access_token": access_token,
"token_type": "bearer",
"expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects),
}
}