Java基础 | JWT登录场景化最优方案(一)

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. 测试验证:完整流程测试步骤)
    • 场景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. 高并发测试:压力测试配置 + 预期结果)
    • 场景5:多设备管控/精细化权限
    • 场景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. 测试验证:微服务间认证测试步骤)

引言

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密钥存储规范
  1. 禁止硬编码到代码中,优先存储在Nacos/Apollo等配置中心,配置项命名:jwt.rsa.public.keyjwt.rsa.private.key
  2. 私钥文件需加密存储,权限设置为仅管理员可读取(Linux:chmod 600),避免泄露。
  3. 公钥可对外暴露(如微服务资源服务器、第三方合作系统),私钥仅存储在认证服务中。

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 + 短期有效 + 本地缓存

核心说明

  1. 适用场景:小型内部工具(如后台管理系统、数据统计工具)、单机部署项目、用户量小于1000的小型应用。
  2. 核心优势:无需依赖Redis等中间件,部署简单,开发成本低,满足小型项目基础认证需求。
  3. 注意事项
    • 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. 本地运行步骤

    • 步骤1:将上述代码复制到Spring Boot项目中,调整包名与项目一致。
    • 步骤2:运行JwtSecretGenerator.java的main方法,生成HS512密钥,配置到application.yml中。
    • 步骤3:启动Spring Boot应用,端口默认443(HTTPS),若需调试可改为8080并关闭HTTPS配置。
    • 步骤4:使用Postman等工具调用接口,进行测试。
  2. 接口调用示例

    接口地址 请求方法 请求头 请求体 预期结果
    /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 产品

核心说明

  1. 适用场景:中小型ToB产品(如企业管理系统、SaaS平台)、用户量1000-10万的业务系统、集群部署但并发量不高的项目。
  2. 核心优势
    • 依赖Redis实现Token版本号控制,支持集群部署,解决单机本地缓存同步问题。
    • 使用HttpOnly Cookie存储Token,防止XSS攻击,提升安全性。
    • 支持Token刷新,减少用户频繁登录,提升用户体验。
  3. 注意事项
    • 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. 本地运行前置条件

    • 步骤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,可按需调整)。
  2. 接口调用示例(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的版本号被删除
  3. 核心功能验证要点

    • 验证1:登录后,Redis中生成jwt:user:version:10001键值对,Cookie中存在access_tokenrefresh_token
    • 验证2:刷新Token接口调用后,新的Token写入Cookie,Redis版本号更新。
    • 验证3:退出登录后,Redis版本号被删除,旧Token无法通过鉴权。
    • 验证4:调用logout-all接口后,用户所有设备的旧Token均失效,需重新登录。
    • 验证5:关闭浏览器后重新打开,Cookie未失效(未到过期时间),仍可正常访问/api/auth/test接口。

场景3:中大型ToC产品

最优方案:JWT + 双Token + Redis + 无感刷新

核心说明

  1. 适用场景:中大型ToC产品(如电商App、社交平台、短视频平台)、用户量10万+、多端(Web/APP/小程序)登录、对用户体验要求极高的业务系统。
  2. 核心优势
    • 无感刷新:Access Token过期前,前端静默调用刷新接口获取新Token,用户无感知,避免频繁登录影响体验。
    • 双Token分离:Access Token(短期有效,1-2小时)用于接口鉴权,Refresh Token(长期有效,7-30天)用于刷新Token,降低Token泄露风险。
    • 集群兼容:基于Redis存储Token信息,支持分布式部署,解决多节点数据同步问题。
    • 灵活管控:支持单设备退出、全部设备退出,满足ToC产品用户账号安全管控需求。
  3. 无感刷新逻辑详解
    1. 前端存储:登录成功后,前端存储Access Token、Refresh Token及Access Token过期时间戳。
    2. 前置判断:每次发起接口请求前,判断Access Token是否即将过期(如剩余时间小于5分钟)。
    3. 静默刷新:若即将过期,前端先静默调用刷新Token接口,获取新的双Token后更新本地存储,再发起原业务请求。
    4. 异常兜底:若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. 本地运行前置条件

    • 步骤1:启动本地Redis(127.0.0.1:6379),密码与application.yml一致。
    • 步骤2:复制通用核心模块+场景3代码到Spring Boot项目,调整包名。
    • 步骤3:生成HS512密钥并配置到jwt.secret.key
    • 步骤4:启动Spring Boot应用(端口8080),启动前端项目(Vue 3)。
  2. 核心流程测试步骤

    测试步骤 操作内容 预期结果
    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/logout Cookie清除,本地存储删除,Redis版本号删除,旧Token无法鉴权
    6. 全部设备退出 调用/api/auth/logout-all/20001 该用户所有设备的旧Token均失效,需重新登录
  3. 异常场景测试

    • 场景1:Refresh Token过期后调用刷新接口 → 返回401,前端引导用户登录。
    • 场景2:篡改Access Token后调用鉴权接口 → 返回401,鉴权失败。
    • 场景3:多端登录同一账号,其中一端退出全部设备 → 所有端均需重新登录。

场景4:高并发场景/黑名单量大

最优方案:JWT + 双Token + Redis + 布隆过滤器

核心说明

  1. 适用场景:高并发ToC产品(如秒杀平台、直播平台、电商大促场景)、Token黑名单量达百万/千万级、Redis查询压力过大、对接口响应速度要求极高(毫秒级)的业务系统。
  2. 高并发适配细节
    • 布隆过滤器前置拦截:将黑名单Token存入布隆过滤器,先通过布隆过滤器快速判断Token是否在黑名单,减少Redis查询次数(Redis查询耗时远高于布隆过滤器)。
    • 异步批量操作:Token黑名单写入采用异步批量提交,避免高并发下同步写入Redis造成的性能瓶颈。
    • 分层存储:布隆过滤器(内存/Redis)存储黑名单标识,Redis存储黑名单详细信息(过期时间等),实现"快速判断+精准存储"。
    • 动态扩容:布隆过滤器随黑名单数量增长自动扩容,避免误判率升高。
  3. 布隆过滤器原理
    • 核心结构:位图(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. 高并发测试:压力测试配置 + 预期结果
  1. JMeter压力测试配置

    • 线程组配置:线程数1000, Ramp-Up时间10秒(每秒启动100线程),循环次数100次(总请求数10万)。
    • HTTP请求配置:
    1. 登录请求:POST http://localhost:8080/api/auth/login,传入正确用户名密码。
    2. 鉴权请求:GET http://localhost:8080/api/auth/seckill/test,携带登录返回的Token。
    3. 退出请求:POST http://localhost:8080/api/auth/logout,携带Token。
    • 断言配置:响应码200,响应信息包含"鉴权成功"。
    • 监听器配置:聚合报告、查看结果树、Summary Report。
  2. 预期测试结果

    指标 预期值 说明
    平均响应时间 < 50ms 布隆过滤器前置拦截,减少Redis查询,响应速度快
    吞吐量 > 2000 QPS 高并发场景下满足秒杀/直播业务需求
    错误率 < 0.1% 仅少量请求因网络/线程池耗尽失败
    Redis QPS < 500 布隆过滤器拦截大部分黑名单查询,Redis压力大幅降低
    布隆过滤器误判率 < 0.001 符合配置的误判率要求,无大量无效Redis查询
  3. 测试优化要点

    • 若响应时间过长:增大Redis连接池、优化布隆过滤器哈希函数个数、采用Redis布隆过滤器集群。
    • 若错误率过高:增大Tomcat线程池(server.tomcat.threads.max)、优化JVM内存配置。
    • 若布隆过滤器误判率过高:增大预计插入量、降低误判率配置、缩短重建周期。

场景5:多设备管控/精细化权限

最优方案:JWT + 双Token + Redis(用户+设备)

核心说明

  1. 适用场景

    • 企业办公系统(限制员工账号登录设备数量,防止账号泄露)
    • 付费会员产品(如视频会员、云服务会员,限制多设备共享登录)
    • 金融/政务App(高安全要求,需精细化管控设备权限,防止盗号操作)
    • 智能家居管理平台(设备绑定用户,不同设备对应不同操作权限)
    • 核心特征:用户账号安全要求高、需限制登录设备数量、权限需按用户/设备维度精细化划分
  2. 多设备管控逻辑

    1. 设备唯一标识 :前端生成/获取设备唯一ID(设备类型+系统版本+设备序列号/UUID,格式示例:PC_Windows_xxx/Mobile_Android_xxx),登录时传入后端。
    2. 设备绑定 :登录成功后,后端以 jwt:user:device:{userId}:{deviceId} 为Redis Key,存储设备信息(设备类型、登录时间、备注、Token版本),并设置过期时间与Refresh Token一致。
    3. 数量校验:登录前,后端先查询该用户已绑定的有效设备数量,若超过配置的最大设备数,提示用户"设备数量超限,请先下线旧设备",并支持用户选择下线指定旧设备。
    4. Token关联设备:JWT载荷中加入设备ID,后端校验Token时,不仅校验签名、过期时间、版本号,还需校验该设备ID是否在用户的有效设备列表中。
    5. 设备下线:用户可主动下线指定设备(前端选择设备,后端删除对应Redis设备信息),该设备的Token立即失效,无法再访问接口。
  3. 权限精细化设计

    • 三层权限维度
    1. 用户维度:基于角色的权限(如管理员/普通用户/会员用户),存储在JWT载荷中,控制用户整体功能权限。
    2. 设备维度:基于设备类型的权限(如PC端可操作全部功能、移动端仅可查看数据、小程序端仅支持基础操作),存储在Redis设备信息和JWT载荷中。
    3. 接口维度:基于接口的细粒度权限(如"用户信息修改"接口仅允许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)前置条件
  1. 启动本地Redis(地址:127.0.0.1:6379,密码与application.yml一致,数据库3)。
  2. 将通用核心模块+场景5代码复制到Spring Boot项目,调整包名与项目一致。
  3. 生成HS512密钥并配置到jwt.secret.key
  4. 启动Spring Boot应用(默认端口8080),确保无报错。
  5. 准备3个不同的设备ID(示例:PC_Windows_001Mobile_Android_001Mini_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台设备信息(按登录时间倒序),设备权限分别为FULLVIEWVIEW
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)异常场景测试
  1. 篡改设备ID的Token:手动修改Token中的设备ID后调用鉴权接口 → 返回401,Token校验无效。
  2. 下线不存在的设备 :调用POST /api/device/offline/Invalid_Device_001 → 返回400,提示"设备不存在或下线失败"。
  3. Refresh Token过期:等待7天后(或手动删除Redis中Token版本号),调用刷新Token接口 → 返回401,提示"Refresh Token无效,请重新登录"。
  4. 设备数量动态调整 :修改device.max-login-count为5,重启项目后,用户可登录5台设备 → 登录成功,Redis新增设备信息。

场景6:企业级微服务/SSO/第三方授权

最优方案:JWT + Spring Security/Authorization Server

核心说明

  1. 适用场景

    • 企业级微服务架构(如电商微服务集群、政务服务平台、金融核心业务系统),多服务统一认证授权需求。
    • SSO单点登录(如企业OA、CRM、财务系统、人事系统统一登录,一次登录多系统免登)。
    • 第三方授权登录(对接微信、支付宝、QQ、GitHub等平台,遵循OAuth 2.0协议)。
    • 需细粒度权限管控、令牌统一管理、跨域认证共享的复杂业务系统。
    • 核心特征:系统数量多、部署分散、用户体系统一、安全要求高、支持第三方接入。
  2. 微服务认证流程

    1. 统一认证中心搭建:基于Spring Authorization Server搭建独立的认证授权中心(Authorization Server),作为微服务集群的统一入口。
    2. 用户登录获取Token:用户通过前端访问任意微服务,未登录时跳转至认证中心,登录成功后获取JWT格式的Access Token和Refresh Token(遵循OAuth 2.0协议)。
    3. 资源服务器校验Token:各微服务作为资源服务器(Resource Server),接收前端携带的JWT Token,通过公钥/共享密钥校验Token有效性、用户身份及权限。
    4. 跨服务认证共享:JWT Token包含用户核心信息(ID、角色、权限),资源服务器无需与认证中心实时交互,实现无状态认证,支持微服务集群水平扩展。
    5. Token过期刷新:Access Token过期后,前端携带Refresh Token向认证中心申请新的Access Token,无需用户重新登录,提升体验。
    6. 权限精细化控制 :结合Spring Security的角色权限(ROLE_)和资源权限(SCOPE_),实现接口级、资源级的权限拦截。
  3. SSO原理

    • 核心本质:认证中心统一存储用户登录状态,各业务系统通过认证中心验证用户身份,实现"一次登录,多系统免登"。
    • OAuth 2.0核心模式 :SSO常用授权码模式(Authorization Code),流程如下:
    1. 未登录用户访问业务系统A,跳转至认证中心并携带回调URI。
    2. 用户在认证中心登录成功后,认证中心生成授权码并跳转回业务系统A的回调接口。
    3. 业务系统A携带授权码向认证中心申请JWT Token。
    4. 认证中心验证授权码有效性,返回Token给业务系统A。
    5. 业务系统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)前置条件
  1. 启动本地Redis(地址:127.0.0.1:6379,无需密码,默认数据库0)。

  2. 将上述代码复制到Spring Boot项目,调整包名与项目一致。

  3. 启动认证授权中心(端口:8080,项目名称:jwt-sso-microservice)。

  4. 搭建两个微服务(业务系统A:8081,业务系统B:8082),引入Spring Security OAuth2客户端依赖,配置对接认证中心。

  5. 业务系统A/B的application.yml核心配置(以业务系统A为例):

    yaml 复制代码
    spring:
      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)微服务间认证关键验证点
  1. 无状态认证:业务系统A/B无需存储用户登录状态,仅通过JWT Token即可完成认证,支持水平扩展。
  2. Token有效性:篡改JWT Token后,业务系统无法通过校验,返回401未授权。
  3. 跨域认证:业务系统A/B部署在不同端口(跨域),通过SSO实现统一认证,无跨域权限问题。
  4. 单点注销:认证中心注销后,所有关联业务系统的登录状态均失效,实现"一处注销,处处注销"。
相关推荐
weixin_457340216 小时前
lora监督微调(SFT)
开发语言·python
_200_6 小时前
Lua 运算符
开发语言·junit·lua
UP_Continue6 小时前
C++11--引言折叠与完美转发
开发语言·c++
码农三叔6 小时前
(4-2-05)Python SDK仓库:MCP服务器端(5)Streamable HTTP传输+Streamable HTTP传输
开发语言·python·http·大模型·1024程序员节·mcp·mcp sdk
十铭忘6 小时前
Vue3实现Pixso中的钢笔工具
开发语言·javascript·vue
IT枫斗者6 小时前
Spring Boot 4.0 正式发布:新一代起点到底“新”在哪?(Spring Framework 7 / Java 25 / JSpecify / API 版本管理 / HTTP Service
java·开发语言·spring boot·后端·python·spring·http
龙茶清欢6 小时前
WebClient:Spring WebFlux 响应式 HTTP 客户端权威说明文档
java·spring·http
AI大佬的小弟6 小时前
Python基础(10):Python函数基础详解
开发语言·python·函数·ai大模型基础·嵌套函数·变量的作用域·全局变量和局部变量
Evand J6 小时前
【2026课题推荐】基于累计概率方法匹配轨迹的飞行目标轨迹定位,附MATLAB代码的演示效果
开发语言·matlab·目标跟踪·定位·轨迹匹配