JWT 与双令牌机制详解:从结构原理到 Java 代码实现

🤔 一、一个登录请求背后的困境

你写完了一个登录接口,用户提交用户名密码,服务端验证通过后创建 Session,把用户信息存进去,返回一个 JSESSIONID 的 Cookie。后续请求自动带上这个 Cookie,服务端从 Session 中取出用户信息——这是最传统的 Session 认证方式。

@PostMapping("/login")
public String login(HttpSession session, @RequestBody LoginRequest req) {
    User user = userService.verify(req.getUsername(), req.getPassword());
    if (user == null) {
        return "用户名或密码错误";
    }
    session.setAttribute("currentUser", user);  // 存入Session
    return "登录成功";
}

@GetMapping("/info")
public User info(HttpSession session) {
    return (User) session.getAttribute("currentUser");  // 从Session取
}

这段代码在单机部署时没有问题。但当你部署到 3 台服务器、前面挂了一个 Nginx 负载均衡时,问题就出现了:

sequenceDiagram
    participant C as 客户端
    participant N as Nginx (负载均衡)
    participant S1 as 服务器1
    participant S2 as 服务器2

    Note over C,S2: 问题:Session 存在服务器1的内存中,服务器2不认识

    C->>N: POST /login
    N->>S1: 转发到服务器1
    S1->>S1: 验证密码,创建Session\n存入内存
    S1-->>C: JSESSIONID=ABC123

    C->>N: GET /info (Cookie: JSESSIONID=ABC123)
    N->>S2: 转发到服务器2
    S2->>S2: 查找 Session ABC123\n找不到!
    S2-->>C: 401 未登录(实际已登录)

Session 存在服务器 1 的内存中,当请求被 Nginx 转发到服务器 2 时,服务器 2 找不到这个 Session,用户被判定为"未登录"。

解决这个问题的传统方案是 Session 共享——把 Session 存到 Redis 中,所有服务器都去 Redis 里读。但这引入了新的依赖(Redis),并且每次请求都要访问 Redis。

JWT(JSON Web Token)用另一种思路解决了这个问题:服务端不保存任何会话数据,而是把用户信息编码进一个 Token 里,签上名,返回给客户端。后续请求客户端带上这个 Token,服务端只需验证签名就能确认"这个 Token 确实是我签发的,里面的信息可信"。


🔍 二、JWT 三段式结构详解

一个完整的 JWT 长这样(为了方便阅读,分段展示):

eyJhbGciOiJIUzI1NiJ9
.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsInJvbGUiOiJST0xFX1VTRVIiLCJpYXQiOjE2NjAxMjM0MDAsImV4cCI6MTY2MDEyNTIwMH0
.vzD5XgQpLNm3FkWxHj7tYqR2bKc8sMwP1nAeB6fTdU4

. 分割后是三段:Header.Payload.Signature

🧬 2.1 结构总览

flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    JWT[JWT Token\nHeader.Payload.Signature]

    JWT --> H[Header 头部\nBase64编码]
    H --> H1["alg: HS256\n(签名算法)"]
    H --> H2["typ: JWT\n(令牌类型)"]

    JWT --> P[Payload 载荷\nBase64编码]
    P --> P1["sub: 用户ID\n(主题)"]
    P --> P2["iat: 签发时间\n(Issued At)"]
    P --> P3["exp: 过期时间\n(Expiration)"]
    P --> P4["自定义字段\nusername, role ..."]

    JWT --> S[Signature 签名]
    S --> S1["HMAC-SHA256(\n  Base64Url(Header)\n  + '.' +\n  Base64Url(Payload),\n  secret\n)"]

    class JWT startEnd;
    class H,P,S process;
    class H1,H2,P1,P2,P3,P4,S1 data;

🏷️ 2.2 Header(头部)——声明签名算法

第一段 eyJhbGciOiJIUzI1NiJ9 经过 Base64 解码后:

{
  "alg": "HS256",
  "typ": "JWT"
}
字段含义常见取值
alg签名算法(Algorithm)HS256(HMAC-SHA256)、RS256(RSA-SHA256)
typ令牌类型JWT

HS256 表示使用对称密钥签名——签发和验证使用同一个 secret。这是单体应用中最常用的方式。RS256 使用非对称密钥(私钥签发,公钥验证),适用于微服务中多个服务需要验证 Token 但不需要知道私钥的场景。

📦 2.3 Payload(载荷)——存放用户信息

第二段 Base64 解码后:

{
  "sub": "1001",
  "username": "zhangsan",
  "role": "ROLE_USER",
  "iat": 1660123400,
  "exp": 1660125200
}

Payload 中的字段分为两类:

类别字段全称含义示例值
标准注册声明subSubject主题,通常存用户 ID"1001"
iatIssued At签发时间(Unix 秒级时间戳)1660123400
expExpiration过期时间(Unix 秒级时间戳)1660125200
issIssuer签发者"mall-api"
audAudience接收方"mall-app"
jtiJWT IDToken 唯一标识(UUID)"a1b2c3d4-..."
自定义私有声明username用户名"zhangsan"
role角色"ROLE_USER"
typeToken 类型"access"

关键理解:Payload 是 Base64 编码,不是加密。任何人拿到 JWT 都可以解码看到里面的内容。因此 绝对不能把密码、手机号等敏感信息放入 Payload

🔏 2.4 Signature(签名)——防篡改的核心

签名的计算过程:

Signature = HMAC-SHA256(
    Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload),
    secret
)

签名的唯一作用是防止 Payload 被篡改:

  • 攻击者修改 Payload 中的 userId"1001" 改为 "1002" → Payload 变了
  • 但攻击者不知道 secret,无法生成新的有效签名
  • 服务端用 secret 重新计算签名 → 与 Token 中的签名不一致 → 拒绝
flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    RECEIVE([收到JWT]) --> SPLIT[按.分割为三段]
    SPLIT --> RECALC[用secret对Header.Payload\n重新计算签名]
    RECALC --> COMPARE{"重新计算的签名\n== 收到的签名 ?"}

    COMPARE -- 一致 --> TRUST[Payload未被篡改\n可信任其中的用户信息]
    COMPARE -- 不一致 --> REJECT([签名无效\n拒绝请求])

    TRUST --> CHECK_EXP{"exp > 当前时间 ?"}
    CHECK_EXP -- 是 --> OK([Token有效, 放行])
    CHECK_EXP -- 否 --> EXPIRED([Token已过期, 拒绝])

    class RECEIVE startEnd;
    class COMPARE,CHECK_EXP condition;
    class SPLIT,RECALC,TRUST process;
    class REJECT,EXPIRED,OK startEnd;

💻 三、Java 代码实现:用 JJWT 签发和验证

以下代码使用 JJWTio.jsonwebtoken)库,它是 Java 生态中功能最完整的 JWT 实现。

📋 3.1 Maven 依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

✍️ 3.2 签发 JWT

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;

public class JwtIssueDemo {

    // 密钥,生产环境至少256位(32字节),从配置文件读取
    private static final String SECRET =
        "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";

    // 有效期30分钟(毫秒)
    private static final long EXPIRE_MS = 30 * 60 * 1000;

    public static void main(String[] args) {
        // 1. 构建密钥对象
        SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

        // 2. 设置Payload中的字段
        Date now = new Date();
        Date expiration = new Date(now.getTime() + EXPIRE_MS);

        String jwt = Jwts.builder()
                // ---- 标准注册声明 ----
                .setId(UUID.randomUUID().toString())   // jti: Token唯一ID
                .setSubject("1001")                     // sub: 用户ID
                .setIssuer("mall-api")                  // iss: 签发者
                .setIssuedAt(now)                       // iat: 签发时间
                .setExpiration(expiration)              // exp: 过期时间

                // ---- 自定义私有声明 ----
                .claim("username", "zhangsan")          // 用户名
                .claim("role", "ROLE_USER")             // 角色

                // ---- 签名 ----
                .signWith(key)                         // HS256 签名

                .compact();  // 生成最终字符串

        System.out.println("生成的JWT:");
        System.out.println(jwt);
        System.out.println("长度: " + jwt.length() + " 字符");
    }
}

运行输出:

生成的JWT:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ZTk2ZjNhOC0xMjM0LTQ1NjctODkwMS1hYmNkZWYxMjM0NTYiLCJzdWIiOiIxMDAxIiwiaXNzIjoibWFsbC1hcGkiLCJpYXQiOjE2NjAxMjM0MDAsImV4cCI6MTY2MDEyNTIwMCwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsInJvbGUiOiJST0xFX1VTRVIifQ.P8qR2sT5vW7xYzA1bC3dE4fG6hIjK8lM9nO0pQrStUv
长度: 206 字符

✅ 3.3 验证和解析 JWT

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

public class JwtVerifyDemo {

    private static final String SECRET =
        "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";

    public static void main(String[] args) {

        // 模拟从请求头中获取的Token
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ZTk2ZjNhOC0xMjM0LTQ1NjctODkwMS1hYmNkZWYxMjM0NTYiLCJzdWIiOiIxMDAxIiwiaXNzIjoibWFsbC1hcGkiLCJpYXQiOjE2NjAxMjM0MDAsImV4cCI6MTY2MDEyNTIwMCwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsInJvbGUiOiJST0xFX1VTRVIifQ.P8qR2sT5vW7xYzA1bC3dE4fG6hIjK8lM9nO0pQrStUv";

        SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

        try {
            // 1. 解析并验证(签名 + 过期时间)
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();

            // 2. 从 Payload 中读取字段
            String jti = claims.getId();                      // jti
            String userId = claims.getSubject();              // sub
            String issuer = claims.getIssuer();               // iss
            Date issuedAt = claims.getIssuedAt();             // iat
            Date expiration = claims.getExpiration();         // exp
            String username = claims.get("username", String.class);  // 自定义
            String role = claims.get("role", String.class);         // 自定义

            System.out.println("========== Token 解析成功 ==========");
            System.out.println("jti (Token ID)   : " + jti);
            System.out.println("sub (用户ID)      : " + userId);
            System.out.println("iss (签发者)      : " + issuer);
            System.out.println("iat (签发时间)     : " + issuedAt);
            System.out.println("exp (过期时间)     : " + expiration);
            System.out.println("username (用户名)  : " + username);
            System.out.println("role (角色)       : " + role);

            // 3. 计算剩余有效时间
            long remaining = expiration.getTime() - System.currentTimeMillis();
            System.out.println("剩余有效时间       : " + remaining / 1000 + " 秒");

        } catch (ExpiredJwtException e) {
            System.err.println("Token 已过期!过期时间: " + e.getClaims().getExpiration());
        } catch (JwtException e) {
            System.err.println("Token 无效!原因: " + e.getMessage());
        }
    }
}

运行输出:

========== Token 解析成功 ==========
jti (Token ID)   : 8e96f3a8-1234-4567-8901-abcdef123456
sub (用户ID)      : 1001
iss (签发者)      : mall-api
iat (签发时间)     : Mon Aug 15 12:30:00 CST 2022
exp (过期时间)     : Mon Aug 15 13:00:00 CST 2022
username (用户名)  : zhangsan
role (角色)       : ROLE_USER
剩余有效时间       : 1753 秒

🚨 3.4 JJWT 异常体系

parseClaimsJws(token) 在不同失败场景下会抛出不同的异常:

异常类型触发条件说明
ExpiredJwtExceptionexp < 当前时间Token 已过期,可从异常中取 Claims
SignatureException签名不匹配Token 被篡改或使用了错误的密钥
MalformedJwtException格式不正确不是合法的 JWT 格式(没有两个 .
UnsupportedJwtException不支持的格式Header 中的 algtyp 不符合预期
IllegalArgumentExceptionToken 为空或 null请求头中没有携带 Token

正确的异常处理应该区分这些类型,返回不同的错误信息给客户端:

public JwtVerifyResult verifyToken(String token) {
    try {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
        return JwtVerifyResult.success(claims);

    } catch (ExpiredJwtException e) {
        return JwtVerifyResult.fail("Token已过期,请刷新或重新登录", 401);
    } catch (SignatureException e) {
        return JwtVerifyResult.fail("Token签名无效,可能被篡改", 401);
    } catch (MalformedJwtException e) {
        return JwtVerifyResult.fail("Token格式错误", 400);
    } catch (JwtException | IllegalArgumentException e) {
        return JwtVerifyResult.fail("Token无效", 401);
    }
}

⚠️ 四、单 Token 方案的致命缺陷

上面的代码只用了一个 Token(Access Token)。这在简单场景下能跑,但在以下场景中会出问题:

⚠️ 4.1 缺陷场景

场景问题描述
Token 过期太短(如 5 分钟)用户每 5 分钟就要重新登录,体验极差
Token 过期太长(如 7 天)Token 泄露后,攻击者可以在 7 天内随意访问,无法主动撤销
用户修改密码旧的 Token 在有效期内仍然可用,攻击者用泄露的旧密码登录后拿到的 Token 依然有效
管理员踢人下线纯 JWT 无状态,服务端没有记录"谁当前在线",无法强制某人下线

根本矛盾:Token 过期时间短 → 用户体验差;Token 过期时间长 → 安全风险大。单 Token 方案无法同时解决这两个问题。

🚫 4.2 常见但错误的补丁方案

有些开发者在服务端维护一个"Token 黑名单"(如 Set<String> invalidatedTokens),登出时把 Token 加进去。但这带来了新问题:

  • 内存无限增长:每个被撤销的 Token 都要记到过期为止,大量用户频繁登录登出会导致内存膨胀
  • 分布式不一致:多台服务器之间的黑名单需要同步
  • 失去了 JWT 无状态的优势:最终还是需要查一个中心化存储

🔑 五、双令牌机制(Access Token + Refresh Token)

💡 5.1 核心思想

把"身份认证"拆成两个职责不同的 Token:

flowchart LR
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    ROOT[双令牌机制]

    ROOT --> AT[Access Token\n访问令牌]
    AT --> AT1["有效期: 短 (15 ~ 30 分钟)"]
    AT --> AT2["职责: 每次请求携带\n用于访问业务接口"]
    AT --> AT3["存储: 客户端内存\n(变量/localStorage)"]

    ROOT --> RT[Refresh Token\n刷新令牌]
    RT --> RT1["有效期: 长 (7 ~ 30 天)"]
    RT --> RT2["职责: 仅用于获取\n新的Access Token"]
    RT --> RT3["存储: HttpOnly Cookie\n或安全存储"]
    RT --> RT4["仅暴露给 /refresh 接口"]

    class ROOT root;
    class AT,RT branch;
    class AT1,AT2,AT3,RT1,RT2,RT3,RT4 leaf;

🔄 5.2 工作流程

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    participant DB as 数据库

    Note over C,DB: ====== 阶段一:登录 ======
    C->>S: POST /login\n用户名 + 密码
    S->>DB: 查询用户,验证密码
    DB-->>S: 用户信息
    S->>S: 生成 Access Token (30分钟)\n生成 Refresh Token (7天)
    S-->>C: { accessToken, refreshToken }

    Note over C,DB: ====== 阶段二:正常请求 ======
    C->>S: GET /api/users\nAuthorization: Bearer {accessToken}
    S->>S: 验证AccessToken签名+过期
    S-->>C: 200 用户数据

    Note over C,DB: ====== 阶段三:Token过期,刷新 ======
    C->>S: GET /api/users\nAuthorization: Bearer {accessToken}
    S-->>C: 401 AccessToken过期

    C->>S: POST /refresh\n携带 RefreshToken
    S->>S: 验证RefreshToken\n(签名 + 过期 + 未被撤销)
    S->>S: 生成新的AccessToken (30分钟)\n生成新的RefreshToken (7天)
    S-->>C: { newAccessToken, newRefreshToken }

    Note over C,DB: ====== 阶段四:RefreshToken也过期 ======
    C->>S: POST /refresh\n携带 RefreshToken
    S-->>C: 401 请重新登录

关键设计:Refresh Token 也过期时,用户必须重新输入用户名密码登录。这保证了即使长期不用的 Token 泄露,攻击者的窗口期也是有限的。

⚖️ 5.3 为什么双令牌能解决单 Token 的矛盾

问题双令牌方案如何解决
安全性Access Token 只有 15 ~ 30 分钟有效期,即使泄露,攻击窗口很小
用户体验Refresh Token 有效期 7 天,用户不需要频繁输入密码,客户端自动用 Refresh Token 换新的 Access Token
主动撤销在服务端(Redis / 数据库)记录 Refresh Token 的状态,删除后用户下次刷新时会被拒绝
踢人下线删除该用户在 Redis 中存储的所有 Refresh Token,所有设备同时下线

📊 5.4 双令牌对比单 Token

维度单 Token(仅 Access Token)双令牌(Access + Refresh)
过期时间设置只能二选一:短则体验差,长则不安全Access 短(安全),Refresh 长(体验)
Token 泄露风险Token 有效期 = 攻击窗口攻击窗口 = Access Token 有效期(分钟级)
主动撤销不支持(除非引入外部存储)Refresh Token 存 Redis,可主动删除
实现复杂度中(多一个刷新接口 + Refresh Token 存储)
客户端复杂度低(存一个 Token)中(需处理 Token 过期 → 刷新 → 重试逻辑)
适用场景内部工具、低安全要求互联网产品、企业应用(推荐)

💻 六、Java 代码实现:双令牌

🔧 6.1 调整 JWT 工具类,支持双 Token

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;

public class JwtDualTokenUtil {

    private final SecretKey key;
    private final long accessExpireMs;   // Access Token 过期(毫秒)
    private final long refreshExpireMs;  // Refresh Token 过期(毫秒)

    public JwtDualTokenUtil(String secret,
                            long accessExpireMinutes,
                            long refreshExpireMinutes) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessExpireMs = accessExpireMinutes * 60 * 1000;
        this.refreshExpireMs = refreshExpireMinutes * 60 * 1000;
    }

    /**
     * 生成 Access Token——有效期短,携带完整用户信息
     */
    public String createAccessToken(Long userId, String username, String role) {
        Date now = new Date();
        return Jwts.builder()
                .setId(UUID.randomUUID().toString())
                .setSubject(userId.toString())
                .claim("username", username)
                .claim("role", role)
                .claim("type", "access")           // 标记类型
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessExpireMs))
                .signWith(key)
                .compact();
    }

    /**
     * 生成 Refresh Token——有效期长,只携带必要信息
     */
    public String createRefreshToken(Long userId) {
        Date now = new Date();
        return Jwts.builder()
                .setId(UUID.randomUUID().toString())
                .setSubject(userId.toString())
                .claim("type", "refresh")          // 标记类型
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshExpireMs))
                .signWith(key)
                .compact();
    }

    /**
     * 解析 Token(不对type做校验)
     */
    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 判断是否为 Access Token
     */
    public boolean isAccessToken(Claims claims) {
        return "access".equals(claims.get("type", String.class));
    }

    /**
     * 判断是否为 Refresh Token
     */
    public boolean isRefreshToken(Claims claims) {
        return "refresh".equals(claims.get("type", String.class));
    }

    // ---- 便捷读取方法 ----
    public String getJti(Claims claims) {
        return claims.getId();
    }

    public Long getUserId(Claims claims) {
        return Long.valueOf(claims.getSubject());
    }

    public String getUsername(Claims claims) {
        return claims.get("username", String.class);
    }

    public String getRole(Claims claims) {
        return claims.get("role", String.class);
    }
}

🔑 6.2 登录接口——同时签发两个 Token

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final JwtDualTokenUtil jwtUtil;
    private final UserService userService;

    public AuthController(JwtDualTokenUtil jwtUtil, UserService userService) {
        this.jwtUtil = jwtUtil;
        this.userService = userService;
    }

    /**
     * 登录:返回 Access Token + Refresh Token
     */
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(
            @Valid @RequestBody LoginRequest request) {

        // 1. 验证用户名密码
        User user = userService.verify(request.getUsername(), request.getPassword());
        if (user == null) {
            return ResponseEntity.status(401)
                    .body(LoginResponse.fail("用户名或密码错误"));
        }

        // 2. 生成双 Token
        String accessToken = jwtUtil.createAccessToken(
                user.getId(), user.getUsername(), user.getRole());
        String refreshToken = jwtUtil.createRefreshToken(user.getId());

        // 3. (可选)将 Refresh Token 存入 Redis,用于后续撤销
        // refreshTokenService.store(user.getId(), refreshToken);

        return ResponseEntity.ok(LoginResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .tokenType("Bearer")
                .accessTokenExpire(30 * 60)      // 30分钟(秒)
                .refreshTokenExpire(7 * 24 * 3600) // 7天(秒)
                .build());
    }
}

🔄 6.3 刷新接口——用 Refresh Token 换新的 Access Token

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    // ... 登录方法同上 ...

    /**
     * 刷新Token:用RefreshToken换取新的AccessToken + RefreshToken
     */
    @PostMapping("/refresh")
    public ResponseEntity<LoginResponse> refresh(
            @Valid @RequestBody RefreshRequest request) {

        String refreshToken = request.getRefreshToken();

        // 1. 解析 Refresh Token(验证签名 + 过期)
        Claims claims;
        try {
            claims = jwtUtil.parseToken(refreshToken);
        } catch (ExpiredJwtException e) {
            return ResponseEntity.status(401)
                    .body(LoginResponse.fail("Refresh Token已过期,请重新登录"));
        } catch (JwtException e) {
            return ResponseEntity.status(401)
                    .body(LoginResponse.fail("Refresh Token无效"));
        }

        // 2. 必须是Refresh Token类型(防止用Access Token来刷新)
        if (!jwtUtil.isRefreshToken(claims)) {
            return ResponseEntity.status(400)
                    .body(LoginResponse.fail("请使用Refresh Token刷新,不支持Access Token"));
        }

        // 3. (可选)检查RefreshToken是否在Redis白名单中
        // if (!refreshTokenService.isValid(jwtUtil.getJti(claims))) {
        //     return ResponseEntity.status(401)
        //             .body(LoginResponse.fail("Refresh Token已被撤销,请重新登录"));
        // }

        // 4. 从数据库查询用户最新信息(角色可能已变更)
        Long userId = jwtUtil.getUserId(claims);
        User user = userService.findById(userId);

        // 5. 签发新的双 Token
        String newAccessToken = jwtUtil.createAccessToken(
                user.getId(), user.getUsername(), user.getRole());
        String newRefreshToken = jwtUtil.createRefreshToken(user.getId());

        // 6. (可选)旧的 Refresh Token 失效,新的写入 Redis
        // refreshTokenService.replace(jwtUtil.getJti(claims), newRefreshToken);

        return ResponseEntity.ok(LoginResponse.builder()
                .accessToken(newAccessToken)
                .refreshToken(newRefreshToken)
                .tokenType("Bearer")
                .accessTokenExpire(30 * 60)
                .refreshTokenExpire(7 * 24 * 3600)
                .build());
    }
}

🛡️ 6.4 拦截器——验证 Access Token

@Component
public class JwtInterceptor implements HandlerInterceptor {

    private final JwtDualTokenUtil jwtUtil;

    public JwtInterceptor(JwtDualTokenUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        // 1. 从请求头提取 Token
        String token = extractToken(request);
        if (token == null) {
            sendError(response, 401, "请先登录");
            return false;
        }

        // 2. 解析并验证
        Claims claims;
        try {
            claims = jwtUtil.parseToken(token);
        } catch (ExpiredJwtException e) {
            sendError(response, 401, "Token已过期,请刷新");
            return false;
        } catch (JwtException e) {
            sendError(response, 401, "Token无效");
            return false;
        }

        // 3. 必须是 Access Token
        if (!jwtUtil.isAccessToken(claims)) {
            sendError(response, 400, "请使用Access Token访问接口");
            return false;
        }

        // 4. 将用户信息存入 request 属性,Controller 可以直接读取
        request.setAttribute("userId", jwtUtil.getUserId(claims));
        request.setAttribute("username", jwtUtil.getUsername(claims));
        request.setAttribute("role", jwtUtil.getRole(claims));

        return true;
    }

    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }

    private void sendError(HttpServletResponse response,
                           int status, String message) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(status);
        response.getWriter().write(
            String.format("{\"code\":%d,\"message\":\"%s\"}", status, message));
    }
}

📱 6.5 客户端示例——Token 过期自动刷新

前端(或移动端)需要实现"请求 → 发现 401 → 自动刷新 Token → 重试原请求"的逻辑。以下用 Java 代码演示这个模式:

public class ApiClient {

    private String accessToken;
    private String refreshToken;

    private final JwtDualTokenUtil jwtUtil;
    private final RestTemplate restTemplate;

    /**
     * 发送带自动刷新的 GET 请求
     */
    public <T> T getWithAuth(String url, Class<T> responseType) {
        try {
            // 第一次尝试
            return doGet(url, responseType);
        } catch (TokenExpiredException e) {
            // Access Token 过期 → 刷新 → 重试
            boolean refreshed = refreshAccessToken();
            if (refreshed) {
                return doGet(url, responseType);  // 重试
            } else {
                throw new RuntimeException("Token刷新失败,请重新登录");
            }
        }
    }

    /**
     * 实际发送请求
     */
    private <T> T doGet(String url, Class<T> responseType) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);

        ResponseEntity<T> response = restTemplate.exchange(
                url, HttpMethod.GET,
                new HttpEntity<>(headers), responseType);

        if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
            throw new TokenExpiredException();
        }
        return response.getBody();
    }

    /**
     * 使用 Refresh Token 获取新的 Access Token
     */
    private boolean refreshAccessToken() {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);

            Map<String, String> body = Map.of("refreshToken", refreshToken);

            ResponseEntity<LoginResponse> response = restTemplate.exchange(
                    "/api/auth/refresh", HttpMethod.POST,
                    new HttpEntity<>(body, headers), LoginResponse.class);

            if (response.getStatusCode() == HttpStatus.OK) {
                LoginResponse resp = response.getBody();
                this.accessToken = resp.getAccessToken();
                this.refreshToken = resp.getRefreshToken();
                return true;
            }
        } catch (Exception e) {
            // Refresh Token 也过期了 → 需要重新登录
        }
        return false;
    }

    // 内部异常类
    private static class TokenExpiredException extends RuntimeException {}
}

前端实现要点:这个"401 → 刷新 → 重试"的逻辑在前端通常通过 Axios(Vue/React)的拦截器实现。关键细节是要加并发锁——如果同时有 3 个请求都收到 401,只触发一次刷新,其余等待刷新完成后再重试。


🔒 七、双令牌的存储安全建议

Token 类型推荐存储位置原因
Access Token客户端内存(JS 变量 / Redux Store / Vuex)生命周期短(分钟级),页面关闭即消失,无需持久化
Refresh TokenHttpOnly CookieJS 无法读取,XSS 攻击无法窃取;自动随请求发送
// 服务端设置 Refresh Token Cookie
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(...) {
    // ... 生成 Token ...

    // 将 Refresh Token 设入 HttpOnly Cookie
    ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
            .httpOnly(true)           // JS 不可读
            .secure(true)             // 仅 HTTPS
            .sameSite("Strict")       // 防止 CSRF
            .path("/api/auth")        // 仅 /api/auth 路径下发送
            .maxAge(7 * 24 * 3600)   // 7天
            .build();

    return ResponseEntity.ok()
            .header(HttpHeaders.SET_COOKIE, cookie.toString())
            .body(response);  // Access Token 放在 Body 中返回
}

🎯 八、总结

flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    SUMMARY[(JWT 与双令牌)]

    SUMMARY --> CORE[JWT 核心结构]
    CORE --> C1["Header.Payload.Signature\n三段式, 用.分隔"]
    CORE --> C2["Base64 编码, 非加密\n不可放敏感信息"]
    CORE --> C3["Signature 防篡改\n修改Payload → 签名失效"]

    SUMMARY --> DUAL[双令牌设计]
    DUAL --> D1["Access Token: 短期\n携带用户信息, 访问接口"]
    DUAL --> D2["Refresh Token: 长期\n仅用于刷新, 存HttpOnly Cookie"]

    SUMMARY --> FLOW[刷新流程]
    FLOW --> F1["Access过期 → 401"]
    FLOW --> F2["用Refresh换新Access"]
    FLOW --> F3["Refresh也过期 → 重新登录"]

    SUMMARY --> JAVA[Java 实现]
    JAVA --> J1["JJWT签发/验证"]
    JAVA --> J2["拦截器校验Access"]
    JAVA --> J3["刷新接口校验Refresh"]
    JAVA --> J4["客户端自动重试"]

    class SUMMARY startEnd;
    class CORE,DUAL,FLOW,JAVA process;
    class C1,C2,C3,D1,D2,F1,F2,F3,J1,J2,J3,J4 process;

核心结论

  1. JWT 解决的是"服务端不存状态"的问题:通过签名验证代替 Session 查询,天然支持分布式扩展
  2. JWT 的 Payload 是编码不是加密:绝不能在 Payload 中放密码、手机号等敏感信息
  3. 单 Token 存在不可调和的矛盾:过期短则体验差,过期长则不安全
  4. 双令牌是这个矛盾的工程解法:Access Token 负责安全(短有效期)、Refresh Token 负责体验(长有效期)。Refresh Token 可配合 Redis 实现主动撤销