ARTICLE DETAIL

资讯详情

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

前后端分离下从Session到JWT的无状态认证与安全实践

前后端分离下从Session到JWT的无状态认证与安全实践 1. 为什么前后端分离之后Session方案开始变得别扭1.1 从Session到JWT的迁移背景我最早接触用户认证是做传统服务端渲染项目登录后服务器在Session里记一条用户状态再把Session ID通过Cookie塞给浏览器。这套流程在单体应用时代非常成熟浏览器每次请求自动带上Cookie服务器拿Session ID去内存或数据库里查一下用户是谁立刻就知道。但到了前后端分离、接口多端复用Web、App、小程序、第三方开放平台的阶段Session方案的问题就逐渐卡脖子了。第一个问题是存储Session是有状态的服务器必须为每个登录用户保留一份会话数据用户量上来之后要么扩内存、要么把Session挪到Redis总之每个请求都要做一次远程读取。第二个问题是跨域前端项目跑在localhost:8080API服务在api.example.comCookie跨域携带需要额外配置CORS和credentialsApp端更是压根没有Cookie概念只能手动把Session ID放进请求头绕了一圈比Token还麻烦。第三个问题是微服务化之后更明显用户调用订单服务、支付服务、商品服务每个服务都要知道你是谁如果都去Session中心查Session中心就成了高并发下的瓶颈点。所以前后端分离项目转到JWT不是赶时髦而是被状态共享的复杂度逼出来的。1.2 无状态认证被逼出来的设计思路JWTJSON Web Token解决的核心问题是让用户身份信息本身可以随身携带并且无法被伪造。你可以把Session模式理解为去健身房办月卡健身房前台服务器保存了你的照片和卡号每次进门都要报卡号前台翻本子核对。而JWT模式是盖了防伪钢印的通行证通行证上印着你的姓名、会员等级、有效期门口保安只检查钢印是否真实钢印是真的就直接放行不需要再打电话给总部核对。这个钢印就是JWT的签名。服务器用密钥对Token内容签名以后任何服务拿到这张Token只要能用同一个密钥验证签名合法就信任里面的用户信息。整个过程不需要查数据库、不需要查Redis、不需要共享Session存储天然适合多服务、多端场景。这就是无状态认证身份信息在客户端手里服务端只负责验签。1.3 一张表看懂Session、Token、JWT的区别很多初学者会把Token和JWT混为一谈实际它们是两个层面的东西。我整理了一张对照表维度Session-Cookie普通Token不透明TokenJWT服务端存储必须有否则无从查起必须有Token指向一条记录不需要信息在Token里用户信息读取每次请求查存储每次请求查存储验签后直接解析跨域/多端麻烦需要Cookie策略简单请求头携带简单请求头携带主动注销方便删Session即可方便删记录即可困难只能等过期泄露风险Session ID本身无信息Token本身无信息Payload可逆不能放敏感数据对于API来说JWT最大的价值是把认证和授权两个动作合并了认证靠验签授权靠解析Payload里的角色和权限字段。这也是今天要展开的核心。2. JWT的三大组成Header、Payload、Signature背后的机制2.1 Token长什么样拆解一段真实的JWT字符串JWT的完整形态是三个用点号分隔的Base64Url字符串形如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE4MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c三段分别是Header、Payload、Signature。把第一段做Base64解码得到{alg:HS256,typ:JWT}这告诉服务器我用的签名算法是HS256类型是JWT。第二段解码后是{sub:1234567890,name:John Doe,role:admin,exp:1718000000}这就是核心声明部分常用的有sub用户唯一标识、name、role、exp过期时间、iat签发时间、jtiToken唯一ID等。第三段Signature是对前两段加上密钥做签名后的结果它的作用就是防伪。2.2 签名算法选型HS256还是RS256这是个问题签名算法决定了钢印怎么盖。最常用的两个是HS256和RS256但它们的密钥体系完全不同HS256是对称签名签发和验签用同一个密钥。实现简单性能好但密钥一旦泄露任何人都能自己造Token。适合单体应用、服务之间不复杂的场景。RS256是非对称签名签发用私钥验签用公钥。私钥只掌握在认证中心手里各个API服务只保存公钥即使某个服务被攻破拿到公钥也无法伪造Token。适合微服务架构、多服务独立验签的场景。我给客户的默认建议是服务实例超过3个或者有第三方服务需要验签直接用RS256。只有一个后端服务先上HS256也够用但密钥一定要通过环境变量或密钥管理服务注入绝不能写死在代码仓库里。2.3 为什么说JWT的内容可以被解出来但改不了这里有个必须反复强调的点JWT的Payload是Base64Url编码不是加密。Base64只是编码规则任何人都能用在线工具把第二段解码出来看到里面的内容。所以千万不能把密码、身份证号、手机号等敏感数据放进Payload。但既然内容能被看到为什么还能防伪造因为第三段签名。签名是对Header.Base64Payload这个整体做的哈希运算只要Payload被改任何一个字符签名验证就会失败。换句话说Token里写的用户ID是userId1001攻击者改成userId1002服务器一验签就会因签名对不上直接拒绝。所以JWT的安全模型可以概括为内容透明但内容不可篡改身份可被服务端验证但无法被伪造。理解了这个模型后面看漏洞才有意思。3. 从零落地登录签发、中间件验签、前端携带Token的完整闭环3.1 环境准备与依赖选择为了能直接跑通我以Node.js Express为例原因是jsonwebtoken这个库生态成熟且很多前后端分离项目都在用。如果你用PythonPyJWT或Javajjwt思路完全一致后面我会补Python的对照片段。先安装依赖npm install express jsonwebtoken准备一个.env文件把密钥放进去我这里只演示结构实际项目请用随机生成的强密钥JWT_SECRETplease_replace_this_with_a_long_random_string JWT_EXPIRES_IN2h3.2 登录接口签发第一张Token登录接口负责验证用户名密码验证通过后生成Token返回给前端。这里只示范核心的签发逻辑const jwt require(jsonwebtoken); const express require(express); const app express(); app.use(express.json()); const SECRET process.env.JWT_SECRET; const EXPIRES_IN process.env.JWT_EXPIRES_IN || 2h; app.post(/api/login, async (req, res) { const { username, password } req.body; // 实际项目请替换为数据库查询 密码哈希校验 const user findUserByUsername(username); if (!user || !verifyPassword(password, user.passwordHash)) { return res.status(401).json({ code: 401, message: 用户名或密码错误 }); } const token jwt.sign( { sub: user.id, name: user.username, role: user.role, // 只放非敏感、用于授权的信息 }, SECRET, { expiresIn: EXPIRES_IN, algorithm: HS256 } ); res.json({ token, tokenType: Bearer, expiresIn: EXPIRES_IN }); });有几个细节我需要刻意提醒。第一sub字段放用户ID不要放用户名因为用户名理论上允许修改而用户的唯一标识不应该变。第二role字段放角色信息方便授权判断但粒度要到够用即可。第三签发的Token里没有存密码、没有存手机号这一个习惯能帮你规避80%的Payload泄露事故。3.3 认证中间件每次请求都要过的安检门中间件是JWT验签的核心位置。在Express里我可以写一个authMiddleware需要登录才能访问的接口全部挂上它function authMiddleware(req, res, next) { const header req.headers.authorization || ; const token header.startsWith(Bearer ) ? header.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, message: 缺少Token }); } try { const payload jwt.verify(token, SECRET, { algorithms: [HS256] }); req.user payload; next(); } catch (err) { return res.status(401).json({ code: 401, message: Token无效或已过期 }); } } // 需要登录的接口 app.get(/api/profile, authMiddleware, (req, res) { res.json({ userId: req.user.sub, name: req.user.name }); });注意jwt.verify和jwt.decode是两回事新手最容易在这里翻车decode只做Base64解码完全不验签verify才会验证签名和过期时间。线上代码一定用verify。另一个容易忽略的参数是algorithms白名单强制指定[HS256]可以堵住后面要讲的算法混淆漏洞。Python那边的对照实现FastAPI PyJWT大概是这样的感觉import jwt from fastapi import Header, HTTPException async def get_current_user(authorization: str Header(default)): token authorization.removeprefix(Bearer ) try: payload jwt.decode(token, SECRET, algorithms[HS256]) return payload except jwt.PyJWTError: raise HTTPException(status_code401, detailToken无效或已过期)3.4 前端接入把Token放在哪里最合适前端拿到Token之后存放方式有讲究。浏览器场景下有两个选择localStorage和HttpOnly Cookie。如果放localStorage前端JS可以随时读取并放进Authorization头实现最简单但代价是任何XSS漏洞都能把Token偷走。如果放HttpOnly CookieJS拿不到TokenXSS攻击面大幅缩小但需要配置CORS的credentials并且跨域场景要处理SameSite和Secure属性麻烦一些。我个人的建议是内部系统、后台管理系统直接localStorage加Authorization头开发效率高面向C端用户、涉及资金交易的业务优先HttpOnly Cookie并在服务端做CSRF防护。请求侧的统一写法很简单fetch(/api/profile, { headers: { Authorization: Bearer ${localStorage.getItem(token)} } })从这一步起登录签发、请求验签、前端携带的闭环就跑通了。但真正上线前还得看安全和续签这两个大头。4. 被黑产盯上的JWT常见绕过手法与加固清单4.1 四个真实存在的漏洞套路JWT相关的漏洞在热搜词里常年有讨论我挑几个实战中真实遇到过的说。第一个是algnone绕过。JWT规范里留了一个none算法表示我不签名了。如果服务端在验签时没有明确禁用none攻击者把Header改成{alg:none}去掉签名部分一些实现不健全的库就会直接放行。对应的加固方式就是我前面说的验签时显式声明algorithms白名单jsonwebtoken在verify时传{algorithms: [HS256]}none自然被挡在门外。第二个是算法混淆攻击。场景是这样的服务端原本用RS256验签用的公钥是公开的公钥本来就不怕公开。攻击者拿到公钥后把Header里的alg改成HS256然后用这个公钥当HMAC密钥去签名。如果服务端没有做算法白名单验签时会用公钥字符串去执行HS256发现签名居然能对上。修复方法依然是固定算法白名单并且在密钥管理上把对称密钥和非对称密钥分开用不要混。第三个是kid参数注入。Header里的kid字段通常用于告诉服务器用哪把密钥来验签有些实现会拿kid去拼文件路径或查数据库。如果没做过滤攻击者把kid改成../../etc/passwd或者一段SQL注入语句可能读文件、可能拖库。加固方式是把kid映射表维护在服务端拿请求里的kid去查表拿到密钥ID而不是直接拼路径。第四个是Payload放敏感信息。这个不算攻击手法但比攻击手法更常见。我在审计别人代码时见过把用户手机号、支付密码哈希直接塞进JWT的理由是懒得查库。结果Token一旦在日志里泄露等于把用户资料送给别人。切记JWT不是加密容器它只是防篡改的透明信封。4.2 生产环境加固清单结合上面的漏洞我整理了一份可以直接抄的加固清单加固项做法解决什么问题算法白名单verify时强制algorithms数组algnone、算法混淆Payload最小化只放sub、role、必要声明敏感信息泄露密钥托管用环境变量/密钥管理服务不进代码仓库密钥泄露过期时间常规接口2小时以内高权限操作更短Token滥用单Token唯一ID签发时带jti后续可做吊销/黑名单主动注销难kid白名单严格匹配密钥ID映射表kid注入定期轮换密钥密钥定期更新配合双密钥兼容期长期泄露风险这些条目每一条的背后都有真实攻击案例线上项目值得逐个过一遍。5. Token过期与续签不让用户在关键操作时被踢下线5.1 为什么不能把过期时间设成一个月有些开发者嫌Token过期麻烦直接把expiresIn设成30天甚至一年。短期看用户确实省事了长期看风险不可控Token一旦被偷攻击者能在一个月内任意冒充这个用户。而且JWT一旦签发在过期之前服务端无法主动让它失效所以过期时间越长出事后的爆炸半径越大。正确思路是缩短Access Token的有效期比如15分钟到2小时让Token的暴露窗口尽量小。但这样又带来一个新问题用户每隔十几分钟就被要求重新登录一次体验非常糟糕。解法就是双Token方案。5.2 双Token方案Access Token Refresh Token把认证凭证拆成两个Access Token短时效比如15分钟用来访问业务APIAPI服务验签它即可。Refresh Token长时效比如7天只用来调用刷新接口换新的Access Token不访问业务接口。用户登录时服务端同时签发这两个Token。Access Token正常访问APIAccess Token过期后前端拿到401就带着Refresh Token去请求/api/auth/refresh服务端验证Refresh Token有效后签一个新的Access Token返回。Refresh Token必须在服务端留存建议存Redis或数据库并且绑定用户ID、设备信息。这样如果用户换设备、强制退出、修改密码服务端可以直接删除甚至清空该用户的Refresh Token变相实现让旧的Access Token失效。5.3 续签流程的落地实现后端刷新接口可以参考这样写app.post(/api/auth/refresh, async (req, res) { const { refreshToken } req.body; if (!refreshToken) return res.status(400).json({ code: 400, message: 缺少refreshToken }); // 校验Refresh Token是否存在且未被吊销 const stored await redis.get(refresh:${hash(refreshToken)}); if (!stored) return res.status(401).json({ code: 401, message: Refresh Token无效 }); try { const payload jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: [HS256] }); if (payload.userId ! stored.userId) { return res.status(401).json({ code: 401, message: Refresh Token与用户不匹配 }); } // 签发新的Access Token const newAccessToken jwt.sign( { sub: payload.userId, role: stored.role }, SECRET, { expiresIn: 15m, algorithm: HS256 } ); res.json({ accessToken: newAccessToken, tokenType: Bearer }); } catch (err) { return res.status(401).json({ code: 401, message: Refresh Token已过期 }); } });这里有一个实践细节Refresh Token轮换。每次刷新时不仅发新的Access Token也应该把旧的Refresh Token作废、重新签发一个新的Refresh Token。这样一个Refresh Token只能被用一次即使某个Refresh Token被偷攻击者用完一次之后它立即失效风险窗口进一步缩小。前端在收到401时自动走刷新逻辑我通常封装在请求拦截器里api.interceptors.response.use( response response, async error { const original error.config; if (error.response?.status 401 !original._retried) { original._retried true; const { accessToken } await api.post(/api/auth/refresh, { refreshToken }); localStorage.setItem(accessToken, accessToken); original.headers.Authorization Bearer ${accessToken}; return api(original); } return Promise.reject(error); } );这套流程跑通之后用户基本感知不到Token过期只有在Refresh Token也过期的情况下才需要重新登录。6. 上线后逃不掉的麻烦时间偏移、黑名单、日志脱敏与401排查6.1 顺手排查一次401热搜词里有大量关于unexpected status 401 unauthorized: incorrect api key provided的讨论虽然那是API Key场景但排查思路是通用的。我自己线上遇到401时通常是这几个原因请求头里压根没带Authorization或者格式写成了Token xxx而不是Bearer xxx。Access Token确实过期了但前端没有正确走刷新逻辑。多实例部署时各服务拿到的JWT_SECRET不一致A服务签发的Token拿到B服务验签签名必然对不上。Token里带的时间戳和服务器当前时间差太多时区或时钟同步问题见下一条。实操时我建议在认证中间件里把错误信息分得更细一点Token过期返回401 code: TOKEN_EXPIRED签名错误返回401 code: TOKEN_INVALID。前端拿到TOKEN_EXPIRED才走刷新逻辑而不是所有401都无脑刷新否则Refresh Token也过期时会陷入死循环。6.2 服务器时钟偏移是怎么坑你的JWT的exp和iat依赖服务器时间。如果服务器时钟没有做NTP同步容易出现两种诡异现象一是Token明明没过期却被判过期二是Token已经过期了还能用一小会儿。前者对用户影响大后者对安全有隐患。解决方式有两层基础设施层面所有服务器统一NTP时间同步应用层面验签时允许一个小的时钟容差比如jsonwebtoken的clockTolerance参数可以设置成30秒。但容差不要设太大给攻击者留时间余量没有意义。6.3 强制下线与远程注销JWT天生的短板JWT无状态的优点同时也带来了主动注销的短板。用户在修改密码或账号被踢下线之后已经发出的旧Token理论上在过期前依然有效。我见过不少团队在这一点上踩坑改完密码用旧Token调接口居然还能成功。通用解法是引入黑名单机制在Redis里维护一个blacklist:{jti}集合加入黑名单的Token在过期前验签时额外查一次Redis。只要在关键节点改密码、退出登录、管理员封号把该用户当前有效的jti加进黑名单就能让旧Token立即失效。代价是每次验签多一次Redis访问但只针对敏感操作或所有请求全量查可以根据业务容忍度来取舍。6.4 日志和监控里的Token泄露隐患最后一个不起眼但很重要的习惯不要打印Token。我审计过的项目里有人把整个Authorization头打到日志里有人把登录接口的响应体原样打印出来结果里面带着完整Token。日志一旦外泄等于把用户的登录凭证送给攻击者。正确的做法是只打印jti或用户ID方便关联排查但不泄露关键凭证。同时监控告警里如果发现401错误率突然飙升大概率是密钥被更换、时钟偏移、或者有攻击者在批量尝试伪造Token优先级要拉高。关于JWT我个人在实际项目里的体感做了这么多年API认证我的核心体会是JWT不是一个银弹它解决的是无状态、多端、多服务的身份信任问题代价是你必须接受一旦签发、到期前难以主动作废这个现实。所以选型时不要一上来就JWT先问清楚自己的场景如果只是给内部管理后台做个登录Session完全够用如果是开放API、微服务、多端复用JWT或类似的不透明Token加Redis方案都行。真正把JWT用好功夫不在签发那一下而在验签的严谨性、Token生命周期的设计、以及安全加固的完整度。我见过太多项目把JWT当做一个库引入就以为万事大吉结果在算法白名单、Payload内容、密钥管理这些细节上翻车。如果你正在做前后端分离项目的用户认证我建议先把本文的闭环跑通再按加固清单逐项自查一遍。遇到具体问题时带着你的实际代码和报错信息来交流比背概念更有价值。
返回列表