双Token机制(Access Token + Refresh Token)安全高效

双Token机制(Access Token + Refresh Token)的详细实现步骤:


1. 令牌设计与生成

1.1 令牌定义

  • Access Token

    • 有效期:30分钟(短效)
    • 存储方式:客户端内存或非持久化存储(如JavaScript变量)
    • 内容:用户ID、权限范围、设备指纹哈希、签发时间
    • 格式:JWT(含exp声明)
  • Refresh Token

    • 有效期:7天(长效)
    • 存储方式:HttpOnly + Secure Cookie(防XSS)
    • 内容:全局唯一标识符(UUID)、用户ID、设备指纹哈希
    • 格式:不透明字符串(存储于Redis)

1.2 登录接口实现

scss 复制代码
// AuthController.java
@PostMapping("/login")
public R<LoginResult> login(@RequestBody LoginRequest request) {
    // 1. 验证用户密码
    LoginUser user = remoteUserService.authenticate(request);
    
    // 2. 生成双Token
    String accessToken = JwtUtils.generateAccessToken(user);
    String refreshToken = UUID.randomUUID().toString();
    
    // 3. 存储Refresh Token到Redis(绑定设备和用户)
    String deviceFingerprint = buildDeviceFingerprint(request);
    String redisKey = buildRefreshTokenKey(user.getUserId(), deviceFingerprint);
    redisService.setEx(redisKey, refreshToken, 7, TimeUnit.DAYS);
    
    // 4. 设置Refresh Token到Cookie
    ResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken)
        .httpOnly(true)
        .secure(true)
        .path("/")
        .maxAge(7 * 24 * 3600)
        .sameSite("Strict")
        .build();
    
    return R.ok(new LoginResult(accessToken))
        .addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}

2. 令牌刷新接口

2.1 刷新端点实现

typescript 复制代码
// AuthController.java
@PostMapping("/auth/refresh")
public R<LoginResult> refreshToken(
    @CookieValue(name = "refresh_token", required = false) String refreshToken,
    HttpServletRequest request) {
    
    // 1. 验证Refresh Token存在性
    if (StringUtils.isEmpty(refreshToken)) {
        return R.fail(HttpStatus.UNAUTHORIZED, "缺少刷新令牌");
    }
    
    // 2. 提取设备指纹
    String deviceFingerprint = buildDeviceFingerprint(request);
    
    // 3. 查询Redis验证有效性
    String redisKey = buildRefreshTokenKeyFromRequest(request); // 根据请求生成Key
    String storedToken = redisService.get(redisKey);
    if (!refreshToken.equals(storedToken)) {
        return R.fail(HttpStatus.UNAUTHORIZED, "刷新令牌无效");
    }
    
    // 4. 生成新Access Token
    LoginUser user = getCurrentUser(); // 从上下文获取用户
    String newAccessToken = JwtUtils.generateAccessToken(user);
    
    // 5. 可选:刷新Refresh Token有效期(滑动过期)
    redisService.expire(redisKey, 7, TimeUnit.DAYS);
    
    return R.ok(new LoginResult(newAccessToken));
}

2.2 设备指纹生成逻辑

vbscript 复制代码
private String buildDeviceFingerprint(HttpServletRequest request) {
    String ip = ServletUtils.getClientIP(request);
    String userAgent = request.getHeader("User-Agent");
    return Hashing.sha256().hashString(ip + userAgent, StandardCharsets.UTF_8).toString();
}

3. 网关过滤器改造

3.1 验证流程调整

scss 复制代码
// AuthFilter.java
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    
    // 1. 白名单直接放行
    if (isIgnorePath(request.getPath().toString())) {
        return chain.filter(exchange);
    }
    
    // 2. 尝试获取Access Token
    String accessToken = getAccessToken(request);
    
    try {
        // 3. 验证Access Token有效性
        Claims claims = JwtUtils.parseToken(accessToken);
        if (claims != null && isTokenValid(claims)) {
            // 正常流程
            return chain.filter(addHeaders(exchange, claims));
        }
    } catch (ExpiredJwtException ex) {
        // 4. Access Token过期,尝试刷新
        return handleTokenRefresh(exchange, chain, ex.getClaims());
    }
    
    // 5. 无有效令牌
    return unauthorizedResponse(exchange, "请重新登录");
}
​
private Mono<Void> handleTokenRefresh(ServerWebExchange exchange, 
                                     GatewayFilterChain chain,
                                     Claims expiredClaims) {
    // 1. 获取Refresh Token
    String refreshToken = getRefreshTokenFromCookie(exchange);
    
    // 2. 调用刷新接口(内部转发)
    return WebClient.create()
        .post()
        .uri("http://auth-service/auth/refresh")
        .cookie("refresh_token", refreshToken)
        .retrieve()
        .bodyToMono(R.class)
        .flatMap(result -> {
            if (result.getCode() == HttpStatus.SUCCESS) {
                // 3. 更新请求头中的Access Token
                String newToken = result.getData().get("accessToken");
                ServerHttpRequest newRequest = exchange.getRequest().mutate()
                    .header("Authorization", "Bearer " + newToken)
                    .build();
                return chain.filter(exchange.mutate().request(newRequest).build());
            } else {
                return unauthorizedResponse(exchange, "会话已过期");
            }
        });
}

4. 安全增强措施

4.1 Token绑定设备

typescript 复制代码
// JWT生成时加入设备指纹
public static String generateAccessToken(LoginUser user, HttpServletRequest request) {
    String fingerprint = buildDeviceFingerprint(request);
    return Jwts.builder()
        .setSubject(user.getUsername())
        .claim("user_id", user.getUserId())
        .claim("fp", fingerprint)
        .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
        .signWith(SECRET_KEY)
        .compact();
}
​
// 网关验证时检查设备
private boolean validateDeviceFingerprint(Claims claims, HttpServletRequest request) {
    String currentFp = buildDeviceFingerprint(request);
    String tokenFp = claims.get("fp", String.class);
    return currentFp.equals(tokenFp);
}

4.2 主动令牌撤销

scss 复制代码
// 注销接口
@PostMapping("/logout")
public R<Void> logout(HttpServletRequest request) {
    // 1. 获取当前设备指纹
    String fingerprint = buildDeviceFingerprint(request);
    
    // 2. 删除Redis中的Refresh Token
    String redisKey = buildRefreshTokenKey(getCurrentUserId(), fingerprint);
    redisService.delete(redisKey);
    
    // 3. 将Access Token加入黑名单(剩余有效期内拒绝)
    String accessToken = getAccessToken(request);
    redisService.setEx("token_blacklist:" + accessToken, "1", 
        JwtUtils.getRemainingTime(accessToken), TimeUnit.SECONDS);
    
    // 4. 清除客户端Cookie
    ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
        .maxAge(0)
        .build();
    
    return R.ok().addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}

5. 客户端实现示例

5.1 前端自动令牌管理

ini 复制代码
// axios拦截器
axios.interceptors.response.use(response => {
  return response;
}, error => {
  const originalRequest = error.config;
  
  if (error.response?.status === 401 && !originalRequest._retry) {
    originalRequest._retry = true;
    
    // 调用刷新接口
    return axios.post('/auth/refresh', {}, { withCredentials: true })
      .then(res => {
        const newToken = res.data.accessToken;
        localStorage.setItem('access_token', newToken);
        originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
        return axios(originalRequest);
      });
  }
  return Promise.reject(error);
});

5.2 静默刷新机制

javascript 复制代码
// 定时检查Token有效期
setInterval(() => {
  const token = localStorage.getItem('access_token');
  if (token && isTokenExpiringSoon(token)) { // 剩余<5分钟
    axios.post('/auth/refresh', {}, { withCredentials: true })
      .then(res => {
        localStorage.setItem('access_token', res.data.accessToken);
      });
  }
}, 300000); // 每5分钟检查

6. 监控与运维

6.1 关键监控指标

指标名称 监控方式 报警阈值
刷新令牌失败率 Prometheus计数器 >5% (持续5分钟)
并发刷新冲突次数 Redis分布式锁统计 >10次/秒
黑名单令牌数量 Redis键空间统计 突增50%时告警

6.2 日志审计要点

ini 复制代码
# 成功刷新日志
[INFO] 用户[1001]通过设备[192.168.1.1|Chrome]刷新令牌,新有效期至2023-10-01 12:30
​
# 异常事件日志
[WARN] 检测到异常刷新请求,用户[1001]的设备[192.168.1.2|Firefox]与记录不匹配

7. 部署与回滚

7.1 分阶段部署

  1. Phase 1

    • 先部署新的Auth Service(含双Token接口)
    • 保持旧网关兼容两种令牌模式
  2. Phase 2

    • 部署新网关过滤器
    • 前端逐步灰度发布新逻辑
  3. Phase 3

    • 完全禁用旧令牌模式
    • 清理遗留的单一Token数据

7.2 回滚方案

  1. 紧急开关

    kotlin 复制代码
    @Value("${security.token.mode:SINGLE}")
    private String tokenMode;
    ​
    public Mono<Void> filter(...) {
        if ("SINGLE".equals(tokenMode)) {
            // 回退到旧逻辑
        }
    }
  2. 数据兼容

    • 保持旧Token验证逻辑1周
    • 双写Refresh Token到新旧Redis结构

方案优势总结

  1. 安全性提升

    • Access Token短有效期降低泄露风险
    • Refresh Token通过HttpOnly Cookie保护
    • 设备指纹绑定防止跨设备滥用
  2. 用户体验优化

    • 无感知自动刷新机制
    • 支持多设备独立会话管理
  3. 系统扩展性

    • 易于实现令牌吊销列表(黑名单)
    • 支持细粒度权限变更实时生效
  4. 合规性保障

    • 符合OAuth 2.0规范
    • 满足GDPR等数据保护要求
相关推荐
面朝大海,春不暖,花不开17 分钟前
自定义Spring Boot Starter的全面指南
java·spring boot·后端
得过且过的勇者y18 分钟前
Java安全点safepoint
java
Tipray200619 分钟前
让敏感数据在流转与存储中始终守护在安全范围
安全
夜晚回家1 小时前
「Java基本语法」代码格式与注释规范
java·开发语言
斯普信云原生组1 小时前
Docker构建自定义的镜像
java·spring cloud·docker
前端页面仔1 小时前
易语言是什么?易语言能做什么?
开发语言·安全
wangjinjin1801 小时前
使用 IntelliJ IDEA 安装通义灵码(TONGYI Lingma)插件,进行后端 Java Spring Boot 项目的用户用例生成及常见问题处理
java·spring boot·intellij-idea
wtg44521 小时前
使用 Rest-Assured 和 TestNG 进行购物车功能的 API 自动化测试
java
白宇横流学长2 小时前
基于SpringBoot实现的大创管理系统设计与实现【源码+文档】
java·spring boot·后端
fat house cat_2 小时前
【redis】线程IO模型
java·redis