Files
taiji-AI-PAD/services/mcp-server/app/auth.py
T
2026-05-05 14:13:59 +08:00

469 lines
17 KiB
Python

"""Authentication helpers (API Key + JWT) for MCP server."""
from __future__ import annotations
import hashlib
import os
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import structlog
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models import APIKey, User, TokenBlacklist
logger = structlog.get_logger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
创建访问令牌 (Access Token)
默认有效期:根据配置 jwt_expire_minutes(通常为 24 小时)
"""
to_encode = data.copy()
now = datetime.utcnow()
expire = now + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
to_encode.update({
"exp": expire,
"iat": now, # 添加签发时间,用于登出验证
"type": "access", # 标识 token 类型
})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
# Refresh Token 有效期:7 天
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
创建刷新令牌 (Refresh Token)
默认有效期:7 天,比 Access Token 更长
Refresh Token 仅用于获取新的 Access Token
"""
to_encode = data.copy()
now = datetime.utcnow()
expire = now + (expires_delta or timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))
to_encode.update({
"exp": expire,
"iat": now,
"type": "refresh", # 标识 token 类型
})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
async def _get_user_by_email(email: str, db: AsyncSession) -> Optional[User]:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def authenticate_user(email: str, password: str, db: AsyncSession) -> Optional[User]:
user = await _get_user_by_email(email, db)
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None
return user
async def ensure_user(email: str, password: str, db: AsyncSession) -> User:
user = await _get_user_by_email(email, db)
if user:
return user
hashed = get_password_hash(password or os.urandom(8).hex())
username = email.split("@")[0]
user = User(
name=username, # 添加name字段
username=username,
email=email,
password_hash=hashed, # 使用password_hash
hashed_password=hashed, # 兼容字段
full_name=email,
role="user", # 添加role字段
is_active=True,
is_admin=False,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def _check_api_key(key: str, db: AsyncSession) -> Optional[APIKey]:
hashed = hashlib.sha256(key.encode()).hexdigest()
result = await db.execute(select(APIKey).where(APIKey.prefix == key[:8]))
api_keys = result.scalars().all()
for api_key in api_keys:
try:
if verify_password(key, api_key.key_hash) or api_key.key_hash == hashed:
if api_key.is_active:
return api_key
except Exception:
continue
return None
async def _is_user_logged_out(user_id: str, token_iat: Optional[int], db: AsyncSession) -> bool:
"""
检查用户是否已登出
通过检查 token_blacklist 表中是否存在该用户的登出记录,
且登出时间在 token 签发时间之后。
Args:
user_id: 用户ID
token_iat: Token 签发时间戳 (iat claim)
db: 数据库会话
Returns:
True 如果用户已登出,False 否则
"""
try:
# 查询该用户的登出记录
result = await db.execute(
select(TokenBlacklist).where(
TokenBlacklist.user_id == user_id,
TokenBlacklist.reason == "logout",
TokenBlacklist.expires_at > datetime.utcnow() # 只查询未过期的记录
).order_by(TokenBlacklist.created_at.desc()).limit(1)
)
blacklist_entry = result.scalar_one_or_none()
if not blacklist_entry:
return False
# 如果有登出记录,检查 token 签发时间
if token_iat:
token_issued_at = datetime.utcfromtimestamp(token_iat)
# 从 token_jti 中提取登出时间戳
# 格式: logout_{user_id}_{timestamp}
try:
logout_timestamp = float(blacklist_entry.token_jti.split("_")[-1])
logout_time = datetime.utcfromtimestamp(logout_timestamp)
# 如果 token 是在登出之前签发的,则认为已登出
if token_issued_at < logout_time:
return True
except (ValueError, IndexError):
# 无法解析登出时间,保守起见认为已登出
return True
else:
# 没有 iat claim,保守起见认为已登出
return True
return False
except SQLAlchemyError as e:
# 数据库错误时保守处理:视为已登出,避免被吊销的 token 在 DB 抖动期间通过认证
logger.error("logout_check_db_error", user_id=user_id, error=str(e))
return True
async def require_auth(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
"""
认证依赖,支持 JWT Token 和 API Key 两种方式
认证方式:
1. Authorization: Bearer <jwt_token> - JWT Token 认证
2. Authorization: Bearer sk-xxx - API Key 认证
3. X-API-Key: sk-xxx - API Key 认证
"""
path = request.url.path
allow_paths = {
"/health",
"/metrics",
"/docs",
"/redoc",
"/openapi.json",
"/api/channel/auth/login",
"/api/admin/auth/login",
"/api/providers/auth/login",
"/api/auth/login", # 添加统一登录接口
"/agents/templates", # 模板列表公开访问
}
# 允许公开路径和非 API/agents 路径
if path in allow_paths:
return {}
# 模板详情也公开访问
if path.startswith("/agents/templates/"):
return {}
# 非 API 且非 agents 路径不需要认证
if not path.startswith("/api") and not path.startswith("/agents"):
return {}
# 1. 优先检查 X-API-Key 头
api_key_header = request.headers.get("X-API-Key")
if api_key_header and api_key_header.startswith("sk-"):
api_key = await _check_api_key(api_key_header, db)
if api_key:
# 更新最后使用时间和请求计数
api_key.last_used = datetime.utcnow()
api_key.total_requests = (api_key.total_requests or 0) + 1
await db.commit()
# 获取用户信息
user_result = await db.execute(
select(User).where(User.id == api_key.user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
principal = {
"type": "api_key",
"user_id": str(api_key.user_id),
"email": user.email,
"role": user.role,
"channel_id": str(user.channel_id) if user.channel_id else None,
"scopes": api_key.scopes,
"api_key_id": str(api_key.id),
"claims": {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
},
}
request.state.principal = principal
return principal
# 2. 检查 Authorization: Bearer 头
if credentials and credentials.scheme.lower() == "bearer":
token = credentials.credentials
# 2.1 判断是 API Key 还是 JWT Token
if token.startswith("sk-"):
# API Key 认证
api_key = await _check_api_key(token, db)
if api_key:
# 更新最后使用时间和请求计数
api_key.last_used = datetime.utcnow()
api_key.total_requests = (api_key.total_requests or 0) + 1
await db.commit()
# 获取用户信息
user_result = await db.execute(
select(User).where(User.id == api_key.user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
principal = {
"type": "api_key",
"user_id": str(api_key.user_id),
"email": user.email,
"role": user.role,
"channel_id": str(user.channel_id) if user.channel_id else None,
"scopes": api_key.scopes,
"api_key_id": str(api_key.id),
"claims": {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
},
}
request.state.principal = principal
return principal
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的 API Key")
# 2.2 JWT Token 认证(现有逻辑)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
user_id: str | None = payload.get("sub")
email: str | None = payload.get("email")
token_iat: int | None = payload.get("iat")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
# 检查用户是否已登出
if await _is_user_logged_out(user_id, token_iat, db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已失效,请重新登录")
request.state.principal = {"type": "jwt", "user_id": user_id, "email": email, "claims": payload}
return request.state.principal
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from None
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
async def authenticate_request(request: Request, db: AsyncSession) -> Optional[Dict[str, Any]]:
"""
认证请求(用于中间件),支持 JWT Token 和 API Key 两种方式
认证方式:
1. Authorization: Bearer <jwt_token> - JWT Token 认证
2. Authorization: Bearer sk-xxx - API Key 认证
3. X-API-Key: sk-xxx - API Key 认证
"""
path = request.url.path
allow_paths = {
"/health",
"/metrics",
"/docs",
"/redoc",
"/openapi.json",
"/api/channel/auth/login",
"/api/admin/auth/login",
"/api/providers/auth/login",
"/api/auth/login", # 添加统一登录接口
"/agents/templates", # 模板列表公开访问
}
# 允许公开路径
if path in allow_paths:
return {}
# 模板详情也公开访问
if path.startswith("/agents/templates/"):
return {}
# 非 API 且非 agents 路径不需要认证
if not path.startswith("/api") and not path.startswith("/agents"):
return {}
# 1. 检查 X-API-Key 头
api_key_header = request.headers.get("X-API-Key")
if api_key_header and api_key_header.startswith("sk-"):
api_key = await _check_api_key(api_key_header, db)
if api_key:
# 更新最后使用时间和请求计数
api_key.last_used = datetime.utcnow()
api_key.total_requests = (api_key.total_requests or 0) + 1
await db.commit()
# 获取用户信息
user_result = await db.execute(
select(User).where(User.id == api_key.user_id)
)
user = user_result.scalar_one_or_none()
if user:
return {
"type": "api_key",
"user_id": str(api_key.user_id),
"email": user.email,
"role": user.role,
"channel_id": str(user.channel_id) if user.channel_id else None,
"scopes": api_key.scopes,
"api_key_id": str(api_key.id),
}
# 2. 检查 Authorization 头
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
# 2.1 判断是 API Key 还是 JWT Token
if token.startswith("sk-"):
# API Key 认证
api_key = await _check_api_key(token, db)
if api_key:
# 更新最后使用时间和请求计数
api_key.last_used = datetime.utcnow()
api_key.total_requests = (api_key.total_requests or 0) + 1
await db.commit()
# 获取用户信息
user_result = await db.execute(
select(User).where(User.id == api_key.user_id)
)
user = user_result.scalar_one_or_none()
if user:
return {
"type": "api_key",
"user_id": str(api_key.user_id),
"email": user.email,
"role": user.role,
"channel_id": str(user.channel_id) if user.channel_id else None,
"scopes": api_key.scopes,
"api_key_id": str(api_key.id),
}
else:
# 2.2 JWT Token 认证
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
user_id: str | None = payload.get("sub")
email: str | None = payload.get("email")
token_iat: int | None = payload.get("iat")
if user_id is None:
return None
# 检查用户是否已登出
if await _is_user_logged_out(user_id, token_iat, db):
return None
return {"type": "jwt", "user_id": user_id, "email": email, "claims": payload}
except JWTError:
return None
return None
# Alias for compatibility
get_current_user = require_auth
def require_role(allowed_roles: list):
"""
创建一个依赖项,要求用户具有指定角色之一
Args:
allowed_roles: 允许的角色列表,如 ["super_admin", "billing_admin"]
Returns:
FastAPI dependency function
Usage:
@router.get("/admin-only")
async def admin_endpoint(
current_user: dict = Depends(require_role(["super_admin", "admin"]))
):
...
"""
async def role_checker(
current_user: dict = Depends(get_current_user)
) -> dict:
user_role = current_user.get("role", "user")
if user_role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"权限不足。需要角色: {', '.join(allowed_roles)},当前角色: {user_role}"
)
return current_user
return role_checker