ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JWT API认证实战:从Session到双Token的原理与最佳实践

JWT API认证实战:从Session到双Token的原理与最佳实践 做后端最烦的一件事就是刚上线一个接口产品那边突然说“这个接口必须登录才能调”。以前项目少的时候我直接在接口里写死校验逻辑前端传个userId过来我查一下库能用就行。等接口一多、服务一拆这种玩法彻底崩了每个接口都要往数据库里翻用户分布式部署下session也存不到一块去换端登录更是鸡飞狗跳。后来我在新项目里全面换成了JWTJSON Web Token做用户认证与授权把token签发给客户端后端只负责验签不存登录状态。这篇文章把我从设计到落地、再到踩坑的完整过程写出来包含可以直接抄的代码、双token续签方案以及我在线上遇到过的问题排查思路。不管你是刚入门API开发的新手还是正在重构老系统的后端应该都能从中找到能用的东西。1. 为什么JWT比传统Session更适合API场景1.1 传统Session认证的核心痛点传统的Session认证流程大家应该都写过用户登录成功服务端生成一个sessionId存到内存或Redis里再把sessionId通过Cookie返回给浏览器。之后的请求带上Cookie服务端拿着sessionId去查对应的会话数据查到就算登录了。这套流程在小单体时代没毛病但放在现在的API场景里就有点别扭了。举个例子一个系统同时有Web端、小程序端、iOS和Android端客户端有的能存Cookie有的只能自己管理token你没法统一一套“写Cookie、自动携带Cookie”的玩法。更头疼的是后端一旦部署多个实例session存到哪个实例是个大问题——负载均衡落在A实例的会话请求被转发到B实例就找不到登录态了。还有个更麻烦的场景多个后端服务互相调用。比如订单服务要确认“当前用户是否是VIP”它没有用户的session数据只能再去用户服务查一次。一次调用变成两次链路一长延迟和故障率都上来了。1.2 JWT的结构三段字符串里藏了什么JWT的思路很直接把认证信息直接交给客户端保存服务端不再存会话状态。一个完整的JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.signature点号分割成三段Header头部声明token类型和签名算法一般是{alg:HS256,typ:JWT}。Payload载荷放业务数据比如用户IDsub、过期时间exp、签发时间iat、自定义的role字段这部分只是Base64编码任何人都能解码。Signature签名用Header里声明的算法把前两段加上密钥一起做签名计算。只要密钥不泄露token内容就不能被篡改。我用PyJWT生成一个token的代码非常简单import jwt import time SECRET your-32-bytes-random-secret payload { sub: 1001, role: admin, iat: int(time.time()), exp: int(time.time()) 1800, } token jwt.encode(payload, SECRET, algorithmHS256) print(token)注意encode默认只做Base64和签名不做加密。也就是说任何拿到token的人都能看到你的sub和role字段。这不代表不能用而是要求你别往payload里塞密码、手机号、身份证这类敏感信息。1.3 JWT和Session怎么选很多新手纠结JWT和Session哪个好其实它们没有绝对的优劣关键看场景。对比项Session认证JWT认证登录态存储服务端内存或Redis客户端保存token横向扩容需要集中式session存储天然支持多实例主动注销删除服务端session即可立即生效需要额外黑名单机制跨端适配Cookie为主App流程别扭任意客户端统一处理接口性能每次请求查一次存储每次请求做一次验签一句话总结我自己的选型经验前后端分离、多端共存、微服务架构优先JWT传统服务端渲染、后台管理系统、对“踢人下线”有硬性要求的场景Session反而更好实现。如果非要JWT做强制下线也不是不行后面会讲黑名单方案。2. 认证方案设计不只发一个token那么简单2.1 登录接口校验完密码后签发什么登录接口的核心动作就两个校验用户名密码签发token返回给客户端。我这里的校验逻辑不展开重点在签发token时往payload里放了什么。我的通用设计import uuid def create_access_token(user_id: str, role: str, expires_minutes: int 30) - str: now int(time.time()) payload { sub: str(user_id), # 用户ID必须唯一 role: role, # 角色用来做权限判断 iat: now, # 签发时间 exp: now expires_minutes * 60, # 过期时间 jti: uuid.uuid4().hex, # token唯一ID注销/黑名单要用 } return jwt.encode(payload, SECRET_KEY, algorithmALGORITHM)很多人只放sub和exp等我上了生产环境才发现远远不够。role字段能让你在接口层直接做权限拦截少查一次数据库jti是黑名单机制的关键后面讲续签和登出的时候会体现。2.2 别再只发一个token了双token机制详解早期我做JWT只发一个access token过期时间设置比较长比如24小时。结果出过两个事一个用户的token在公共电脑上忘了退被人截了之后整整一天都能访问接口另一个是token到期后强制重新登录用户吐槽“我用着用着突然被踢下线”。后来我改成标准的双token方案access_token有效期短一般15分钟到2小时用来正常访问API。refresh_token有效期长一般7到14天只用来换取新的access_token。访问接口只认access_token过期了前端就拿着refresh_token去调/api/refresh换一个新的access_token回来。这样就算access_token泄露攻击者也只有很短的利用窗口而refresh_token即使泄露控制好用途、加上轮换策略风险也可控得多。2.3 校验中间件每个受保护接口的第一步JWT的校验逻辑不是每个接口单独写一遍而是统一在中间件或拦截器里做。我拿FastAPI举个完整例子import jwt from fastapi import Depends, FastAPI, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials app FastAPI() security HTTPBearer() def get_current_user(credentials: HTTPAuthorizationCredentials Depends(security)): token credentials.credentials try: payload jwt.decode( token, SECRET_KEY, algorithms[ALGORITHM], # 必须显式指定算法白名单 ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code401, detailtoken expired) except jwt.InvalidTokenError: raise HTTPException(status_code401, detailinvalid token)这段代码里有几个关键点值得强调。第一HTTPBearer会自动检查请求头里的Authorization字段要求格式必须是Bearer token。有些客户端喜欢直接把token裸放在query参数或者自定义header里我不推荐因为Authorization是HTTP标准字段网关、日志、代理都会天然兼容它。第二algorithms[ALGORITHM]这个参数不能省。PyJWT较新版本要求必须显式指定算法白名单这是一个非常关键的安全约束。如果你不限制算法攻击者可以自己伪造一个alg:none的token来绕过签名校验。第三校验通过后我直接把payload返回给接口。后续接口想拿用户ID、角色都从payload里取不需要再查库。2.4 续签与登出两个必须落地的接口继续说双token机制里的两个配套接口。续签接口的逻辑核心是拿refresh_token换新的access_token。我贴一个重点流程的伪代码app.post(/api/refresh) def refresh_token(refresh_token: str): # 1. 验证refresh_token的签名和过期时间失败直接401 # 2. 去Redis检查该token的jti是否在黑名单中在就拒绝 # 3. 从payload里取user_id签发新的access_token # 4. 推荐顺便轮换refresh_token旧refresh_token作废签发新的refresh_token return {access_token: new_access, refresh_token: new_refresh}登出接口的原理不是删除一个不存在的服务端session而是把当前token的jti加入黑名单缓存时间一直到它的exp为止import redis r redis.Redis() def logout(token_jti: str, token_exp: int): ttl token_exp - int(time.time()) if ttl 0: r.setex(fblacklist:{token_jti}, ttl, 1)校验token的时候加一步def is_blacklisted(jti: str) - bool: return r.exists(fblacklist:{jti}) 1有了这套设计我才能在JWT体系下实现“主动踢人”也算弥补了JWT无状态的一大短板。3. 完整实操从零搭一个带JWT认证的API服务3.1 依赖选型与配置项我这里用Python的FastAPI PyJWT做演示因为代码量少、易读换个语言也能照着思路翻译。你要是用Java对应的是jjwt或者Spring Security里的JwtDecoder设计逻辑完全一致。先准备依赖pip install fastapi uvicorn PyJWT redisJava侧的Maven坐标也列一下方便需要的朋友dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.x/version /dependency配置项方面我习惯把密钥和过期时间全部放进环境变量防止密钥硬编码进代码库搞不好就跟着代码一起泄露了。配置项推荐值说明JWT_SECRET_KEY随机生成32字节以上字符串HS256签名使用的密钥必须足够强JWT_ALGORITHMHS256如果走RS256则改为RS256ACCESS_TOKEN_EXPIRE_MINUTES30access_token有效分钟数REFRESH_TOKEN_EXPIRE_DAYS7refresh_token有效天数生成一个足够强的密钥可以用这条命令openssl rand -base64 32这里要专门说一句网上很多教程直接写SECRET_KEY my-secret这种弱密钥在HS256下非常容易被暴力破解。攻击者拿到一个有效token的样本后可以用大量常见单词离线尝试签名匹配。密钥一旦被还原他能任意伪造你的token整个认证体系等于作废。3.2 用户登录与受保护接口完整代码完整的接口代码如下我把能缩略的地方尽量精简重点展示JWT相关链路import time import uuid import jwt from fastapi import Depends, FastAPI, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials SECRET_KEY from_env_or_secret_manager ALGORITHM HS256 app FastAPI() security HTTPBearer() # 模拟用户表生产环境请用数据库 bcrypt/argon2 存密码 USERS { admin: {password: $2b$12$hashed_password_example, role: admin}, } def create_access_token(user_id: str, role: str, expires_minutes: int 30): now int(time.time()) payload { sub: user_id, role: role, iat: now, exp: now expires_minutes * 60, jti: uuid.uuid4().hex, } return jwt.encode(payload, SECRET_KEY, algorithmALGORITHM) def verify_token(token: str) - dict: try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) return payload except jwt.PyJWTError: raise HTTPException(status_code401, detailinvalid token) app.post(/api/login) def login(username: str, password: str): user USERS.get(username) if not user or not verify_password(password, user[password]): raise HTTPException(status_code401, detailbad credentials) access_token create_access_token(username, user[role]) return {access_token: access_token, token_type: bearer} app.get(/api/user/info) def user_info(credentials: HTTPAuthorizationCredentials Depends(security)): payload verify_token(credentials.credentials) return {user_id: payload[sub], role: payload[role]}我实际跑过这个例子用curl验证整个流程非常直观。先调登录接口拿token再带token访问受保护接口。curl -X POST http://localhost:8000/api/login?usernameadminpassword123456返回一个token后curl -H Authorization: Bearer eyJhbGciOi... http://localhost:8000/api/user/info如果请求头不带token或者token过期服务端就直接返回401 JSON前端不用再猜失败原因。3.3 前端如何规范携带token后端接口准备好了前端这一侧也有一堆细节。我要求团队用Axios统一封装请求在拦截器里自动带上tokenimport axios from axios; const apiClient axios.create({ baseURL: /api }); apiClient.interceptors.request.use(config { const token localStorage.getItem(access_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); apiClient.interceptors.response.use( response response, error { if (error.response error.response.status 401) { // 这里可以调用刷新token接口或直接跳登录页 window.location.href /login; } return Promise.reject(error); } );这里有两个实操注意点。一个是token放localStorage还是cookie各有利弊。localStorage的好处是前端读取方便缺点是XSS攻击能把token偷走cookie配合httpOnly可以防XSS但要额外处理CSRF。我在常见场景下先用localStorage如果项目对安全等级要求高再用cookieCSRF token的叠加方案。另一个是刷新token失败时不能无限重试。我踩过几次坑access_token过期后前端并发发了多个请求每个请求都触发刷新结果把refresh_token用一次就作废了轮换策略下导致后续请求全部401。解决办法是做一个并发锁——刷新过程中其他请求先排队等待同一个刷新Promise结果。3.4 跑通全流程的调试经验完整的验证流程我会分四步走登录拿token确认返回结构里有access_token和token_type。不带token访问受保护接口确认返回401。带有效token访问确认返回200。手工构造一个过期token把payload里的exp改成过去时间再重新签名确认返回401。第四步很多人会忽略但偏偏特别重要。你永远不知道客户端会在什么情况下拿到一个过期token提前验证服务端能优雅处理总比线上用户看到500错误强。4. JWT安全性与性能绕开这些经典漏洞4.1 算法混淆从alg:none到RS256/HS256切换JWT最经典的攻击之一就是算法混淆。我举三种典型情况。第一种是alg:none。某些JWT库在实现时允许alg字段为none意思是“我不做签名校验”攻击者直接把Header里的算法改成none删掉签名部分服务端如果没做算法白名单校验就会把这个伪造token当成合法token接受。不过现在主流库默认都会拒绝alg:nonePyJWT里遇到这种情况会直接抛错。防御方式就是前面说的decode时指定algorithms[ALGORITHM]不要信任token自身声明的算法。第二种是密钥混淆。如果你的服务既支持HS256又支持RS256攻击者可以把token的alg改成HS256然后用RS256的公钥当HMAC密钥来签名。乍一听很绕攻击逻辑是服务端用公钥验证RS256签名但攻击者改用HS256签名用的密钥是“公钥的内容”而公钥通常是公开的。服务端如果没限制算法就会用公钥做HS256验签结果验签成功。防御同样是指定算法白名单并在代码里彻底禁用不需要的算法。4.2 弱密钥和密钥管理HS256还是RS256HS256是对称算法签发和验证用的是同一个密钥。RS256是非对称算法签发用私钥验证只用公钥。这里我给出自己的选型建议如果是单体服务或者服务间通信场景不复杂HS256完全够用管理一个密钥就行如果你要做开放API平台第三方需要用你提供的公钥来验签或者系统里有多个服务需要各自独立验签优先用RS256。RS256的另一个好处是私钥只存在于认证服务其他服务即使被攻破也无法伪造新token只能验证已有token。不管用哪种密钥都不能写死在代码里。我的实际做法是开发环境用环境变量生产环境用专门的密钥管理系统或者部署平台的安全配置定期轮换。轮换密钥的时候注意新旧密钥要有一段时间的并存期否则线上业务会有大量401。4.3 Payload不是密文别把敏感数据放进去每次有同事拿着JWT来问我“为什么token里能看到用户手机号”我都得重复一遍JWT的Payload只是Base64编码不是加密。写个base64.urlsafe_b64decode就能还原出完整的JSON。所以下面的东西绝对不要放Payload密码、手机号、身份证号、邮箱、银行卡等任何个人敏感信息也不要放大的业务对象比如用户完整的地址列表。一方面是信息泄露风险另一方面token每次请求都带着Payload体积过大直接拖慢请求速度。Payload里放个sub用户ID加一个role角色字段就够用了需要更多用户信息时让接口按ID查数据库。4.4 重放攻击、时钟偏移和性能开销JWT用exp字段控制过期但exp不是万能的。攻击者截获一个还有效的token在过期前可以反复使用。要降低重放风险可以结合三件事缩短access_token的有效期降低被利用的窗口关键业务接口增加额外校验比如来源IP或者设备指纹用jti做一次性校验记录已经被使用过的token ID。注意jti一次性校验对access_token的意义不大它更多是用在refresh_token和短信验证码这类一次性凭证上。时钟偏移是另一个容易被忽略的点。签发token的服务器和验证token的服务器如果时间相差太大明明token还没到过期时间验签时报ExpiredSignatureError或者反过来签发出来的token因为服务器时间慢了一出生就被判定“已过期”。排查方式就是检查各节点的时间同步是否正常NTP校时是常规操作。代码层面可以在验证过期时通过options{leeway: 30}之类的参数做一点容忍窗口。性能方面JWT的优势在于验签过程纯CPU计算不依赖网络IO。一次RS256验签大概是微秒到毫秒级别比每次请求都查一次Redis要快得多。但代价是黑名单机制一旦引入每次校验又要查一次Redis。我的取舍是普通高流量接口只做纯验签不查黑名单只有登录、退出、改密码这类关键操作才强制检查黑名单。5. 常见问题与排查技巧速查表5.1 典型报错和排查对照我把线上和开发过程中遇到最多的问题整理成表格你如果遇到类似报错可以直接对号入座。现象可能原因排查方向401 invalid token签名密钥不一致或算法白名单配置缺失检查签发和校验的SECRET_KEY是否一致algorithms参数是否显式声明401 token expiredaccess_token过期或服务器时钟不准先看exp和当前时间差值再用leeway排除时钟偏差403 Forbidden已通过认证但权限不足检查payload中的role、scope确认接口层权限判断是否覆盖了本次请求请求带token却仍然401Authorization头格式错误缺少Bearer前缀用curl逐字检查请求头内容很多客户端拼接字符串时多打了空格刷新token后马上失效refresh_token被并发使用轮换逻辑有竞争加并发锁刷新接口保证同一时刻只有一个刷新请求在处理登录成功但前端找不到token后端返回字段名不统一全端约定好access_token、token_type的命名和大小写5.2 Token过期与续签的几个真实案例有一次同事找我说生产环境大量用户“登录状态突然丢失”。我查了一圈发现是refresh_token接口在鉴权时误用了access_token的过期时间校验。因为两套token用的是同一套解析方法但refresh_token的exp是28天后而解析方法里写了leeway0结果某个节点时间同步出问题导致一批refresh_token被误判过期。从那之后我坚持把access_token和refresh_token的校验逻辑拆成两个独立函数各自维护过期规则。还有一次是第三方App接入我们的API他们的客户端在access_token过期时不是调刷新接口而是直接调登录接口重新登录。他们的后端是在一个循环里反复重试结果登录接口频繁被调用数据库压力暴涨。排查之后我建议他们的策略变成“刷新优先登录兜底”并且给登录接口加了失败频控。这也提醒我对外提供API时一定要把续签流程的时序图写明白别让调用方自己猜。5.3 调试阶段必须会的一手操作调试JWT最常用的工具是jwt.io它能把token的Header和Payload直接解码出来看。但这里我要强调一个安全意识生产环境的真实token绝对不要粘贴到第三方网站去看就算是无土传输也一样随便把线上token贴出去等于把用户身份交出去。我自己的做法是开发测试时用测试环境生成的token随便贴线上排障时只打印token的jti和过期时间这些非敏感信息。另外我调试时会故意构造一些异常token来测试服务的健壮性比如乱写的字符串测试无效token分支。签名字节被篡改一个字符的token测试签名校验分支。过期时间设为过去时间的token测试过期分支。缺sub字段的token测试解析分支。把这几类token挨个打一遍接口基本就能确认中间件的异常处理是否都返回了正确的401和错误信息而不是让异常直接冒到最外层变成500。还有一个实用技巧日志里不要把完整token打出来容易被日志收集系统当成敏感信息泄露。我都是打印最后四位和jti够排查问题了又能保护token完整内容。写在项目里的最后一点经验我做了好几个接入JWT的项目之后最大的体会是JWT不是加一层签名就完事的库而是一套需要设计的认证架构。签发时你想清楚放哪些claim、过期时间定多长、要不要双token、黑名单用什么存储接收时你想清楚算法白名单、密钥来源、异常分支怎么返回运维时你想清楚密钥轮换、时钟同步、日志脱敏。这些环节缺一个线上的坑迟早会踩。如果你现在正要给API接入JWT我建议别急着写代码先花半小时画一下完整的请求链路登录→签发access_token→带token访问接口→过期→刷新→登出。把这几个节点全部画出来再按照这个链路去填代码最后能少改一两轮。这也是我在多轮重构后最想记住的一点。
返回列表