""" Casdoor OAuth 认证集成 + JWT 会话管理 流程: 1. 前端跳转到 Casdoor 登录页 → 用户登录 2. Casdoor 回调到前端(带 code 参数) 3. 前端将 code 发到后端 /api/auth/login 4. 后端用 code 向 Casdoor 换取 access_token + id_token 5. 后端从 id_token (JWT) 解析用户信息 6. 后端签发自己的 JWT,返回给前端 """ import logging import os from datetime import datetime, timedelta from typing import Optional import httpx from jose import JWTError, jwt from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.user import User, UserRoleEnum from app.core.deps import get_db logger = logging.getLogger("pingwatch.auth") security = HTTPBearer(auto_error=False) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: """签发 PingWatch 自己的 JWT""" to_encode = data.copy() expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") def _load_casdoor_certificate() -> str: """加载 Casdoor 证书:优先读取 dahua.pem,否则用环境变量""" pem_path = os.path.join(os.path.dirname(__file__), "..", "..", "dahua.pem") pem_path = os.path.normpath(pem_path) if os.path.isfile(pem_path): with open(pem_path, "r") as f: return f.read() return settings.CASDOOR_CERTIFICATE async def exchange_code_for_user(code: str) -> Optional[dict]: """ 用 OAuth code 向 Casdoor 换取用户信息。 步骤: 1. POST → /api/login/oauth/access_token 换取 id_token 2. 解码 id_token (JWT) 得到用户信息 """ token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token" data = { "grant_type": "authorization_code", "client_id": settings.CASDOOR_CLIENT_ID, "client_secret": settings.CASDOOR_CLIENT_SECRET, "code": code, } async with httpx.AsyncClient(timeout=15) as client: try: resp = await client.post(token_url, data=data) if resp.status_code != 200: logger.error("Casdoor token 换取失败: status=%s", resp.status_code) return None token_data = resp.json() id_token = token_data.get("id_token") if not id_token: logger.error("Casdoor 返回中没有 id_token") return None cert = _load_casdoor_certificate() if not cert: logger.error("Casdoor token 验证证书未配置") return None payload = jwt.decode( id_token, key=cert, audience=settings.CASDOOR_CLIENT_ID, ) return payload except httpx.TimeoutException: logger.error("Casdoor token 请求超时") return None except Exception as e: logger.error(f"Casdoor token 请求异常: {e}", exc_info=True) return None async def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), db: AsyncSession = Depends(get_db), ) -> User: """从 PingWatch JWT 中解析当前登录用户""" if not settings.auth_enabled: return User( id=0, casdoor_uid="local-anonymous-admin", username="local-admin", display_name="本地管理员", role=UserRoleEnum.admin, is_active=True, ) if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少认证信息") token = credentials.credentials try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) casdoor_uid: str = payload.get("sub", "") if not casdoor_uid: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token") except JWTError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token") result = await db.execute(select(User).where(User.casdoor_uid == casdoor_uid)) user = result.scalar_one_or_none() if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用") return user async def require_admin(current_user: User = Depends(get_current_user)) -> User: if current_user.role != UserRoleEnum.admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可执行此操作") return current_user