FastAPI JWT 登录认证实战:Access Token、Refresh Token 与权限保护
在前后端分离项目中,用户登录后如何保持身份状态,是后端开发必须解决的问题。
传统 Web 项目经常使用 Session:用户登录成功后,服务器保存会话数据,并向浏览器返回 Session ID。但在移动端、多服务部署和前后端分离场景中,JWT(JSON Web Token)是一种更常见的身份认证方案。
本文将使用 FastAPI 实现一套基础的 JWT 登录认证机制,内容包括:
用户密码验证;
Access Token 的生成与解析;
Refresh Token 的设计;
接口身份保护;
Token 过期与刷新;
退出登录与 Token 撤销;
实际项目中的安全注意事项。
一、JWT 是什么?
JWT 是一种可以在客户端和服务器之间传递声明信息的 Token 格式。
一个 JWT 通常由三部分组成:
Header.Payload.Signature例如:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMDAxIiwiZXhwIjoxNzIwMDAwMDAwfQ . xxxxxxxxxxxxxxxx三部分分别表示:
Header:使用的签名算法和 Token 类型;Payload:用户 ID、过期时间、Token 类型等声明;Signature:服务器根据密钥生成的签名。
JWT 的 Payload 只是经过 Base64URL 编码,并不是加密内容。因此,不应该在其中保存密码、身份证号、聊天内容等敏感信息。
JWT 签名的主要作用是防止内容被篡改,而不是隐藏内容。
二、JWT 登录认证的基本流程
一次完整的 JWT 登录过程如下:
用户提交账号和密码 ↓ 服务器验证用户信息 ↓ 生成 Access Token 和 Refresh Token ↓ 客户端保存 Token ↓ 请求接口时携带 Access Token ↓ 服务器验证签名和有效期 ↓ 允许或拒绝访问客户端一般通过请求头携带 Token:
Authorization: Bearer <access_token>Access Token 过期后,客户端可以使用 Refresh Token 获取新的 Access Token,而不需要用户立即重新输入密码。
三、安装项目依赖
安装 FastAPI、JWT 和密码哈希相关依赖:
pip install fastapi uvicorn pyjwt bcrypt项目可以先使用一个文件演示:
jwt_demo/ └── main.py启动命令:
uvicorn main:app --reload四、不要明文保存用户密码
用户密码不能直接保存到数据库中,应该保存密码经过哈希计算后的结果。
可以使用bcrypt处理密码:
import bcrypt def hash_password(password: str) -> str: password_bytes = password.encode("utf-8") hashed = bcrypt.hashpw( password_bytes, bcrypt.gensalt(), ) return hashed.decode("utf-8") def verify_password( plain_password: str, hashed_password: str, ) -> bool: return bcrypt.checkpw( plain_password.encode("utf-8"), hashed_password.encode("utf-8"), )注册用户时保存哈希结果:
password_hash = hash_password( "example_password" )用户登录时,通过verify_password()判断输入密码是否正确。
密码哈希与普通加密不同。系统不需要还原用户的原始密码,只需要验证用户本次输入是否与之前保存的密码一致。
五、配置 JWT 密钥
JWT 签名密钥不能直接写死在代码仓库中,应该通过环境变量读取:
import os JWT_SECRET = os.environ["JWT_SECRET"] JWT_ALGORITHM = "HS256"启动服务前配置环境变量:
export JWT_SECRET="请替换成足够长的随机字符串" uvicorn main:app --reload生产环境中的密钥应该:
具有足够的随机性;
不提交到 Git 仓库;
不直接输出到日志;
定期进行安全检查;
通过密钥管理服务或安全配置系统保存。
如果密钥泄露,攻击者就可能伪造合法 Token。
六、生成 Access Token
Access Token 用于访问需要登录的接口,其有效时间通常较短。
from datetime import ( datetime, timedelta, timezone, ) from uuid import uuid4 import jwt ACCESS_TOKEN_EXPIRE_MINUTES = 30 def create_access_token(user_id: int) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "type": "access", "iat": now, "exp": now + timedelta( minutes=ACCESS_TOKEN_EXPIRE_MINUTES ), "jti": str(uuid4()), } return jwt.encode( payload, JWT_SECRET, algorithm=JWT_ALGORITHM, )这里使用了几个常见字段:
| 字段 | 含义 |
|---|---|
sub | Token 对应的用户 |
type | Token 类型 |
iat | Token 签发时间 |
exp | Token 过期时间 |
jti | Token 的唯一编号 |
建议使用 UTC 时间处理 Token 有效期,减少不同服务器时区产生的问题。
七、生成 Refresh Token
Refresh Token 只用于申请新的 Access Token,其有效期通常更长:
REFRESH_TOKEN_EXPIRE_DAYS = 7 def create_refresh_token(user_id: int) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "type": "refresh", "iat": now, "exp": now + timedelta( days=REFRESH_TOKEN_EXPIRE_DAYS ), "jti": str(uuid4()), } return jwt.encode( payload, JWT_SECRET, algorithm=JWT_ALGORITHM, )虽然两种 Token 都使用 JWT 格式,但必须通过type字段进行区分。
如果后端没有检查 Token 类型,攻击者可能把有效期较长的 Refresh Token 当成 Access Token 使用,从而绕过原本的有效期设计。
八、实现用户登录接口
首先定义请求和响应模型:
from pydantic import BaseModel class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer"为了简化示例,使用字典模拟数据库:
fake_users = { "demo": { "id": 1001, "username": "demo", "password_hash": hash_password( "123456" ), "is_active": True, } }实现登录接口:
from fastapi import FastAPI, HTTPException app = FastAPI() @app.post( "/auth/login", response_model=TokenResponse, ) def login(request: LoginRequest): user = fake_users.get(request.username) if not user: raise HTTPException( status_code=401, detail="用户名或密码错误", ) if not verify_password( request.password, user["password_hash"], ): raise HTTPException( status_code=401, detail="用户名或密码错误", ) if not user["is_active"]: raise HTTPException( status_code=403, detail="用户已被禁用", ) return TokenResponse( access_token=create_access_token( user["id"] ), refresh_token=create_refresh_token( user["id"] ), )无论用户名不存在还是密码错误,接口都返回相同提示,可以避免向外部暴露账号是否存在。
九、解析和验证 Token
FastAPI 提供了OAuth2PasswordBearer,可以从请求头中提取 Bearer Token:
from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer( tokenUrl="/auth/login" )然后实现当前用户解析逻辑:
from fastapi import Depends, HTTPException from jwt import ( ExpiredSignatureError, InvalidTokenError, ) def get_current_user_id( token: str = Depends(oauth2_scheme), ) -> int: try: payload = jwt.decode( token, JWT_SECRET, algorithms=[JWT_ALGORITHM], ) if payload.get("type") != "access": raise HTTPException( status_code=401, detail="Token 类型错误", ) user_id = payload.get("sub") if not user_id: raise HTTPException( status_code=401, detail="Token 缺少用户信息", ) return int(user_id) except ExpiredSignatureError: raise HTTPException( status_code=401, detail="Token 已过期", ) except ( InvalidTokenError, TypeError, ValueError, ): raise HTTPException( status_code=401, detail="无效的 Token", )jwt.decode()会验证签名和过期时间。验证失败时,接口应该返回401 Unauthorized。
十、保护需要登录的接口
有了get_current_user_id(),就可以通过依赖注入保护接口:
@app.get("/users/me") def get_current_user( user_id: int = Depends( get_current_user_id ), ): return { "user_id": user_id, "message": "身份验证成功", }客户端请求时必须携带 Access Token:
curl \ -H "Authorization: Bearer <access_token>" \ http://localhost:8000/users/me如果 Token 不存在、签名错误或已经过期,服务器会拒绝访问。
十一、实现 Token 刷新接口
定义刷新请求模型:
class RefreshRequest(BaseModel): refresh_token: str刷新接口需要确认传入的是 Refresh Token:
@app.post("/auth/refresh") def refresh_access_token( request: RefreshRequest, ): try: payload = jwt.decode( request.refresh_token, JWT_SECRET, algorithms=[JWT_ALGORITHM], ) if payload.get("type") != "refresh": raise HTTPException( status_code=401, detail="Token 类型错误", ) user_id = payload.get("sub") if not user_id: raise HTTPException( status_code=401, detail="Token 缺少用户信息", ) return { "access_token": create_access_token( int(user_id) ), "token_type": "bearer", } except ExpiredSignatureError: raise HTTPException( status_code=401, detail="Refresh Token 已过期", ) except ( InvalidTokenError, TypeError, ValueError, ): raise HTTPException( status_code=401, detail="无效的 Refresh Token", )实际项目中,刷新时还应该检查:
用户是否仍然存在;
用户是否已被禁用;
Refresh Token 是否被撤销;
用户密码是否已经修改;
当前设备是否仍然可信。
不能只验证 JWT 签名后就无条件签发新 Token。
十二、退出登录为什么不能只删除前端 Token?
JWT 的特点之一是服务器可以不保存会话状态。
这也意味着,只要 Token 没有过期,服务器通常就会认为它有效。用户在前端点击退出登录,只是删除了当前设备保存的 Token,并不能让已经泄露的 Token 立即失效。
如果业务要求退出后立即失效,可以使用 Redis 保存 Token 黑名单。
退出时,将 Access Token 的jti写入 Redis,并设置与 Token 剩余有效期相同的过期时间:
jwt:blacklist:<jti> = 1验证 Token 时检查:
jti = payload.get("jti") if redis_client.exists( f"jwt:blacklist:{jti}" ): raise HTTPException( status_code=401, detail="Token 已失效", )当 Token 自然过期后,对应的黑名单记录也可以自动删除。
另一种方案是保存用户的 Token 版本号。修改密码、退出所有设备或封禁账号时,提高版本号,让之前签发的 Token 全部失效。
十三、同言翻译场景中的身份认证设计
对于具有个人账号、历史会话和跨设备使用需求的应用,身份认证不仅关系到接口能否访问,也关系到用户数据是否会被错误读取。
以同言翻译为例,用户可能需要查看自己的翻译记录、管理术语配置或在不同设备间同步会话。后端接口需要通过 Access Token 确认请求者身份,并在查询数据时同时校验资源归属关系。
下面这种写法只根据会话 ID 查询数据,存在越权风险:
@app.get("/sessions/{session_id}") def get_session(session_id: int): return query_session(session_id)即使接口要求登录,用户仍然可能通过修改session_id访问其他人的会话。
更安全的方式是同时使用当前用户 ID 查询:
@app.get("/sessions/{session_id}") def get_session( session_id: int, user_id: int = Depends( get_current_user_id ), ): session = query_user_session( user_id=user_id, session_id=session_id, ) if not session: raise HTTPException( status_code=404, detail="会话不存在", ) return session对于同言翻译这类涉及语音、文本和会话内容的应用,仅验证“用户是否登录”是不够的,还必须验证“当前用户是否有权访问这份数据”。
此外,可以为敏感操作增加更严格的安全措施,例如:
修改密码前重新验证身份;
导出历史记录时进行二次确认;
Refresh Token 按设备分别管理;
异常登录后使旧 Token 失效;
对重要接口记录安全审计日志;
不在 JWT 中保存翻译原文或会话内容。
十四、Access Token 应该保存在哪里?
不同客户端需要采用不同的 Token 保存策略。
浏览器应用
常见方式包括:
内存变量;
HttpOnly Cookie;sessionStorage;localStorage。
将 Token 保存到localStorage实现简单,但如果页面存在 XSS 漏洞,恶意脚本可能读取 Token。
使用HttpOnly Cookie可以阻止 JavaScript 直接读取 Cookie,但需要额外处理 CSRF、防跨站请求和 Cookie 安全属性。
如果使用 Cookie,通常应该合理配置:
HttpOnly Secure SameSite没有一种方案适合所有项目,需要结合前端架构、跨域方式和安全要求进行选择。
移动端应用
移动端应使用系统提供的安全存储能力,不建议把 Token 直接保存在普通配置文件或明文数据库中。
十五、生产环境中的安全建议
1. Access Token 不要设置得过长
Access Token 有效期越长,泄露后的风险持续时间越长。
可以使用:
短期 Access Token + 长期 Refresh Token在用户体验和安全性之间取得平衡。
2. Refresh Token 应支持撤销
Refresh Token 的有效期较长,一旦泄露,攻击者可能不断申请新的 Access Token。
生产环境中可以在数据库或 Redis 中保存 Refresh Token 的状态,并记录:
Token 唯一编号;
所属用户;
登录设备;
签发时间;
过期时间;
是否已经撤销。
3. 使用 HTTPS
如果使用明文 HTTP,Token 可能在传输过程中被截获。
生产环境必须使用 HTTPS,WebSocket 则应该使用wss://。
4. 不要在日志中记录完整 Token
排查问题时,可以记录 Token 的jti、用户 ID或部分摘要,但不应该输出完整 Token。
5. 为登录接口增加限流
攻击者可能持续尝试不同密码,因此登录接口应该增加:
IP 限流;
账号维度限流;
连续失败次数限制;
验证码或其他人机验证;
异常登录告警。
6. 修改密码后撤销旧 Token
如果用户修改密码,但旧 Token 仍然可以继续使用,那么已经泄露的 Token 不会自动失效。
可以通过 Token 版本号、黑名单或会话记录实现统一撤销。
十六、JWT 常见误区
误区一:JWT 中的数据是加密的
JWT Payload 通常只是编码,任何获得 Token 的人都可以解析其中内容。
误区二:使用 JWT 就完全不需要服务器状态
如果需要退出登录、设备管理、Token 撤销和风险控制,服务器仍然可能需要保存部分状态。
误区三:Refresh Token 可以访问业务接口
Refresh Token 只能用于刷新身份凭证。业务接口必须检查 Token 类型,只接受 Access Token。
误区四:只要签名正确就代表用户可以访问所有数据
签名正确只能说明 Token 是服务器签发的。具体资源是否属于当前用户,仍然需要在业务层进行权限校验。
误区五:JWT 可以替代所有权限系统
JWT 负责传递身份信息,但角色权限、资源权限、数据归属和操作范围仍然需要单独设计。
十七、总结
使用 FastAPI 实现 JWT 登录认证,核心流程包括:
对用户密码进行安全哈希;
登录成功后生成 Access Token 和 Refresh Token;
客户端通过 Bearer Token 访问接口;
服务器验证签名、类型和过期时间;
Access Token 过期后使用 Refresh Token 更新;
对重要接口进行资源归属和权限检查;
通过黑名单或 Token 版本实现主动撤销。
JWT 能够让前后端分离项目更方便地传递身份信息,但它并不是“生成一个字符串”这么简单。
真正可靠的认证系统,还需要综合考虑密钥管理、Token 存储、退出登录、设备管理、越权访问、接口限流和安全审计。
身份认证解决的是“你是谁”,权限校验解决的是“你能做什么”。只有同时做好这两部分,才能真正保护用户数据和系统接口。