从Cookie到双令牌JWT的演进之路

大家好,我是程序员小策。

场景:你和前端联调一个登录功能,本地跑得好好的,部署到测试环境就频繁跳登录页。排查了半天,发现是两台应用服务器之间 Session 没同步。你加了个 Redis 共享 Session,问题解决了。

过了两个月,项目要接入 App。前端说 App 不支持 Cookie。你又加了个 Token 认证。又过了三个月,安全审计说 Token 过期时间太长有风险,你改短了过期时间。然后用户开始反馈"用着用着就要重新登录"。

你陷入了沉思------这认证方案到底该怎么设计?

这不是段子。这是我亲眼见过的真实故事,而且不止一次。

今天这篇文章,我们就来把这个过程完整走一遍。从最朴素的 Cookie 开始,一步步演进到企业级双令牌 JWT 架构。每一步解决什么问题、引入了什么新问题、为什么下一步是必然,我都会给你讲清楚。


第一代:Cookie------无状态的"有状态"方案

你可能觉得 Cookie 太古老了。但它的设计思想直到今天都在用。

Cookie 是服务器下发并存储在客户端的小型数据片段,由浏览器自动在每次请求时携带,用于维持请求之间的状态。

这句话的关键词是"自动携带"。一个 HTTP 请求从诞生起就是无状态的------服务器处理完请求就忘了你。Cookie 的发明,让服务器有了"认人"的能力。

写一个登录逻辑,最原始的方式是这样的:

java 复制代码
// 登录成功后,在服务端生成一个标识,通过 Cookie 下发给浏览器
@PostMapping("/login")
public String login(String username, String password, HttpServletResponse response) {
    // 验证用户名密码(简化处理,生产请用 BCrypt)
    if ("admin".equals(username) && "123456".equals(password)) {
        // 创建一个简单的 Token:用户名 + 时间戳 + 简单签名
        // ★ 注意:这只是演示!生产绝不能自己拼签名,要用标准算法
        String rawToken = username + ":" + System.currentTimeMillis();
        String cookieValue = rawToken + ":" + md5(rawToken + "secret");
        // 设置 Cookie:HttpOnly 防止 JS 读取,path=/ 全局生效
        Cookie cookie = new Cookie("AUTH_TOKEN", cookieValue);
        cookie.setHttpOnly(true);  // 防 XSS 攻击窃取 Cookie
        cookie.setPath("/");
        response.addCookie(cookie);
        return "login success";
    }
    return "login failed";
}

这个方案的问题很明显------Token 里只有用户名和时间戳,没有任何安全机制。只要有人截获了你的 Cookie,他就能伪造身份。而且这个 Token 是永不过期的。

所以很快它就进化成了 Session。


第二代:Session------把"令牌"变成"房卡"

Session 的核心变化,就是把身份信息从客户端挪到了服务端。

java 复制代码
// Session 方案:登录信息存在服务器内存中
@PostMapping("/login")
public String login(String username, String password, HttpServletRequest request) {
    if ("admin".equals(username) && "123456".equals(password)) {
        // 创建 Session,将用户信息存入服务器
        HttpSession session = request.getSession();
        session.setAttribute("userId", 1001);
        session.setAttribute("username", username);
        // Session ID 会通过 Cookie 自动返回给浏览器(JSESSIONID)
        // 服务器内存中维护着 Session ID -> Session 数据的映射
        return "login success";
    }
    return "login failed";
}

// 从 Session 获取当前用户
@GetMapping("/profile")
public String profile(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session == null) {
        return "not logged in";
    }
    String username = (String) session.getAttribute("username");
    return "hello, " + username;
}

Session 方案把"令牌验证"变成了"房卡开门"------你拿着房卡(Session ID)去前台(服务器),前台翻一下登记本(Session 存储)就知道你是谁。

单机部署没问题。但分布式下,问题就来了

用户的两次请求可能落在不同的服务器上。Server 1 有你的 Session,Server 2 没有。

解决方案也很成熟:用 Redis 集中存储 Session。

java 复制代码
// 引入 Spring Session + Redis,一行注解解决分布式 Session 问题
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisSessionConfig {
    // maxInactiveIntervalInSeconds = 1800 表示 Session 30 分钟过期
    // Spring Session 会自动将 Session 存储从内存切换到 Redis
    // 所有应用服务器共享同一个 Redis,Session 不再丢失
}

但 Session 方案有一个硬伤------它是有状态的。每个请求都要查一次 Redis。Redis 挂了,认证系统也挂了。而且移动端天然不支持 Cookie,你需要额外适配 Token。

这时候,JWT 出现了。


第三代:JWT 单令牌------从"查我"变成"验我"

JWT 的核心思想:不在服务端存任何状态,把认证信息直接编码进 Token 里,服务端只验签名。

JWT(JSON Web Token)是一种自包含的、紧凑的 Token 格式,它将用户身份和权限信息加密签名在 Token 本身中,服务器无需查询任何存储就能验证用户身份。

这就是"无状态"认证------你的请求落到集群里的哪台机器上都行,每台机器自己就能验证。

JwtProvider 是核心,负责两件事:签发和验证 Token。

java 复制代码
package com.hendisantika.springbootjwtauthentication.jwt;

import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
 * JWT Token 的核心工具类
 * 负责两件事:签发 Token(登录成功后调用)、验证 Token(每次请求时调用)
 */
@Component
public class JwtProvider {

    private static final Logger logger = LoggerFactory.getLogger(JwtProvider.class);

    // 签名密钥:从配置文件中注入,用于签发和验证 Token 的签名
    // ★ 生产环境绝对不能硬编码!必须通过配置中心或环境变量注入
    @Value("${app.jwtSecret}")
    private String jwtSecret;

    // Token 过期时间:单位毫秒,比如 86400000 表示 24 小时
    @Value("${app.jwtExpiration}")
    private int jwtExpiration;

    /**
     * 生成 JWT Token
     * 用户登录成功后调用,将用户身份编码进 Token
     */
    public String generateJwtToken(Authentication authentication) {
        UserPrinciple userPrincipal = (UserPrinciple) authentication.getPrincipal();

        return Jwts.builder()
                // setSubject: 将用户名作为 Token 主题(后续解析时从这里拿用户身份)
                .setSubject(userPrincipal.getUsername())
                // setIssuedAt: 签发时间
                .setIssuedAt(new Date())
                // setExpiration: 过期时间 = 当前时间 + 配置时长
                .setExpiration(new Date(new Date().getTime() + jwtExpiration))
                // signWith: 用 HS512 算法 + 密钥签名,防止 Token 被篡改
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }

    /**
     * 从 Token 中解析出用户名
     * 每次请求过滤器时调用,获取当前操作用户的身份
     */
    public String getUserNameFromJwtToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody().getSubject();
    }

    /**
     * 验证 Token 是否合法
     * 返回 true 表示有效,false 表示 Token 有问题
     */
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException e) {
            // 签名不匹配 → Token 被篡改
            logger.error("Invalid JWT signature: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            // Token 过期
            logger.error("Expired JWT token: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            // Token 格式错误
            logger.error("Invalid JWT token: {}", e.getMessage());
        }
        return false;
    }
}

JwtAuthTokenFilter 是拦截每个请求的"安检闸机":

java 复制代码
package com.hendisantika.springbootjwtauthentication.jwt;

import com.hendisantika.springbootjwtauthentication.service.UserDetailsServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * JWT 认证过滤器:每个请求经过时,从请求头提取 Token → 验证 → 注入安全上下文
 * 继承 OncePerRequestFilter 确保每个请求只过滤一次
 */
public class JwtAuthTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtProvider tokenProvider;

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) {

        try {
            // 第一步:从 Authorization 请求头中提取 JWT Token
            String jwt = getJwtFromRequest(request);

            // 第二步:Token 存在且验证通过,则解析用户信息并注入安全上下文
            if (jwt != null && tokenProvider.validateJwtToken(jwt)) {
                String username = tokenProvider.getUserNameFromJwtToken(jwt);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));

                // 将认证信息注入 SecurityContext
                // ★ 这一步之后,Controller 中就能通过 @AuthenticationPrincipal 获取当前用户
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("Can NOT set user authentication: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 从请求头中提取 Token
     * 标准格式:Authorization: Bearer <token>
     */
    private String getJwtFromRequest(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.replace("Bearer ", "");
        }
        return null;
    }
}

WebSecurityConfig 是 Spring Security 的配置------这里有一个关键配置:

java 复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .cors().and().csrf().disable()
        .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()  // 登录注册放行
            .anyRequest().authenticated()
        .and()
        .exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
        .and()
        // ★ 核心:完全关闭 Session 管理
        // 告诉 Spring Security:不要创建 HttpSession,也不要从 Session 中恢复认证
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    // ★ 将 JWT 过滤器插入到 UsernamePasswordAuthenticationFilter 之前
    http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}

单令牌 JWT 解决了 Session 的分布式问题,但又引入了一个新问题

Token 无法主动失效。

Session 方案里,你从 Redis 删掉 Session,用户立即被踢下线。但 JWT 一旦签发,在过期之前都是有效的。你改密码了、用户被管理员封禁了------对不起,只要 Token 没过期,他还能继续操作。

怎么办?时间设短一点?

设 15 分钟,用户体验差------用着用着就过期了。设 24 小时,安全风险大------泄露了能存活一天。

于是,企业级方案出现了。


第四代:双令牌 JWT------Access Token + Refresh Token

双令牌架构将认证拆分为两个 Token:Access Token(短时效,用于实际认证)和 Refresh Token(长时效,专门用于续签 Access Token),在安全性和体验之间找到了平衡。

Access Token 相当于你去游乐园玩项目时拿的"快速通行券"------有效期短(15-30分钟),丢失了危害有限。

Refresh Token 相当于你的"年卡"------有效期长(7-30天),平时收在保险柜里,只在需要换通行券时才拿出来用。

它完美解决了前三个时代的所有痛点:

痛点 解决方式
Session 需要集中存储(有状态) Access Token 纯无状态,不需要任何服务端存储
JWT 单令牌无法主动失效 Access Token 过期短(15分钟),退出登录时删掉 Refresh Token 的存储记录,Refresh Token 失效后 Access Token 很快自动过期
JWT 过期时间短则体验差 Access Token 短(安全),Refresh Token 长(体验),两者配合兼顾
移动端不支持 Cookie 双令牌都通过 HTTP Header 传递,天然跨平台

下面这套代码来自 GitHub 上的企业级项目 MTVS-3rdBE 的 JWT 双令牌实现,我加上了详细的中文注释:

java 复制代码
package com.mtvs.mtvs3rdbe.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

/**
 * 双令牌 JWT 提供者
 * 
 * 设计理念:
 * - Access Token:有效期 15 分钟,携带用户身份和权限信息,用于 API 认证
 * - Refresh Token:有效期 3 天,仅包含"类型"标识,专门用于续签 Access Token
 * - 两个 Token 使用相同的密钥签名,但通过 type 字段区分用途
 */
@Slf4j
@Component
public class JWTTokenProvider {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String AUTHORITIES_KEY = "auth";      // 权限信息的 Claims Key
    private static final String BEARER_TYPE = "Bearer";
    private static final String TYPE_ACCESS = "access";          // Access Token 类型标识
    private static final String TYPE_REFRESH = "refresh";        // Refresh Token 类型标识
    private static final String CLAIM_TYPE = "type";             // Token 类型 Claims Key

    // 签名密钥,Base64 解码后生成 HMAC-SHA 密钥
    private final Key secretKey;

    // Access Token 有效期:1 小时(注释写 15 分钟,可根据实际情况调整)
    private static final long ACCESS_TOKEN_LIFETIME = 60 * 60 * 1000L;

    // Refresh Token 有效期:3 天
    private static final long REFRESH_TOKEN_LIFETIME = 3 * 24 * 60 * 60 * 1000L;

    public JWTTokenProvider(@Value("${JWT.SECRET}") String secretKey) {
        // ★ 使用 Decoders.BASE64 而不是直接 getBytes()
        // 因为密钥是 Base64 编码的字符串,直接 getBytes 会得到完全不同的字节数组
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 生成双令牌(同时签发 Access Token 和 Refresh Token)
     * 
     * 这是核心方法,在用户登录成功后调用
     * 返回两个 Token:
     * - accessToken:短时效,携带完整的用户信息(用户名 + 权限)
     * - refreshToken:长时效,仅携带 type=refresh 标识
     */
    public UserResponseDTO.authTokenDTO generateToken(Authentication authentication) {
        // 从认证信息中提取用户名和权限列表
        String name = authentication.getName();
        Collection<? extends GrantedAuthority> grantedAuthorities = authentication.getAuthorities();

        // 将权限列表拼接成逗号分隔的字符串(如 "ROLE_USER,ROLE_ADMIN")
        String authorities = grantedAuthorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        Date now = new Date();

        // ---- 签发 Access Token ----
        // ★ Access Token 包含完整的用户身份和权限信息
        // 这样下游服务拿到 Token 后不需要再查数据库就能知道用户是谁、能做什么
        String accessToken = Jwts.builder()
                .setSubject(name)                            // 用户名
                .claim(AUTHORITIES_KEY, authorities)          // 权限列表
                .claim(CLAIM_TYPE, TYPE_ACCESS)              // 标记为 Access Token
                .setIssuedAt(now)                             // 签发时间
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_LIFETIME))  // 15分钟后过期
                .signWith(secretKey, SignatureAlgorithm.HS256) // HS256 签名
                .compact();

        // ---- 签发 Refresh Token ----
        // ★ Refresh Token 只有 type 和过期时间,没有用户身份信息
        // 因为它只用来"续" Access Token,不需要知道具体是谁
        String refreshToken = Jwts.builder()
                .claim(CLAIM_TYPE, TYPE_REFRESH)              // 标记为 Refresh Token
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_LIFETIME))  // 3天后过期
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();

        // 返回双令牌,客户端需要保存两个 Token
        return new UserResponseDTO.authTokenDTO(
                BEARER_TYPE,           // "Bearer"
                accessToken,           // 短时效 Access Token
                ACCESS_TOKEN_LIFETIME, // 有效期(毫秒)
                refreshToken,          // 长时效 Refresh Token
                REFRESH_TOKEN_LIFETIME // 有效期(毫秒)
        );
    }

    /**
     * 从 Token 中解析出 Spring Security 的 Authentication 对象
     * 过滤器拿到 Access Token 后调用此方法,还原用户身份
     */
    public Authentication getAuthentication(String token) {
        // 解析 Token 的 Claims(Payload 部分)
        Claims claims = parseClaims(token);

        // 没有权限信息 → 拒绝
        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("Token has no authority info");
        }

        // 从 Claims 中还原权限列表
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // 创建 UserDetails 对象(Spring Security 内置的 User 类)
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        // 返回 Authentication 对象,交给 SecurityContextHolder
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    /**
     * 验证 Token 有效性
     * 区分不同类型的异常,方便排查问题
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.info("Invalid JWT signature: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.info("JWT claims is empty: {}", e.getMessage());
        }
        return false;
    }

    /**
     * 判断 Token 是否是 Refresh Token
     * 在 /api/auth/reissue 接口中用到------只有 Refresh Token 才能换新的 Access Token
     */
    public boolean isRefreshToken(String token) {
        String type = (String) Jwts.parserBuilder()
                .setSigningKey(secretKey).build()
                .parseClaimsJws(token).getBody().get(CLAIM_TYPE);
        return TYPE_REFRESH.equals(type);
    }

    /**
     * 从 HTTP 请求头中提取 Token
     * 标准格式:Authorization: Bearer <token>
     */
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7); // 去掉 "Bearer " 前缀
        }
        return null;
    }
}

过滤器中的关键逻辑:对 /api/auth/reissue(续签接口)和 /api/auth/logout(登出接口)跳过认证,因为这两个接口本身就是用来处理 Token 的。

java 复制代码
package com.mtvs.mtvs3rdbe.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

/**
 * 双令牌认证过滤器
 * 
 * 处理逻辑与单令牌 JWT 的区别:
 * 1. 只验证 Access Token,不验证 Refresh Token
 * 2. 对 /api/auth/reissue 路径放行(这个接口内部用 Refresh Token 换 Access Token)
 * 3. 对 /api/auth/logout 路径放行(登出只需要找到并删除 Refresh Token 的存储)
 */
@RequiredArgsConstructor
@Slf4j
public class JWTTokenFilter extends GenericFilterBean {

    private final JWTTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {

        String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
        String requestURI = ((HttpServletRequest) servletRequest).getRequestURI();

        // Token 不为空且验证通过
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // ★ 对续签和登出接口不做认证注入(这两个接口本身就在处理认证流程)
            if (!"/api/auth/reissue".equals(requestURI)
                    && !"/api/auth/logout".equals(requestURI)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("User {} authenticated", authentication.getName());
            }
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
}

续签接口的实现逻辑(伪代码):

java 复制代码
@PostMapping("/api/auth/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request) {
    String refreshToken = jwtTokenProvider.resolveToken(request);

    // 1. 验证 Refresh Token 是否有效
    if (!jwtTokenProvider.validateToken(refreshToken)) {
        return ResponseEntity.status(401).body("Refresh Token expired");
    }

    // 2. 确认是 Refresh Token(不是 Access Token 伪装的)
    if (!jwtTokenProvider.isRefreshToken(refreshToken)) {
        return ResponseEntity.status(401).body("Not a refresh token");
    }

    // 3. (可选)检查 Redis 黑名单:如果 Refresh Token 已被注销则拒绝
    if (redisTemplate.hasKey("blacklist:" + refreshToken)) {
        return ResponseEntity.status(401).body("Refresh Token has been revoked");
    }

    // 4. 签发新的 Access Token(不需要重新登录!)
    Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
    String newAccessToken = jwtTokenProvider.generateToken(authentication).getAccessToken();

    return ResponseEntity.ok(new JwtResponse(newAccessToken));
}

这些生产陷阱,你应该心里有数

陷阱一:Access Token 时间设多长最合适?

没有标准答案,取决于你的业务安全等级。一般建议:

  • 金融/支付类:5-10 分钟
  • 通用业务类:15-30 分钟
  • 低敏感类:1-2 小时

重要的是:Access Token 的时间决定了"用户被封禁后多久才能真正失效"。 设 5 分钟,最多忍受 5 分钟的安全窗口。

陷阱二:Refresh Token 的存储必须安全

Refresh Token 是用户的"长期通行证"。浏览器端必须存 HttpOnly Cookie(防 XSS),移动端必须存安全存储区(iOS Keychain / Android EncryptedSharedPreferences)。绝对不要存 localStorage

陷阱三:退出登录需要做两件事

  1. 客户端删除 Access Token 和 Refresh Token
  2. 服务端将 Refresh Token 加入黑名单(Redis Set 或 DB 标记)

如果不做第二步,Refresh Token 仍然是有效的,任何人都可以用它续签新的 Access Token。典型的做法是在 Redis 中维护一个"已注销 Refresh Token 集合",续签接口先查这个集合。

陷阱四:续签接口要防重放攻击

如果有人截获了你的 Refresh Token,他可以反复调用续签接口获取新的 Access Token。解法:Refresh Token 用一次就作废(轮替机制),每次续签时签发新的 Refresh Token,旧的立即失效。


四个时代的方案对比

方案 存储位置 状态 移动端支持 可主动失效 分布式友好 适用场景
Cookie 原生 客户端 无状态 不支持 极简场景,基本被淘汰
Session + Redis 服务端 Redis 有状态 需额外适配 需共享 Redis 单体/中小规模分布式
JWT 单令牌 Token 本身 无状态 原生支持 微服务、前后端分离
双令牌 JWT Token 本身 + Redis(黑名单) 无状态+轻量状态 原生支持 是(黑名单机制) 企业级生产首选

一句话选型:做 Demo 用单令牌 JWT 够了,上生产直接上双令牌架构------安全性和用户体验不用二选一。


面试追问

面试追问 1:双令牌架构下,Access Token 过期后客户端怎么知道要去续签?

两种方案:1)前端在 HTTP 拦截器中检测 401 状态码,自动调用续签接口重试;2)Access Token 过期前几分钟,前端根据 expires_in 字段提前续签。方案 1 更可靠,方案 2 体验更好(无感知刷新)。

面试追问 2:为什么双令牌架构中,续签接口不能用 Access Token 而必须用 Refresh Token?

Access Token 有效期短,如果允许用 Access Token 续签,攻击者在 15 分钟内截获了 Token,就可以无限续期,等于 Token 永不过期。Refresh Token 需要更高级别的保护(服务端黑名单 + 短生命周期)。

面试追问 3:如果 Redis 集群挂了,双令牌架构的"黑名单"机制失效,怎么兜底?

方案一:本地缓存 + 短 TTL(Caffeine 缓存黑名单 1 分钟,即使 Redis 挂了最多 1 分钟延迟)。方案二:降级为只验证 Access Token 的签名和过期时间(退化为单令牌模式),虽然不能主动失效,但 Access Token 有效期短,影响有限。

面试追问 4:不引入 Refresh Token,用短期 Access Token + 每次重新登录行不行?

可以,但用户体验极差。想象一下:你打开银行 App,查个余额 → 输入密码,转个账 → 又输入密码。双令牌架构本质上就是"延长用户会话"同时"维持短 Token 的安全性"------这是权衡后的最优解。没有 Refresh Token 的方案,在安全性相等的情况下体验输了一大截。


双令牌 JWT 是目前企业级认证架构中,在安全性、体验、分布式友好三个维度上权衡得最均衡的方案。

读完这篇你应该能:画出从 Cookie 到双令牌 JWT 的完整演进路线图、解释清楚每一代方案解决了什么问题又引入了什么问题、独立搭建一套生产可用的 Spring Boot 双令牌认证模块、在面试时讲清楚 Access Token 和 Refresh Token 的设计动机而不只是背概念。

认证架构选什么方案,取决于你愿意为安全牺牲多少体验,又愿意为体验承担多少风险。

相关推荐
1104.北光c°2 个月前
双令牌机制:让认证更安全、体验更流畅
java·开发语言·笔记·后端·安全·token·双令牌