848f804169
- FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""
|
|
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()
|
|
|
|
|
|
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, verify=False) as client:
|
|
try:
|
|
resp = await client.post(token_url, data=data)
|
|
if resp.status_code != 200:
|
|
logger.error(f"Casdoor token 换取失败: {resp.status_code} {resp.text}")
|
|
return None
|
|
|
|
token_data = resp.json()
|
|
id_token = token_data.get("id_token")
|
|
if not id_token:
|
|
logger.error("Casdoor 返回中没有 id_token")
|
|
return None
|
|
|
|
# 解码 id_token (JWT) payload,不验证签名(HTTPS 已保证传输安全)
|
|
# 生产环境建议验证 Casdoor 证书
|
|
cert = _load_casdoor_certificate()
|
|
try:
|
|
payload = jwt.decode(
|
|
id_token,
|
|
key=cert or None,
|
|
options={"verify_signature": bool(cert)},
|
|
audience=settings.CASDOOR_CLIENT_ID,
|
|
)
|
|
except JWTError:
|
|
# 不验证签名的方式解码
|
|
payload = jwt.get_unverified_claims(id_token)
|
|
|
|
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: HTTPAuthorizationCredentials = Depends(security),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""从 PingWatch JWT 中解析当前登录用户"""
|
|
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
|