Refresh Token 实战:让用户“无感”续期,告别重复登录

【学习记录】Refresh Token 实战:让用户"无感"续期,告别重复登录

在 JWT 认证体系中,Access Token 有效期短则频繁踢出用户,长则安全风险大。Refresh Token(刷新令牌) 机制用两个令牌巧妙解决了这个矛盾:短期的 Access Token 保证安全,长期的 Refresh Token 实现无感续期。本文结合一个完整的文件上传系统(FastAPI + Streamlit),详细讲解 Refresh Token 的原理、实现方式以及用户感知的巨大差异。


📌 目录

  1. [传统 JWT 的痛点](#传统 JWT 的痛点)
  2. [Refresh Token 原理与工作流程](#Refresh Token 原理与工作流程)
  3. [用户体验:有 Refresh Token 和无 Refresh Token 的天壤之别](#用户体验:有 Refresh Token 和无 Refresh Token 的天壤之别)
  4. 后端实现深度解析(FastAPI)
    • 4.1 双令牌生成与存储
    • 4.2 Refresh Token 接口
    • 4.3 Access Token 验证
  5. 前端自动刷新实现(Streamlit)
  6. 安全实践与注意事项
  7. 总结

一、传统 JWT 的痛点

JWT(JSON Web Token)作为 Access Token 使用时,通常包含用户标识(sub)和过期时间(exp)。服务端通过签名验证其真实性,无需存储会话。

然而,设计有效期时存在两难

有效期 优点 缺点
短(如 1 小时) 泄露后危害小 用户频繁被踢出,体验差
长(如 30 天) 用户无需重复登录 一旦泄露,攻击者可长期冒充用户

Refresh Token 方案 用一个长期令牌(Refresh Token)专门用来换取短期 Access Token,从而兼顾安全与体验。


二、Refresh Token 原理与工作流程

2.1 双令牌设计

令牌类型 用途 有效期 传输频率
Access Token 访问受保护资源 短(如 24 小时) 每次请求都携带
Refresh Token 获取新的 Access Token 长(如 30 天) 仅在刷新时传输

2.2 工作流程

业务API 认证服务 Frontend User 业务API 认证服务 Frontend User #mermaid-svg-HnSpyr2QoyT40uWt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HnSpyr2QoyT40uWt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HnSpyr2QoyT40uWt .error-icon{fill:#552222;}#mermaid-svg-HnSpyr2QoyT40uWt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HnSpyr2QoyT40uWt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HnSpyr2QoyT40uWt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HnSpyr2QoyT40uWt .marker.cross{stroke:#333333;}#mermaid-svg-HnSpyr2QoyT40uWt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HnSpyr2QoyT40uWt p{margin:0;}#mermaid-svg-HnSpyr2QoyT40uWt .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HnSpyr2QoyT40uWt text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HnSpyr2QoyT40uWt .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HnSpyr2QoyT40uWt .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-HnSpyr2QoyT40uWt .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HnSpyr2QoyT40uWt .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HnSpyr2QoyT40uWt #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HnSpyr2QoyT40uWt .sequenceNumber{fill:white;}#mermaid-svg-HnSpyr2QoyT40uWt #sequencenumber{fill:#333;}#mermaid-svg-HnSpyr2QoyT40uWt #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HnSpyr2QoyT40uWt .messageText{fill:#333;stroke:none;}#mermaid-svg-HnSpyr2QoyT40uWt .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HnSpyr2QoyT40uWt .labelText,#mermaid-svg-HnSpyr2QoyT40uWt .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HnSpyr2QoyT40uWt .loopText,#mermaid-svg-HnSpyr2QoyT40uWt .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HnSpyr2QoyT40uWt .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HnSpyr2QoyT40uWt .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HnSpyr2QoyT40uWt .noteText,#mermaid-svg-HnSpyr2QoyT40uWt .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HnSpyr2QoyT40uWt .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HnSpyr2QoyT40uWt .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HnSpyr2QoyT40uWt .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HnSpyr2QoyT40uWt .actorPopupMenu{position:absolute;}#mermaid-svg-HnSpyr2QoyT40uWt .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-HnSpyr2QoyT40uWt .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HnSpyr2QoyT40uWt .actor-man circle,#mermaid-svg-HnSpyr2QoyT40uWt line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HnSpyr2QoyT40uWt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Access Token 过期(401) 登录 用户名/密码 Access + Refresh Token 请求资源(带 Access Token) 正常响应 发送 Refresh Token 新的 Access Token 重试原请求(新 Token) 成功响应

核心优势

  • Access Token 暴露频繁但有效期短,降低泄露风险。
  • Refresh Token 仅在认证服务间传输,且可被服务端撤销。
  • 客户端自动在后台刷新,用户完全无感知。

三、用户体验:有 Refresh Token 和无 Refresh Token 的天壤之别

3.1 没有 Refresh Token 的系统

复制代码
登录 → 获得 Access Token(24小时有效)
       ↓
  上传文件、查看记录
       ↓
  24小时后 Token 过期
       ↓
  用户再次访问 → 401 Unauthorized
       ↓
  必须重新输入用户名和密码

用户抱怨:"我明明刚登录过,怎么又要输密码?"

3.2 有 Refresh Token 的系统

复制代码
登录 → 获得 Access Token(24小时)+ Refresh Token(30天)
       ↓
  每天使用,前端自动刷新 Access Token
       ↓
  用户从未察觉 Token 过期
       ↓
  30天后 Refresh Token 过期,需要重新登录

用户感受

  • 今天登录后关掉浏览器
  • 明天打开直接正常使用
  • 下周、下下周依然无需登录
  • 仿佛系统"记住"了我的状态

体验提升:对于企业管理后台、文档系统等需要长期登录的场景,Refresh Token 几乎是标配。


四、后端实现深度解析(FastAPI)

提供的 backend.py 实现了一个完整的双令牌认证系统,我们逐块分析。

4.1 双令牌生成与存储

python 复制代码
def create_access_token(user_id: str):
    payload = {
        "sub": user_id,
        "exp": datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

def create_refresh_token():
    return str(uuid4())   # 随机 UUID 作为 Refresh Token
  • Access Token:JWT,包含用户 ID 和过期时间(24 小时)。
  • Refresh Token :随机 UUID,无结构,存储在数据库 users 表的 refresh_token 字段中。

为什么 Refresh Token 不用 JWT?

  • 更简洁,且便于服务端主动撤销(只需删除数据库字段)。
  • 不需要携带用户信息,只作为"凭证"使用。

4.2 登录时同时下发两个令牌

python 复制代码
@app.post("/login")
async def login(username: str, password: str):
    # ... 验证密码 ...
    access_token = create_access_token(username)
    refresh_token = create_refresh_token()
    # 将 refresh_token 存入数据库,绑定用户
    await db.execute(
        "UPDATE users SET refresh_token = ? WHERE username = ?",
        (refresh_token, username)
    )
    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "user_id": username
    }
  • 每次登录都会刷新 Refresh Token(旧 token 失效),这可以防止一个用户在多个设备上同时持有有效 Refresh Token(视业务需求)。

4.3 Refresh Token 接口

python 复制代码
@app.post("/refresh")
async def refresh_token(refresh_token: str):
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT username FROM users WHERE refresh_token = ?",
            (refresh_token,)
        )
        row = await cursor.fetchone()
    if not row:
        raise HTTPException(status_code=401, detail="无效 refresh token")
    username = row[0]
    new_access_token = create_access_token(username)
    return {"access_token": new_access_token}
  • 接收前端传来的 Refresh Token,在数据库中查找对应的用户名。
  • 找到后生成新的 Access Token(有效期重新计算),返回给前端。
  • 注意:此实现没有轮换 Refresh Token (即 Refresh Token 保持不变)。这有一个小缺陷:若 Refresh Token 泄露,攻击者可以无限刷新。生产环境建议Refresh Token 轮换(刷新时同时颁发新的 Refresh Token,并使旧的失效)。

4.4 Access Token 验证

python 复制代码
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
    token = credentials.credentials
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        user_id = payload.get("sub")
        return user_id
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token已过期")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Token无效")
  • 这是一个依赖项,用于保护需要认证的接口(如 /upload/records)。
  • 只验证 Access Token,不涉及数据库查询,高效。

五、前端自动刷新实现(Streamlit)

frontend.py 中封装了一个 api_request 函数,实现了 401 自动刷新并重试

python 复制代码
def api_request(method, url, headers=None, files=None, params=None):
    headers = headers or {}
    headers["Authorization"] = f"Bearer {st.session_state.token}"
    resp = requests.request(method, url, headers=headers, files=files, params=params)
    if resp.status_code == 401:
        try:
            refresh_resp = requests.post(
                REFRESH_URL,
                params={"refresh_token": st.session_state.refresh_token}
            )
            if refresh_resp.status_code == 200:
                data = refresh_resp.json()
                # 更新 session 中的 Access Token
                st.session_state.token = data["access_token"]
                # 用新 Token 重试原请求
                headers["Authorization"] = f"Bearer {st.session_state.token}"
                resp = requests.request(method, url, headers=headers, files=files, params=params)
        except:
            pass
    return resp

工作流程

  1. 发起任何需要认证的请求(上传、查询记录)都使用该函数。
  2. 第一次请求携带当前 Access Token。
  3. 若收到 401,立即用 Refresh Token 调用 /refresh
  4. 若刷新成功,更新本地 session_state 中的 Access Token,并自动重试原始请求。
  5. 重试成功后,原始请求得到正确响应,用户完全无感知。

关键技术点st.rerun() 并未在刷新时调用,因为重试逻辑在函数内部完成了,不需要重新加载整个页面。只有登录、退出登录等操作才需要 rerun。


六、安全实践与注意事项

安全措施 本实现 生产级改进建议
Refresh Token 存储 数据库明文存储 可哈希存储(但需支持查找,通常就明文)
Refresh Token 泄露风险 若泄露可无限刷新 Refresh Token 轮换:刷新时同时颁发新 Refresh Token,使旧 token 立即失效
Refresh Token 绑定 绑定用户 IP、设备指纹,增加校验
Refresh Token 撤销 用户重新登录会覆盖 维护黑名单或 refresh_token 版本号
Access Token 签名密钥 硬编码 JWT_SECRET 使用环境变量,定期轮换
传输安全 未强制 HTTPS 生产环境必须启用 HTTPS

改进版 Refresh Token 轮换逻辑(伪代码):

python 复制代码
@app.post("/refresh")
async def refresh_token(old_refresh_token: str):
    user = get_user_by_refresh_token(old_refresh_token)
    if not user:
        raise HTTPException(401)
    # 生成新的 Access Token 和新的 Refresh Token
    new_access = create_access_token(user.username)
    new_refresh = create_refresh_token()
    # 更新数据库:替换 refresh_token 为新值
    update_user_refresh_token(user.id, new_refresh)
    return {
        "access_token": new_access,
        "refresh_token": new_refresh    # 同时下发新 refresh token
    }

这样,每次刷新都会使旧的 Refresh Token 失效,即使泄露也只有一个短暂的窗口期。


代码

backend.py

python 复制代码
import os
import shutil
import uuid
import sqlite3
from datetime import datetime, timedelta

import aiosqlite
from fastapi import FastAPI, File, UploadFile, HTTPException, Depends
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from passlib.context import CryptContext
from uuid import uuid4

# =====================================================
# 配置
# =====================================================
UPLOAD_DIR = "./uploaded_files"
DATABASE_PATH = "./upload_records.db"
MAX_FILE_SIZE = 50 * 1024 * 1024  # 50 MB
JWT_SECRET = "replace_with_your_secret_key"
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24小时

os.makedirs(UPLOAD_DIR, exist_ok=True)

pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
security = HTTPBearer()

# =====================================================
# 初始化数据库
# =====================================================
def init_db():
    conn = sqlite3.connect(DATABASE_PATH)
    cursor = conn.cursor()
    # 上传记录表
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS upload_records (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            original_filename TEXT NOT NULL,
            saved_filename TEXT NOT NULL,
            saved_path TEXT NOT NULL,
            file_size INTEGER NOT NULL,
            user_id TEXT NOT NULL,
            upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    # 用户表
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            refresh_token TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.commit()
    conn.close()

# =====================================================
# JWT 工具
# =====================================================
def create_access_token(user_id: str):
    payload = {
        "sub": user_id,
        "exp": datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

def create_refresh_token():
    return str(uuid4())

async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
    token = credentials.credentials
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401, detail="无效Token")
        return user_id
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token已过期")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Token无效")

# =====================================================
# 数据库操作
# =====================================================
async def insert_upload_record(original_filename, saved_filename, saved_path, file_size, user_id):
    async with aiosqlite.connect(DATABASE_PATH) as db:
        await db.execute("""
            INSERT INTO upload_records
            (original_filename, saved_filename, saved_path, file_size, user_id, upload_time)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (original_filename, saved_filename, saved_path, file_size, user_id, datetime.now()))
        await db.commit()

# =====================================================
# FastAPI 应用
# =====================================================
app = FastAPI(title="文档上传系统")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.on_event("startup")
async def startup():
    init_db()

@app.get("/")
async def root():
    return {"message": "服务运行正常"}

# =====================================================
# 用户注册/登录
# =====================================================
@app.post("/register")
async def register(username: str, password: str):
    safe_password = password[:72]  # bcrypt/pbkdf2_sha256 限制长度
    password_hash = pwd_context.hash(safe_password)
    async with aiosqlite.connect(DATABASE_PATH) as db:
        try:
            await db.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, password_hash)
            )
            await db.commit()
        except aiosqlite.IntegrityError:
            raise HTTPException(status_code=400, detail="用户名已存在")
    return {"message": "注册成功"}

@app.post("/login")
async def login(username: str, password: str):
    safe_password = password[:72]
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT password_hash FROM users WHERE username = ?",
            (username,)
        )
        row = await cursor.fetchone()
        if not row:
            raise HTTPException(status_code=401, detail="用户名或密码错误")
        password_hash = row[0]
        if not pwd_context.verify(safe_password, password_hash):
            raise HTTPException(status_code=401, detail="用户名或密码错误")
        # 生成 token
        access_token = create_access_token(username)
        refresh_token = create_refresh_token()
        await db.execute(
            "UPDATE users SET refresh_token = ? WHERE username = ?",
            (refresh_token, username)
        )
        await db.commit()
    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_type": "bearer",
        "user_id": username
    }

# =====================================================
# 刷新 Access Token
# =====================================================
@app.post("/refresh")
async def refresh_token(refresh_token: str):
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute(
            "SELECT username FROM users WHERE refresh_token = ?",
            (refresh_token,)
        )
        row = await cursor.fetchone()
    if not row:
        raise HTTPException(status_code=401, detail="无效 refresh token")
    username = row[0]
    new_access_token = create_access_token(username)
    return {
        "access_token": new_access_token,
        "token_type": "bearer",
        "user_id": username
    }

# =====================================================
# 上传文件
# =====================================================
@app.post("/upload")
async def upload_document(file: UploadFile = File(...), current_user: str = Depends(get_current_user)):
    file.file.seek(0, 2)
    size = file.file.tell()
    if size > MAX_FILE_SIZE:
        raise HTTPException(status_code=413, detail=f"文件超过 {MAX_FILE_SIZE // (1024*1024)}MB")
    file.file.seek(0)

    original_filename = file.filename
    ext = os.path.splitext(original_filename)[1]
    unique_filename = f"{uuid.uuid4().hex}{ext}"
    save_path = os.path.join(UPLOAD_DIR, unique_filename)

    try:
        with open(save_path, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")

    await insert_upload_record(original_filename, unique_filename, save_path, size, current_user)

    return JSONResponse(content={
        "status": "success",
        "message": "上传成功",
        "user_id": current_user,
        "saved_filename": unique_filename,
        "file_size": size
    })

# =====================================================
# 查询上传记录
# =====================================================
@app.get("/records")
async def get_records(current_user: str = Depends(get_current_user), limit: int = 50):
    async with aiosqlite.connect(DATABASE_PATH) as db:
        cursor = await db.execute("""
            SELECT *
            FROM upload_records
            WHERE user_id = ?
            ORDER BY upload_time DESC
            LIMIT ?
        """, (current_user, limit))
        rows = await cursor.fetchall()
        columns = [desc[0] for desc in cursor.description]
        return [dict(zip(columns, row)) for row in rows]

# =====================================================
# 启动
# =====================================================
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=6006)

frontend.py

python 复制代码
import streamlit as st
import requests

BASE_URL = "http://127.0.0.1:6006"
REGISTER_URL = f"{BASE_URL}/register"
LOGIN_URL = f"{BASE_URL}/login"
REFRESH_URL = f"{BASE_URL}/refresh"
UPLOAD_URL = f"{BASE_URL}/upload"
RECORDS_URL = f"{BASE_URL}/records"

st.set_page_config(page_title="文档上传系统", page_icon="📄")

# ----------------- Session -----------------
if "token" not in st.session_state:
    st.session_state.token = None
if "refresh_token" not in st.session_state:
    st.session_state.refresh_token = None
if "user_id" not in st.session_state:
    st.session_state.user_id = None

# ----------------- 注册 -----------------
st.subheader("用户注册")
reg_username = st.text_input("用户名(注册)", key="reg_username")
reg_password = st.text_input("密码", type="password", key="reg_password")
if st.button("注册"):
    try:
        resp = requests.post(
            REGISTER_URL,
            params={"username": reg_username, "password": reg_password}
        )
        st.write("状态码:", resp.status_code)
        st.code(resp.text)
        if resp.status_code == 200:
            st.success("注册成功,请登录")
        else:
            st.error("注册失败")
    except Exception as e:
        st.error(str(e))
st.markdown("---")

# ----------------- 登录 -----------------
if st.session_state.token is None:
    st.subheader("用户登录")
    username = st.text_input("用户名", key="login_username")
    password = st.text_input("密码", type="password", key="login_password")
    if st.button("登录"):
        try:
            resp = requests.post(
                LOGIN_URL,
                params={"username": username, "password": password}
            )
            st.write("状态码:", resp.status_code)
            st.code(resp.text)
            if resp.status_code == 200:
                data = resp.json()
                st.session_state.token = data["access_token"]
                st.session_state.refresh_token = data["refresh_token"]
                st.session_state.user_id = data["user_id"]
                st.success("登录成功")
                st.rerun()
            else:
                st.error("登录失败")
        except Exception as e:
            st.error(str(e))
    st.stop()

# ----------------- API 请求封装(自动刷新 Access Token) -----------------
def api_request(method, url, headers=None, files=None, params=None):
    headers = headers or {}
    headers["Authorization"] = f"Bearer {st.session_state.token}"
    resp = requests.request(method, url, headers=headers, files=files, params=params)
    if resp.status_code == 401:
        # token过期,尝试刷新
        try:
            refresh_resp = requests.post(
                REFRESH_URL,
                params={"refresh_token": st.session_state.refresh_token}
            )
            if refresh_resp.status_code == 200:
                data = refresh_resp.json()
                st.session_state.token = data["access_token"]
                headers["Authorization"] = f"Bearer {st.session_state.token}"
                resp = requests.request(method, url, headers=headers, files=files, params=params)
        except:
            pass
    return resp

# ----------------- 已登录 -----------------
st.success(f"当前用户:{st.session_state.user_id}")

# ----------------- 上传文件 -----------------
uploaded_file = st.file_uploader("选择文件")
if uploaded_file is not None:
    st.write("文件名:", uploaded_file.name)
    st.write("文件大小:", uploaded_file.size)
    if st.button("上传文件"):
        files = {"file": (uploaded_file.name, uploaded_file.getvalue(), uploaded_file.type)}
        try:
            with st.spinner("上传中..."):
                resp = api_request("POST", UPLOAD_URL, files=files)
            if resp.status_code == 200:
                st.success("上传成功")
                st.json(resp.json())
            else:
                st.error(resp.text)
        except Exception as e:
            st.error(str(e))

# ----------------- 查询记录 -----------------
st.markdown("---")
if st.button("查看我的上传记录"):
    try:
        resp = api_request("GET", RECORDS_URL)
        if resp.status_code == 200:
            records = resp.json()
            if not records:
                st.info("暂无上传记录")
            else:
                st.subheader("上传历史")
                for rec in records:
                    st.markdown(
                        f"""
**原文件名:** {rec['original_filename']}

**保存文件名:** `{rec['saved_filename']}`

**上传时间:** {rec['upload_time']}

**文件大小:** {rec['file_size']} 字节

---
"""
                    )
        else:
            st.error(resp.text)
    except Exception as e:
        st.error(str(e))

# ----------------- 退出登录 -----------------
st.markdown("---")
if st.button("退出登录"):
    st.session_state.token = None
    st.session_state.refresh_token = None
    st.session_state.user_id = None
    st.rerun()

七、总结

通过引入 Refresh Token,我们实现了:

  • 安全性:Access Token 有效期短(24 小时),降低泄露危害。
  • 用户体验:用户长时间无需重新登录,后台自动续期。
  • 灵活性:服务端可以随时撤销 Refresh Token(删除数据库记录)。
  • 简单性:前端只需封装一个自动刷新函数,对业务代码无侵入。

本文给出的完整代码(FastAPI 后端 + Streamlit 前端)可以直接运行测试。你可以在此基础上扩展 Refresh Token 轮换、设备管理、多因素认证等高级功能。

希望这篇文章能帮助你理解 Refresh Token 的价值,并在自己的项目中合理应用。如果你有任何问题或改进建议,欢迎在评论区留言讨论。

相关推荐
abcy0712135 小时前
python fastapi celery hdfs 异步上传
python·hdfs·fastapi
俊俊谢8 小时前
【python】FastAPI 实时推送:从 SSE 到 WebSocket
python·websocket·fastapi
俊俊谢1 天前
[python]FastAPI + 自建SSE 踩坑全记录
开发语言·python·fastapi
li星野1 天前
从零搭建 DeepSeek LLM API 服务:FastAPI + LlamaIndex 实践
fastapi
勇往直前plus1 天前
FastAPI + SQLAlchemy PythonWeb体系梳理
fastapi·python3.11
放下华子我只抽RuiKe52 天前
FastAPI 全栈后端(四):认证与授权
开发语言·前端·javascript·python·深度学习·react.js·fastapi
放下华子我只抽RuiKe52 天前
FastAPI 全栈后端(六):中间件与依赖注入
ai·中间件·fastapi·ai编程·qwen·ai大模型·openclaw
放下华子我只抽RuiKe52 天前
FastAPI 全栈后端(五):后台任务与消息队列
前端·javascript·react.js·ai·前端框架·fastapi·ai编程
li星野3 天前
从零构建安全文件上传系统:FastAPI + JWT + 密码哈希 + Streamlit 前端 + SQLite
安全·哈希算法·fastapi