FastAPI JWT 登录认证实战:Access Token、Refresh Token 与权限保护
在前后端分离项目中用户登录后如何保持身份状态是后端开发必须解决的问题。传统 Web 项目经常使用 Session用户登录成功后服务器保存会话数据并向浏览器返回 Session ID。但在移动端、多服务部署和前后端分离场景中JWTJSON 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 ↓ 服务器验证签名和有效期 ↓ 允许或拒绝访问客户端一般通过请求头携带 TokenAuthorization: Bearer access_tokenAccess 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 TokenAccess 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( minutesACCESS_TOKEN_EXPIRE_MINUTES ), jti: str(uuid4()), } return jwt.encode( payload, JWT_SECRET, algorithmJWT_ALGORITHM, )这里使用了几个常见字段字段含义subToken 对应的用户typeToken 类型iatToken 签发时间expToken 过期时间jtiToken 的唯一编号建议使用 UTC 时间处理 Token 有效期减少不同服务器时区产生的问题。七、生成 Refresh TokenRefresh 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( daysREFRESH_TOKEN_EXPIRE_DAYS ), jti: str(uuid4()), } return jwt.encode( payload, JWT_SECRET, algorithmJWT_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_modelTokenResponse, ) def login(request: LoginRequest): user fake_users.get(request.username) if not user: raise HTTPException( status_code401, detail用户名或密码错误, ) if not verify_password( request.password, user[password_hash], ): raise HTTPException( status_code401, detail用户名或密码错误, ) if not user[is_active]: raise HTTPException( status_code403, detail用户已被禁用, ) return TokenResponse( access_tokencreate_access_token( user[id] ), refresh_tokencreate_refresh_token( user[id] ), )无论用户名不存在还是密码错误接口都返回相同提示可以避免向外部暴露账号是否存在。九、解析和验证 TokenFastAPI 提供了OAuth2PasswordBearer可以从请求头中提取 Bearer Tokenfrom 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_code401, detailToken 类型错误, ) user_id payload.get(sub) if not user_id: raise HTTPException( status_code401, detailToken 缺少用户信息, ) return int(user_id) except ExpiredSignatureError: raise HTTPException( status_code401, detailToken 已过期, ) except ( InvalidTokenError, TypeError, ValueError, ): raise HTTPException( status_code401, 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 Tokencurl \ -H Authorization: Bearer access_token \ http://localhost:8000/users/me如果 Token 不存在、签名错误或已经过期服务器会拒绝访问。十一、实现 Token 刷新接口定义刷新请求模型class RefreshRequest(BaseModel): refresh_token: str刷新接口需要确认传入的是 Refresh Tokenapp.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_code401, detailToken 类型错误, ) user_id payload.get(sub) if not user_id: raise HTTPException( status_code401, detailToken 缺少用户信息, ) return { access_token: create_access_token( int(user_id) ), token_type: bearer, } except ExpiredSignatureError: raise HTTPException( status_code401, detailRefresh Token 已过期, ) except ( InvalidTokenError, TypeError, ValueError, ): raise HTTPException( status_code401, detail无效的 Refresh Token, )实际项目中刷新时还应该检查用户是否仍然存在用户是否已被禁用Refresh Token 是否被撤销用户密码是否已经修改当前设备是否仍然可信。不能只验证 JWT 签名后就无条件签发新 Token。十二、退出登录为什么不能只删除前端 TokenJWT 的特点之一是服务器可以不保存会话状态。这也意味着只要 Token 没有过期服务器通常就会认为它有效。用户在前端点击退出登录只是删除了当前设备保存的 Token并不能让已经泄露的 Token 立即失效。如果业务要求退出后立即失效可以使用 Redis 保存 Token 黑名单。退出时将 Access Token 的jti写入 Redis并设置与 Token 剩余有效期相同的过期时间jwt:blacklist:jti 1验证 Token 时检查jti payload.get(jti) if redis_client.exists( fjwt:blacklist:{jti} ): raise HTTPException( status_code401, detailToken 已失效, )当 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_iduser_id, session_idsession_id, ) if not session: raise HTTPException( status_code404, detail会话不存在, ) return session对于同言翻译这类涉及语音、文本和会话内容的应用仅验证“用户是否登录”是不够的还必须验证“当前用户是否有权访问这份数据”。此外可以为敏感操作增加更严格的安全措施例如修改密码前重新验证身份导出历史记录时进行二次确认Refresh Token 按设备分别管理异常登录后使旧 Token 失效对重要接口记录安全审计日志不在 JWT 中保存翻译原文或会话内容。十四、Access Token 应该保存在哪里不同客户端需要采用不同的 Token 保存策略。浏览器应用常见方式包括内存变量HttpOnly CookiesessionStoragelocalStorage。将 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如果使用明文 HTTPToken 可能在传输过程中被截获。生产环境必须使用 HTTPSWebSocket 则应该使用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 存储、退出登录、设备管理、越权访问、接口限流和安全审计。身份认证解决的是“你是谁”权限校验解决的是“你能做什么”。只有同时做好这两部分才能真正保护用户数据和系统接口。