fastapi 从入门到精通 (1):实现双 Token 无感刷新(低侵入式方案)

65 阅读7分钟

大家好,我是jobleap.cn的小九。

你希望基于 Python + FastAPI 实现低侵入式的双 Token 无感刷新功能,核心要求是「低侵入」—— 即认证逻辑不耦合业务代码,仅通过依赖注入等方式灵活接入。下面我会从核心原理、代码实现到测试验证,完整讲解这套方案。

一、核心原理(低侵入式设计思路)

1. 双 Token 机制

  • Access Token(访问令牌):短期有效(如15分钟),用于接口鉴权,过期快更安全
  • Refresh Token(刷新令牌):长期有效(如7天),仅用于刷新 Access Token,不参与业务鉴权
  • 无感刷新:前端检测到 Access Token 即将过期/已过期时,自动调用刷新接口获取新 Token,用户无感知

2. 低侵入式实现要点

  • 用 FastAPI 的依赖注入(Dependency) 封装认证逻辑,业务路由只需注入依赖即可,无需修改核心代码
  • 认证失败(Token 过期/无效)时返回标准化错误码,前端根据错误码触发刷新逻辑
  • 刷新 Token 接口独立,与业务接口解耦

二、环境准备

先安装所需依赖:

pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart
  • fastapi/uvicorn:核心 Web 框架
  • python-jose:JWT Token 生成与解析
  • passlib:密码加密(示例用,非核心)

三、完整代码实现

1. 项目结构(极简版)

fastapi-token-demo/
└── main.py  # 所有核心代码

2. 完整代码

from datetime import datetime, timedelta
from typing import Optional

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel

# ======================== 配置项(可抽离到配置文件)========================
# 加密密钥(生产环境务必改为随机强密钥,可通过 openssl rand -hex 32 生成)
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
# 加密算法
ALGORITHM = "HS256"
# Access Token 过期时间(15分钟)
ACCESS_TOKEN_EXPIRE_MINUTES = 15
# Refresh Token 过期时间(7天)
REFRESH_TOKEN_EXPIRE_DAYS = 7

# ======================== 密码加密配置 ========================
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# ======================== 模拟用户数据(生产环境替换为数据库)========================
fake_users_db = {
    "user1": {
        "username": "user1",
        "hashed_password": pwd_context.hash("123456"),
        "full_name": "User One",
        "email": "user1@example.com",
        "disabled": False,
    }
}

# ======================== Pydantic 模型 ========================
# Token 响应模型
class Token(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str

# Token 数据模型(解析后的 payload)
class TokenData(BaseModel):
    username: Optional[str] = None

# 用户模型
class User(BaseModel):
    username: str
    email: Optional[str] = None
    full_name: Optional[str] = None
    disabled: Optional[bool] = None

# 数据库用户模型(包含加密密码)
class UserInDB(User):
    hashed_password: str

# ======================== 核心工具函数 ========================
# 验证密码
def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

# 获取用户
def get_user(db, username: str):
    if username in db:
        user_dict = db[username]
        return UserInDB(**user_dict)

# 验证用户(密码+用户名)
def authenticate_user(fake_db, username: str, password: str):
    user = get_user(fake_db, username)
    if not user:
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user

# 生成 Token(通用函数,支持生成 Access/Refresh Token)
def create_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    # 设置过期时间
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    # 生成 JWT Token
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# ======================== 依赖注入:Token 验证(低侵入核心)========================
# 定义 OAuth2 令牌获取方式(从请求头的 Authorization: Bearer <token> 中获取)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")

# 验证 Access Token,返回当前用户(业务路由只需注入此依赖即可鉴权)
async def get_current_user(token: str = Depends(oauth2_scheme)):
    # 定义认证失败的通用异常
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="无法验证凭据(Token无效/过期)",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        # 解析 Token
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception
    # 获取用户信息
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

# 验证当前用户是否可用(非核心,可选)
async def get_current_active_user(current_user: User = Depends(get_current_user)):
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="无效用户")
    return current_user

# ======================== FastAPI 应用实例 ========================
app = FastAPI(title="FastAPI 双 Token 无感刷新示例", version="1.0")

# ======================== 核心接口 ========================
# 1. 登录接口:生成 Access Token + Refresh Token
@app.post("/login", response_model=Token, summary="用户登录,获取双 Token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    # 验证用户
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    # 生成 Access Token(15分钟过期)
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_token(
        data={"sub": user.username, "type": "access"},  # 标记 Token 类型
        expires_delta=access_token_expires
    )
    # 生成 Refresh Token(7天过期)
    refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    refresh_token = create_token(
        data={"sub": user.username, "type": "refresh"},  # 标记 Token 类型
        expires_delta=refresh_token_expires
    )
    # 返回双 Token
    return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}

# 2. 刷新 Token 接口:用 Refresh Token 换取新的 Access Token(无感刷新核心)
@app.post("/refresh-token", response_model=Token, summary="刷新 Access Token")
async def refresh_token(refresh_token: str):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Refresh Token 无效/过期",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        # 解析 Refresh Token
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        token_type: str = payload.get("type")
        # 验证 Token 类型(必须是 refresh token)
        if username is None or token_type != "refresh":
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception
    # 验证用户存在
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    # 生成新的 Access Token
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    new_access_token = create_token(
        data={"sub": user.username, "type": "access"},
        expires_delta=access_token_expires
    )
    # 注意:Refresh Token 可选择是否一起刷新(可选策略:每次刷新生成新的 Refresh Token,延长有效期)
    new_refresh_token = refresh_token  # 简单策略:复用原 Refresh Token,也可重新生成
    return {"access_token": new_access_token, "refresh_token": new_refresh_token, "token_type": "bearer"}

# 3. 业务接口示例:需要认证(低侵入式接入)
@app.get("/users/me/", summary="获取当前用户信息(需认证)")
async def read_users_me(current_user: User = Depends(get_current_active_user)):
    return current_user

@app.get("/api/test", summary="测试接口(需认证)")
async def test_api(current_user: User = Depends(get_current_active_user)):
    return {"code": 200, "msg": "接口调用成功", "data": {"username": current_user.username}}

# ======================== 启动应用 ========================
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

四、代码关键部分解释

1. 低侵入式核心:依赖注入

get_current_user 是核心依赖函数,所有需要认证的业务接口只需通过 Depends(get_current_active_user) 注入即可,无需修改业务逻辑:

# 业务接口无需关心认证细节,只需注入依赖
@app.get("/api/test")
async def test_api(current_user: User = Depends(get_current_active_user)):
    return {"code": 200, "msg": "接口调用成功"}

2. Token 生成与验证

  • create_token 函数通用化:通过 type 字段标记 Token 类型(access/refresh),避免 Refresh Token 被误用为 Access Token
  • 解析 Token 时验证类型:确保 Refresh Token 只能用于刷新接口,不能直接访问业务接口

3. 无感刷新的前端配合逻辑(关键)

虽然代码是后端实现,但无感刷新需要前后端配合,前端逻辑伪代码:

// 封装请求拦截器
axios.interceptors.request.use(config => {
  // 给请求头添加 Access Token
  config.headers.Authorization = `Bearer ${localStorage.getItem('access_token')}`;
  return config;
});

// 封装响应拦截器
axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    // 1. 判断是否是 401 错误(Token 过期/无效)
    // 2. 判断是否已经重试过(避免无限循环)
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        // 用 Refresh Token 调用刷新接口
        const res = await axios.post('/refresh-token', {
          refresh_token: localStorage.getItem('refresh_token')
        });
        // 保存新的 Access Token
        localStorage.setItem('access_token', res.data.access_token);
        // 重新设置请求头,并重试原请求
        originalRequest.headers.Authorization = `Bearer ${res.data.access_token}`;
        return axios(originalRequest);
      } catch (refreshError) {
        // Refresh Token 也过期了,跳转到登录页
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

五、测试验证

1. 启动服务

python main.py

访问 Swagger 文档:http://127.0.0.1:8000/docs,可直接在线测试接口。

2. 测试步骤

  1. 登录:调用 /login 接口,用户名 user1,密码 123456,获取 access_tokenrefresh_token
  2. 访问业务接口:调用 /users/me//api/test,在请求头添加 Authorization: Bearer <access_token>,可正常返回数据。
  3. 模拟 Access Token 过期:修改 ACCESS_TOKEN_EXPIRE_MINUTES 为 0(立即过期),重新登录获取 Token,再调用业务接口会返回 401。
  4. 刷新 Token:调用 /refresh-token 接口,传入过期的 Access Token 对应的 refresh_token,可获取新的 access_token
  5. 重新访问业务接口:用新的 access_token 调用接口,恢复正常。

六、进阶优化(生产环境建议)

  1. Refresh Token 存储:生产环境需将 Refresh Token 存储到数据库/Redis,并记录过期时间、设备信息,支持手动注销。
  2. Token 黑名单:若用户登出,需将 Refresh Token 加入黑名单,防止被滥用。
  3. Refresh Token 轮换:每次刷新 Access Token 时,生成新的 Refresh Token(原 Token 失效),降低安全风险。
  4. 配置抽离:将 SECRET_KEY、过期时间等配置抽离到 .env 文件(用 python-dotenv 读取)。
  5. 异常标准化:统一返回格式(如 {"code": 401, "msg": "Token过期", "data": null}),方便前端处理。

总结

  1. 低侵入式核心:通过 FastAPI 的依赖注入封装认证逻辑,业务路由仅需注入依赖即可完成鉴权,无需耦合认证代码。
  2. 双 Token 流程:登录生成短期 Access Token + 长期 Refresh Token → Access Token 过期后,前端用 Refresh Token 无感刷新 → 刷新失败则跳转登录。
  3. 关键安全点:Token 需标记类型(access/refresh)、Refresh Token 需存储验证、生产环境需使用强密钥并加密传输(HTTPS)。