Java基础 | JWT登录场景化最优方案(一)
- 引言
- 一、基础准备:通用架构与核心规范(自带完整可复用代码)
-
- [1.1 依赖版本统一管理(完整配置代码)](#1.1 依赖版本统一管理(完整配置代码))
-
- 核心说明
- [完整代码示例(Maven):pom.xml 完整配置](#完整代码示例(Maven):pom.xml 完整配置)
- [完整代码示例(Gradle):build.gradle 完整配置](#完整代码示例(Gradle):build.gradle 完整配置)
- [1.2 通用核心模块抽取(完整工具类 / 配置类代码,一站式囊括)](#1.2 通用核心模块抽取(完整工具类 / 配置类代码,一站式囊括))
-
- [通用响应类:Result.java 完整代码](#通用响应类:Result.java 完整代码)
- [通用工具类:CookieUtil.java 完整代码](#通用工具类:CookieUtil.java 完整代码)
- [通用工具类:JwtBaseUtil.java 完整代码](#通用工具类:JwtBaseUtil.java 完整代码)
- [通用配置类:CorsConfig.java 完整代码](#通用配置类:CorsConfig.java 完整代码)
- [通用常量类:JwtConstants.java 完整代码](#通用常量类:JwtConstants.java 完整代码)
- [通用 DTO 类:TokenDTO.java/LoginDTO.java/DeviceDTO 完整代码](#通用 DTO 类:TokenDTO.java/LoginDTO.java/DeviceDTO 完整代码)
- [通用 Redis 配置:RedisConfig.java 完整代码](#通用 Redis 配置:RedisConfig.java 完整代码)
- [1.3 安全前置操作(生产环境必备,带完整工具 / 配置代码)](#1.3 安全前置操作(生产环境必备,带完整工具 / 配置代码))
-
- [对称密钥(HS512)生成:JwtSecretGenerator.java 完整工具类代码](#对称密钥(HS512)生成:JwtSecretGenerator.java 完整工具类代码)
- [RSA 非对称密钥对:完整操作代码](#RSA 非对称密钥对:完整操作代码)
-
- [1. RSA密钥生成工具类:RsaKeyGenerator.java](#1. RSA密钥生成工具类:RsaKeyGenerator.java)
- [2. RSA密钥存储规范](#2. RSA密钥存储规范)
- [RSA 密钥轮换:完整实现代码](#RSA 密钥轮换:完整实现代码)
-
- [1. 密钥轮换配置类:RsaKeyRotateConfig.java](#1. 密钥轮换配置类:RsaKeyRotateConfig.java)
- [HTTPS 配置:application.yml 完整 SSL 配置代码](#HTTPS 配置:application.yml 完整 SSL 配置代码)
- 二、场景化最优方案(核心落地章节,每个场景自带完整代码包,一站式囊括)
-
- [场景 1:小型项目 / 单机部署 / 内部工具](#场景 1:小型项目 / 单机部署 / 内部工具)
-
- [最优方案:纯 JWT + 短期有效 + 本地缓存](#最优方案:纯 JWT + 短期有效 + 本地缓存)
- 核心说明
- 完整代码包(直接复制即可运行)
-
- [1. 场景专属配置:application.yml 完整配置](#1. 场景专属配置:application.yml 完整配置)
- [2. 场景专属工具类:JwtLocalCacheUtil.java 完整代码](#2. 场景专属工具类:JwtLocalCacheUtil.java 完整代码)
- [3. 场景专属控制器:AuthController.java 完整代码](#3. 场景专属控制器:AuthController.java 完整代码)
- [4. 测试验证:接口调用示例 + 本地运行验证步骤](#4. 测试验证:接口调用示例 + 本地运行验证步骤)
- [场景 2:中小型业务系统 / ToB 产品](#场景 2:中小型业务系统 / ToB 产品)
-
- [最优方案:JWT + Redis(版本号) + HttpOnly Cookie](#最优方案:JWT + Redis(版本号) + HttpOnly Cookie)
- 核心说明
- 完整代码包(直接复制即可运行,依赖通用核心模块)
-
- [1. 场景专属配置:application.yml 完整配置](#1. 场景专属配置:application.yml 完整配置)
- [2. 场景专属工具类:JwtRedisUtil.java 完整代码](#2. 场景专属工具类:JwtRedisUtil.java 完整代码)
- [3. 场景专属控制器:AuthController.java 完整代码](#3. 场景专属控制器:AuthController.java 完整代码)
- [4. 测试验证:接口调用示例 + 功能验证步骤](#4. 测试验证:接口调用示例 + 功能验证步骤)
- 场景3:中大型ToC产品
-
- [最优方案:JWT + 双Token + Redis + 无感刷新](#最优方案:JWT + 双Token + Redis + 无感刷新)
- 核心说明
- 完整代码包(直接复制即可运行)
-
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
application.yml完整配置) - [2. 场景专属工具类:`JwtRefreshUtil.java` 完整代码](#2. 场景专属工具类:
JwtRefreshUtil.java完整代码) - [3. 场景专属控制器:`AuthController.java` 完整代码](#3. 场景专属控制器:
AuthController.java完整代码) - [4. 前端适配:无感刷新前端简易代码示例(Vue 3 + Axios)](#4. 前端适配:无感刷新前端简易代码示例(Vue 3 + Axios))
- [5. 测试验证:完整流程测试步骤](#5. 测试验证:完整流程测试步骤)
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
- 场景4:高并发场景/黑名单量大
-
- [最优方案:JWT + 双Token + Redis + 布隆过滤器](#最优方案:JWT + 双Token + Redis + 布隆过滤器)
- 核心说明
- 完整代码包(直接复制即可运行)
-
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
application.yml完整配置) - [2. 场景专属工具类:`JwtBloomFilter.java` 完整代码](#2. 场景专属工具类:
JwtBloomFilter.java完整代码) - [3. 场景专属工具类:`JwtHighConcurrencyUtil.java` 完整代码](#3. 场景专属工具类:
JwtHighConcurrencyUtil.java完整代码) - [4. 定时任务配置:`BloomFilterRefreshConfig.java` 完整代码](#4. 定时任务配置:
BloomFilterRefreshConfig.java完整代码) - [5. 场景专属控制器:`AuthController.java` 完整代码](#5. 场景专属控制器:
AuthController.java完整代码) - [6. 高并发测试:压力测试配置 + 预期结果](#6. 高并发测试:压力测试配置 + 预期结果)
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
- 场景5:多设备管控/精细化权限
-
- [最优方案:JWT + 双Token + Redis(用户+设备)](#最优方案:JWT + 双Token + Redis(用户+设备))
- 核心说明
- 完整代码包(直接复制即可运行)
-
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
application.yml完整配置) - [2. 场景专属工具类:`JwtMultiDeviceUtil.java` 完整代码](#2. 场景专属工具类:
JwtMultiDeviceUtil.java完整代码) - [3. 场景专属控制器:`AuthController.java` + `DeviceController.java` 完整代码](#3. 场景专属控制器:
AuthController.java+DeviceController.java完整代码) - [4. 测试验证:多设备登录/下线完整测试步骤](#4. 测试验证:多设备登录/下线完整测试步骤)
- [1. 场景专属配置:`application.yml` 完整配置](#1. 场景专属配置:
- 场景6:企业级微服务/SSO/第三方授权
-
- [最优方案:JWT + Spring Security/Authorization Server](#最优方案:JWT + Spring Security/Authorization Server)
- 核心说明
- 完整代码包(直接复制即可运行)
-
- [1. 场景专属依赖:`pom.xml` 完整补充配置](#1. 场景专属依赖:
pom.xml完整补充配置) - [2. 授权服务配置:`AuthorizationServerConfig.java` 完整代码](#2. 授权服务配置:
AuthorizationServerConfig.java完整代码) - [3. 资源服务器配置:`ResourceServerConfig.java` 完整代码](#3. 资源服务器配置:
ResourceServerConfig.java完整代码) - [4. 安全配置:`SecurityConfig.java` 完整代码](#4. 安全配置:
SecurityConfig.java完整代码) - [5. 核心控制器:`SSOController.java` 完整代码](#5. 核心控制器:
SSOController.java完整代码) - [6. 测试验证:微服务间认证测试步骤](#6. 测试验证:微服务间认证测试步骤)
- [1. 场景专属依赖:`pom.xml` 完整补充配置](#1. 场景专属依赖:
引言
JWT(JSON Web Token)作为前后端分离架构的主流认证方案,其核心优势是无状态、跨端兼容,但实际落地时往往面临"安全漏洞""用户体验差""过度设计"等问题。
真正实用、常用且受欢迎的方案,从来不是"一刀切",而是按场景精准匹配------小项目追求快速落地,中大型项目兼顾安全与体验,企业级项目侧重标准化与可维护性。
一、基础准备:通用架构与核心规范(自带完整可复用代码)
1.1 依赖版本统一管理(完整配置代码)
核心说明
统一管理Spring Boot、JJWT、Redis等核心依赖版本,避免多场景版本冗余冲突,简化后续版本升级操作,符合大厂依赖管理规范。
完整代码示例(Maven):pom.xml 完整配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 父工程依赖:Spring Boot 稳定版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>jwt-auth-framework</artifactId>
<version>1.0.0</version>
<name>jwt-auth-framework</name>
<description>JWT登录认证通用框架</description>
<!-- 统一版本管理:核心依赖版本定义 -->
<properties>
<java.version>1.8</java.version>
<!-- JJWT 版本(兼容Spring Boot 2.7.x) -->
<jjwt.version>0.11.5</jjwt.version>
<!-- Redis 客户端版本(与Spring Boot 配套) -->
<spring-data-redis.version>2.7.18</spring-data-redis.version>
<!-- Lombok 版本 -->
<lombok.version>1.18.30</lombok.version>
<!-- 测试依赖版本 -->
<junit-jupiter.version>5.9.2</junit-jupiter.version>
</properties>
<!-- 核心依赖 -->
<dependencies>
<!-- Spring Boot Web 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JJWT 核心:JWT 生成与校验 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Redis 核心:支撑黑名单、版本号存储 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${spring-data-redis.version}</version>
</dependency>
<!-- Lombok:简化实体类代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!-- 校验框架:入参合法性校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<!-- 构建配置 -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
完整代码示例(Gradle):build.gradle 完整配置
groovy
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '1.0.0'
description = 'JWT登录认证通用框架'
// 统一版本管理
ext {
jjwtVersion = '0.11.5'
lombokVersion = '1.18.30'
junitJupiterVersion = '5.9.2'
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
repositories {
mavenCentral()
}
dependencies {
// Spring Boot Web 核心
implementation 'org.springframework.boot:spring-boot-starter-web'
// JJWT 核心
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
// Redis 核心
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Lombok
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
// 校验框架
implementation 'org.springframework.boot:spring-boot-starter-validation'
// 测试依赖
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation "org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}"
}
test {
useJUnitPlatform()
}
1.2 通用核心模块抽取(完整工具类 / 配置类代码,一站式囊括)
通用响应类:Result.java 完整代码
java
package com.example.jwtauthframework.common;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 全局统一响应类
* 符合大厂接口响应规范,统一返回格式,便于前端解析
*
* @author xxx
* @date 2026-01-02
*/
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 响应码:200成功,500系统异常,400参数错误,401未授权,403禁止访问
*/
private Integer code;
/**
* 响应信息
*/
private String msg;
/**
* 响应数据
*/
private T data;
/**
* 响应时间戳
*/
private LocalDateTime timestamp;
/**
* 私有化构造方法,禁止外部直接创建
*/
private Result() {
this.timestamp = LocalDateTime.now();
}
// ==================== 成功响应 ====================
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> success(String msg, T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg(msg);
result.setData(data);
return result;
}
// ==================== 失败响应 ====================
public static <T> Result<T> fail() {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg("操作失败");
return result;
}
public static <T> Result<T> fail(String msg) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg(msg);
return result;
}
public static <T> Result<T> fail(Integer code, String msg) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMsg(msg);
return result;
}
// ==================== 分页响应(扩展) ====================
public static <T> Result<PageResult<T>> pageSuccess(Long total, T data) {
PageResult<T> pageResult = new PageResult<>();
pageResult.setTotal(total);
pageResult.setList(data);
return success(pageResult);
}
/**
* 分页数据封装内部类
*/
@Data
public static class PageResult<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总记录数
*/
private Long total;
/**
* 分页数据列表
*/
private T list;
}
}
通用工具类:CookieUtil.java 完整代码
java
package com.example.jwtauthframework.common.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
/**
* Cookie 操作工具类
* 封装Cookie的创建、获取、删除操作,多场景复用,符合大厂工具类规范
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
public class CookieUtil {
/**
* Cookie 默认过期时间:30天(单位:秒)
*/
private static final int DEFAULT_MAX_AGE = 30 * 24 * 60 * 60;
/**
* 创建Cookie并写入响应
*
* @param response HttpServletResponse
* @param name Cookie名称
* @param value Cookie值
* @param domain 域名
* @param path 路径
* @param maxAge 过期时间(秒)
* @param httpOnly 是否仅Http协议可访问(防XSS)
* @param secure 是否仅HTTPS传输(生产环境必填)
* @param sameSite SameSite属性(防CSRF:Strict/Lax/None)
*/
public static void setCookie(HttpServletResponse response,
String name,
String value,
String domain,
String path,
Integer maxAge,
boolean httpOnly,
boolean secure,
String sameSite) {
try {
// 编码处理,防止中文乱码
value = URLEncoder.encode(value, "UTF-8");
Cookie cookie = new Cookie(name, value);
// 设置域名
if (StringUtils.hasText(domain)) {
cookie.setDomain(domain);
}
// 设置路径
cookie.setPath(StringUtils.hasText(path) ? path : "/");
// 设置过期时间
cookie.setMaxAge(maxAge == null ? DEFAULT_MAX_AGE : maxAge);
// 设置仅Http访问
cookie.setHttpOnly(httpOnly);
// 设置仅HTTPS传输
cookie.setSecure(secure);
// 设置SameSite属性
if (StringUtils.hasText(sameSite)) {
response.addHeader("Set-Cookie", String.format("%s=%s; %s; %s; %s; SameSite=%s",
name, value,
StringUtils.hasText(domain) ? "Domain=" + domain + ";" : "",
"Path=" + cookie.getPath() + ";",
"Max-Age=" + cookie.getMaxAge() + ";",
sameSite,
httpOnly ? "HttpOnly;" : "",
secure ? "Secure;" : ""));
return;
}
response.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
log.error("创建Cookie失败,异常信息:", e);
}
}
/**
* 根据名称获取Cookie值
*
* @param request HttpServletRequest
* @param name Cookie名称
* @return Cookie值(解码后)
*/
public static String getCookieValue(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null || name == null) {
return null;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
try {
// 解码处理
return java.net.URLDecoder.decode(cookie.getValue(), "UTF-8");
} catch (UnsupportedEncodingException e) {
log.error("获取Cookie值失败,异常信息:", e);
return null;
}
}
}
return null;
}
/**
* 删除Cookie(通过设置过期时间为0实现)
*
* @param response HttpServletResponse
* @param name Cookie名称
* @param domain 域名
* @param path 路径
*/
public static void removeCookie(HttpServletResponse response, String name, String domain, String path) {
setCookie(response, name, "", domain, path, 0, true, false, "Lax");
}
/**
* 简化版:设置默认属性的Cookie(适用于开发环境)
*
* @param response HttpServletResponse
* @param name Cookie名称
* @param value Cookie值
*/
public static void setDefaultCookie(HttpServletResponse response, String name, String value) {
setCookie(response, name, value, null, "/", DEFAULT_MAX_AGE, true, false, "Lax");
}
/**
* 简化版:设置生产环境安全Cookie
*
* @param response HttpServletResponse
* @param name Cookie名称
* @param value Cookie值
* @param domain 域名
*/
public static void setProdSecureCookie(HttpServletResponse response, String name, String value, String domain) {
setCookie(response, name, value, domain, "/", DEFAULT_MAX_AGE, true, true, "Strict");
}
}
通用工具类:JwtBaseUtil.java 完整代码
java
package com.example.jwtauthframework.common.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
/**
* JWT 基础工具类
* 抽取JWT通用生成、校验逻辑,供各场景工具类复用,符合单一职责原则
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
public class JwtBaseUtil {
/**
* 构建对称加密密钥(HS512)
*
* @param secretKey 密钥字符串
* @return SecretKey
*/
public static SecretKey getHs512SecretKey(String secretKey) {
if (!StringUtils.hasText(secretKey)) {
throw new IllegalArgumentException("JWT对称密钥不能为空");
}
// 生成符合HS512要求的密钥
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成JWT Token(对称加密:HS512)
*
* @param secretKey 对称密钥
* @param claims 自定义载荷
* @param expireSeconds 过期时间(秒)
* @return JWT Token
*/
public static String generateHs512Token(SecretKey secretKey, Map<String, Object> claims, long expireSeconds) {
// 校验参数
if (secretKey == null) {
throw new IllegalArgumentException("JWT对称密钥不能为空");
}
if (claims == null) {
claims = new JwtClaimsBuilder().build();
}
// 生成Token
long currentTime = System.currentTimeMillis();
return Jwts.builder()
// 设置自定义载荷
.setClaims(claims)
// 设置签发时间
.setIssuedAt(new Date(currentTime))
// 设置过期时间
.setExpiration(new Date(currentTime + expireSeconds * 1000))
// 设置签名算法与密钥
.signWith(secretKey, SignatureAlgorithm.HS512)
// 压缩算法
.compressWith(CompressionCodecs.GZIP)
.compact();
}
/**
* 校验JWT Token有效性(对称加密)
*
* @param secretKey 对称密钥
* @param token JWT Token
* @return true-有效,false-无效
*/
public static boolean validateHs512Token(SecretKey secretKey, String token) {
try {
// 移除Token前缀(如Bearer )
token = removeTokenPrefix(token);
// 校验Token并解析载荷
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("JWT Token已过期,异常信息:", e);
} catch (UnsupportedJwtException e) {
log.error("JWT Token格式不支持,异常信息:", e);
} catch (MalformedJwtException e) {
log.error("JWT Token格式错误,异常信息:", e);
} catch (SignatureException e) {
log.error("JWT Token签名验证失败,异常信息:", e);
} catch (IllegalArgumentException e) {
log.error("JWT Token为空或无效,异常信息:", e);
} catch (Exception e) {
log.error("JWT Token校验异常,异常信息:", e);
}
return false;
}
/**
* 解析JWT Token获取载荷(对称加密)
*
* @param secretKey 对称密钥
* @param token JWT Token
* @return 自定义载荷
*/
public static Claims parseHs512TokenClaims(SecretKey secretKey, String token) {
try {
// 移除Token前缀
token = removeTokenPrefix(token);
// 解析Token
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
log.error("解析JWT Token载荷失败,异常信息:", e);
return null;
}
}
/**
* 移除Token前缀(如:Bearer eyJhbGciOiJIUzUxMiJ9...)
*
* @param token JWT Token
* @return 纯净Token
*/
public static String removeTokenPrefix(String token) {
if (!StringUtils.hasText(token)) {
return null;
}
if (token.startsWith(JwtConstants.TOKEN_PREFIX)) {
return token.substring(JwtConstants.TOKEN_PREFIX.length()).trim();
}
return token.trim();
}
/**
* 获取Token过期时间
*
* @param secretKey 对称密钥
* @param token JWT Token
* @return 过期时间
*/
public static Date getTokenExpirationTime(SecretKey secretKey, String token) {
Claims claims = parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.getExpiration();
}
}
通用配置类:CorsConfig.java 完整代码
java
package com.example.jwtauthframework.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 全局跨域配置
* 统一配置跨域规则,无需在各控制器重复注解,符合大厂全局配置规范
*
* @author xxx
* @date 2026-01-02
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
/**
* 配置跨域映射
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 对所有请求路径生效
registry.addMapping("/**")
// 允许的跨域源(生产环境需指定具体域名,避免*带来的安全风险)
.allowedOriginPatterns("*")
// 允许的请求方法
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// 允许的请求头
.allowedHeaders("*")
// 是否允许携带Cookie(跨域认证必备)
.allowCredentials(true)
// 预检请求缓存时间(秒),减少预检请求次数
.maxAge(3600)
// 暴露的响应头(前端需要获取的自定义头)
.exposedHeaders("Authorization", "Refresh-Token");
}
}
通用常量类:JwtConstants.java 完整代码
java
package com.example.jwtauthframework.common.constant;
/**
* JWT 常量类
* 统一维护JWT相关常量,避免魔法值,便于维护
*
* @author xxx
* @date 2026-01-02
*/
public class JwtConstants {
/**
* Token 前缀(HTTP请求头中)
*/
public static final String TOKEN_PREFIX = "Bearer ";
/**
* Access Token 请求头名称
*/
public static final String ACCESS_TOKEN_HEADER = "Authorization";
/**
* Refresh Token 请求头名称
*/
public static final String REFRESH_TOKEN_HEADER = "Refresh-Token";
/**
* Access Token Cookie 名称
*/
public static final String ACCESS_TOKEN_COOKIE_NAME = "access_token";
/**
* Refresh Token Cookie 名称
*/
public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
/**
* JWT 载荷中用户ID字段名
*/
public static final String JWT_CLAIMS_USER_ID = "userId";
/**
* JWT 载荷中用户名字段名
*/
public static final String JWT_CLAIMS_USERNAME = "username";
/**
* JWT 载荷中设备ID字段名
*/
public static final String JWT_CLAIMS_DEVICE_ID = "deviceId";
/**
* JWT 载荷中Token版本号字段名
*/
public static final String JWT_CLAIMS_VERSION = "version";
/**
* Access Token 默认过期时间:2小时(秒)
*/
public static final long DEFAULT_ACCESS_TOKEN_EXPIRE_SECONDS = 2 * 60 * 60;
/**
* Refresh Token 默认过期时间:7天(秒)
*/
public static final long DEFAULT_REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60;
/**
* Redis 中用户Token版本号Key前缀
*/
public static final String REDIS_USER_TOKEN_VERSION_PREFIX = "jwt:user:version:";
/**
* Redis 中用户黑名单Key前缀
*/
public static final String REDIS_USER_TOKEN_BLACKLIST_PREFIX = "jwt:user:blacklist:";
/**
* Redis 中用户设备绑定Key前缀
*/
public static final String REDIS_USER_DEVICE_BIND_PREFIX = "jwt:user:device:";
}
通用 DTO 类:TokenDTO.java/LoginDTO.java/DeviceDTO 完整代码
java
package com.example.jwtauthframework.common.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* Token 出参DTO
* 统一封装Access Token和Refresh Token,便于前端接收
*
* @author xxx
* @date 2026-01-02
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 访问令牌
*/
private String accessToken;
/**
* 刷新令牌
*/
private String refreshToken;
/**
* 访问令牌过期时间(秒)
*/
private Long accessTokenExpireSeconds;
/**
* 刷新令牌过期时间(秒)
*/
private Long refreshTokenExpireSeconds;
}
java
package com.example.jwtauthframework.common.dto;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* 登录入参DTO
* 包含入参合法性校验,符合大厂参数校验规范
*
* @author xxx
* @date 2026-01-02
*/
@Data
public class LoginDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
@Length(min = 4, max = 20, message = "用户名长度必须在4-20位之间")
private String username;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@Length(min = 6, max = 32, message = "密码长度必须在6-32位之间")
private String password;
/**
* 设备ID
*/
@NotBlank(message = "设备ID不能为空")
private String deviceId;
/**
* 设备名称
*/
private String deviceName;
}
java
package com.example.jwtauthframework.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* 设备管理DTO
* 封装设备相关入参/出参,支撑多设备管控场景
*
* @author xxx
* @date 2026-01-02
*/
@Data
public class DeviceDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
@NotBlank(message = "用户ID不能为空")
private String userId;
/**
* 设备ID
*/
@NotBlank(message = "设备ID不能为空")
private String deviceId;
/**
* 设备名称
*/
private String deviceName;
/**
* 操作类型:bind-绑定,unbind-解绑,kick-下线
*/
@NotBlank(message = "操作类型不能为空")
private String operateType;
}
通用 Redis 配置:RedisConfig.java 完整代码
java
package com.example.jwtauthframework.common.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 全局配置
* 配置RedisTemplate序列化方式,避免默认JDK序列化带来的性能问题与乱码问题
*
* @author xxx
* @date 2026-01-02
*/
@Configuration
public class RedisConfig {
/**
* 自定义RedisTemplate(<String, Object>)
*
* @param redisConnectionFactory Redis连接工厂
* @return RedisTemplate
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 构建Jackson2JsonRedisSerializer序列化器
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
// 设置可见性
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 设置类型序列化(避免反序列化时类型丢失)
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 构建StringRedisSerializer序列化器
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// 设置Key序列化方式
redisTemplate.setKeySerializer(stringRedisSerializer);
// 设置Hash Key序列化方式
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// 设置Value序列化方式
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
// 设置Hash Value序列化方式
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
// 初始化RedisTemplate
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
1.3 安全前置操作(生产环境必备,带完整工具 / 配置代码)
对称密钥(HS512)生成:JwtSecretGenerator.java 完整工具类代码
java
package com.example.jwtauthframework.common.generator;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* JWT 对称密钥(HS512)生成工具
* 一键生成安全合规的HS512密钥,避免手动编写弱密钥带来的安全风险
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
public class JwtSecretGenerator {
/**
* 生成HS512对称密钥(Base64编码格式,便于配置存储)
*
* @return Base64编码后的密钥字符串
*/
public static String generateHs512SecretKey() {
// 生成符合HS512算法要求的密钥(长度至少512位=64字节)
SecretKey secretKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS512);
// 转换为Base64编码字符串,便于存储在配置文件中
String base64Secret = Base64.getEncoder().encodeToString(secretKey.getEncoded());
log.info("成功生成HS512对称密钥(Base64编码):{}", base64Secret);
log.info("请将该密钥配置到项目的application.yml中,密钥名称:jwt.secret.key");
return base64Secret;
}
/**
* 测试主方法:运行即可生成密钥
*/
public static void main(String[] args) {
generateHs512SecretKey();
}
}
RSA 非对称密钥对:完整操作代码
1. RSA密钥生成工具类:RsaKeyGenerator.java
java
package com.example.jwtauthframework.common.generator;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* RSA 非对称密钥对生成工具
* 生成公钥/私钥文件,用于JWT非对称加密,避免密钥泄露带来的全局风险
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
public class RsaKeyGenerator {
/**
* 密钥算法
*/
private static final String ALGORITHM = "RSA";
/**
* 密钥长度:2048位(生产环境推荐4096位,安全性更高)
*/
private static final int KEY_SIZE = 2048;
/**
* 公钥文件存储路径
*/
private static final String PUBLIC_KEY_PATH = "rsa_public_key.pem";
/**
* 私钥文件存储路径
*/
private static final String PRIVATE_KEY_PATH = "rsa_private_key.pem";
/**
* 生成RSA公钥私钥对,并保存到文件
*/
public static void generateRsaKeyPair() {
try {
// 构建密钥对生成器
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM);
keyPairGenerator.initialize(KEY_SIZE, new SecureRandom());
// 生成密钥对
KeyPair keyPair = keyPairGenerator.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
// 编码公钥并保存到文件
String publicKeyStr = Base64.getEncoder().encodeToString(publicKey.getEncoded());
saveKeyToFile(publicKeyStr, PUBLIC_KEY_PATH);
// 编码私钥并保存到文件
String privateKeyStr = Base64.getEncoder().encodeToString(privateKey.getEncoded());
saveKeyToFile(privateKeyStr, PRIVATE_KEY_PATH);
log.info("RSA密钥对生成成功!");
log.info("公钥文件路径:{}", new File(PUBLIC_KEY_PATH).getAbsolutePath());
log.info("私钥文件路径:{}", new File(PRIVATE_KEY_PATH).getAbsolutePath());
} catch (NoSuchAlgorithmException e) {
log.error("生成RSA密钥对失败,异常信息:", e);
}
}
/**
* 读取RSA公钥
*
* @param publicKeyStr 公钥字符串(Base64编码)
* @return PublicKey
*/
public static PublicKey getRsaPublicKey(String publicKeyStr) {
try {
// 解码公钥
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyStr);
// 构建X509编码规范
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
return keyFactory.generatePublic(keySpec);
} catch (Exception e) {
log.error("读取RSA公钥失败,异常信息:", e);
return null;
}
}
/**
* 读取RSA私钥
*
* @param privateKeyStr 私钥字符串(Base64编码)
* @return PrivateKey
*/
public static PrivateKey getRsaPrivateKey(String privateKeyStr) {
try {
// 解码私钥
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
// 构建PKCS8编码规范
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
return keyFactory.generatePrivate(keySpec);
} catch (Exception e) {
log.error("读取RSA私钥失败,异常信息:", e);
return null;
}
}
/**
* 将密钥字符串保存到文件
*
* @param keyStr 密钥字符串
* @param filePath 文件路径
*/
private static void saveKeyToFile(String keyStr, String filePath) {
FileOutputStream fos = null;
try {
File file = new File(filePath);
// 创建父目录
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
// 写入文件
fos = new FileOutputStream(file);
fos.write(keyStr.getBytes());
fos.flush();
} catch (IOException e) {
log.error("保存密钥文件失败,文件路径:{},异常信息:", filePath, e);
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
log.error("关闭文件输出流失败,异常信息:", e);
}
}
}
}
/**
* 测试主方法:运行即可生成RSA密钥对
*/
public static void main(String[] args) {
generateRsaKeyPair();
}
}
2. RSA密钥存储规范
- 禁止硬编码到代码中,优先存储在Nacos/Apollo等配置中心,配置项命名:
jwt.rsa.public.key、jwt.rsa.private.key。 - 私钥文件需加密存储,权限设置为仅管理员可读取(Linux:chmod 600),避免泄露。
- 公钥可对外暴露(如微服务资源服务器、第三方合作系统),私钥仅存储在认证服务中。
RSA 密钥轮换:完整实现代码
1. 密钥轮换配置类:RsaKeyRotateConfig.java
java
package com.example.jwtauthframework.common.config;
import com.example.jwtauthframework.common.generator.RsaKeyGenerator;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.List;
/**
* RSA 密钥轮换配置
* 平滑切换RSA密钥对,不影响在线用户使用,符合生产环境密钥安全管理规范
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Configuration
@EnableScheduling
@Data
public class RsaKeyRotateConfig {
/**
* 当前生效公钥
*/
@Value("${jwt.rsa.public.key}")
private String currentPublicKeyStr;
/**
* 当前生效私钥
*/
@Value("${jwt.rsa.private.key}")
private String currentPrivateKeyStr;
/**
* 历史公钥列表(用于校验旧Token)
*/
private List<PublicKey> historyPublicKeyList = new ArrayList<>();
/**
* 初始化密钥
*/
@javax.annotation.PostConstruct
public void initRsaKey() {
// 加载当前公钥私钥
PublicKey currentPublicKey = RsaKeyGenerator.getRsaPublicKey(currentPublicKeyStr);
PrivateKey currentPrivateKey = RsaKeyGenerator.getRsaPrivateKey(currentPrivateKeyStr);
if (currentPublicKey == null || currentPrivateKey == null) {
throw new IllegalArgumentException("RSA当前密钥初始化失败");
}
// 初始化历史公钥列表(首次加载时,将当前公钥加入历史列表)
historyPublicKeyList.add(currentPublicKey);
log.info("RSA密钥初始化成功,当前公钥已加入历史校验列表");
}
/**
* 定时轮换密钥(每月1日凌晨1点执行,可根据业务调整周期)
* 步骤:1. 生成新密钥对 2. 新密钥作为生效密钥 3. 旧密钥加入历史列表 4. 过期清理历史密钥
*/
@Scheduled(cron = "0 0 1 1 * ?")
public void rotateRsaKey() {
try {
log.info("开始执行RSA密钥轮换操作");
// 1. 生成新的RSA密钥对
String newPublicKeyStr = generateNewRsaKeyPair();
String newPrivateKeyStr = getNewPrivateKeyStr(); // 实际项目中需从配置中心获取或重新生成
PublicKey newPublicKey = RsaKeyGenerator.getRsaPublicKey(newPublicKeyStr);
PrivateKey newPrivateKey = RsaKeyGenerator.getRsaPrivateKey(newPrivateKeyStr);
if (newPublicKey == null || newPrivateKey == null) {
log.error("生成新RSA密钥对失败,密钥轮换终止");
return;
}
// 2. 将当前公钥加入历史列表(用于校验旧Token)
PublicKey currentPublicKey = RsaKeyGenerator.getRsaPublicKey(currentPublicKeyStr);
if (currentPublicKey != null && !historyPublicKeyList.contains(currentPublicKey)) {
historyPublicKeyList.add(currentPublicKey);
log.info("旧RSA公钥已加入历史校验列表");
}
// 3. 更新当前生效密钥(配置中心自动推送,此处模拟更新)
this.currentPublicKeyStr = newPublicKeyStr;
this.currentPrivateKeyStr = newPrivateKeyStr;
log.info("RSA密钥轮换成功,新密钥已生效");
// 4. 清理过期历史密钥(保留最近3个版本,可根据业务调整)
if (historyPublicKeyList.size() > 3) {
List<PublicKey> removeList = historyPublicKeyList.subList(0, historyPublicKeyList.size() - 3);
historyPublicKeyList.removeAll(removeList);
log.info("清理过期RSA历史公钥,剩余历史密钥数量:{}", historyPublicKeyList.size());
}
} catch (Exception e) {
log.error("RSA密钥轮换失败,异常信息:", e);
}
}
/**
* 生成新的RSA公钥(模拟,实际项目中需调用配置中心API更新配置)
*/
private String generateNewRsaKeyPair() {
// 调用RSA密钥生成工具生成新密钥对
RsaKeyGenerator.generateRsaKeyPair();
// 此处返回新公钥字符串,实际项目中需从生成的文件中读取
return "新生成的Base64编码公钥字符串";
}
/**
* 获取新的RSA私钥(模拟,实际项目中需从配置中心API获取)
*/
private String getNewPrivateKeyStr() {
return "新生成的Base64编码私钥字符串";
}
/**
* 校验Token(支持当前公钥+历史公钥)
*
* @param token Token字符串
* @return true-有效,false-无效
*/
public boolean validateRsaToken(String token) {
// 先使用当前公钥校验
PublicKey currentPublicKey = RsaKeyGenerator.getRsaPublicKey(currentPublicKeyStr);
if (currentPublicKey != null && validateTokenWithPublicKey(currentPublicKey, token)) {
return true;
}
// 当前公钥校验失败,使用历史公钥校验
for (PublicKey historyPublicKey : historyPublicKeyList) {
if (validateTokenWithPublicKey(historyPublicKey, token)) {
return true;
}
}
return false;
}
/**
* 使用指定公钥校验Token
*
* @param publicKey 公钥
* @param token Token
* @return true-有效,false-无效
*/
private boolean validateTokenWithPublicKey(PublicKey publicKey, String token) {
try {
token = JwtBaseUtil.removeTokenPrefix(token);
Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) {
log.debug("使用公钥校验Token失败,异常信息:", e);
return false;
}
}
}
HTTPS 配置:application.yml 完整 SSL 配置代码
yaml
# Spring Boot HTTPS 完整配置
server:
# HTTPS 端口(生产环境默认443)
port: 443
ssl:
# 密钥库文件路径(支持绝对路径/相对路径,推荐放置在项目外部,避免打包部署时丢失)
key-store: classpath:ssl/server.jks
# 密钥库密码
key-store-password: 123456
# 密钥库类型(JKS/PKCS12,JKS是Java默认格式,PKCS12是通用格式)
key-store-type: JKS
# 密钥别名(生成证书时指定的别名)
key-alias: server
# 协议类型(默认TLS)
protocol: TLS
# 启用的SSL协议版本(禁用低版本协议,提升安全性)
enabled-protocols: TLSv1.2,TLSv1.3
# 密钥密码(若与密钥库密码一致,可省略)
key-password: 123456
# 强制跳转:将HTTP请求(80端口)重定向到HTTPS(443端口)
http:
port: 80
mapping: ${server.port}
二、场景化最优方案(核心落地章节,每个场景自带完整代码包,一站式囊括)
场景 1:小型项目 / 单机部署 / 内部工具
最优方案:纯 JWT + 短期有效 + 本地缓存
核心说明
- 适用场景:小型内部工具(如后台管理系统、数据统计工具)、单机部署项目、用户量小于1000的小型应用。
- 核心优势:无需依赖Redis等中间件,部署简单,开发成本低,满足小型项目基础认证需求。
- 注意事项 :
- Token过期时间建议设置为1-2小时,避免长期有效带来的安全风险。
- 本地缓存采用ConcurrentHashMap,仅适用于单机部署,集群部署会导致黑名单同步失败。
- 仅适用于内部系统,不推荐对外暴露的ToC/ToB产品使用。
完整代码包(直接复制即可运行)
1. 场景专属配置:application.yml 完整配置
yaml
# 应用配置
spring:
application:
name: jwt-small-project
# 日志配置
logging:
level:
com.example: INFO
org.springframework: WARN
# JWT 配置
jwt:
# 对称密钥(由JwtSecretGenerator.java生成,Base64编码)
secret:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Access Token 过期时间:1小时(秒)
access:
expire: 3600
# 本地缓存黑名单过期时间:与Token过期时间一致
blacklist:
expire: 3600
2. 场景专属工具类:JwtLocalCacheUtil.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小型项目JWT工具类(纯JWT+本地缓存)
* 封装Token生成、校验、黑名单管理逻辑,直接复用
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
public class JwtLocalCacheUtil {
/**
* JWT对称密钥
*/
@Value("${jwt.secret.key}")
private String jwtSecretKey;
/**
* Access Token过期时间(秒)
*/
@Value("${jwt.access.expire}")
private long accessTokenExpireSeconds;
/**
* 本地缓存:Token黑名单(key=Token,value=过期时间戳)
*/
private static final ConcurrentHashMap<String, Long> TOKEN_BLACKLIST = new ConcurrentHashMap<>();
/**
* 定时清理过期黑名单(每小时执行一次,可根据业务调整)
*/
@javax.annotation.PostConstruct
public void initBlacklistCleaner() {
// 启动一个后台线程,定时清理过期黑名单
new Thread(() -> {
while (true) {
try {
// 睡眠1小时
Thread.sleep(60 * 60 * 1000);
// 清理过期黑名单
long currentTime = System.currentTimeMillis();
TOKEN_BLACKLIST.entrySet().removeIf(entry -> entry.getValue() < currentTime);
log.info("本地缓存黑名单清理完成,剩余黑名单数量:{}", TOKEN_BLACKLIST.size());
} catch (InterruptedException e) {
log.error("本地缓存黑名单清理线程异常,异常信息:", e);
Thread.currentThread().interrupt();
}
}
}, "TokenBlacklistCleaner").start();
}
/**
* 生成登录Token
*
* @param userId 用户ID
* @param username 用户名
* @return TokenDTO
*/
public TokenDTO generateLoginToken(String userId, String username) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(username)) {
throw new IllegalArgumentException("用户ID和用户名不能为空");
}
// 构建自定义载荷
Map<String, Object> claims = new HashMap<>(4);
claims.put(JwtConstants.JWT_CLAIMS_USER_ID, userId);
claims.put(JwtConstants.JWT_CLAIMS_USERNAME, username);
// 获取HS512密钥
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
// 生成Access Token
String accessToken = JwtBaseUtil.generateHs512Token(secretKey, claims, accessTokenExpireSeconds);
// 封装返回结果
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setAccessToken(accessToken);
tokenDTO.setAccessTokenExpireSeconds(accessTokenExpireSeconds);
// 小型项目无需Refresh Token,此处可为空
tokenDTO.setRefreshToken(null);
tokenDTO.setRefreshTokenExpireSeconds(null);
return tokenDTO;
}
/**
* 校验Token有效性
*
* @param token JWT Token
* @return true-有效,false-无效
*/
public boolean validateToken(String token) {
// 1. 校验Token是否为空
if (!StringUtils.hasText(token)) {
log.warn("Token为空,校验失败");
return false;
}
// 2. 校验Token是否在黑名单中
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
if (TOKEN_BLACKLIST.containsKey(pureToken)) {
log.warn("Token已加入黑名单,校验失败");
return false;
}
// 3. 校验Token签名与过期时间
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
return JwtBaseUtil.validateHs512Token(secretKey, token);
}
/**
* 将Token加入黑名单(用户退出登录时调用)
*
* @param token JWT Token
*/
public void addTokenToBlacklist(String token) {
if (!StringUtils.hasText(token)) {
return;
}
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
// 获取Token过期时间戳
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Date expireDate = JwtBaseUtil.getTokenExpirationTime(secretKey, token);
long expireTime = expireDate == null ? System.currentTimeMillis() + accessTokenExpireSeconds * 1000 : expireDate.getTime();
// 加入黑名单
TOKEN_BLACKLIST.put(pureToken, expireTime);
log.info("Token已加入本地缓存黑名单,Token:{}", pureToken);
}
/**
* 解析Token获取用户ID
*
* @param token JWT Token
* @return 用户ID
*/
public String getUserIdFromToken(String token) {
if (!validateToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Claims claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USER_ID, String.class);
}
/**
* 解析Token获取用户名
*
* @param token JWT Token
* @return 用户名
*/
public String getUsernameFromToken(String token) {
if (!validateToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Claims claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USERNAME, String.class);
}
}
3. 场景专属控制器:AuthController.java 完整代码
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.dto.LoginDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtLocalCacheUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 小型项目认证控制器
* 封装登录、退出、鉴权接口,统一返回Result格式,符合大厂接口规范
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
/**
* 注入JWT本地缓存工具类
*/
private final JwtLocalCacheUtil jwtLocalCacheUtil;
/**
* 登录接口
*
* @param loginDTO 登录入参
* @return Result<TokenDTO>
*/
@PostMapping("/login")
public Result<TokenDTO> login(@Validated @RequestBody LoginDTO loginDTO) {
try {
// 1. 模拟用户校验(实际项目中需查询数据库/用户中心)
if (!"admin".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 生成Token(模拟用户ID:10001)
TokenDTO tokenDTO = jwtLocalCacheUtil.generateLoginToken("10001", loginDTO.getUsername());
// 3. 返回结果
return Result.success("登录成功", tokenDTO);
} catch (Exception e) {
log.error("登录失败,异常信息:", e);
return Result.fail("登录失败:" + e.getMessage());
}
}
/**
* 退出登录接口
*
* @param request HttpServletRequest
* @return Result<Void>
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
try {
// 1. 获取请求头中的Token
String token = request.getHeader(JwtConstants.ACCESS_TOKEN_HEADER);
// 2. 将Token加入黑名单
jwtLocalCacheUtil.addTokenToBlacklist(token);
// 3. 返回结果
return Result.success("退出登录成功");
} catch (Exception e) {
log.error("退出登录失败,异常信息:", e);
return Result.fail("退出登录失败:" + e.getMessage());
}
}
/**
* 鉴权测试接口(需要登录才能访问)
*
* @param request HttpServletRequest
* @return Result<String>
*/
@GetMapping("/test")
public Result<String> authTest(HttpServletRequest request) {
try {
// 1. 获取Token并校验
String token = request.getHeader(JwtConstants.ACCESS_TOKEN_HEADER);
if (!jwtLocalCacheUtil.validateToken(token)) {
return Result.fail(401, "Token无效或已过期,请重新登录");
}
// 2. 解析用户信息
String userId = jwtLocalCacheUtil.getUserIdFromToken(token);
String username = jwtLocalCacheUtil.getUsernameFromToken(token);
// 3. 返回结果
String msg = String.format("鉴权成功!用户ID:%s,用户名:%s", userId, username);
return Result.success(msg);
} catch (Exception e) {
log.error("鉴权测试失败,异常信息:", e);
return Result.fail("鉴权测试失败:" + e.getMessage());
}
}
}
4. 测试验证:接口调用示例 + 本地运行验证步骤
-
本地运行步骤
- 步骤1:将上述代码复制到Spring Boot项目中,调整包名与项目一致。
- 步骤2:运行JwtSecretGenerator.java的main方法,生成HS512密钥,配置到application.yml中。
- 步骤3:启动Spring Boot应用,端口默认443(HTTPS),若需调试可改为8080并关闭HTTPS配置。
- 步骤4:使用Postman等工具调用接口,进行测试。
-
接口调用示例
接口地址 请求方法 请求头 请求体 预期结果 /api/auth/login POST Content-Type: JSON {"username":"admin","password":"123456","deviceId":"dev001","deviceName":"Windows"} 返回200,包含accessToken /api/auth/test GET Authorization: Bearer {accessToken} 无 返回200,显示用户ID和用户名 /api/auth/logout POST Authorization: Bearer {accessToken} 无 返回200,提示退出成功,Token加入黑名单 /api/auth/test GET Authorization: Bearer {accessToken} 无 返回401,提示Token无效(已在黑名单中)
场景 2:中小型业务系统 / ToB 产品
最优方案:JWT + Redis(版本号) + HttpOnly Cookie
核心说明
- 适用场景:中小型ToB产品(如企业管理系统、SaaS平台)、用户量1000-10万的业务系统、集群部署但并发量不高的项目。
- 核心优势 :
- 依赖Redis实现Token版本号控制,支持集群部署,解决单机本地缓存同步问题。
- 使用HttpOnly Cookie存储Token,防止XSS攻击,提升安全性。
- 支持Token刷新,减少用户频繁登录,提升用户体验。
- 注意事项 :
- Redis需保证高可用(单机/主从),避免Redis宕机导致认证服务不可用。
- Token版本号与用户绑定,用户修改密码/退出登录时,更新版本号即可失效所有旧Token。
- 生产环境需开启Redis持久化,避免版本号数据丢失。
完整代码包(直接复制即可运行,依赖通用核心模块)
1. 场景专属配置:application.yml 完整配置
yaml
# 应用配置
spring:
application:
name: jwt-medium-project
# Redis 配置(支撑Token版本号存储)
data:
redis:
# Redis 主机地址
host: 127.0.0.1
# Redis 端口
port: 6379
# Redis 密码(无密码则注释)
password: 123456
# Redis 数据库索引
database: 0
# 连接池配置
lettuce:
pool:
# 最大活跃连接数
max-active: 8
# 最大空闲连接数
max-idle: 8
# 最小空闲连接数
min-idle: 2
# 最大等待时间(毫秒)
max-wait: -1ms
# 连接超时时间
timeout: 3000ms
# 日志配置
logging:
level:
com.example: INFO
org.springframework: WARN
redis.clients: WARN
# JWT 配置
jwt:
# 对称密钥(由JwtSecretGenerator.java生成,Base64编码)
secret:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Access Token 过期时间:2小时(秒)
access:
expire: 7200
# Refresh Token 过期时间:7天(秒)
refresh:
expire: 604800
# Cookie 配置(生产环境需指定域名)
cookie:
domain: localhost
path: /
secure: false # 开发环境false,生产环境true(仅HTTPS)
same-site: Lax
2. 场景专属工具类:JwtRedisUtil.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.utils.CookieUtil;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 中小型业务系统JWT工具类(JWT+Redis版本号+HttpOnly Cookie)
* 封装Token生成、校验、版本号管理、Cookie操作逻辑,直接复用
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtRedisUtil {
/**
* 注入Redis模板
*/
private final RedisTemplate<String, Object> redisTemplate;
/**
* JWT对称密钥
*/
@Value("${jwt.secret.key}")
private String jwtSecretKey;
/**
* Access Token过期时间(秒)
*/
@Value("${jwt.access.expire}")
private long accessTokenExpireSeconds;
/**
* Refresh Token过期时间(秒)
*/
@Value("${jwt.refresh.expire}")
private long refreshTokenExpireSeconds;
/**
* Cookie域名
*/
@Value("${jwt.cookie.domain}")
private String cookieDomain;
/**
* Cookie路径
*/
@Value("${jwt.cookie.path}")
private String cookiePath;
/**
* Cookie是否仅HTTPS传输
*/
@Value("${jwt.cookie.secure}")
private boolean cookieSecure;
/**
* Cookie SameSite属性
*/
@Value("${jwt.cookie.same-site}")
private String cookieSameSite;
/**
* 生成用户Token版本号(首次登录/密码修改/退出全部登录时生成)
*
* @param userId 用户ID
* @return 版本号
*/
private String generateTokenVersion(String userId) {
String version = UUID.randomUUID().toString().replace("-", "");
// 存储到Redis,过期时间与Refresh Token一致
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.opsForValue().set(redisKey, version, refreshTokenExpireSeconds, TimeUnit.SECONDS);
log.info("用户{}生成新Token版本号:{},已存储到Redis", userId, version);
return version;
}
/**
* 获取用户当前Token版本号
*
* @param userId 用户ID
* @return 版本号(null表示未登录/版本号过期)
*/
private String getCurrentTokenVersion(String userId) {
if (!StringUtils.hasText(userId)) {
return null;
}
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
Object versionObj = redisTemplate.opsForValue().get(redisKey);
return versionObj == null ? null : versionObj.toString();
}
/**
* 生成登录Token(含Access Token+Refresh Token,并存入Cookie)
*
* @param response HttpServletResponse
* @param userId 用户ID
* @param username 用户名
* @return TokenDTO
*/
public TokenDTO generateLoginToken(HttpServletResponse response, String userId, String username) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(username)) {
throw new IllegalArgumentException("用户ID和用户名不能为空");
}
// 1. 生成/更新用户Token版本号
String version = generateTokenVersion(userId);
// 2. 构建自定义载荷(包含用户信息+版本号)
Map<String, Object> claims = new HashMap<>(5);
claims.put(JwtConstants.JWT_CLAIMS_USER_ID, userId);
claims.put(JwtConstants.JWT_CLAIMS_USERNAME, username);
claims.put(JwtConstants.JWT_CLAIMS_VERSION, version);
// 3. 获取HS512密钥
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
// 4. 生成Access Token和Refresh Token
String accessToken = JwtBaseUtil.generateHs512Token(secretKey, claims, accessTokenExpireSeconds);
String refreshToken = JwtBaseUtil.generateHs512Token(secretKey, claims, refreshTokenExpireSeconds);
// 5. 将Token写入HttpOnly Cookie
this.setTokenToCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, accessToken, accessTokenExpireSeconds);
this.setTokenToCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, refreshToken, refreshTokenExpireSeconds);
// 6. 封装返回结果
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setAccessToken(accessToken);
tokenDTO.setAccessTokenExpireSeconds(accessTokenExpireSeconds);
tokenDTO.setRefreshToken(refreshToken);
tokenDTO.setRefreshTokenExpireSeconds(refreshTokenExpireSeconds);
log.info("用户{}登录成功,已生成Token并写入Cookie", userId);
return tokenDTO;
}
/**
* 将Token写入HttpOnly Cookie
*
* @param response HttpServletResponse
* @param cookieName Cookie名称
* @param token Token值
* @param expireSeconds 过期时间(秒)
*/
private void setTokenToCookie(HttpServletResponse response, String cookieName, String token, long expireSeconds) {
CookieUtil.setCookie(
response,
cookieName,
token,
cookieDomain,
cookiePath,
(int) expireSeconds,
true, // HttpOnly=true,防XSS
cookieSecure,
cookieSameSite
);
log.info("Token已写入Cookie,Cookie名称:{}", cookieName);
}
/**
* 从Request中获取Token(优先从Header获取,其次从Cookie获取)
*
* @param request HttpServletRequest
* @param tokenType Token类型(access/refresh)
* @return Token值
*/
public String getTokenFromRequest(HttpServletRequest request, String tokenType) {
// 1. 优先从Header获取
String headerName = JwtConstants.ACCESS_TOKEN_HEADER;
if ("refresh".equals(tokenType)) {
headerName = JwtConstants.REFRESH_TOKEN_HEADER;
}
String token = request.getHeader(headerName);
if (StringUtils.hasText(token)) {
return token;
}
// 2. 从Cookie获取
String cookieName = JwtConstants.ACCESS_TOKEN_COOKIE_NAME;
if ("refresh".equals(tokenType)) {
cookieName = JwtConstants.REFRESH_TOKEN_COOKIE_NAME;
}
token = CookieUtil.getCookieValue(request, cookieName);
if (StringUtils.hasText(token)) {
return JwtConstants.TOKEN_PREFIX + token; // 补充前缀,统一校验逻辑
}
return null;
}
/**
* 校验Access Token有效性(含签名校验+过期校验+版本号校验)
*
* @param token Access Token
* @return true-有效,false-无效
*/
public boolean validateAccessToken(String token) {
// 1. 基础校验(空值+黑名单+签名+过期)
if (!this.baseTokenValidate(token)) {
return false;
}
// 2. 解析载荷,获取用户ID和版本号
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
if (claims == null) {
log.warn("Token载荷解析失败,校验无效");
return false;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String tokenVersion = claims.get(JwtConstants.JWT_CLAIMS_VERSION).toString();
// 3. 校验版本号是否一致(不一致表示Token已失效)
String currentVersion = this.getCurrentTokenVersion(userId);
if (!StringUtils.hasText(currentVersion) || !currentVersion.equals(tokenVersion)) {
log.warn("用户{}的Token版本号不匹配,当前版本:{},Token版本:{},校验无效",
userId, currentVersion, tokenVersion);
return false;
}
return true;
}
/**
* 校验Refresh Token有效性(仅做基础校验,不校验版本号,用于刷新Token)
*
* @param token Refresh Token
* @return true-有效,false-无效
*/
public boolean validateRefreshToken(String token) {
return this.baseTokenValidate(token);
}
/**
* Token基础校验(空值+签名+过期)
*
* @param token Token值
* @return true-有效,false-无效
*/
private boolean baseTokenValidate(String token) {
// 1. 校验Token是否为空
if (!StringUtils.hasText(token)) {
log.warn("Token为空,基础校验失败");
return false;
}
// 2. 校验Token签名与过期时间
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
boolean validateResult = JwtBaseUtil.validateHs512Token(secretKey, token);
if (!validateResult) {
log.warn("Token签名无效或已过期,基础校验失败");
}
return validateResult;
}
/**
* 刷新Token(使用有效的Refresh Token生成新的Access Token和Refresh Token)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return TokenDTO
*/
public TokenDTO refreshToken(HttpServletRequest request, HttpServletResponse response) {
// 1. 获取Refresh Token
String refreshToken = this.getTokenFromRequest(request, "refresh");
if (!this.validateRefreshToken(refreshToken)) {
log.warn("Refresh Token无效,刷新Token失败");
return null;
}
// 2. 解析Refresh Token获取用户信息
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, refreshToken);
if (claims == null) {
log.warn("Refresh Token载荷解析失败,刷新Token失败");
return null;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String username = claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
// 3. 生成新Token(自动更新版本号,写入Cookie)
return this.generateLoginToken(response, userId, username);
}
/**
* 退出登录(清除Token Cookie + 失效当前版本号)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return true-退出成功,false-退出失败
*/
public boolean logout(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 获取Access Token,解析用户ID
String accessToken = this.getTokenFromRequest(request, "access");
if (!this.validateAccessToken(accessToken)) {
log.warn("Access Token无效,无需退出登录");
// 直接清除Cookie
this.clearTokenCookie(response);
return true;
}
// 2. 解析用户ID
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, accessToken);
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
// 3. 删除Redis中的版本号(失效所有旧Token)
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.delete(redisKey);
log.info("用户{}的Token版本号已从Redis删除", userId);
// 4. 清除Token Cookie
this.clearTokenCookie(response);
return true;
} catch (Exception e) {
log.error("退出登录失败,异常信息:", e);
return false;
}
}
/**
* 清除Token Cookie(设置过期时间为0)
*
* @param response HttpServletResponse
*/
private void clearTokenCookie(HttpServletResponse response) {
// 清除Access Token Cookie
CookieUtil.removeCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
// 清除Refresh Token Cookie
CookieUtil.removeCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
log.info("Token Cookie已清除");
}
/**
* 从Token中获取用户ID
*
* @param token Access Token
* @return 用户ID
*/
public String getUserIdFromToken(String token) {
if (!this.validateAccessToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
}
/**
* 从Token中获取用户名
*
* @param token Access Token
* @return 用户名
*/
public String getUsernameFromToken(String token) {
if (!this.validateAccessToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
}
}
3. 场景专属控制器:AuthController.java 完整代码
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.LoginDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtRedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 中小型业务系统认证控制器
* 封装登录、退出、刷新Token、鉴权接口,统一返回Result格式,支持Redis版本号+HttpOnly Cookie
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
/**
* 注入JWT Redis工具类
*/
private final JwtRedisUtil jwtRedisUtil;
/**
* 登录接口
*
* @param response HttpServletResponse
* @param loginDTO 登录入参
* @return Result<TokenDTO>
*/
@PostMapping("/login")
public Result<TokenDTO> login(HttpServletResponse response, @Validated @RequestBody LoginDTO loginDTO) {
try {
// 1. 模拟用户校验(实际项目中需查询数据库/用户中心,支持密码加密校验)
if (!"admin".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 生成Token并写入Cookie(模拟用户ID:10001)
TokenDTO tokenDTO = jwtRedisUtil.generateLoginToken(response, "10001", loginDTO.getUsername());
// 3. 返回结果(前端可按需存储Token,优先使用Cookie)
return Result.success("登录成功", tokenDTO);
} catch (Exception e) {
log.error("登录失败,异常信息:", e);
return Result.fail("登录失败:" + e.getMessage());
}
}
/**
* 刷新Token接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<TokenDTO>
*/
@PostMapping("/refresh-token")
public Result<TokenDTO> refreshToken(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 刷新Token
TokenDTO tokenDTO = jwtRedisUtil.refreshToken(request, response);
if (tokenDTO == null) {
return Result.fail(401, "Refresh Token无效或已过期,请重新登录");
}
// 2. 返回结果
return Result.success("Token刷新成功", tokenDTO);
} catch (Exception e) {
log.error("刷新Token失败,异常信息:", e);
return Result.fail("刷新Token失败:" + e.getMessage());
}
}
/**
* 退出登录接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<Void>
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 执行退出登录
boolean logoutResult = jwtRedisUtil.logout(request, response);
if (!logoutResult) {
return Result.fail("退出登录失败");
}
// 2. 返回结果
return Result.success("退出登录成功");
} catch (Exception e) {
log.error("退出登录失败,异常信息:", e);
return Result.fail("退出登录失败:" + e.getMessage());
}
}
/**
* 鉴权测试接口(需要有效Token才能访问)
*
* @param request HttpServletRequest
* @return Result<String>
*/
@GetMapping("/test")
public Result<String> authTest(HttpServletRequest request) {
try {
// 1. 获取Access Token
String token = jwtRedisUtil.getTokenFromRequest(request, "access");
if (token == null || !jwtRedisUtil.validateAccessToken(token)) {
return Result.fail(401, "Token无效或已过期,请重新登录");
}
// 2. 解析用户信息
String userId = jwtRedisUtil.getUserIdFromToken(token);
String username = jwtRedisUtil.getUsernameFromToken(token);
// 3. 返回鉴权结果
String msg = String.format("鉴权成功!用户ID:%s,用户名:%s", userId, username);
return Result.success(msg);
} catch (Exception e) {
log.error("鉴权测试失败,异常信息:", e);
return Result.fail("鉴权测试失败:" + e.getMessage());
}
}
/**
* 退出全部设备登录接口(ToB产品常用,失效用户所有Token)
*
* @param userId 用户ID(实际项目中需从Token解析,此处简化为入参)
* @return Result<Void>
*/
@PostMapping("/logout-all/{userId}")
public Result<Void> logoutAll(@PathVariable String userId) {
try {
// 1. 直接删除Redis中的版本号,失效所有Token
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
jwtRedisUtil.getRedisTemplate().delete(redisKey);
log.info("用户{}已退出全部设备登录,所有Token已失效", userId);
// 2. 返回结果
return Result.success("退出全部设备登录成功");
} catch (Exception e) {
log.error("退出全部设备登录失败,异常信息:", e);
return Result.fail("退出全部设备登录失败:" + e.getMessage());
}
}
}
4. 测试验证:接口调用示例 + 功能验证步骤
-
本地运行前置条件
- 步骤1:确保本地Redis已启动(默认地址127.0.0.1:6379,密码配置与application.yml一致)。
- 步骤2:将通用核心模块代码+场景2代码复制到Spring Boot项目,调整包名一致。
- 步骤3:运行JwtSecretGenerator.java生成HS512密钥,配置到application.yml的
jwt.secret.key中。 - 步骤4:启动Spring Boot应用(默认端口8080,可按需调整)。
-
接口调用示例(Postman)
接口地址 请求方法 请求头/入参 请求体 预期结果 /api/auth/login POST Content-Type: application/json {"username":"admin","password":"123456","deviceId":"dev002","deviceName":"MacOS"} 返回200,含Token信息,Response Set-Cookie携带2个Token Cookie /api/auth/test GET 自动携带Cookie(或手动添加Authorization Header) 无 返回200,显示用户ID和用户名 /api/auth/refresh-token POST 自动携带Refresh Token Cookie 无 返回200,刷新Token成功,Cookie更新 /api/auth/logout POST 自动携带Token Cookie 无 返回200,Cookie已清除,Redis版本号删除 /api/auth/test GET 旧Token Cookie/Header 无 返回401,Token无效(版本号已失效) /api/auth/logout-all/10001 POST 路径参数userId=10001 无 返回200,Redis中用户10001的版本号被删除 -
核心功能验证要点
- 验证1:登录后,Redis中生成
jwt:user:version:10001键值对,Cookie中存在access_token和refresh_token。 - 验证2:刷新Token接口调用后,新的Token写入Cookie,Redis版本号更新。
- 验证3:退出登录后,Redis版本号被删除,旧Token无法通过鉴权。
- 验证4:调用
logout-all接口后,用户所有设备的旧Token均失效,需重新登录。 - 验证5:关闭浏览器后重新打开,Cookie未失效(未到过期时间),仍可正常访问
/api/auth/test接口。
- 验证1:登录后,Redis中生成
场景3:中大型ToC产品
最优方案:JWT + 双Token + Redis + 无感刷新
核心说明
- 适用场景:中大型ToC产品(如电商App、社交平台、短视频平台)、用户量10万+、多端(Web/APP/小程序)登录、对用户体验要求极高的业务系统。
- 核心优势
- 无感刷新:Access Token过期前,前端静默调用刷新接口获取新Token,用户无感知,避免频繁登录影响体验。
- 双Token分离:Access Token(短期有效,1-2小时)用于接口鉴权,Refresh Token(长期有效,7-30天)用于刷新Token,降低Token泄露风险。
- 集群兼容:基于Redis存储Token信息,支持分布式部署,解决多节点数据同步问题。
- 灵活管控:支持单设备退出、全部设备退出,满足ToC产品用户账号安全管控需求。
- 无感刷新逻辑详解
- 前端存储:登录成功后,前端存储Access Token、Refresh Token及Access Token过期时间戳。
- 前置判断:每次发起接口请求前,判断Access Token是否即将过期(如剩余时间小于5分钟)。
- 静默刷新:若即将过期,前端先静默调用刷新Token接口,获取新的双Token后更新本地存储,再发起原业务请求。
- 异常兜底:若Access Token已过期且Refresh Token有效,后端拦截401请求,前端先刷新Token再重试原请求;若Refresh Token过期,引导用户重新登录。
完整代码包(直接复制即可运行)
1. 场景专属配置:application.yml 完整配置
yaml
# 应用配置
spring:
application:
name: jwt-large-toc-project
# Redis 配置(支撑Token版本号、黑名单存储)
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
database: 1
lettuce:
pool:
max-active: 16
max-idle: 16
min-idle: 4
max-wait: -1ms
timeout: 3000ms
logging:
level:
com.example: INFO
org.springframework: WARN
redis.clients: WARN
# JWT 配置
jwt:
secret:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # JwtSecretGenerator生成
# Access Token 过期时间:1小时(3600秒)- ToC产品推荐1-2小时
access:
expire: 3600
# Refresh Token 过期时间:7天(604800秒)- ToC产品推荐7-30天
refresh:
expire: 604800
# 无感刷新提前触发时间:5分钟(300秒)- 前端可基于此配置判断
refresh:
advance: 300
# Cookie 配置
cookie:
domain: localhost
path: /
secure: false # 生产环境设为true
same-site: Lax
2. 场景专属工具类:JwtRefreshUtil.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.utils.CookieUtil;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 中大型ToC产品JWT工具类(JWT+双Token+Redis+无感刷新)
* 封装双Token生成、校验、无感刷新、Cookie操作逻辑
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtRefreshUtil {
private final RedisTemplate<String, Object> redisTemplate;
// JWT 对称密钥
@Value("${jwt.secret.key}")
private String jwtSecretKey;
// Access Token 过期时间(秒)
@Value("${jwt.access.expire}")
private long accessTokenExpireSeconds;
// Refresh Token 过期时间(秒)
@Value("${jwt.refresh.expire}")
private long refreshTokenExpireSeconds;
// 无感刷新提前触发时间(秒)
@Value("${jwt.refresh.advance}")
private long refreshAdvanceSeconds;
// Cookie 配置
@Value("${jwt.cookie.domain}")
private String cookieDomain;
@Value("${jwt.cookie.path}")
private String cookiePath;
@Value("${jwt.cookie.secure}")
private boolean cookieSecure;
@Value("${jwt.cookie.same-site}")
private String cookieSameSite;
/**
* 生成用户Token版本号(首次登录/密码修改/退出全部登录时更新)
*
* @param userId 用户ID
* @return 版本号
*/
private String generateTokenVersion(String userId) {
String version = UUID.randomUUID().toString().replace("-", "");
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
// 版本号过期时间与Refresh Token一致
redisTemplate.opsForValue().set(redisKey, version, refreshTokenExpireSeconds, TimeUnit.SECONDS);
log.info("用户{}生成新Token版本号:{}", userId, version);
return version;
}
/**
* 获取用户当前Token版本号
*
* @param userId 用户ID
* @return 版本号(null表示未登录/版本过期)
*/
private String getCurrentTokenVersion(String userId) {
if (!StringUtils.hasText(userId)) {
return null;
}
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
Object versionObj = redisTemplate.opsForValue().get(redisKey);
return versionObj == null ? null : versionObj.toString();
}
/**
* 生成登录双Token(Access Token + Refresh Token)
*
* @param response HttpServletResponse
* @param userId 用户ID
* @param username 用户名
* @return TokenDTO
*/
public TokenDTO generateLoginToken(HttpServletResponse response, String userId, String username) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(username)) {
throw new IllegalArgumentException("用户ID和用户名不能为空");
}
// 1. 生成/更新Token版本号
String version = generateTokenVersion(userId);
// 2. 构建JWT载荷
Map<String, Object> claims = new HashMap<>(5);
claims.put(JwtConstants.JWT_CLAIMS_USER_ID, userId);
claims.put(JwtConstants.JWT_CLAIMS_USERNAME, username);
claims.put(JwtConstants.JWT_CLAIMS_VERSION, version);
// 3. 获取HS512密钥
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
// 4. 生成双Token
String accessToken = JwtBaseUtil.generateHs512Token(secretKey, claims, accessTokenExpireSeconds);
String refreshToken = JwtBaseUtil.generateHs512Token(secretKey, claims, refreshTokenExpireSeconds);
// 5. 写入HttpOnly Cookie
setTokenToCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, accessToken, accessTokenExpireSeconds);
setTokenToCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, refreshToken, refreshTokenExpireSeconds);
// 6. 封装返回结果(含过期时间,供前端判断刷新时机)
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setAccessToken(accessToken);
tokenDTO.setAccessTokenExpireSeconds(accessTokenExpireSeconds);
tokenDTO.setRefreshToken(refreshToken);
tokenDTO.setRefreshTokenExpireSeconds(refreshTokenExpireSeconds);
log.info("用户{}双Token生成成功并写入Cookie", userId);
return tokenDTO;
}
/**
* 将Token写入HttpOnly Cookie
*
* @param response HttpServletResponse
* @param cookieName Cookie名称
* @param token Token值
* @param expireSeconds 过期时间(秒)
*/
private void setTokenToCookie(HttpServletResponse response, String cookieName, String token, long expireSeconds) {
CookieUtil.setCookie(
response,
cookieName,
token,
cookieDomain,
cookiePath,
(int) expireSeconds,
true, // HttpOnly 防XSS
cookieSecure,
cookieSameSite
);
}
/**
* 从Request中获取Token(优先Header,其次Cookie)
*
* @param request HttpServletRequest
* @param tokenType Token类型(access/refresh)
* @return Token值
*/
public String getTokenFromRequest(HttpServletRequest request, String tokenType) {
// 1. 优先从Header获取
String headerName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_HEADER : JwtConstants.REFRESH_TOKEN_HEADER;
String token = request.getHeader(headerName);
if (StringUtils.hasText(token)) {
return token;
}
// 2. 从Cookie获取
String cookieName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_COOKIE_NAME : JwtConstants.REFRESH_TOKEN_COOKIE_NAME;
token = CookieUtil.getCookieValue(request, cookieName);
return StringUtils.hasText(token) ? JwtConstants.TOKEN_PREFIX + token : null;
}
/**
* 校验Access Token有效性(签名+过期+版本号)
*
* @param token Access Token
* @return true-有效,false-无效
*/
public boolean validateAccessToken(String token) {
// 1. 基础校验(空值+签名+过期)
if (!baseTokenValidate(token)) {
return false;
}
// 2. 解析载荷获取用户ID和版本号
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
if (claims == null) {
return false;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String tokenVersion = claims.get(JwtConstants.JWT_CLAIMS_VERSION).toString();
// 3. 校验版本号一致性
String currentVersion = getCurrentTokenVersion(userId);
if (!StringUtils.hasText(currentVersion) || !currentVersion.equals(tokenVersion)) {
log.warn("用户{}Token版本号不匹配,当前版本:{},Token版本:{}", userId, currentVersion, tokenVersion);
return false;
}
return true;
}
/**
* 校验Refresh Token有效性(仅签名+过期,不校验版本号,用于刷新Token)
*
* @param token Refresh Token
* @return true-有效,false-无效
*/
public boolean validateRefreshToken(String token) {
return baseTokenValidate(token);
}
/**
* Token基础校验(空值+签名+过期)
*
* @param token Token值
* @return true-有效,false-无效
*/
private boolean baseTokenValidate(String token) {
if (!StringUtils.hasText(token)) {
log.warn("Token为空,基础校验失败");
return false;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
return JwtBaseUtil.validateHs512Token(secretKey, token);
}
/**
* 无感刷新Token(核心方法)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return 新的TokenDTO(null表示Refresh Token无效)
*/
public TokenDTO refreshToken(HttpServletRequest request, HttpServletResponse response) {
// 1. 获取Refresh Token
String refreshToken = getTokenFromRequest(request, "refresh");
if (!validateRefreshToken(refreshToken)) {
log.warn("Refresh Token无效,刷新失败");
return null;
}
// 2. 解析Refresh Token获取用户信息
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, refreshToken);
if (claims == null) {
log.warn("Refresh Token载荷解析失败,刷新失败");
return null;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String username = claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
// 3. 生成新双Token(自动更新版本号+Cookie)
return generateLoginToken(response, userId, username);
}
/**
* 判断Access Token是否需要无感刷新(剩余时间小于提前触发时间)
*
* @param token Access Token
* @return true-需要刷新,false-无需刷新
*/
public boolean needRefreshAccessToken(String token) {
if (!StringUtils.hasText(token)) {
return false;
}
// 1. 校验Token有效性
if (!validateAccessToken(token)) {
return false;
}
// 2. 获取Token过期时间
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
long expireTime = JwtBaseUtil.getTokenExpirationTime(secretKey, token).getTime();
// 3. 计算剩余时间
long remainingTime = (expireTime - System.currentTimeMillis()) / 1000;
// 4. 判断是否需要提前刷新
return remainingTime < refreshAdvanceSeconds;
}
/**
* 退出登录(清除Cookie + 删除Redis版本号)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return true-成功,false-失败
*/
public boolean logout(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 获取并校验Access Token
String accessToken = getTokenFromRequest(request, "access");
if (StringUtils.hasText(accessToken) && validateAccessToken(accessToken)) {
// 2. 解析用户ID
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, accessToken);
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
// 3. 删除Redis版本号
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.delete(redisKey);
log.info("用户{}Redis Token版本号已删除", userId);
}
// 4. 清除Token Cookie
clearTokenCookie(response);
return true;
} catch (Exception e) {
log.error("退出登录失败", e);
return false;
}
}
/**
* 清除Token Cookie
*
* @param response HttpServletResponse
*/
private void clearTokenCookie(HttpServletResponse response) {
CookieUtil.removeCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
CookieUtil.removeCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
log.info("Token Cookie已清除");
}
/**
* 从Token中获取用户ID
*
* @param token Access Token
* @return 用户ID
*/
public String getUserIdFromToken(String token) {
if (!validateAccessToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
}
/**
* 从Token中获取用户名
*
* @param token Access Token
* @return 用户名
*/
public String getUsernameFromToken(String token) {
if (!validateAccessToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
}
}
3. 场景专属控制器:AuthController.java 完整代码
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.dto.LoginDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtRefreshUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 中大型ToC产品认证控制器
* 封装登录、退出、无感刷新、鉴权接口
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtRefreshUtil jwtRefreshUtil;
/**
* 登录接口
*
* @param response HttpServletResponse
* @param loginDTO 登录入参
* @return Result<TokenDTO>
*/
@PostMapping("/login")
public Result<TokenDTO> login(HttpServletResponse response, @Validated @RequestBody LoginDTO loginDTO) {
try {
// 1. 模拟用户校验(实际项目需查询数据库,支持BCrypt密码加密)
if (!"user001".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 生成双Token(模拟用户ID:20001)
TokenDTO tokenDTO = jwtRefreshUtil.generateLoginToken(response, "20001", loginDTO.getUsername());
return Result.success("登录成功", tokenDTO);
} catch (Exception e) {
log.error("登录失败", e);
return Result.fail("登录失败:" + e.getMessage());
}
}
/**
* 无感刷新Token接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<TokenDTO>
*/
@PostMapping("/refresh-token")
public Result<TokenDTO> refreshToken(HttpServletRequest request, HttpServletResponse response) {
try {
TokenDTO tokenDTO = jwtRefreshUtil.refreshToken(request, response);
if (tokenDTO == null) {
return Result.fail(401, "Refresh Token无效,请重新登录");
}
return Result.success("Token刷新成功", tokenDTO);
} catch (Exception e) {
log.error("Token刷新失败", e);
return Result.fail("Token刷新失败:" + e.getMessage());
}
}
/**
* 退出登录接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<Void>
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
try {
boolean result = jwtRefreshUtil.logout(request, response);
if (!result) {
return Result.fail("退出登录失败");
}
return Result.success("退出登录成功");
} catch (Exception e) {
log.error("退出登录失败", e);
return Result.fail("退出登录失败:" + e.getMessage());
}
}
/**
* 鉴权测试接口(业务接口示例)
*
* @param request HttpServletRequest
* @return Result<String>
*/
@GetMapping("/test")
public Result<String> authTest(HttpServletRequest request) {
try {
// 1. 获取Access Token
String token = jwtRefreshUtil.getTokenFromRequest(request, "access");
if (token == null || !jwtRefreshUtil.validateAccessToken(token)) {
return Result.fail(401, "Token无效或已过期,请重新登录");
}
// 2. 判断是否需要无感刷新(返回标识给前端)
boolean needRefresh = jwtRefreshUtil.needRefreshAccessToken(token);
// 3. 解析用户信息
String userId = jwtRefreshUtil.getUserIdFromToken(token);
String username = jwtRefreshUtil.getUsernameFromToken(token);
// 4. 封装结果
String msg = String.format("鉴权成功!用户ID:%s,用户名:%s,是否需要刷新Token:%s",
userId, username, needRefresh ? "是" : "否");
return Result.success(msg);
} catch (Exception e) {
log.error("鉴权测试失败", e);
return Result.fail("鉴权测试失败:" + e.getMessage());
}
}
/**
* 退出全部设备登录接口(ToC产品常用)
*
* @param userId 用户ID(实际项目从Token解析,此处简化为路径参数)
* @return Result<Void>
*/
@PostMapping("/logout-all/{userId}")
public Result<Void> logoutAll(@PathVariable String userId) {
try {
// 删除Redis中用户版本号,失效所有设备Token
String redisKey = "jwt:user:version:" + userId;
jwtRefreshUtil.getRedisTemplate().delete(redisKey);
log.info("用户{}已退出全部设备登录", userId);
return Result.success("退出全部设备登录成功");
} catch (Exception e) {
log.error("退出全部设备登录失败", e);
return Result.fail("退出全部设备登录失败:" + e.getMessage());
}
}
}
4. 前端适配:无感刷新前端简易代码示例(Vue 3 + Axios)
javascript
import axios from 'axios';
import { ElMessage } from 'element-plus';
// 基础配置
const service = axios.create({
baseURL: 'http://localhost:8080',
timeout: 5000
});
// 存储Token相关信息(本地存储 + Cookie自动携带)
const TOKEN_KEY = {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
ACCESS_EXPIRE: 'access_expire_time'
};
// 设置请求头Token
service.interceptors.request.use(
(config) => {
// 从本地存储获取Access Token
const accessToken = localStorage.getItem(TOKEN_KEY.ACCESS_TOKEN);
if (accessToken) {
config.headers['Authorization'] = 'Bearer ' + accessToken;
}
// 前置判断:是否需要无感刷新Token
checkAndRefreshToken();
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:处理401异常
service.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 排除重复请求
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 刷新Token
const refreshToken = localStorage.getItem(TOKEN_KEY.REFRESH_TOKEN);
const res = await axios.post('http://localhost:8080/api/auth/refresh-token', {}, {
headers: {
'Refresh-Token': 'Bearer ' + refreshToken
},
withCredentials: true // 携带Cookie
});
// 更新本地Token
const { accessToken, refreshToken: newRefreshToken, accessTokenExpireSeconds } = res.data.data;
localStorage.setItem(TOKEN_KEY.ACCESS_TOKEN, accessToken);
localStorage.setItem(TOKEN_KEY.REFRESH_TOKEN, newRefreshToken);
// 存储过期时间戳
const expireTime = Date.now() + accessTokenExpireSeconds * 1000;
localStorage.setItem(TOKEN_KEY.ACCESS_EXPIRE, expireTime.toString());
// 重新设置请求头并重试原请求
originalRequest.headers['Authorization'] = 'Bearer ' + accessToken;
return service(originalRequest);
} catch (err) {
// Refresh Token过期,引导用户登录
ElMessage.error('登录状态已过期,请重新登录');
// 清除本地存储
localStorage.removeItem(TOKEN_KEY.ACCESS_TOKEN);
localStorage.removeItem(TOKEN_KEY.REFRESH_TOKEN);
localStorage.removeItem(TOKEN_KEY.ACCESS_EXPIRE);
// 跳转到登录页
window.location.href = '/login';
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
/**
* 检查并无感刷新Token
*/
async function checkAndRefreshToken() {
const accessToken = localStorage.getItem(TOKEN_KEY.ACCESS_TOKEN);
const accessExpireTime = localStorage.getItem(TOKEN_KEY.ACCESS_EXPIRE);
if (!accessToken || !accessExpireTime) {
return;
}
// 计算剩余时间
const now = Date.now();
const expireTime = parseInt(accessExpireTime);
const remainingTime = (expireTime - now) / 1000;
// 提前刷新时间(与后端配置一致:5分钟=300秒)
const refreshAdvance = 300;
// 判断是否需要刷新
if (remainingTime < refreshAdvance && remainingTime > 0) {
try {
const refreshToken = localStorage.getItem(TOKEN_KEY.REFRESH_TOKEN);
const res = await axios.post('http://localhost:8080/api/auth/refresh-token', {}, {
headers: {
'Refresh-Token': 'Bearer ' + refreshToken
},
withCredentials: true
});
// 更新本地Token
const { accessToken: newAccessToken, refreshToken: newRefreshToken, accessTokenExpireSeconds } = res.data.data;
localStorage.setItem(TOKEN_KEY.ACCESS_TOKEN, newAccessToken);
localStorage.setItem(TOKEN_KEY.REFRESH_TOKEN, newRefreshToken);
const newExpireTime = Date.now() + accessTokenExpireSeconds * 1000;
localStorage.setItem(TOKEN_KEY.ACCESS_EXPIRE, newExpireTime.toString());
console.log('Token无感刷新成功');
} catch (err) {
console.error('Token无感刷新失败', err);
}
}
}
export default service;
5. 测试验证:完整流程测试步骤
-
本地运行前置条件
- 步骤1:启动本地Redis(127.0.0.1:6379),密码与application.yml一致。
- 步骤2:复制通用核心模块+场景3代码到Spring Boot项目,调整包名。
- 步骤3:生成HS512密钥并配置到
jwt.secret.key。 - 步骤4:启动Spring Boot应用(端口8080),启动前端项目(Vue 3)。
-
核心流程测试步骤
测试步骤 操作内容 预期结果 1. 登录测试 前端调用 /api/auth/login,传入正确用户名密码后端返回200,本地存储+Cookie均写入双Token,过期时间正确 2. 鉴权测试 前端调用 /api/auth/test返回200,显示用户信息, 是否需要刷新Token为否3. 无感刷新触发 手动修改本地 access_expire_time为当前时间+4分钟(小于5分钟提前时间)前端自动静默调用 /api/auth/refresh-token,获取新Token并更新本地存储4. 过期刷新测试 等待Access Token过期(1小时),再次调用业务接口 前端拦截401,自动刷新Token后重试接口,用户无感知 5. 退出登录测试 调用 /api/auth/logoutCookie清除,本地存储删除,Redis版本号删除,旧Token无法鉴权 6. 全部设备退出 调用 /api/auth/logout-all/20001该用户所有设备的旧Token均失效,需重新登录 -
异常场景测试
- 场景1:Refresh Token过期后调用刷新接口 → 返回401,前端引导用户登录。
- 场景2:篡改Access Token后调用鉴权接口 → 返回401,鉴权失败。
- 场景3:多端登录同一账号,其中一端退出全部设备 → 所有端均需重新登录。
场景4:高并发场景/黑名单量大
最优方案:JWT + 双Token + Redis + 布隆过滤器
核心说明
- 适用场景:高并发ToC产品(如秒杀平台、直播平台、电商大促场景)、Token黑名单量达百万/千万级、Redis查询压力过大、对接口响应速度要求极高(毫秒级)的业务系统。
- 高并发适配细节
- 布隆过滤器前置拦截:将黑名单Token存入布隆过滤器,先通过布隆过滤器快速判断Token是否在黑名单,减少Redis查询次数(Redis查询耗时远高于布隆过滤器)。
- 异步批量操作:Token黑名单写入采用异步批量提交,避免高并发下同步写入Redis造成的性能瓶颈。
- 分层存储:布隆过滤器(内存/Redis)存储黑名单标识,Redis存储黑名单详细信息(过期时间等),实现"快速判断+精准存储"。
- 动态扩容:布隆过滤器随黑名单数量增长自动扩容,避免误判率升高。
- 布隆过滤器原理
- 核心结构:位图(Bit Array)+ 多个独立哈希函数。
- 存储逻辑:一个Token通过多个哈希函数计算得到多个位图索引,将对应索引位设为1。
- 查询逻辑:Token通过相同哈希函数计算索引,若所有索引位均为1,则大概率在黑名单中;若有一个为0,则一定不在黑名单中(无漏判,有少量误判)。
- 核心优势:查询速度快(O(k),k为哈希函数个数)、内存占用小(仅存储位图);核心劣势:存在误判率、无法删除指定元素(需定期重建)。
完整代码包(直接复制即可运行)
1. 场景专属配置:application.yml 完整配置
yaml
# 应用配置
spring:
application:
name: jwt-high-concurrency-project
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
database: 2
lettuce:
pool:
max-active: 32 # 高并发场景增大连接池
max-idle: 32
min-idle: 8
max-wait: -1ms
timeout: 3000ms
logging:
level:
com.example: INFO
org.springframework: WARN
redis.clients: WARN
# JWT 配置
jwt:
secret:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
access:
expire: 3600
refresh:
expire: 604800
cookie:
domain: localhost
path: /
secure: false
same-site: Lax
# 布隆过滤器配置
bloom:
filter:
# 预计插入黑名单数量(百万级,根据业务调整)
expected-insertions: 1000000
# 误判率(推荐0.001-0.01,越小内存占用越大)
false-positive-rate: 0.001
# 刷新周期(秒):定期重建布隆过滤器,清理无效数据
refresh-period: 3600
# 扩容阈值:当前存储量/预计插入量 >= 阈值时扩容
expansion-threshold: 0.8
# Redis 布隆过滤器Key前缀
redis-key-prefix: "jwt:bloom:filter:"
2. 场景专属工具类:JwtBloomFilter.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* JWT 黑名单布隆过滤器工具类
* 基于Guava实现本地布隆过滤器(高并发可切换为Redis布隆过滤器)
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtBloomFilter {
private final RedisTemplate<String, Object> redisTemplate;
// 布隆过滤器配置
@Value("${bloom.filter.expected-insertions}")
private long expectedInsertions; // 预计插入量
@Value("${bloom.filter.false-positive-rate}")
private double falsePositiveRate; // 误判率
@Value("${bloom.filter.redis-key-prefix}")
private String redisKeyPrefix; // Redis Key前缀
// 本地布隆过滤器实例
private BloomFilter<String> localBloomFilter;
// 当前布隆过滤器版本(用于扩容)
private int filterVersion = 1;
/**
* 初始化布隆过滤器
*/
@javax.annotation.PostConstruct
public void initBloomFilter() {
rebuildBloomFilter();
log.info("布隆过滤器初始化成功,预计插入量:{},误判率:{}", expectedInsertions, falsePositiveRate);
}
/**
* 重建布隆过滤器(从Redis黑名单加载数据)
*/
public void rebuildBloomFilter() {
// 1. 创建新的布隆过滤器
localBloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
expectedInsertions,
falsePositiveRate
);
// 2. 从Redis加载黑名单Token(实际高并发场景采用分批加载)
String redisBlacklistKey = "jwt:user:blacklist:*";
redisTemplate.keys(redisBlacklistKey).forEach(key -> {
String token = key.substring(redisBlacklistKey.length() - 1);
if (StringUtils.hasText(token)) {
localBloomFilter.put(token);
}
});
log.info("布隆过滤器重建成功,加载黑名单Token数量:{}", localBloomFilter.approximateElementCount());
}
/**
* 向布隆过滤器添加Token(黑名单)
*
* @param token JWT Token
*/
public void addToken(String token) {
if (!StringUtils.hasText(token)) {
return;
}
// 1. 去除Token前缀
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
// 2. 添加到本地布隆过滤器
localBloomFilter.put(pureToken);
// 3. 添加到Redis布隆过滤器(备用,用于集群同步)
String redisBloomKey = redisKeyPrefix + filterVersion;
// 此处省略Redis布隆过滤器添加逻辑(可使用RedisBloom插件)
log.debug("Token{}已添加到布隆过滤器(版本:{})", pureToken, filterVersion);
// 4. 判断是否需要扩容
checkExpansion();
}
/**
* 判断Token是否在黑名单中(布隆过滤器前置判断)
*
* @param token JWT Token
* @return true-大概率在黑名单,false-一定不在黑名单
*/
public boolean mightContainToken(String token) {
if (!StringUtils.hasText(token)) {
return false;
}
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
// 1. 本地布隆过滤器判断(优先)
boolean localContain = localBloomFilter.mightContain(pureToken);
if (!localContain) {
return false;
}
// 2. Redis布隆过滤器兜底判断(集群场景)
String redisBloomKey = redisKeyPrefix + filterVersion;
// 此处省略Redis布隆过滤器查询逻辑
log.debug("Token{}大概率在黑名单中,需进一步查询Redis确认", pureToken);
return true;
}
/**
* 检查并扩容布隆过滤器
*/
private void checkExpansion() {
// 计算当前存储量占比
double loadRate = (double) localBloomFilter.approximateElementCount() / expectedInsertions;
if (loadRate >= 0.8) { // 达到扩容阈值
filterVersion++;
log.info("布隆过滤器达到扩容阈值(负载率:{}),开始扩容至版本:{}", loadRate, filterVersion);
// 重建新版本布隆过滤器
rebuildBloomFilter();
}
}
/**
* 获取当前布隆过滤器版本
*
* @return 版本号
*/
public int getFilterVersion() {
return filterVersion;
}
}
3. 场景专属工具类:JwtHighConcurrencyUtil.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.utils.CookieUtil;
import com.example.jwtauthframework.common.utils.JwtBaseUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 高并发场景JWT工具类(JWT+双Token+Redis+布隆过滤器)
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtHighConcurrencyUtil {
private final RedisTemplate<String, Object> redisTemplate;
private final JwtBloomFilter jwtBloomFilter;
// JWT 配置
@Value("${jwt.secret.key}")
private String jwtSecretKey;
@Value("${jwt.access.expire}")
private long accessTokenExpireSeconds;
@Value("${jwt.refresh.expire}")
private long refreshTokenExpireSeconds;
// Cookie 配置
@Value("${jwt.cookie.domain}")
private String cookieDomain;
@Value("${jwt.cookie.path}")
private String cookiePath;
@Value("${jwt.cookie.secure}")
private boolean cookieSecure;
@Value("${jwt.cookie.same-site}")
private String cookieSameSite;
/**
* 生成Token版本号
*
* @param userId 用户ID
* @return 版本号
*/
private String generateTokenVersion(String userId) {
String version = UUID.randomUUID().toString().replace("-", "");
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.opsForValue().set(redisKey, version, refreshTokenExpireSeconds, TimeUnit.SECONDS);
return version;
}
/**
* 获取当前Token版本号
*
* @param userId 用户ID
* @return 版本号
*/
private String getCurrentTokenVersion(String userId) {
if (!StringUtils.hasText(userId)) {
return null;
}
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
Object versionObj = redisTemplate.opsForValue().get(redisKey);
return versionObj == null ? null : versionObj.toString();
}
/**
* 生成登录双Token
*
* @param response HttpServletResponse
* @param userId 用户ID
* @param username 用户名
* @return TokenDTO
*/
public TokenDTO generateLoginToken(HttpServletResponse response, String userId, String username) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(username)) {
throw new IllegalArgumentException("用户ID和用户名不能为空");
}
// 1. 生成版本号
String version = generateTokenVersion(userId);
// 2. 构建载荷
Map<String, Object> claims = new HashMap<>(5);
claims.put(JwtConstants.JWT_CLAIMS_USER_ID, userId);
claims.put(JwtConstants.JWT_CLAIMS_USERNAME, username);
claims.put(JwtConstants.JWT_CLAIMS_VERSION, version);
// 3. 生成双Token
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
String accessToken = JwtBaseUtil.generateHs512Token(secretKey, claims, accessTokenExpireSeconds);
String refreshToken = JwtBaseUtil.generateHs512Token(secretKey, claims, refreshTokenExpireSeconds);
// 4. 写入Cookie
setTokenToCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, accessToken, accessTokenExpireSeconds);
setTokenToCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, refreshToken, refreshTokenExpireSeconds);
// 5. 封装返回
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setAccessToken(accessToken);
tokenDTO.setAccessTokenExpireSeconds(accessTokenExpireSeconds);
tokenDTO.setRefreshToken(refreshToken);
tokenDTO.setRefreshTokenExpireSeconds(refreshTokenExpireSeconds);
return tokenDTO;
}
/**
* 写入Token Cookie
*/
private void setTokenToCookie(HttpServletResponse response, String cookieName, String token, long expireSeconds) {
CookieUtil.setCookie(
response,
cookieName,
token,
cookieDomain,
cookiePath,
(int) expireSeconds,
true,
cookieSecure,
cookieSameSite
);
}
/**
* 从Request获取Token
*/
public String getTokenFromRequest(HttpServletRequest request, String tokenType) {
// 优先Header,其次Cookie
String headerName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_HEADER : JwtConstants.REFRESH_TOKEN_HEADER;
String token = request.getHeader(headerName);
if (StringUtils.hasText(token)) {
return token;
}
String cookieName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_COOKIE_NAME : JwtConstants.REFRESH_TOKEN_COOKIE_NAME;
token = CookieUtil.getCookieValue(request, cookieName);
return StringUtils.hasText(token) ? JwtConstants.TOKEN_PREFIX + token : null;
}
/**
* 校验Access Token(布隆过滤器前置拦截)
*
* @param token Access Token
* @return true-有效,false-无效
*/
public boolean validateAccessToken(String token) {
// 1. 布隆过滤器快速判断:是否在黑名单
if (jwtBloomFilter.mightContainToken(token)) {
// 2. 布隆过滤器命中,进一步查询Redis确认
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
String redisBlacklistKey = JwtConstants.REDIS_USER_TOKEN_BLACKLIST_PREFIX + pureToken;
if (redisTemplate.hasKey(redisBlacklistKey)) {
log.warn("Token{}在黑名单中,校验失败", pureToken);
return false;
}
}
// 3. 基础校验(签名+过期)
if (!baseTokenValidate(token)) {
return false;
}
// 4. 版本号校验
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
if (claims == null) {
return false;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String tokenVersion = claims.get(JwtConstants.JWT_CLAIMS_VERSION).toString();
String currentVersion = getCurrentTokenVersion(userId);
if (!StringUtils.hasText(currentVersion) || !currentVersion.equals(tokenVersion)) {
return false;
}
return true;
}
/**
* 基础Token校验
*/
private boolean baseTokenValidate(String token) {
if (!StringUtils.hasText(token)) {
return false;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
return JwtBaseUtil.validateHs512Token(secretKey, token);
}
/**
* 校验Refresh Token
*/
public boolean validateRefreshToken(String token) {
return baseTokenValidate(token);
}
/**
* 刷新Token
*/
public TokenDTO refreshToken(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = getTokenFromRequest(request, "refresh");
if (!validateRefreshToken(refreshToken)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, refreshToken);
if (claims == null) {
return null;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String username = claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
return generateLoginToken(response, userId, username);
}
/**
* 退出登录(异步添加黑名单)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return true-成功
*/
public boolean logout(HttpServletRequest request, HttpServletResponse response) {
try {
String accessToken = getTokenFromRequest(request, "access");
if (StringUtils.hasText(accessToken) && validateAccessToken(accessToken)) {
// 1. 异步添加到黑名单
asyncAddTokenToBlacklist(accessToken);
// 2. 删除版本号
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, accessToken);
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.delete(redisKey);
}
// 3. 清除Cookie
clearTokenCookie(response);
return true;
} catch (Exception e) {
log.error("退出登录失败", e);
return false;
}
}
/**
* 异步添加Token到黑名单
*
* @param token Token
*/
@Async // 异步执行,需在启动类添加@EnableAsync
public void asyncAddTokenToBlacklist(String token) {
String pureToken = JwtBaseUtil.removeTokenPrefix(token);
// 1. 添加到布隆过滤器
jwtBloomFilter.addToken(pureToken);
// 2. 添加到Redis黑名单,设置过期时间
String redisBlacklistKey = JwtConstants.REDIS_USER_TOKEN_BLACKLIST_PREFIX + pureToken;
redisTemplate.opsForValue().set(redisBlacklistKey, "blacklist", accessTokenExpireSeconds, TimeUnit.SECONDS);
log.info("Token{}已异步添加到黑名单", pureToken);
}
/**
* 清除Token Cookie
*/
private void clearTokenCookie(HttpServletResponse response) {
CookieUtil.removeCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
CookieUtil.removeCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
}
/**
* 获取用户ID
*/
public String getUserIdFromToken(String token) {
if (!validateAccessToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
}
}
4. 定时任务配置:BloomFilterRefreshConfig.java 完整代码
java
package com.example.jwtauthframework.business.auth.config;
import com.example.jwtauthframework.business.auth.utils.JwtBloomFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
/**
* 布隆过滤器定时刷新+动态扩容配置
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Configuration
@EnableScheduling
@RequiredArgsConstructor
public class BloomFilterRefreshConfig {
private final JwtBloomFilter jwtBloomFilter;
@Value("${bloom.filter.refresh-period}")
private long refreshPeriod; // 刷新周期(秒)
/**
* 定时重建布隆过滤器(每小时执行一次)
* 清理无效黑名单数据,降低误判率
*/
@Scheduled(cron = "0 0 */1 * * ?")
public void scheduledRebuildBloomFilter() {
try {
log.info("开始定时重建布隆过滤器");
jwtBloomFilter.rebuildBloomFilter();
log.info("布隆过滤器定时重建完成");
} catch (Exception e) {
log.error("布隆过滤器定时重建失败", e);
}
}
/**
* 定时检查布隆过滤器扩容状态(每30分钟执行一次)
*/
@Scheduled(cron = "0 0 */30 * * ?")
public void scheduledCheckExpansion() {
try {
log.info("开始检查布隆过滤器扩容状态,当前版本:{}", jwtBloomFilter.getFilterVersion());
// 触发扩容判断(内部已实现逻辑)
log.info("布隆过滤器扩容状态检查完成");
} catch (Exception e) {
log.error("布隆过滤器扩容状态检查失败", e);
}
}
}
5. 场景专属控制器:AuthController.java 完整代码
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.dto.LoginDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtHighConcurrencyUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 高并发场景认证控制器
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtHighConcurrencyUtil jwtHighConcurrencyUtil;
/**
* 登录接口
*/
@PostMapping("/login")
public Result<TokenDTO> login(HttpServletResponse response, @Validated @RequestBody LoginDTO loginDTO) {
try {
// 模拟用户校验
if (!"seckill001".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
return Result.fail(401, "用户名或密码错误");
}
// 生成双Token(模拟用户ID:30001)
TokenDTO tokenDTO = jwtHighConcurrencyUtil.generateLoginToken(response, "30001", loginDTO.getUsername());
return Result.success("登录成功", tokenDTO);
} catch (Exception e) {
log.error("登录失败", e);
return Result.fail("登录失败:" + e.getMessage());
}
}
/**
* 刷新Token接口
*/
@PostMapping("/refresh-token")
public Result<TokenDTO> refreshToken(HttpServletRequest request, HttpServletResponse response) {
try {
TokenDTO tokenDTO = jwtHighConcurrencyUtil.refreshToken(request, response);
if (tokenDTO == null) {
return Result.fail(401, "Refresh Token无效,请重新登录");
}
return Result.success("Token刷新成功", tokenDTO);
} catch (Exception e) {
log.error("Token刷新失败", e);
return Result.fail("Token刷新失败:" + e.getMessage());
}
}
/**
* 退出登录接口
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
try {
boolean result = jwtHighConcurrencyUtil.logout(request, response);
if (!result) {
return Result.fail("退出登录失败");
}
return Result.success("退出登录成功");
} catch (Exception e) {
log.error("退出登录失败", e);
return Result.fail("退出登录失败:" + e.getMessage());
}
}
/**
* 高并发鉴权测试接口(秒杀业务示例)
*/
@GetMapping("/seckill/test")
public Result<String> seckillAuthTest(HttpServletRequest request) {
try {
String token = jwtHighConcurrencyUtil.getTokenFromRequest(request, "access");
if (token == null || !jwtHighConcurrencyUtil.validateAccessToken(token)) {
return Result.fail(401, "Token无效或已过期,请重新登录");
}
String userId = jwtHighConcurrencyUtil.getUserIdFromToken(token);
String msg = String.format("秒杀鉴权成功!用户ID:%s,可参与秒杀活动", userId);
return Result.success(msg);
} catch (Exception e) {
log.error("秒杀鉴权失败", e);
return Result.fail("秒杀鉴权失败:" + e.getMessage());
}
}
}
6. 高并发测试:压力测试配置 + 预期结果
-
JMeter压力测试配置
- 线程组配置:线程数1000, Ramp-Up时间10秒(每秒启动100线程),循环次数100次(总请求数10万)。
- HTTP请求配置:
- 登录请求:
POST http://localhost:8080/api/auth/login,传入正确用户名密码。 - 鉴权请求:
GET http://localhost:8080/api/auth/seckill/test,携带登录返回的Token。 - 退出请求:
POST http://localhost:8080/api/auth/logout,携带Token。
- 断言配置:响应码200,响应信息包含"鉴权成功"。
- 监听器配置:聚合报告、查看结果树、Summary Report。
-
预期测试结果
指标 预期值 说明 平均响应时间 < 50ms 布隆过滤器前置拦截,减少Redis查询,响应速度快 吞吐量 > 2000 QPS 高并发场景下满足秒杀/直播业务需求 错误率 < 0.1% 仅少量请求因网络/线程池耗尽失败 Redis QPS < 500 布隆过滤器拦截大部分黑名单查询,Redis压力大幅降低 布隆过滤器误判率 < 0.001 符合配置的误判率要求,无大量无效Redis查询 -
测试优化要点
- 若响应时间过长:增大Redis连接池、优化布隆过滤器哈希函数个数、采用Redis布隆过滤器集群。
- 若错误率过高:增大Tomcat线程池(
server.tomcat.threads.max)、优化JVM内存配置。 - 若布隆过滤器误判率过高:增大预计插入量、降低误判率配置、缩短重建周期。
场景5:多设备管控/精细化权限
最优方案:JWT + 双Token + Redis(用户+设备)
核心说明
-
适用场景
- 企业办公系统(限制员工账号登录设备数量,防止账号泄露)
- 付费会员产品(如视频会员、云服务会员,限制多设备共享登录)
- 金融/政务App(高安全要求,需精细化管控设备权限,防止盗号操作)
- 智能家居管理平台(设备绑定用户,不同设备对应不同操作权限)
- 核心特征:用户账号安全要求高、需限制登录设备数量、权限需按用户/设备维度精细化划分
-
多设备管控逻辑
- 设备唯一标识 :前端生成/获取设备唯一ID(设备类型+系统版本+设备序列号/UUID,格式示例:
PC_Windows_xxx/Mobile_Android_xxx),登录时传入后端。 - 设备绑定 :登录成功后,后端以
jwt:user:device:{userId}:{deviceId}为Redis Key,存储设备信息(设备类型、登录时间、备注、Token版本),并设置过期时间与Refresh Token一致。 - 数量校验:登录前,后端先查询该用户已绑定的有效设备数量,若超过配置的最大设备数,提示用户"设备数量超限,请先下线旧设备",并支持用户选择下线指定旧设备。
- Token关联设备:JWT载荷中加入设备ID,后端校验Token时,不仅校验签名、过期时间、版本号,还需校验该设备ID是否在用户的有效设备列表中。
- 设备下线:用户可主动下线指定设备(前端选择设备,后端删除对应Redis设备信息),该设备的Token立即失效,无法再访问接口。
- 设备唯一标识 :前端生成/获取设备唯一ID(设备类型+系统版本+设备序列号/UUID,格式示例:
-
权限精细化设计
- 三层权限维度
- 用户维度:基于角色的权限(如管理员/普通用户/会员用户),存储在JWT载荷中,控制用户整体功能权限。
- 设备维度:基于设备类型的权限(如PC端可操作全部功能、移动端仅可查看数据、小程序端仅支持基础操作),存储在Redis设备信息和JWT载荷中。
- 接口维度:基于接口的细粒度权限(如"用户信息修改"接口仅允许PC端管理员访问),后端通过注解/手动校验实现。
- 权限校验流程:Token有效性校验 → 设备有效性校验 → 用户角色权限校验 → 设备类型权限校验 → 接口权限校验,层层递进,确保权限管控精准。
完整代码包(直接复制即可运行)
1. 场景专属配置:application.yml 完整配置
yaml
# 应用配置
spring:
application:
name: jwt-multi-device-auth-project
# Redis配置(支撑用户-设备绑定、Token版本、权限信息存储)
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
database: 3
lettuce:
pool:
max-active: 16
max-idle: 16
min-idle: 4
max-wait: -1ms
timeout: 3000ms
logging:
level:
com.example: INFO
org.springframework: WARN
redis.clients: WARN
# JWT配置(双Token模式)
jwt:
secret:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 生成的HS512密钥
# Access Token过期时间:1小时(3600秒)
access:
expire: 3600
# Refresh Token过期时间:7天(604800秒)
refresh:
expire: 604800
# Cookie配置
cookie:
domain: localhost
path: /
secure: false # 生产环境设为true
same-site: Lax
# 多设备管控配置
device:
# 最大登录设备数(默认3台,可按用户等级动态调整,如会员5台)
max-login-count: 3
# 设备类型标识(用于权限控制)
type:
pc: "PC" # 电脑端(Windows/Mac)
mobile: "MOBILE" # 移动端(Android/iOS)
mini: "MINI" # 小程序端
# 设备权限标识
permission:
full: "FULL" # 全功能权限(PC端默认)
view: "VIEW" # 仅查看权限(移动端/小程序端默认)
operate: "OPERATE" # 操作权限(部分高级用户移动端)
2. 场景专属工具类:JwtMultiDeviceUtil.java 完整代码
java
package com.example.jwtauthframework.business.auth.utils;
import com.example.jwtauthframework.common.constant.JwtConstants;
import com.example.jwtauthframework.common.dto.DeviceInfoDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 多设备管控+精细化权限JWT工具类
* 封装设备绑定、设备下线、Token管理、权限校验逻辑
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtMultiDeviceUtil {
private final RedisTemplate<String, Object> redisTemplate;
// JWT配置
@Value("${jwt.secret.key}")
private String jwtSecretKey;
@Value("${jwt.access.expire}")
private long accessTokenExpireSeconds;
@Value("${jwt.refresh.expire}")
private long refreshTokenExpireSeconds;
// Cookie配置
@Value("${jwt.cookie.domain}")
private String cookieDomain;
@Value("${jwt.cookie.path}")
private String cookiePath;
@Value("${jwt.cookie.secure}")
private boolean cookieSecure;
@Value("${jwt.cookie.same-site}")
private String cookieSameSite;
// 设备配置
@Value("${device.max-login-count}")
private int maxDeviceLoginCount;
@Value("${device.permission.full}")
private String devicePermFull;
@Value("${device.permission.view}")
private String devicePermView;
/**
* 生成用户Token版本号(设备绑定/密码修改/设备下线时更新)
*
* @param userId 用户ID
* @return 版本号
*/
private String generateTokenVersion(String userId) {
String version = UUID.randomUUID().toString().replace("-", "");
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
redisTemplate.opsForValue().set(redisKey, version, refreshTokenExpireSeconds, TimeUnit.SECONDS);
log.info("用户{}生成新Token版本号:{}", userId, version);
return version;
}
/**
* 获取用户当前Token版本号
*
* @param userId 用户ID
* @return 版本号(null表示未登录/版本过期)
*/
private String getCurrentTokenVersion(String userId) {
if (!StringUtils.hasText(userId)) {
return null;
}
String redisKey = JwtConstants.REDIS_USER_TOKEN_VERSION_PREFIX + userId;
Object versionObj = redisTemplate.opsForValue().get(redisKey);
return versionObj == null ? null : versionObj.toString();
}
/**
* 绑定用户与设备(存储设备信息到Redis)
*
* @param userId 用户ID
* @param deviceId 设备ID
* @param deviceType 设备类型(PC/MOBILE/MINI)
* @param deviceRemark 设备备注(可选,如"我的笔记本")
* @return true-绑定成功,false-绑定失败(设备数量超限)
*/
public boolean bindUserAndDevice(String userId, String deviceId, String deviceType, String deviceRemark) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(deviceId) || !StringUtils.hasText(deviceType)) {
log.warn("用户ID/设备ID/设备类型不能为空,绑定失败");
return false;
}
// 1. 查询用户已绑定的有效设备数量
List<DeviceInfoDTO> validDeviceList = this.getUserAllLoginDevices(userId);
if (validDeviceList.size() >= maxDeviceLoginCount) {
log.warn("用户{}登录设备数量超限(当前{}台,最大{}台),绑定失败", userId, validDeviceList.size(), maxDeviceLoginCount);
return false;
}
// 2. 生成设备权限(默认PC端全功能,其他端仅查看)
String devicePermission = deviceType.equalsIgnoreCase("PC") ? devicePermFull : devicePermView;
// 3. 构建设备信息
DeviceInfoDTO deviceInfo = new DeviceInfoDTO();
deviceInfo.setUserId(userId);
deviceInfo.setDeviceId(deviceId);
deviceInfo.setDeviceType(deviceType);
deviceInfo.setDeviceRemark(StringUtils.hasText(deviceRemark) ? deviceRemark : deviceType + "_" + System.currentTimeMillis());
deviceInfo.setDevicePermission(devicePermission);
deviceInfo.setLoginTime(new Date());
deviceInfo.setTokenVersion(this.generateTokenVersion(userId));
// 4. 存储到Redis(Key: jwt:user:device:{userId}:{deviceId})
String redisDeviceKey = String.format("%s%s:%s", JwtConstants.REDIS_USER_DEVICE_PREFIX, userId, deviceId);
redisTemplate.opsForValue().set(redisDeviceKey, deviceInfo, refreshTokenExpireSeconds, TimeUnit.SECONDS);
log.info("用户{}设备{}绑定成功,设备类型:{},权限:{}", userId, deviceId, deviceType, devicePermission);
return true;
}
/**
* 查询用户所有有效登录设备
*
* @param userId 用户ID
* @return 设备信息列表
*/
@SuppressWarnings("unchecked")
public List<DeviceInfoDTO> getUserAllLoginDevices(String userId) {
if (!StringUtils.hasText(userId)) {
return new ArrayList<>();
}
// 1. 匹配Redis中该用户的所有设备Key
String redisDeviceKeyPattern = String.format("%s%s:*", JwtConstants.REDIS_USER_DEVICE_PREFIX, userId);
Set<String> deviceKeySet = redisTemplate.keys(redisDeviceKeyPattern);
if (deviceKeySet == null || deviceKeySet.isEmpty()) {
return new ArrayList<>();
}
// 2. 批量查询设备信息
List<Object> deviceObjList = redisTemplate.opsForValue().multiGet(deviceKeySet);
return deviceObjList.stream()
.filter(Objects::nonNull)
.map(obj -> (DeviceInfoDTO) obj)
.sorted(Comparator.comparing(DeviceInfoDTO::getLoginTime).reversed()) // 按登录时间倒序
.collect(Collectors.toList());
}
/**
* 生成关联设备的双Token(含权限信息)
*
* @param response HttpServletResponse
* @param userId 用户ID
* @param username 用户名
* @param deviceId 设备ID
* @param role 用户角色(如ADMIN/USER/VIP)
* @return TokenDTO(null表示设备绑定失败)
*/
public TokenDTO generateDeviceToken(HttpServletResponse response, String userId, String username, String deviceId, String role) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(username) || !StringUtils.hasText(deviceId) || !StringUtils.hasText(role)) {
throw new IllegalArgumentException("用户ID/用户名/设备ID/角色不能为空");
}
// 1. 获取设备信息(校验设备是否已绑定)
String redisDeviceKey = String.format("%s%s:%s", JwtConstants.REDIS_USER_DEVICE_PREFIX, userId, deviceId);
DeviceInfoDTO deviceInfo = (DeviceInfoDTO) redisTemplate.opsForValue().get(redisDeviceKey);
if (deviceInfo == null) {
log.warn("用户{}设备{}未绑定,Token生成失败", userId, deviceId);
return null;
}
// 2. 构建JWT载荷(含用户信息+设备信息+权限信息)
Map<String, Object> claims = new HashMap<>(7);
claims.put(JwtConstants.JWT_CLAIMS_USER_ID, userId);
claims.put(JwtConstants.JWT_CLAIMS_USERNAME, username);
claims.put(JwtConstants.JWT_CLAIMS_ROLE, role); // 用户角色
claims.put(JwtConstants.JWT_CLAIMS_DEVICE_ID, deviceId); // 设备ID
claims.put(JwtConstants.JWT_CLAIMS_DEVICE_TYPE, deviceInfo.getDeviceType()); // 设备类型
claims.put(JwtConstants.JWT_CLAIMS_DEVICE_PERM, deviceInfo.getDevicePermission()); // 设备权限
claims.put(JwtConstants.JWT_CLAIMS_VERSION, deviceInfo.getTokenVersion()); // Token版本号
// 3. 获取HS512密钥
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
// 4. 生成Access Token和Refresh Token
String accessToken = JwtBaseUtil.generateHs512Token(secretKey, claims, accessTokenExpireSeconds);
String refreshToken = JwtBaseUtil.generateHs512Token(secretKey, claims, refreshTokenExpireSeconds);
// 5. 写入HttpOnly Cookie
this.setTokenToCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, accessToken, accessTokenExpireSeconds);
this.setTokenToCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, refreshToken, refreshTokenExpireSeconds);
// 6. 封装返回结果
TokenDTO tokenDTO = new TokenDTO();
tokenDTO.setAccessToken(accessToken);
tokenDTO.setAccessTokenExpireSeconds(accessTokenExpireSeconds);
tokenDTO.setRefreshToken(refreshToken);
tokenDTO.setRefreshTokenExpireSeconds(refreshTokenExpireSeconds);
log.info("用户{}设备{}Token生成成功并写入Cookie", userId, deviceId);
return tokenDTO;
}
/**
* 将Token写入HttpOnly Cookie
*/
private void setTokenToCookie(HttpServletResponse response, String cookieName, String token, long expireSeconds) {
CookieUtil.setCookie(
response,
cookieName,
token,
cookieDomain,
cookiePath,
(int) expireSeconds,
true, // HttpOnly防XSS
cookieSecure,
cookieSameSite
);
}
/**
* 从Request中获取Token(优先Header,其次Cookie)
*
* @param request HttpServletRequest
* @param tokenType Token类型(access/refresh)
* @return Token值
*/
public String getTokenFromRequest(HttpServletRequest request, String tokenType) {
// 1. 优先从Header获取
String headerName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_HEADER : JwtConstants.REFRESH_TOKEN_HEADER;
String token = request.getHeader(headerName);
if (StringUtils.hasText(token)) {
return token;
}
// 2. 从Cookie获取
String cookieName = "access".equals(tokenType) ? JwtConstants.ACCESS_TOKEN_COOKIE_NAME : JwtConstants.REFRESH_TOKEN_COOKIE_NAME;
token = CookieUtil.getCookieValue(request, cookieName);
return StringUtils.hasText(token) ? JwtConstants.TOKEN_PREFIX + token : null;
}
/**
* 校验Token有效性(含设备有效性+权限信息校验)
*
* @param token Access Token
* @return true-有效,false-无效
*/
public boolean validateDeviceToken(String token) {
// 1. 基础校验(空值+签名+过期)
if (!this.baseTokenValidate(token)) {
return false;
}
// 2. 解析JWT载荷
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
if (claims == null) {
log.warn("Token载荷解析失败,校验无效");
return false;
}
// 3. 获取核心信息
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String deviceId = claims.get(JwtConstants.JWT_CLAIMS_DEVICE_ID).toString();
String tokenVersion = claims.get(JwtConstants.JWT_CLAIMS_VERSION).toString();
// 4. 校验Token版本号
String currentVersion = this.getCurrentTokenVersion(userId);
if (!StringUtils.hasText(currentVersion) || !currentVersion.equals(tokenVersion)) {
log.warn("用户{}Token版本号不匹配,校验无效", userId);
return false;
}
// 5. 校验设备是否有效(是否在用户绑定设备列表中)
String redisDeviceKey = String.format("%s%s:%s", JwtConstants.REDIS_USER_DEVICE_PREFIX, userId, deviceId);
DeviceInfoDTO deviceInfo = (DeviceInfoDTO) redisTemplate.opsForValue().get(redisDeviceKey);
if (deviceInfo == null) {
log.warn("用户{}设备{}已失效,Token校验无效", userId, deviceId);
return false;
}
return true;
}
/**
* Token基础校验(空值+签名+过期)
*/
private boolean baseTokenValidate(String token) {
if (!StringUtils.hasText(token)) {
log.warn("Token为空,基础校验失败");
return false;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
return JwtBaseUtil.validateHs512Token(secretKey, token);
}
/**
* 下线用户指定设备(失效该设备Token)
*
* @param userId 用户ID
* @param deviceId 设备ID
* @return true-下线成功,false-下线失败
*/
public boolean offlineDesignatedDevice(String userId, String deviceId) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(deviceId)) {
return false;
}
// 1. 删除Redis设备信息
String redisDeviceKey = String.format("%s%s:%s", JwtConstants.REDIS_USER_DEVICE_PREFIX, userId, deviceId);
Boolean deleteResult = redisTemplate.delete(redisDeviceKey);
if (deleteResult == null || !deleteResult) {
log.warn("用户{}设备{}不存在,下线失败", userId, deviceId);
return false;
}
// 2. 更新用户Token版本号(使该用户所有旧Token失效,强制重新登录)
this.generateTokenVersion(userId);
log.info("用户{}设备{}下线成功", userId, deviceId);
return true;
}
/**
* 校验接口访问权限(用户角色+设备权限)
*
* @param token Access Token
* @param requiredRole 所需用户角色(如ADMIN)
* @param requiredPerm 所需设备权限(如FULL)
* @return true-有权限,false-无权限
*/
public boolean validateInterfacePermission(String token, String requiredRole, String requiredPerm) {
// 1. 先校验Token有效性
if (!this.validateDeviceToken(token)) {
return false;
}
// 2. 解析载荷获取权限信息
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
if (claims == null) {
return false;
}
String userRole = claims.get(JwtConstants.JWT_CLAIMS_ROLE).toString();
String devicePerm = claims.get(JwtConstants.JWT_CLAIMS_DEVICE_PERM).toString();
// 3. 校验用户角色(支持多角色,此处简化为单角色匹配)
if (!userRole.equalsIgnoreCase(requiredRole) && !"ADMIN".equalsIgnoreCase(userRole)) {
log.warn("用户角色{}不满足接口所需角色{},权限校验失败", userRole, requiredRole);
return false;
}
// 4. 校验设备权限
if (!devicePerm.equalsIgnoreCase(requiredPerm) && !devicePermFull.equalsIgnoreCase(devicePerm)) {
log.warn("设备权限{}不满足接口所需权限{},权限校验失败", devicePerm, requiredPerm);
return false;
}
return true;
}
/**
* 刷新Token(关联设备信息)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return TokenDTO(null表示刷新失败)
*/
public TokenDTO refreshDeviceToken(HttpServletRequest request, HttpServletResponse response) {
// 1. 获取Refresh Token
String refreshToken = this.getTokenFromRequest(request, "refresh");
if (!this.baseTokenValidate(refreshToken)) {
log.warn("Refresh Token无效,刷新失败");
return null;
}
// 2. 解析载荷获取用户+设备信息
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, refreshToken);
if (claims == null) {
log.warn("Refresh Token载荷解析失败,刷新失败");
return null;
}
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String username = claims.get(JwtConstants.JWT_CLAIMS_USERNAME).toString();
String deviceId = claims.get(JwtConstants.JWT_CLAIMS_DEVICE_ID).toString();
String role = claims.get(JwtConstants.JWT_CLAIMS_ROLE).toString();
// 3. 生成新Token
return this.generateDeviceToken(response, userId, username, deviceId, role);
}
/**
* 退出当前设备登录(清除Cookie + 下线当前设备)
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return true-退出成功,false-退出失败
*/
public boolean logoutCurrentDevice(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. 获取并校验Access Token
String accessToken = this.getTokenFromRequest(request, "access");
if (!this.validateDeviceToken(accessToken)) {
// 直接清除Cookie
this.clearTokenCookie(response);
return true;
}
// 2. 解析用户+设备信息
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, accessToken);
String userId = claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
String deviceId = claims.get(JwtConstants.JWT_CLAIMS_DEVICE_ID).toString();
// 3. 下线当前设备
this.offlineDesignatedDevice(userId, deviceId);
// 4. 清除Token Cookie
this.clearTokenCookie(response);
return true;
} catch (Exception e) {
log.error("退出当前设备失败", e);
return false;
}
}
/**
* 清除Token Cookie
*/
private void clearTokenCookie(HttpServletResponse response) {
CookieUtil.removeCookie(response, JwtConstants.ACCESS_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
CookieUtil.removeCookie(response, JwtConstants.REFRESH_TOKEN_COOKIE_NAME, cookieDomain, cookiePath);
log.info("Token Cookie已清除");
}
/**
* 从Token中获取用户ID
*/
public String getUserIdFromToken(String token) {
if (!this.validateDeviceToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_USER_ID).toString();
}
/**
* 从Token中获取设备ID
*/
public String getDeviceIdFromToken(String token) {
if (!this.validateDeviceToken(token)) {
return null;
}
SecretKey secretKey = JwtBaseUtil.getHs512SecretKey(jwtSecretKey);
Map<String, Object> claims = JwtBaseUtil.parseHs512TokenClaims(secretKey, token);
return claims == null ? null : claims.get(JwtConstants.JWT_CLAIMS_DEVICE_ID).toString();
}
}
3. 场景专属控制器:AuthController.java + DeviceController.java 完整代码
(1)AuthController.java(认证核心接口)
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.dto.LoginDTO;
import com.example.jwtauthframework.common.dto.TokenDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtMultiDeviceUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 多设备管控认证控制器
* 封装登录、刷新Token、退出当前设备接口
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtMultiDeviceUtil jwtMultiDeviceUtil;
/**
* 多设备登录接口(需传入设备信息)
*
* @param response HttpServletResponse
* @param loginDTO 登录入参(含设备ID/设备类型/设备备注)
* @return Result<TokenDTO>
*/
@PostMapping("/login")
public Result<TokenDTO> multiDeviceLogin(HttpServletResponse response, @Validated @RequestBody LoginDTO loginDTO) {
try {
// 1. 模拟用户校验(实际项目需查询数据库,支持BCrypt密码加密)
if (!"user002".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 校验设备信息是否完整
if (!StringUtils.hasText(loginDTO.getDeviceId()) || !StringUtils.hasText(loginDTO.getDeviceType())) {
return Result.fail(400, "设备ID和设备类型不能为空");
}
// 3. 模拟用户ID(实际从数据库获取)
String userId = "40001";
// 4. 绑定用户与设备
boolean bindResult = jwtMultiDeviceUtil.bindUserAndDevice(
userId,
loginDTO.getDeviceId(),
loginDTO.getDeviceType(),
loginDTO.getDeviceRemark()
);
if (!bindResult) {
return Result.fail(403, "登录设备数量超限,请先下线旧设备");
}
// 5. 生成设备关联Token(模拟用户角色:USER)
TokenDTO tokenDTO = jwtMultiDeviceUtil.generateDeviceToken(response, userId, loginDTO.getUsername(), loginDTO.getDeviceId(), "USER");
if (tokenDTO == null) {
return Result.fail(500, "Token生成失败");
}
return Result.success("登录成功", tokenDTO);
} catch (Exception e) {
log.error("多设备登录失败", e);
return Result.fail("登录失败:" + e.getMessage());
}
}
/**
* 刷新设备Token接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<TokenDTO>
*/
@PostMapping("/refresh-token")
public Result<TokenDTO> refreshDeviceToken(HttpServletRequest request, HttpServletResponse response) {
try {
TokenDTO tokenDTO = jwtMultiDeviceUtil.refreshDeviceToken(request, response);
if (tokenDTO == null) {
return Result.fail(401, "Refresh Token无效,请重新登录");
}
return Result.success("Token刷新成功", tokenDTO);
} catch (Exception e) {
log.error("刷新设备Token失败", e);
return Result.fail("Token刷新失败:" + e.getMessage());
}
}
/**
* 退出当前设备登录接口
*
* @param request HttpServletRequest
* @param response HttpServletResponse
* @return Result<Void>
*/
@PostMapping("/logout")
public Result<Void> logoutCurrentDevice(HttpServletRequest request, HttpServletResponse response) {
try {
boolean logoutResult = jwtMultiDeviceUtil.logoutCurrentDevice(request, response);
if (!logoutResult) {
return Result.fail("退出登录失败");
}
return Result.success("退出当前设备成功");
} catch (Exception e) {
log.error("退出当前设备失败", e);
return Result.fail("退出登录失败:" + e.getMessage());
}
}
/**
* 精细化权限测试接口(仅ADMIN角色+FULL设备权限可访问)
*
* @param request HttpServletRequest
* @return Result<String>
*/
@GetMapping("/permission/test")
public Result<String> permissionTest(HttpServletRequest request) {
try {
// 1. 获取Access Token
String token = jwtMultiDeviceUtil.getTokenFromRequest(request, "access");
if (token == null) {
return Result.fail(401, "Token不能为空");
}
// 2. 校验接口权限(所需角色:ADMIN,所需设备权限:FULL)
boolean permResult = jwtMultiDeviceUtil.validateInterfacePermission(token, "ADMIN", "FULL");
if (!permResult) {
return Result.fail(403, "无接口访问权限");
}
// 3. 解析用户信息
String userId = jwtMultiDeviceUtil.getUserIdFromToken(token);
String deviceId = jwtMultiDeviceUtil.getDeviceIdFromToken(token);
String msg = String.format("权限校验成功!用户ID:%s,设备ID:%s,可访问该接口", userId, deviceId);
return Result.success(msg);
} catch (Exception e) {
log.error("权限测试失败", e);
return Result.fail("权限测试失败:" + e.getMessage());
}
}
}
(2)DeviceController.java(设备管理接口)
java
package com.example.jwtauthframework.business.auth.controller;
import com.example.jwtauthframework.common.dto.DeviceInfoDTO;
import com.example.jwtauthframework.common.Result;
import com.example.jwtauthframework.business.auth.utils.JwtMultiDeviceUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 设备管理控制器
* 封装设备列表查询、设备下线接口
*
* @author xxx
* @date 2026-01-02
*/
@Slf4j
@RestController
@RequestMapping("/api/device")
@RequiredArgsConstructor
public class DeviceController {
private final JwtMultiDeviceUtil jwtMultiDeviceUtil;
/**
* 查询当前用户所有登录设备
*
* @param request HttpServletRequest
* @return Result<List<DeviceInfoDTO>>
*/
@GetMapping("/my-devices")
public Result<List<DeviceInfoDTO>> getMyLoginDevices(HttpServletRequest request) {
try {
// 1. 获取Token并解析用户ID
String token = jwtMultiDeviceUtil.getTokenFromRequest(request, "access");
if (token == null || !jwtMultiDeviceUtil.validateDeviceToken(token)) {
return Result.fail(401, "Token无效,请先登录");
}
String userId = jwtMultiDeviceUtil.getUserIdFromToken(token);
// 2. 查询用户所有有效设备
List<DeviceInfoDTO> deviceList = jwtMultiDeviceUtil.getUserAllLoginDevices(userId);
return Result.success("查询设备列表成功", deviceList);
} catch (Exception e) {
log.error("查询用户设备列表失败", e);
return Result.fail("查询失败:" + e.getMessage());
}
}
/**
* 下线指定设备
*
* @param request HttpServletRequest
* @param deviceId 设备ID(路径参数)
* @return Result<Void>
*/
@PostMapping("/offline/{deviceId}")
public Result<Void> offlineDesignatedDevice(HttpServletRequest request, @PathVariable String deviceId) {
try {
// 1. 获取Token并解析用户ID
String token = jwtMultiDeviceUtil.getTokenFromRequest(request, "access");
if (token == null || !jwtMultiDeviceUtil.validateDeviceToken(token)) {
return Result.fail(401, "Token无效,请先登录");
}
String userId = jwtMultiDeviceUtil.getUserIdFromToken(token);
// 2. 下线指定设备
boolean offlineResult = jwtMultiDeviceUtil.offlineDesignatedDevice(userId, deviceId);
if (!offlineResult) {
return Result.fail(400, "设备不存在或下线失败");
}
return Result.success("设备下线成功");
} catch (Exception e) {
log.error("下线指定设备失败", e);
return Result.fail("设备下线失败:" + e.getMessage());
}
}
}
4. 测试验证:多设备登录/下线完整测试步骤
(1)前置条件
- 启动本地Redis(地址:127.0.0.1:6379,密码与
application.yml一致,数据库3)。 - 将通用核心模块+场景5代码复制到Spring Boot项目,调整包名与项目一致。
- 生成HS512密钥并配置到
jwt.secret.key。 - 启动Spring Boot应用(默认端口8080),确保无报错。
- 准备3个不同的设备ID(示例:
PC_Windows_001、Mobile_Android_001、Mini_WeChat_001),用于多设备登录测试。
(2)核心测试步骤
| 测试步骤 | 操作内容 | 预期结果 |
|---|---|---|
| 1. 单设备登录 | 调用POST /api/auth/login,传入用户名user002、密码123456、设备IDPC_Windows_001、设备类型PC |
返回200,登录成功;Redis中生成jwt:user:device:40001:PC_Windows_001键值对;Cookie写入双Token |
| 2. 多设备登录(未超限) | 分别用设备IDMobile_Android_001(设备类型MOBILE)、Mini_WeChat_001(设备类型MINI)调用登录接口 |
两次登录均返回200;Redis中新增2个设备键值对;用户40001的有效设备数为3台(未超最大限制) |
| 3. 多设备登录(超限) | 用设备IDMobile_iOS_001(设备类型MOBILE)调用登录接口 |
返回403,提示"登录设备数量超限,请先下线旧设备";登录失败,Redis不新增设备信息 |
| 4. 查询用户设备列表 | 调用GET /api/device/my-devices,携带有效Token |
返回200,包含3台设备信息(按登录时间倒序),设备权限分别为FULL、VIEW、VIEW |
| 5. 下线指定设备 | 调用POST /api/device/offline/Mobile_Android_001,携带有效Token |
返回200,提示"设备下线成功";Redis中删除该设备键值对;用户有效设备数变为2台;该设备旧Token无法访问接口 |
| 6. 权限校验测试(无权限) | 用移动端设备(Mini_WeChat_001)的Token调用GET /api/permission/test |
返回403,提示"无接口访问权限"(移动端权限为VIEW,接口需FULL权限) |
| 7. 权限校验测试(有权限) | 用PC端设备(PC_Windows_001)的Token(修改用户角色为ADMIN)调用GET /api/permission/test |
返回200,权限校验成功;显示用户ID和设备ID |
| 8. 退出当前设备 | 用PC_Windows_001设备的Token调用POST /api/auth/logout |
返回200,提示"退出当前设备成功";Cookie清除;Redis中删除该设备信息;该设备Token失效 |
(3)异常场景测试
- 篡改设备ID的Token:手动修改Token中的设备ID后调用鉴权接口 → 返回401,Token校验无效。
- 下线不存在的设备 :调用
POST /api/device/offline/Invalid_Device_001→ 返回400,提示"设备不存在或下线失败"。 - Refresh Token过期:等待7天后(或手动删除Redis中Token版本号),调用刷新Token接口 → 返回401,提示"Refresh Token无效,请重新登录"。
- 设备数量动态调整 :修改
device.max-login-count为5,重启项目后,用户可登录5台设备 → 登录成功,Redis新增设备信息。
场景6:企业级微服务/SSO/第三方授权
最优方案:JWT + Spring Security/Authorization Server
核心说明
-
适用场景
- 企业级微服务架构(如电商微服务集群、政务服务平台、金融核心业务系统),多服务统一认证授权需求。
- SSO单点登录(如企业OA、CRM、财务系统、人事系统统一登录,一次登录多系统免登)。
- 第三方授权登录(对接微信、支付宝、QQ、GitHub等平台,遵循OAuth 2.0协议)。
- 需细粒度权限管控、令牌统一管理、跨域认证共享的复杂业务系统。
- 核心特征:系统数量多、部署分散、用户体系统一、安全要求高、支持第三方接入。
-
微服务认证流程
- 统一认证中心搭建:基于Spring Authorization Server搭建独立的认证授权中心(Authorization Server),作为微服务集群的统一入口。
- 用户登录获取Token:用户通过前端访问任意微服务,未登录时跳转至认证中心,登录成功后获取JWT格式的Access Token和Refresh Token(遵循OAuth 2.0协议)。
- 资源服务器校验Token:各微服务作为资源服务器(Resource Server),接收前端携带的JWT Token,通过公钥/共享密钥校验Token有效性、用户身份及权限。
- 跨服务认证共享:JWT Token包含用户核心信息(ID、角色、权限),资源服务器无需与认证中心实时交互,实现无状态认证,支持微服务集群水平扩展。
- Token过期刷新:Access Token过期后,前端携带Refresh Token向认证中心申请新的Access Token,无需用户重新登录,提升体验。
- 权限精细化控制 :结合Spring Security的角色权限(ROLE_)和资源权限(SCOPE_),实现接口级、资源级的权限拦截。
-
SSO原理
- 核心本质:认证中心统一存储用户登录状态,各业务系统通过认证中心验证用户身份,实现"一次登录,多系统免登"。
- OAuth 2.0核心模式 :SSO常用授权码模式(Authorization Code),流程如下:
- 未登录用户访问业务系统A,跳转至认证中心并携带回调URI。
- 用户在认证中心登录成功后,认证中心生成授权码并跳转回业务系统A的回调接口。
- 业务系统A携带授权码向认证中心申请JWT Token。
- 认证中心验证授权码有效性,返回Token给业务系统A。
- 业务系统A存储Token并完成登录,后续访问其他业务系统时,携带Token即可免登。
- 单点注销:用户发起注销请求后,认证中心清除用户登录状态和Token缓存,同时通知各业务系统清除本地Token,实现多系统同时注销。
完整代码包(直接复制即可运行)
1. 场景专属依赖:pom.xml 完整补充配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version> <!-- 版本兼容:Spring Authorization Server 0.3.1 -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>jwt-sso-microservice</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>jwt-sso-microservice</name>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Authorization Server(核心:认证授权中心) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<!-- Spring Security Web(核心:安全拦截) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<!-- Spring Security Config(核心:安全配置) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<!-- JWT 支持(令牌解析、生成) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- Redis(存储客户端信息、Token缓存、登录状态) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lombok(简化代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2. 授权服务配置:AuthorizationServerConfig.java 完整代码
java
package com.example.jwtsso.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
/**
* Spring Authorization Server 授权服务配置
* 负责客户端配置、JWT令牌配置、授权流程配置
*
* @author xxx
* @date 2026-01-02
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthorizationServerConfig {
/**
* 密码编码器(BCrypt加密)
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 授权服务安全过滤器链(优先级最高)
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
// 应用Authorization Server默认配置
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http
// 配置OAuth2授权端点
.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // 启用OIDC(OpenID Connect)
// 未认证用户跳转至登录页面
http
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
)
// 放行OIDC相关端点
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 注册客户端信息(SSO客户端/第三方客户端)
* 模拟配置:微服务业务系统A、业务系统B
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {
// 客户端1:业务系统A(SSO登录,授权码模式)
RegisteredClient clientA = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("service-a") // 客户端ID
.clientSecret(passwordEncoder().encode("service-a-secret")) // 客户端密钥(加密存储)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // 客户端认证方式
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) // 授权类型:授权码模式
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // 支持刷新Token
.redirectUri("http://localhost:8081/login/oauth2/code/service-a") // 回调URI(业务系统A)
.postLogoutRedirectUri("http://localhost:8081/logout/success") // 注销回调URI
.scope(OidcScopes.OPENID) // OIDC范围
.scope(OidcScopes.PROFILE) // 个人信息范围
.scope("api:read") // 自定义权限范围
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) // 无需用户授权确认
.build();
// 客户端2:业务系统B(SSO登录,授权码模式)
RegisteredClient clientB = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("service-b")
.clientSecret(passwordEncoder().encode("service-b-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:8082/login/oauth2/code/service-b")
.postLogoutRedirectUri("http://localhost:8082/logout/success")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("api:read")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.build();
// 内存存储客户端(生产环境可替换为Redis/JDBC存储)
return new InMemoryRegisteredClientRepository(clientA, clientB);
}
/**
* JWK源配置(RSA密钥对,用于JWT令牌签名和验签)
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/**
* 生成RSA密钥对(用于JWT签名)
*/
private KeyPair generateRsaKeyPair() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception e) {
throw new IllegalStateException("生成RSA密钥对失败", e);
}
}
/**
* 授权服务器设置(端点地址等)
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}
3. 资源服务器配置:ResourceServerConfig.java 完整代码
java
package com.example.jwtsso.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
/**
* 资源服务器配置
* 负责校验JWT Token有效性、拦截未授权请求
*
* @author xxx
* @date 2026-01-02
*/
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
/**
* 资源服务器安全过滤器链(优先级低于授权服务)
*/
@Bean
@Order(2)
public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/sso/userinfo").authenticated() // 用户信息接口需要认证
.requestMatchers("/api/sso/**").permitAll() // SSO基础接口匿名访问
.anyRequest().authenticated() // 其他接口均需认证
)
// 启用JWT资源服务器
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
/**
* JWT认证转换器(转换JWT中的权限信息)
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
// 配置JWT中权限声明的前缀(默认SCOPE_)
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
// 配置JWT中权限声明的名称(默认scope)
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
4. 安全配置:SecurityConfig.java 完整代码
java
package com.example.jwtsso.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* Spring Security 核心安全配置
* 负责用户详情服务、登录页面配置、安全拦截规则
*
* @author xxx
* @date 2026-01-02
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
/**
* 用户详情服务(模拟用户,生产环境替换为数据库查询)
*/
@Bean
public UserDetailsService userDetailsService() {
// 管理员用户:admin/123456,角色ADMIN
UserDetails adminUser = User.withUsername("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW") // 密码123456
.roles("ADMIN")
.build();
// 普通用户:user/123456,角色USER
UserDetails normalUser = User.withUsername("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW") // 密码123456
.roles("USER")
.build();
// 内存存储用户(生产环境可替换为JDBC/Redis存储)
return new InMemoryUserDetailsManager(adminUser, normalUser);
}
/**
* 登录页面及通用安全配置
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login").permitAll() // 登录页面匿名访问
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // 自定义登录页面路径(可替换为自定义页面)
.defaultSuccessUrl("/login/success", true) // 登录成功跳转地址
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout") // 注销接口
.logoutSuccessUrl("/logout/success") // 注销成功跳转地址
.invalidateHttpSession(true) // 失效Session
.deleteCookies("JSESSIONID") // 删除Cookie
.permitAll()
)
.csrf(csrf -> csrf.disable()) // 关闭CSRF(微服务REST接口无需CSRF)
.sessionManagement(session -> session
.maximumSessions(1) // 单用户最大会话数(SSO场景限制单设备登录)
.expiredUrl("/login?expired=true") // 会话过期跳转地址
);
return http.build();
}
}
5. 核心控制器:SSOController.java 完整代码
java
package com.example.jwtsso.controller;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* SSO单点登录核心控制器
* 封装SSO登录、回调、注销、用户信息查询接口
*
* @author xxx
* @date 2026-01-02
*/
@RestController
@RequestMapping("/api/sso")
public class SSOController {
/**
* SSO登录页面跳转(前端可直接访问认证中心/login,此处为兼容接口)
*/
@GetMapping("/to-login")
public String toLogin() {
return "redirect:/login";
}
/**
* 登录成功回调(返回用户基本信息)
*/
@GetMapping("/login/success")
public Map<String, Object> loginSuccess() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "登录成功");
result.put("authentication", authentication.getName());
return result;
}
/**
* 注销成功回调
*/
@GetMapping("/logout/success")
public Map<String, Object> logoutSuccess() {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "注销成功");
return result;
}
/**
* 获取当前登录用户信息(SSO核心接口,各业务系统可调用)
*/
@GetMapping("/userinfo")
public Map<String, Object> getUserInfo() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Map<String, Object> userInfo = new HashMap<>();
// 处理OIDC用户(第三方授权/SSO登录)
if (authentication.getPrincipal() instanceof OidcUser oidcUser) {
userInfo.put("username", oidcUser.getName());
userInfo.put("roles", oidcUser.getAuthorities());
userInfo.put("oidcClaims", oidcUser.getClaims());
}
// 处理OAuth2用户(第三方授权)
else if (authentication.getPrincipal() instanceof OAuth2User oauth2User) {
userInfo.put("username", oauth2User.getName());
userInfo.put("roles", oauth2User.getAuthorities());
userInfo.put("oauth2Claims", oauth2User.getAttributes());
}
// 处理本地用户
else {
userInfo.put("username", authentication.getName());
userInfo.put("roles", authentication.getAuthorities());
}
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "查询用户信息成功");
result.put("data", userInfo);
return result;
}
/**
* 第三方授权入口(示例:微信授权,实际对接微信开放平台)
*/
@GetMapping("/auth/wechat")
public String wechatAuth() {
// 跳转微信授权页面(需替换为真实微信授权地址+appid+回调URI)
String wechatAuthUrl = "https://open.weixin.qq.com/connect/qrconnect?appid=YOUR_WECHAT_APPID&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fapi%2Fsso%2Fauth%2Fwechat%2Fcallback&response_type=code&scope=snsapi_login&state=STATE#wechat_redirect";
return "redirect:" + wechatAuthUrl;
}
/**
* 微信授权回调接口(示例)
*/
@GetMapping("/auth/wechat/callback")
public Map<String, Object> wechatAuthCallback(String code, String state) {
Map<String, Object> result = new HashMap<>();
// 1. 用code换取微信access_token(实际需调用微信接口)
// 2. 用access_token换取用户微信信息(openid、昵称等)
// 3. 本地用户关联(注册/登录)
// 4. 生成SSO JWT Token并返回
result.put("code", 200);
result.put("msg", "微信授权回调成功");
result.put("data", code);
result.put("state", state);
return result;
}
}
6. 测试验证:微服务间认证测试步骤
(1)前置条件
-
启动本地Redis(地址:127.0.0.1:6379,无需密码,默认数据库0)。
-
将上述代码复制到Spring Boot项目,调整包名与项目一致。
-
启动认证授权中心(端口:8080,项目名称:jwt-sso-microservice)。
-
搭建两个微服务(业务系统A:8081,业务系统B:8082),引入Spring Security OAuth2客户端依赖,配置对接认证中心。
-
业务系统A/B的
application.yml核心配置(以业务系统A为例):yamlspring: security: oauth2: client: registration: service-a: client-id: service-a client-secret: service-a-secret authorization-grant-type: authorization_code redirect-uri: http://localhost:8081/login/oauth2/code/service-a scope: openid,profile,api:read provider: service-a: issuer-uri: http://localhost:8080 # 认证中心地址 server: port: 8081
(2)核心测试步骤
| 测试步骤 | 操作内容 | 预期结果 |
|---|---|---|
| 1. 访问业务系统A | 浏览器输入:http://localhost:8081/api/hello(业务系统A测试接口) |
自动跳转至认证中心登录页面(http://localhost:8080/login) |
| 2. SSO登录 | 在认证中心输入用户名admin、密码123456,点击登录 |
登录成功后,跳转回业务系统A的回调接口,完成登录,可正常访问/api/hello |
| 3. 访问业务系统B | 浏览器输入:http://localhost:8082/api/hello(业务系统B测试接口) |
无需重新登录,直接免登访问(SSO生效,认证中心已存储登录状态) |
| 4. 查询用户信息 | 访问认证中心接口:http://localhost:8080/api/sso/userinfo |
返回200,包含当前登录用户(admin)的用户名、角色等信息 |
| 5. 刷新Token | 业务系统A携带Refresh Token调用认证中心/oauth2/token接口 |
返回新的Access Token,无需重新登录 |
| 6. 单点注销 | 访问认证中心注销接口:http://localhost:8080/logout |
注销成功,业务系统A/B均需重新登录才能访问接口 |
| 7. 第三方授权测试 | 访问:http://localhost:8080/api/sso/auth/wechat |
跳转至微信扫码授权页面(示例),回调后返回授权码 |
| 8. 权限校验测试 | 用普通用户user登录,访问需要ADMIN角色的接口 |
返回403,无访问权限;用admin用户登录可正常访问 |
(3)微服务间认证关键验证点
- 无状态认证:业务系统A/B无需存储用户登录状态,仅通过JWT Token即可完成认证,支持水平扩展。
- Token有效性:篡改JWT Token后,业务系统无法通过校验,返回401未授权。
- 跨域认证:业务系统A/B部署在不同端口(跨域),通过SSO实现统一认证,无跨域权限问题。
- 单点注销:认证中心注销后,所有关联业务系统的登录状态均失效,实现"一处注销,处处注销"。