SpringBoot3 登录注册系列(一) - 多模块架构与统一响应封装
第一章. 为什么需要多模块架构
在开始构建认证系统之前,我们先解决一个基础问题:为什么不能把所有代码都塞在一个模块里?
假设我们现在有一个单模块项目,所有代码都在 src/main/java/com/example/auth 下。这种结构在项目初期看起来很简洁,但随着业务增长,会遇到三个致命问题。
1.1. 单模块的三大困境
困境一:职责不清
JwtUtil 是通用的 JWT 工具类,理论上可以被其他项目复用。但现在它和业务代码混在一起,如果其他项目想用,只能复制粘贴代码。
困境二:依赖混乱
AuthController 依赖了 TokenService,TokenService 依赖了 JwtUtil,JwtUtil 依赖了 RedisUtil
。这些依赖关系全部隐藏在代码里,新人接手项目时很难理解架构。
困境三:测试困难
如果我想单独测试 JwtUtil 的功能,必须启动整个 Spring Boot 应用,因为它和 Web 层耦合在一起。
1.2. 多模块架构设计
我们将项目拆分成多个模块,每个模块有明确的职责:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| auth/ # 根聚合器 ├── pom.xml # 父POM(统一管理依赖版本) ├── auth-common/ # 公共基础模块 │ └── src/main/java/.../common/ │ ├── model/Result.java │ └── util/SnowflakeIdGenerator.java ├── auth-core/ # 认证核心模块 │ └── src/main/java/.../core/ │ ├── util/JwtUtil.java │ └── service/TokenService.java └── auth-web/ # Web应用模块 └── src/main/java/.../web/ ├── AuthApplication.java └── controller/AuthController.java
|
依赖关系非常清晰:auth-web → auth-core → auth-common
核心设计:聚合器和父 POM 分离,根目录只声明模块,auth-parent 只管理依赖版本。
第二章. 核心配置文件
2.1. 根 POM 配置(聚合器 + 父工程)
根目录的 pom.xml 同时负责两个职责:
- 聚合器(Aggregator):通过
<modules> 声明包含哪些子模块 - 父工程(Parent):通过
<dependencyManagement> 统一管理依赖版本
📄 文件路径:pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId> <artifactId>auth</artifactId> <version>1.0.0</version> <packaging>pom</packaging>
<name>auth</name> <description>Auth System Multi-Module Project</description>
<properties> <java.version>17</java.version> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <spring-boot.version>3.4.2</spring-boot.version> <jjwt.version>0.12.5</jjwt.version> <hutool.version>5.8.34</hutool.version> <lombok.version>1.18.30</lombok.version> </properties>
<modules> <module>auth-common</module> <module>auth-core</module> <module>auth-web</module> </modules>
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> <scope>provided</scope> </dependency>
<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> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>${jjwt.version}</version> </dependency>
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>${hutool.version}</version> </dependency> </dependencies> </dependencyManagement>
<build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <source>${java.version}</source> <target>${java.version}</target> <encoding>UTF-8</encoding> <parameters>true</parameters> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> </plugin> </plugins> </pluginManagement> </build> </project>
|
标准实践:在 99% 的 Maven 多模块项目中,根目录既是聚合器也是父工程。这样可以:
- 减少目录层级,结构更清晰
- 避免维护多余的 POM 文件
- 简化子模块的
<relativePath> 配置
2.2. 子模块配置
每个子模块都需要指定父 POM 为根目录的 auth,并声明自己的依赖。
auth-common 模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| <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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>auth</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent>
<artifactId>auth-common</artifactId>
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency> </dependencies> </project>
|
子模块的 <relativePath> 指向 ../pom.xml,即父目录的 POM 文件。这样 Maven 可以正确找到父工程。
auth-core 模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>auth</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent>
<artifactId>auth-core</artifactId>
<dependencies> <dependency> <groupId>com.example</groupId> <artifactId>auth-common</artifactId> <version>1.0.0</version> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <scope>runtime</scope> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <scope>runtime</scope> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> </project>
|
auth-web 模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.example</groupId> <artifactId>auth</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent>
<artifactId>auth-web</artifactId>
<dependencies> <dependency> <groupId>com.example</groupId> <artifactId>auth-core</artifactId> <version>1.0.0</version> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
|
第三章. 统一响应封装
3.1. 为什么需要统一响应
假设我们有两个接口,一个返回 Token 对象,一个返回 User 对象。前端收到的响应格式完全不同:
1 2 3 4 5 6 7 8 9 10 11
| { "accessToken": "token123", "refreshToken": "refresh456" }
{ "id": 1001, "username": "admin" }
|
前端开发者会抱怨:“为什么每个接口的格式都不一样?我怎么知道请求成功还是失败?”
我们需要定义一个统一的响应格式,所有接口都返回这个结构:
1 2 3 4 5 6 7
| { "code": 200, "message": "操作成功", "data": { } }
|
3.2. Result 泛型类设计
我们使用泛型 Result<T> 来封装响应,其中 T 可以是任何类型的数据。
📄 文件路径:auth-common/src/main/java/com/example/auth/common/model/Result.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| package com.example.auth.common.model;
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data @NoArgsConstructor @AllArgsConstructor public class Result<T> implements Serializable { private Integer code; private String message; private T data;
public static <T> Result<T> ok(T data) { return new Result<>(200, "操作成功", data); }
public static <T> Result<T> ok() { return new Result<>(200, "操作成功", null); }
public static <T> Result<T> fail(Integer code, String message) { return new Result<>(code, message, null); }
public static <T> Result<T> fail(String message) { return new Result<>(500, message, null); } }
|
现在,我们的接口可以统一返回格式:
1 2 3 4 5 6 7 8 9 10 11 12
| @PostMapping("/login") public Result<AuthToken> login() { AuthToken token = new AuthToken("token123", "refresh456"); return Result.ok(token); }
@GetMapping("/user") public Result<User> getUser() { User user = new User(1001L, "admin"); return Result.ok(user); }
|
前端收到的响应格式完全一致:
1 2 3 4 5 6 7 8
| { "code": 200, "message": "操作成功", "data": { "accessToken": "token123", "refreshToken": "refresh456" } }
|
3.3. 雪花算法工具类
在后续章节中,我们需要生成全局唯一的用户 ID。我们使用 Hutool 提供的雪花算法生成 19 位 Long 类型的趋势递增 ID来实现。
📄 文件路径:auth-common/src/main/java/com/example/auth/common/util/SnowflakeIdGenerator.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package com.example.auth.common.util;
import cn.hutool.core.lang.Snowflake; import cn.hutool.core.util.IdUtil;
public class SnowflakeIdGenerator {
private static final Snowflake SNOWFLAKE = IdUtil.getSnowflake(1, 1);
public static Long nextId() { return SNOWFLAKE.nextId(); } }
|
第四章. 本章小结
我们完成了标准 Maven 多模块架构的搭建,并封装了统一响应格式。
核心成果:
| 模块 | 职责 | 依赖关系 |
|---|
| auth (根 POM) | 聚合器 + 父工程 | 无 |
| auth-common | 公共基础模块 | 继承 auth |
| auth-core | 认证核心模块 | 继承 auth,依赖 auth-common |
| auth-web | Web 应用模块 | 继承 auth,依赖 auth-core |
架构优势:
- 职责分离:每个模块有明确的职责,易于维护
- 可复用性:auth-common 可以被其他项目复用
- 易于测试:auth-core 可以独立测试,不依赖 Web 层
- 版本统一:所有依赖版本在根 POM 中统一管理
- 结构清晰:采用标准的 Maven 多模块结构,减少了多余的目录层级
核心类速查:
| 类名 | 方法 | 作用 |
|---|
| Result | ok(T data) | 成功响应(带数据) |
| Result | ok() | 成功响应(不带数据) |
| Result | fail(Integer code, String message) | 失败响应 |
| SnowflakeIdGenerator | nextId() | 生成全局唯一 ID |
SpringBoot3 登录注册系列(二) - RSA 非对称加密实战
第一章. 为什么选择 RS256
在开始生成密钥之前,我们需要先回答一个核心问题:JWT 的签名算法应该选择 HS256 还是 RS256?
1.1. HS256 的架构困境
HS256 是对称加密签名和验证使用同一个密钥算法。它的工作流程是:
1 2
| 签名:Token + 密钥 → 签名 验证:Token + 密钥 → 验证签名是否匹配
|
假设我们的系统有 20 个微服务,每个服务都需要验证 Token。那么这 20 个服务都必须持有同一个密钥。这带来两个风险:
风险一:攻击面扩大
密钥存在于 20 个不同的配置文件、环境变量、容器镜像中。任何一个环节泄漏,整个系统就沦陷了。
风险二:密钥轮换困难
当我们需要更换密钥时(如定期安全审计、员工离职),必须同时更新 20 个服务的配置并重启。在生产环境中,这几乎不可能做到原子性切换。
1.2. RS256 的架构优势
RS256 是非对称加密使用私钥签名,公钥验证算法。它的工作流程是:
1 2
| 签名:Token + 私钥 → 签名 验证:Token + 公钥 → 验证签名是否匹配
|
在这种架构下:
- 认证服务 持有私钥,负责签发 Token。私钥是整个系统中唯一的敏感数据,只需要保护一个点。
- 网关和业务服务 持有公钥,负责验证 Token。公钥可以公开分发,即使泄漏也不会影响安全性(攻击者无法用公钥伪造签名)。
这种设计的核心价值在于 职责分离:只有认证服务才有 “印钞权”,其他服务只能 “验钞” 而不能 “造钞”。
1.3. 架构对比
| 对比维度 | HS256(对称加密) | RS256(非对称加密) |
|---|
| 密钥数量 | 1 个(签名和验证共用) | 2 个(私钥签名,公钥验证) |
| 密钥分发 | 所有服务都需要持有密钥 | 只有认证服务持有私钥 |
| 安全风险 | 任何一个服务泄漏,全局沦陷 | 只有认证服务泄漏才会沦陷 |
| 密钥轮换 | 需要同时更新所有服务 | 只需要更新认证服务 |
| 性能 | 快(对称加密) | 稍慢(非对称加密) |
在微服务架构下,我们选择 RS256,用稍微的性能损失换取更高的安全性和更灵活的架构。
第二章. 使用 OpenSSL 生成 RSA 密钥对
2.1. 密钥格式要求
Java 的 KeyFactory 对密钥格式有严格要求:
- 私钥必须是 PKCS#8 格式
- 公钥必须是 X.509 格式
如果格式不对,会抛出 InvalidKeySpecException 异常。
2.2. 生成 PKCS#8 格式的私钥
打开终端(Windows 用户可以使用 Git Bash),输入以下命令:
注意,openssl 通常在 git 窗口上才可以执行,请使用 git bash 执行此命令
1
| openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
命令解释:
openssl genpkey:使用 OpenSSL 生成密钥(新版命令,自动生成 PKCS#8 格式)-algorithm RSA:指定算法为 RSA-out private_key.pem:输出文件名-pkeyopt rsa_keygen_bits:2048:密钥长度为 2048 位
执行后,你会看到类似这样的输出:
并且在当前目录下会生成一个 private_key.pem 文件。
用文本编辑器打开 private_key.pem,你应该看到:
1 2 3
| -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC... -----END PRIVATE KEY-----
|
关键标识:-----BEGIN PRIVATE KEY-----(没有 “RSA” 字样),这就是 PKCS#8 格式。
2.3. 从私钥中提取公钥
继续在终端中输入:
1
| openssl rsa -pubout -in private_key.pem -out public_key.pem
|
命令解释:
openssl rsa:使用 OpenSSL 处理 RSA 密钥-pubout:输出公钥-in private_key.pem:指定输入文件(刚才生成的私钥)-out public_key.pem:输出文件名
执行后,你会看到:
用文本编辑器打开 public_key.pem,你应该看到:
1 2 3
| -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtkR... -----END PUBLIC KEY-----
|
2.4. 将密钥放入项目
在 auth-web 模块中,找到 src/main/resources 目录,创建 certs 文件夹:
1 2 3 4
| auth-web/src/main/resources/ └── certs/ ├── private_key.pem └── public_key.pem
|
将刚才生成的两个文件复制到这个目录下。
安全提示:必须在 .gitignore 中添加 **/certs/private_key.pem,防止私钥被提交到 Git 仓库。
第三章. 封装 RsaKeyManager 工具类
3.1. 为什么需要转换
Java 的 PrivateKey 和 PublicKey 对象需要的是二进制格式。我们需要一个工具类来完成以下工作:
- 读取
resources 目录下的密钥文件 - 去除 PEM 格式的头尾标记(
-----BEGIN...-----) - 将 Base64 编码的字符串解码为字节数组
- 使用 Java 的
KeyFactory 将字节数组转换为密钥对象
3.2. RsaKeyManager 核心代码
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/crypto/RsaKeyManager.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| package com.example.auth.core.util.crypto;
import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import org.springframework.util.FileCopyUtils;
import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64;
@Component public class RsaKeyManager {
public String readResourceFile(String resourcePath) throws Exception { ClassPathResource resource = new ClassPathResource(resourcePath); try (InputStreamReader reader = new InputStreamReader( resource.getInputStream(), StandardCharsets.UTF_8)) { return FileCopyUtils.copyToString(reader); } }
public PrivateKey loadPrivateKey(String privateKeyPem) throws Exception { String content = privateKeyPem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("-----BEGIN RSA PRIVATE KEY-----", "") .replace("-----END RSA PRIVATE KEY-----", "") .replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(content);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); return keyFactory.generatePrivate(keySpec); }
public PublicKey loadPublicKey(String publicKeyPem) throws Exception { String content = publicKeyPem .replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(content);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); return keyFactory.generatePublic(keySpec); } }
|
3.3. 关键知识点
为什么要去除 PEM 格式的头尾标记?
PEM 格式是一种文本格式,用于存储密钥和证书。它的结构是:
1 2 3
| -----BEGIN [类型]----- [Base64编码的密钥数据] -----END [类型]-----
|
Java 的 KeyFactory 只能识别纯粹的 Base64 编码数据,不能识别这些标记,所以必须先去除。
PKCS8 和 X509 是什么?
- PKCS8:私钥的存储格式标准(Public Key Cryptography Standards #8)
- X509:公钥证书的标准格式
这两个标准定义了密钥在二进制层面的存储结构。
为什么用 ClassPathResource 而不是 File?
当项目打包成 jar 包后,resources 目录下的文件会被打包到 jar 包内部。此时:
- ❌
new File("certs/public_key.pem") 会失败 - ✅
new ClassPathResource("certs/public_key.pem") 可以正确读取
第四章. 本章小结
我们完成了 RSA 密钥对的生成与加载。
核心成果:
| 步骤 | 操作 | 产出 |
|---|
| 1 | 使用 OpenSSL 生成私钥 | private_key.pem(PKCS#8 格式) |
| 2 | 从私钥中提取公钥 | public_key.pem(X.509 格式) |
| 3 | 将密钥放入项目 | auth-web/src/main/resources/certs/ |
| 4 | 封装工具类 | RsaKeyManager |
方法速查:
| 类名 | 方法名 | 作用 |
|---|
| RsaKeyManager | readResourceFile(String path) | 读取 resources 目录下的文件 |
| RsaKeyManager | loadPrivateKey(String pem) | 加载私钥 |
| RsaKeyManager | loadPublicKey(String pem) | 加载公钥 |
架构对比:
| 对比维度 | HS256 | RS256 |
|---|
| 密钥数量 | 1 个 | 2 个(私钥+公钥) |
| 密钥分发 | 所有服务都需要 | 只有认证服务持有私钥 |
| 安全风险 | 任何服务泄漏,全局沦陷 | 只有认证服务泄漏才沦陷 |
SpringBoot3 登录注册系列(三) - JJWT 0.12 核心 API 详解
第一章. JWT 三段式结构
在开始编写代码之前,我们需要先理解:JWT 到底是什么?它是如何工作的?
1.1. 电影票的类比
假设你现在去电影院看电影。你在售票处买票后,工作人员会给你一张电影票。这张票上印着:
- 电影名称(如《流浪地球 3》)
- 场次时间(如 2025-12-27 20:00)
- 座位号(如 5 排 8 座)
- 一个防伪水印(防止别人伪造票)
当你进入影厅时,检票员只需要看一眼票上的防伪水印,就知道这张票是真的,不需要回到售票处再次确认。
JWT 的工作原理和这张电影票非常相似:
- 电影票 = JWT Token
- 票上的信息(电影名称、场次、座位)= JWT 的 Payload(载荷)
- 防伪水印 = JWT 的 Signature(签名)
- 检票员 = 业务服务(验证 Token)
1.2. JWT 的三段式结构
一个完整的 JWT Token 由三部分组成,用 . 分隔:
1 2 3
| eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
这三部分分别是:
第一部分:Header(头部)
1 2 3 4
| { "alg": "RS256", "typ": "JWT" }
|
alg:签名算法(Algorithm),这里是 RS256typ:类型(Type),固定为 JWT
这部分经过Base64Url 编码一种 URL 安全的 Base64 编码方式后,就是 Token 的第一段。
第二部分:Payload(载荷)
1 2 3 4 5
| { "userId": 1001, "username": "admin", "exp": 1735280000 }
|
userId:用户 ID(自定义字段)username:用户名(自定义字段)exp:过期时间(Expiration,标准字段)
这部分经过 Base64Url 编码后,就是 Token 的第二段。
第三部分:Signature(签名)
1 2 3 4
| Signature = RSA_Sign( Base64Url(Header) + "." + Base64Url(Payload), 私钥 )
|
签名的作用是 防止 Token 被篡改。如果有人修改了 Payload 中的 userId,签名验证就会失败。
第二章. JJWT 0.12 vs 旧版本
2.1. 为什么要强调版本
JJWT 0.12 引入了 类型安全 的设计理念,API 发生了重大变化。如果你直接照搬网上的旧教程,代码很可能会报错。
旧版本(0.9.x)的写法:
1 2 3 4 5 6 7 8
| Jwts.builder() .
signWith(SignatureAlgorithm.HS256, rsaPrivateKey) .
compact();
|
这种写法的致命伤在于:算法与密钥类型分离。你指定了对称加密算法 HS256,却传入了非对称加密的 RSA私钥
。编译器无法发现这个逻辑错误,只有在代码运行到这一行时才会抛出异常。
新版本(0.12.x)的写法:
1 2 3 4 5 6 7 8
| Jwts.builder() .
signWith(rsaPrivateKey, Jwts.SIG.RS256) .
compact();
|
新版本强制要求你只传入密钥,算法由库自动推断或通过强类型常量指定。这样编译器就能在编译期发现错误。
2.2. 核心 API 对比
| 对比维度 | 旧版本(0.9.x) | 新版本(0.12.x) |
|---|
| 签名方式 | signWith(Algorithm, Key) | signWith(Key, Algorithm) |
| 类型安全 | ❌ 运行时才报错 | ✅ 编译期检查 |
| 解析器构建 | 直接使用 | 必须调用 build() |
| 算法常量 | SignatureAlgorithm.RS256 | Jwts.SIG.RS256 |
第三章. 封装 JwtUtil 工具类
3.1. 配置类设计
首先,我们需要定义一个配置类来承载 JWT 的相关配置(如有效期、密钥路径等)。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.auth.core.config.properties;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
@Data @Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties {
private String issuer = "auth-service";
private Integer accessTokenExpireMinutes = 15;
private Long clockSkewSeconds = 30L;
private String publicKeyResource = "certs/public_key.pem";
private String privateKeyResource = "certs/private_key.pem"; }
|
在 auth-web/src/main/resources/application.yml 中添加对应的配置:
1 2 3 4 5 6
| jwt: issuer: pro-auth-service access-token-expire-minutes: 15 clock-skew-seconds: 30 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem
|
3.2. JwtUtil 核心代码
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/JwtUtil.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
| package com.example.auth.core.util;
import cn.hutool.core.util.IdUtil; import com.example.auth.common.util.crypto.RsaKeyManager; import com.example.auth.core.config.properties.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component;
import java.security.PrivateKey; import java.security.PublicKey; import java.util.Date; import java.util.Map;
@Slf4j @Component @RequiredArgsConstructor public class JwtUtil {
private final JwtProperties jwtProperties; private final RsaKeyManager rsaKeyManager;
private PrivateKey privateKey; private PublicKey publicKey;
@PostConstruct public void init() throws Exception { String privateKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPrivateKeyResource()); this.privateKey = rsaKeyManager.loadPrivateKey(privateKeyContent); String publicKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPublicKeyResource()); this.publicKey = rsaKeyManager.loadPublicKey(publicKeyContent); log.info("JWT 密钥加载完成。"); }
public String createToken(Long userId, String username, Map<String, Object> extraClaims) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis);
Date expiration = new Date(nowMillis + jwtProperties.getAccessTokenExpireMinutes() * 60 * 1000L); String jti = IdUtil.fastSimpleUUID(); JwtBuilder builder = Jwts.builder() .header().type("JWT").and() .issuer(jwtProperties.getIssuer()) .subject(userId.toString()) .issuedAt(now) .expiration(expiration) .id(jti) .claim("username", username) .claim("userId", userId); if (extraClaims != null && !extraClaims.isEmpty()) { builder.claims().add(extraClaims); } return builder.signWith(privateKey, Jwts.SIG.RS256).compact(); }
public Claims parseToken(String token) { return Jwts.parser() .verifyWith(publicKey) .clockSkewSeconds(jwtProperties.getClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); }
public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { log.debug("Token验证失败: {}", e.getMessage()); return false; } }
public Long getUserIdFromToken(String token) { try { Claims claims = parseToken(token); return Long.parseLong(claims.getSubject()); } catch (Exception e) { log.warn("从Token中提取用户ID失败: {}", e.getMessage()); return null; } }
public String getUsernameFromToken(String token) { try { Claims claims = parseToken(token); return claims.get("username", String.class); } catch (Exception e) { log.warn("从Token中提取用户名失败: {}", e.getMessage()); return null; } }
public String getJtiFromToken(String token) { try { Claims claims = parseToken(token); return claims.getId(); } catch (Exception e) { log.warn("从Token中提取JTI失败: {}", e.getMessage()); return null; } } }
|
3.3. 关键设计细节
时钟偏差(Clock Skew)的必要性
在代码中我们设置了 .clockSkewSeconds(30)。这是一个非常重要的生产环境细节。
在微服务架构中,认证服务和网关服务可能部署在不同的物理机上,时间可能存在微小的偏差。如果认证服务时间快了 1 秒,它签发的
Token 对于网关来说就是 “未来 1 秒才生效” 的,会导致 PrematureJwtException。设置容忍度可以完美解决这个问题。
JTI(JWT ID)的战略意义
我们在创建 Token 时使用 Hutool 的 IdUtil.fastSimpleUUID() 生成了 JTI 并赋值给 .id(jti)。这不仅仅是一个随机数,它是后续实现
Token 黑名单(Token Revocation)的基础。当用户注销时,我们只需将这个 JTI 放入 Redis,即可让未过期的 Token 提前失效。
@PostConstruct 的性能优化
我们在 init() 方法上使用了 @PostConstruct 注解。这意味着密钥的加载和解析只会在应用启动时执行一次,然后缓存在
privateKey 和 publicKey 字段中。
如果不这样做,每次生成或验证 Token 都需要重新读取文件、解析 PEM 格式,性能会非常差。
第四章. 本章小结
我们完成了 JWT 工具类的构建,掌握了 JJWT 0.12 的核心 API。
核心成果:
| 步骤 | 操作 | 产出 |
|---|
| 1 | 理解 JWT 三段式结构 | Header + Payload + Signature |
| 2 | 掌握 JJWT 0.12 Builder 模式 | 类型安全的 API |
| 3 | 封装 JwtUtil 工具类 | 提供生成、解析、验证功能 |
方法速查:
| 类名 | 方法名 | 作用 |
|---|
| JwtUtil | createToken(userId, username, extraClaims) | 生成 Token |
| JwtUtil | parseToken(token) | 解析 Token |
| JwtUtil | validateToken(token) | 验证 Token |
| JwtUtil | getUserIdFromToken(token) | 提取用户 ID |
| JwtUtil | getUsernameFromToken(token) | 提取用户名 |
| JwtUtil | getJtiFromToken(token) | 提取 JTI |
JJWT 0.12 vs 旧版本:
| 对比维度 | 旧版本(0.9.x) | 新版本(0.12.x) |
|---|
| 签名方式 | signWith(Algorithm, Key) | signWith(Key, Algorithm) |
| 类型安全 | ❌ 运行时才报错 | ✅ 编译期检查 |
| 解析器构建 | 直接使用 | 必须调用 build() |
SpringBoot3 登录注册系列(四) - 双令牌机制与 Redis 状态管理
第一章. 单令牌的困境
在上一篇中,我们封装了 JWT 工具类。现在我们来实现一个最简单的登录系统,然后暴露它的问题。
1.1. 单令牌登录实现
首先,我们创建一个简单的 TokenService 来管理令牌。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package com.example.auth.core.service;
import com.example.auth.core.util.JwtUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service;
@Slf4j @Service @RequiredArgsConstructor public class TokenService {
private final JwtUtil jwtUtil;
public String createToken(Long userId, String username) { log.info("开始创建令牌: userId={}, username={}", userId, username); String token = jwtUtil.createToken(userId, username, null); log.info("令牌创建成功: userId={}", userId); return token; }
public boolean validateToken(String token) { return jwtUtil.validateToken(token); } }
|
然后创建 Controller 提供登录接口。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| package com.example.auth.web.controller;
import com.example.auth.common.model.Result; import com.example.auth.core.service.TokenService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.Map;
@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController {
private final TokenService tokenService;
@PostMapping("/login") public Result<Map<String, Object>> login(@RequestParam Long userId, @RequestParam String username) { log.info("收到登录请求: userId={}, username={}", userId, username);
String token = tokenService.createToken(userId, username);
Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("tokenType", "Bearer");
return Result.ok(data); }
@GetMapping("/validate") public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String token) { log.info("收到验证令牌请求");
if (token.startsWith("Bearer ")) { token = token.substring(7); }
boolean valid = tokenService.validateToken(token);
Map<String, Object> data = new HashMap<>(); data.put("valid", valid);
return Result.ok(data); } }
|
创建启动类。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/AuthApplication.java
1 2 3 4 5 6 7 8 9 10 11 12
| package com.example.auth.web;
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.example.auth") public class AuthApplication {
public static void main(String[] args) { SpringApplication.run(AuthApplication.class, args); } }
|
配置文件。
📄 文件路径:auth-web/src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13
| server: port: 8080
spring: application: name: auth-service
jwt: issuer: pro-auth-service access-token-expire-minutes: 15 clock-skew-seconds: 30 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem
|
1.2. 接口验证与测试
代码编写完成后,我们需要验证以下两个核心流程:
- 登录获取 Token:确保能根据用户信息生成合法的 JWT。
- 携带 Token 访问:确保服务端能正确解析并验证 Token 的有效性。
⚠ 前置准备:请确保你的 resources/certs 目录下已经存在上一章生成的公私钥文件(private_key.pem 和 public_key.pem
),否则启动会报错。
1. 启动服务
运行 AuthApplication 的 main 方法,观察控制台日志,确保没有报错,且端口运行在 8080。
2. 测试登录(获取 Token)
使用 Postman 或终端 Curl 发起登录请求。
- 请求方式:POST
- URL:
http://localhost:8080/auth/login - 参数:
userId=1001, username=admin
Curl 命令示例:
1
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
|
预期响应结果:
1 2 3 4 5 6 7 8 9
| { "code": 200, "msg": "success", "data": { "tokenType": "Bearer", "token": "eyJhGciOiJ...<省略的长字符串>...W8i9s" } }
|
3. 测试验证(校验 Token)
复制上一步获取的 token 值,添加到请求头的 Authorization 字段中。
- 请求方式:GET
- URL:
http://localhost:8080/auth/validate - Header:
Authorization: Bearer <你的Token>
Curl 命令示例:
1 2 3
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <YOUR_TOKEN>"
|
预期响应结果:
1 2 3 4 5 6 7
| { "code": 200, "msg": "success", "data": { "valid": true } }
|
1.3. 暴露问题:单令牌的“死局”
至此,我们的单令牌登录功能看似完美运行。但是,请尝试修改 application.yml,将过期时间改得极短(例如 1 分钟):
1 2
| jwt: access-token-expire-minutes: 1
|
重启服务后,我们会发现一个严重的用户体验问题:
- 用户登录,获得 Token。
- 用户正常浏览页面(耗时 1 分钟)。
- 第 61 秒,用户点击某个按钮,前端携带 Token 请求。
- 服务端返回 401 或验证失败,因为 Token 已过期。
- 后果:用户被强制登出,必须重新输入密码登录。
这就是 单令牌模式的死局:
- 有效期设置太长(如 7 天):Token 一旦泄露,黑客有 7 天时间为所欲为,服务端无法主动注销(除非引入黑名单,但这增加了复杂度和查库成本)。
- 有效期设置太短(如 15 分钟):安全性高了,但用户每隔 15 分钟就要重新登录一次,体验极其糟糕。
为了解决这个“安全”与“体验”的矛盾,我们需要引入 双令牌机制(Access Token + Refresh Token)。
1.2. 单令牌的两大致命问题
现在我们的单令牌系统已经能够正常工作了。但在实际使用中,我们会遇到两个致命问题:
问题一:无法主动注销
假设用户在公司电脑上登录了系统,下班后忘记退出。第二天他发现后,想要远程注销这个登录会话。
但在单令牌机制下,这是做不到的。因为 Token 是自包含的Token 本身携带所有信息,服务器不存储任何状态,服务器没有存储任何状态。只要 Token 没有过期,它就一直有效。
即使用户点击了 “退出登录” 按钮,前端删除了 Token,但如果有人之前复制了这个 Token,他依然可以在 Token 过期前继续使用。
问题二:有效期困境
我们现在面临一个两难的选择:
- 有效期设置太短(如 15 分钟):用户体验差。用户正在编辑文档,突然 Token 过期了,所有操作都失败了,必须重新登录。
- 有效期设置太长(如 7 天):安全风险高。如果 Token 被盗,攻击者可以在 7 天内随意访问用户的数据。
这两个问题看起来是矛盾的:要么牺牲用户体验,要么牺牲安全性。
第二章. 双令牌机制设计
2.1. 设计理念
我们不再只发一个 Token,而是发两个:
- Access Token(访问令牌):短效(15 分钟),真正用来请求接口的凭证
- Refresh Token(刷新令牌):长效(7 天),专门用来换取新 Access Token 的凭证
这就像你去银行办业务:
- Access Token = 排队号码牌(有效期很短,过期就作废)
- Refresh Token = 身份证(有效期很长,可以用来重新取号)
当你的号码牌过期了,你不需要重新排队,只需要拿着身份证去柜台换一个新号码牌。
2.2. 架构流程

2.3. 为什么需要 Redis
你可能会问:我们不是说好了 JWT 是无状态的吗?为什么还需要 Redis?
确实,JWT 的设计理念是无状态。但在实际业务中,我们需要一些 “状态管理” 的能力:
- 主动注销:当用户点击 “退出登录” 时,我们需要立即让 Token 失效
- Token 刷新:当 Access Token 过期时,我们需要验证 Refresh Token 是否有效
- Token 轮换:每次刷新都生成新的 Refresh Token,旧的立即失效(检测盗用)
这些功能都需要服务器 “记住” 一些状态。而 Redis 就是最适合存储这些临时状态的中间件。
为什么选择 Redis 而不是 MySQL?
| 对比维度 | MySQL | Redis |
|---|
| 读写速度 | 毫秒级(磁盘 IO) | 微秒级(内存操作) |
| 过期机制 | 需要定时任务清理 | 原生支持 TTL 自动清理 |
| 数据结构 | 只有表(Table) | 支持 String、Set、ZSet 等 |
| 适用场景 | 持久化存储 | 临时状态存储 |
第三章. Redis 状态管理
3.1. Redis 数据结构选型
Redis 支持五种数据结构,我们在认证系统中主要会用到:
- String:存储 Refresh Token、黑名单标记
- Set:存储用户的在线设备列表
- ZSet:存储用户的在线设备列表(按登录时间排序,用于多设备限制)
3.2. StringRedisTemplate 核心语法
Spring 提供了 StringRedisTemplate 来操作 Redis。它的 API 设计非常直观:先选择数据结构,再执行操作。
核心方法:
opsForValue():操作 StringopsForSet():操作 SetopsForZSet():操作 ZSet
String 操作(opsForValue)
String 是最常用的数据结构,用于存储简单的键值对。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| redisTemplate.opsForValue().
set( "auth:token:refresh:abc123", "1001", 7, TimeUnit.DAYS );
String userId = redisTemplate.opsForValue().get("auth:token:refresh:abc123");
Boolean isBlacklisted = redisTemplate.hasKey("auth:token:black:jti123");
Long failCount = redisTemplate.opsForValue().increment("auth:login:fail:admin"); if(failCount ==1){ redisTemplate.
expire("auth:login:fail:admin",15,TimeUnit.MINUTES); }
|
常用方法速查:
| 方法 | 作用 | 示例 |
|---|
set(key, value, timeout, unit) | 存储并设置过期时间 | set("token:123", "1001", 15, TimeUnit.MINUTES) |
get(key) | 获取字符串 | get("token:123") |
increment(key) | 自增 | increment("login:fail:admin") |
delete(key) | 删除键 | delete("token:123") |
hasKey(key) | 检查键是否存在 | hasKey("token:123") |
expire(key, timeout, unit) | 设置过期时间 | expire("token:123", 10, TimeUnit.MINUTES) |
Set 操作(opsForSet)
Set 是无序不重复集合,用于存储不需要排序的元素列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| redisTemplate.opsForSet().
add("auth:user:tokens:1001","jti123");
Set<String> devices = redisTemplate.opsForSet().members("auth:user:tokens:1001");
Boolean isOnline = redisTemplate.opsForSet().isMember("auth:user:tokens:1001", "jti123");
redisTemplate.
opsForSet().
remove("auth:user:tokens:1001","jti123");
Long deviceCount = redisTemplate.opsForSet().size("auth:user:tokens:1001");
|
常用方法速查:
| 方法 | 作用 | 示例 |
|---|
add(key, values) | 添加元素 | add("user:1001:devices", "jti123") |
members(key) | 获取所有元素 | members("user:1001:devices") |
isMember(key, value) | 检查元素是否存在 | isMember("user:1001:devices", "jti123") |
remove(key, values) | 移除元素 | remove("user:1001:devices", "jti123") |
size(key) | 获取元素数量 | size("user:1001:devices") |
Pipeline 批量操作
在实际业务中,我们经常需要执行多个 Redis 操作。如果每个操作都是一次网络请求,性能会很差。
问题场景:
假设用户登录时,我们需要做三件事:
- 存储 Refresh Token
- 将 Token ID 记录到用户的在线设备列表
- 给在线列表设置过期时间
普通写法(3 次网络请求):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| redisTemplate.opsForValue().
set("auth:token:refresh:abc123","1001",7,TimeUnit.DAYS);
redisTemplate.
opsForSet().
add("auth:user:tokens:1001","jti123");
redisTemplate.
expire("auth:user:tokens:1001",7,TimeUnit.DAYS);
|
每次网络请求的耗时大约是 1-2 毫秒(局域网环境)。3 次请求就是 3-6 毫秒。
Pipeline 写法(1 次网络请求):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| redisTemplate.executePipelined((RedisCallback<Object>) connection ->{
connection.
stringCommands().
setEx( "auth:token:refresh:abc123".getBytes(), 7*86400, "1001".
getBytes() );
connection.
setCommands().
sAdd( "auth:user:tokens:1001".getBytes(), "jti123".
getBytes() );
connection.
keyCommands().
expire( "auth:user:tokens:1001".getBytes(), 7*86400 );
return null; });
|
Pipeline 中的命令是顺序执行的,不是原子性的。如果需要原子性,应该使用 Lua 脚本或 Redis 事务。
3.3. Redis Key 命名规范
为了防止 Key 冲突,我们需要定义统一的 Key 命名规范。
命名规范:项目:模块:业务:主键
| 层级 | 说明 | 示例 |
|---|
| 项目 | 项目名称 | auth(认证系统) |
| 模块 | 模块名称 | token(令牌模块) |
| 业务 | 业务类型 | refresh(刷新令牌) |
| 主键 | 业务主键 | a1b2c3d4(refreshToken) |
完整示例:
1 2 3
| auth:token:refresh:a1b2c3d4e5f6 # 认证系统 - 令牌模块 - 刷新令牌 - Token ID auth:token:black:550e8400e29b # 认证系统 - 令牌模块 - 黑名单 - JTI auth:user:tokens:1001 # 认证系统 - 用户模块 - 在线设备 - 用户ID
|
📄 文件路径:auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package com.example.auth.core.constant;
public class RedisKeyConstants {
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";
public static String buildRefreshTokenKey(String refreshToken) { return TOKEN_REFRESH_PREFIX + refreshToken; }
public static String buildUserOnlineKey(Long userId) { return USER_ONLINE_TOKENS_PREFIX + userId; } }
|
第四章. 双令牌机制实现
4.1. 定义 AuthToken 模型
首先,我们需要定义一个模型来承载双令牌。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/model/AuthToken.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.auth.core.model;
import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class AuthToken implements Serializable {
private String accessToken;
private String refreshToken;
private Long expiresIn;
@Builder.Default private String tokenType = "Bearer"; }
|
4.2. 扩展 JwtProperties
在原有的 JwtProperties 类中,追加 Refresh Token 的配置。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| package com.example.auth.core.config.properties;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
@Data @Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties {
private Integer refreshTokenExpireDays = 7; }
|
更新配置文件。
📄 文件路径:auth-web/src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| server: port: 8080
spring: application: name: auth-service data: redis: host: localhost port: 6379 database: 0 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0
jwt: issuer: pro-auth-service access-token-expire-minutes: 15 refresh-token-expire-days: 7 clock-skew-seconds: 30 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem
|
4.3. 分层架构设计(重构版)
在实现双令牌机制之前,我们先来解决一个架构问题。如果把所有逻辑都堆在 Service 层,会导致:
- 职责不清:
TokenService、TokenStoreService、TokenBlacklistService 看起来是平级的,但实际上有些是底层基础设施,有些是上层业务逻辑 - Controller 保姆化:Controller 需要注入多个 Service,知道了太多细节
- 依赖混乱:像蜘蛛网一样的网状依赖
我们采用 三层架构 来解决这个问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| ┌─────────────────────────────────────────────────────────────┐ │ Controller 层 │ │ 只注入一个 AuthService │ └─────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Facade 层 (AuthService) │ │ "大管家",负责编排所有业务流程 │ └─────────────────────────┬───────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌───────────┐ ┌─────────────────────┐ │ JwtUtil │ │ │ │ │ │ (纯算法工具) │ │ │ │ │ └─────────────────┘ │ │ │ │ │ Manager │ │ Manager │ ┌─────────────────┐ │ 层 │ │ 层 │ │ JwtProperties │ │ │ │ │ │ (配置类) │ │ │ │ │ └─────────────────┘ └───────────┘ └─────────────────────┘ │ │ ▼ ▼ ┌───────────────┐ ┌─────────────────────┐ │TokenRedisManager│ │BlacklistRedisManager│ │ (Token存取) │ │ (黑名单存取) │ └───────────────┘ └─────────────────────┘
|
层级职责:
| 层级 | 类名 | 职责 | 特点 |
|---|
| Controller | AuthController | 接收请求,返回响应 | 只注入 AuthService |
| Facade | AuthService | 编排业务流程 | “大管家”,统一入口 |
| Util | JwtUtil | Token 生成/解析 | 纯算法,无 IO |
| Manager | TokenRedisManager | Token 的 Redis 存取 | 只有 CRUD,无业务逻辑 |
| Manager | BlacklistRedisManager | 黑名单的 Redis 存取 | 只有 CRUD,无业务逻辑 |
心态转变:Manager 不是 Service,它们是"底层苦力",只有存取方法,没有复杂的 if-else。
4.4. 封装 TokenRedisManager(基础设施层)
现在我们创建 Manager 层,负责 Token 在 Redis 中的存储、查询、删除等操作。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/manager/TokenRedisManager.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| package com.example.auth.core.manager;
import com.example.auth.core.constant.RedisKeyConstants; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;
import java.util.Set;
@Slf4j @Component @RequiredArgsConstructor public class TokenRedisManager {
private final StringRedisTemplate redisTemplate;
public void saveRefreshToken(Long userId, String refreshToken, String jti, long expireSeconds) { redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken); connection.stringCommands().setEx( refreshKey.getBytes(), expireSeconds, userId.toString().getBytes() );
String userKey = RedisKeyConstants.buildUserOnlineKey(userId); connection.setCommands().sAdd( userKey.getBytes(), jti.getBytes() );
connection.keyCommands().expire( userKey.getBytes(), expireSeconds );
return null; });
log.debug("存储 Refresh Token 成功: userId={}, jti={}, 有效期={}秒", userId, jti, expireSeconds); }
public Long getUserIdByRefreshToken(String refreshToken) { String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken); String userId = redisTemplate.opsForValue().get(key); return userId == null ? null : Long.parseLong(userId); }
public void deleteRefreshToken(String refreshToken) { String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken); redisTemplate.delete(key); log.debug("删除 Refresh Token: {}", refreshToken); }
public Set<String> getUserOnlineDevices(Long userId) { String key = RedisKeyConstants.buildUserOnlineKey(userId); return redisTemplate.opsForSet().members(key); }
public void removeUserDevice(Long userId, String jti) { String key = RedisKeyConstants.buildUserOnlineKey(userId); redisTemplate.opsForSet().remove(key, jti); log.debug("移除用户设备: userId={}, jti={}", userId, jti); }
public void clearUserDevices(Long userId) { String key = RedisKeyConstants.buildUserOnlineKey(userId); redisTemplate.delete(key); log.debug("清空用户所有设备: userId={}", userId); } }
|
4.5. 封装 AuthService(Facade 层)
现在我们创建"大管家" AuthService,负责编排所有认证相关的业务流程。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/AuthService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| package com.example.auth.core.service;
import cn.hutool.core.util.IdUtil; import com.example.auth.core.config.properties.JwtProperties; import com.example.auth.core.manager.TokenRedisManager; import com.example.auth.core.model.AuthToken; import com.example.auth.core.util.JwtUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service;
@Slf4j @Service @RequiredArgsConstructor public class AuthService {
private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; private final TokenRedisManager tokenRedisManager;
public AuthToken login(Long userId, String username) { log.info("用户登录: userId={}, username={}", userId, username);
String accessToken = jwtUtil.createToken(userId, username, null);
String jti = jwtUtil.getJtiFromToken(accessToken);
String refreshToken = IdUtil.fastSimpleUUID();
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);
log.info("用户登录成功: userId={}, jti={}", userId, jti);
return AuthToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60) .build(); }
public AuthToken refresh(String oldRefreshToken) { log.info("刷新令牌请求");
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);
if (userId == null) { log.warn("Refresh Token 无效或已过期"); throw new RuntimeException("刷新令牌无效或已过期,请重新登录"); }
String username = "User-" + userId;
AuthToken newTokenPair = login(userId, username);
tokenRedisManager.deleteRefreshToken(oldRefreshToken);
log.info("令牌刷新成功: userId={}", userId); return newTokenPair; }
public boolean isTokenValid(String token) { return jwtUtil.validateToken(token); } }
|
4.6. 升级 AuthController(极简版)
现在我们升级 AuthController,它只需要注入一个 AuthService。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| package com.example.auth.web.controller;
import com.example.auth.common.model.Result; import com.example.auth.core.model.AuthToken; import com.example.auth.core.service.AuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.Map;
@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController {
private final AuthService authService;
@PostMapping("/login") public Result<AuthToken> login(@RequestParam Long userId, @RequestParam String username) { log.info("收到登录请求: userId={}, username={}", userId, username); AuthToken authToken = authService.login(userId, username); return Result.ok(authToken); }
@PostMapping("/refresh") public Result<AuthToken> refresh(@RequestParam String refreshToken) { log.info("收到刷新令牌请求"); try { AuthToken authToken = authService.refresh(refreshToken); return Result.ok(authToken); } catch (Exception e) { log.error("刷新令牌失败", e); return Result.fail(401, e.getMessage()); } }
@GetMapping("/validate") public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String authHeader) { log.info("收到验证令牌请求"); String token = extractToken(authHeader); boolean valid = authService.isTokenValid(token); Map<String, Object> data = new HashMap<>(); data.put("valid", valid); return Result.ok(data); }
private String extractToken(String authHeader) { if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } return authHeader; } }
|
完成上述代码开发后,我们通过 HTTP 请求来验证双令牌机制的完整流程,重点观察 Redis 中数据的变化以及令牌轮换(Token
Rotation)的效果。
首先启动 Spring Boot 应用,使用 Postman 或 curl 发起 登录请求,模拟用户 admin (ID: 1001) 登录:
1
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
|
响应结果应包含一对 Token:
1 2 3 4 5 6 7 8 9 10 11
| { "code": 200, "msg": "success", "data": { "accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...", "refreshToken": "a1b2c3d4-e5f6-7890-abcd-1234567890ab", "expiresIn": 900, "tokenType": "Bearer" } }
|
此时,连接 Redis 终端查看数据,可以看到 Refresh Token 已被存储,且关联了用户 ID:
1 2 3 4 5 6 7 8
| 127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab "1001"
127.0.0.1:6379> TTL auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab (integer) 604795
|
接下来,我们模拟 Access Token 过期,使用刚才获取的 refreshToken 调用 刷新接口:
1
| curl -X POST "http://localhost:8080/auth/refresh?refreshToken=YOUR_REFRESH_TOKEN"
|
响应结果会返回一对 全新的 Token(实现了令牌轮换):
1 2 3 4 5 6 7 8 9 10 11
| { "code": 200, "msg": "success", "data": { "accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...", "refreshToken": "f9e8d7c6-b5a4-3210-fedc-0987654321zy", "expiresIn": 900, "tokenType": "Bearer" } }
|
最后再次检查 Redis,验证 旧令牌销毁 机制是否生效。旧的 Refresh Token 应该不存在,只能查到新的 Refresh Token:
1 2 3 4 5 6 7 8
| 127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab (nil)
127.0.0.1:6379> GET auth:token:refresh:f9e8d7c6-b5a4-3210-fedc-0987654321zy "1001"
|
通过以上步骤,我们成功验证了基于 Redis 的双令牌颁发、存储、刷新以及旧令牌自动作废的完整闭环。
SpringBoot3 登录注册系列(五) - Token 黑名单与主动注销
第一章. 双令牌机制的遗留问题
在上一章中,我们实现了双令牌机制,解决了"安全性"与"用户体验"的矛盾。但现在我们面临一个新的问题。
1.1. 注销困境
假设用户在公司电脑上登录了系统,下班后忘记退出。第二天他在家里发现后,想要远程注销公司电脑的登录会话。
他打开手机 App,点击"退出登录"按钮。前端删除了本地存储的 Token,看起来已经退出了。但实际上:
- 公司电脑上的 Access Token 依然有效(还有 14 分钟才过期)
- 公司电脑上的 Refresh Token 依然有效(还有 6 天 23 小时才过期)
如果有人在这段时间内使用公司电脑,依然可以访问用户的数据。这就是 JWT 无状态服务器不存储任何会话信息 设计带来的副作用。
1.2. 业务场景梳理
在实际业务中,我们需要支持以下注销场景:
场景一:用户主动退出
用户点击"退出登录"按钮,当前设备的 Token 应该立即失效。
场景二:远程踢出设备
用户在设备管理页面,点击"踢出"按钮,指定设备的 Token 应该立即失效。
场景三:管理员强制下线
管理员发现某个用户的账号被盗,需要强制注销该用户的所有登录会话。
场景四:密码修改
用户修改密码后,所有设备的 Token 都应该失效,强制重新登录。
这些场景都需要服务器能够 主动让 Token 失效,而不是被动等待 Token 过期。
1.3. JTI 的战略价值
还记得我们在第三章生成 Token 时,埋下的一个伏笔吗?
1 2 3 4
| String jti = IdUtil.fastSimpleUUID(); JwtBuilder builder = Jwts.builder() .id(jti)
|
JTI(JWT ID)是 JWT 标准中定义的一个字段,用于唯一标识一个 Token。它的作用就是为了实现 Token 黑名单。
当我们需要让某个 Token 失效时,只需要将它的 JTI 加入黑名单。验证 Token 时,先检查 JTI 是否在黑名单中,如果在,就拒绝访问。
第二章. 黑名单架构设计
2.1. Redis 数据结构选型
我们需要在 Redis 中存储黑名单,有两种选择:
方案一:使用 String
1 2 3
| Key: auth:token:black:jti123 Value: 1(或任意值) TTL: Token 剩余有效期
|
方案二:使用 Set
1 2
| Key: auth:token:blacklist Value: {jti123, jti456, jti789}
|
我们选择 方案一(String),原因如下:
| 对比维度 | String | Set |
|---|
| 检查性能 | O(1) - EXISTS 命令 | O(1) - SISMEMBER 命令 |
| 过期机制 | ✅ 每个 Key 独立过期 | ❌ 需要定时任务清理 |
| 内存占用 | 较高(每个 JTI 一个 Key) | 较低(所有 JTI 在一个 Set) |
| 适用场景 | Token 数量适中 | Token 数量极大 |
在认证系统中,Token 的数量通常不会太大(假设 10 万在线用户,每人 2 个设备,也就 20 万个 Token)。使用 String 可以利用 Redis
的原生 TTL 机制,自动清理过期的黑名单记录,无需额外的定时任务。
2.2. 过期时间的精确计算
当我们将 JTI 加入黑名单时,需要设置一个过期时间。这个过期时间应该等于 Token 的剩余有效期。
为什么?因为 Token 过期后,即使不在黑名单中,也无法通过验证。继续在 Redis 中保留这个黑名单记录是浪费内存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| Claims claims = jwtUtil.parseToken(token); Date expiration = claims.getExpiration();
long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;
redisTemplate.
opsForValue().
set( "auth:token:black:"+jti, "1", remainingSeconds, TimeUnit.SECONDS );
|
2.3. 黑名单检查的性能优化
在每次请求时,我们都需要检查 Token 是否在黑名单中。这个操作的性能至关重要。
优化策略一:使用 EXISTS 命令
Redis 的 EXISTS 命令是 O(1) 复杂度,性能极高。
1
| Boolean isBlacklisted = redisTemplate.hasKey("auth:token:black:" + jti);
|
优化策略二:先验证签名,再检查黑名单
如果 Token 的签名验证失败(说明 Token 被篡改或格式错误),就没必要再去 Redis 查黑名单了。
1 2 3 4 5 6 7 8 9 10 11 12
| Claims claims = jwtUtil.parseToken(token);
String jti = claims.getId(); if(
isInBlacklist(jti)){ throw new
RuntimeException("Token 已被注销"); }
|
第三章. 实现黑名单服务
3.1. 扩展 RedisKeyConstants
首先,我们在 RedisKeyConstants 中添加黑名单相关的 Key 前缀。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| package com.example.auth.core.constant;
public class RedisKeyConstants {
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";
public static String buildRefreshTokenKey(String refreshToken) { return TOKEN_REFRESH_PREFIX + refreshToken; }
public static String buildUserOnlineKey(Long userId) { return USER_ONLINE_TOKENS_PREFIX + userId; }
public static String buildBlacklistKey(String jti) { return TOKEN_BLACKLIST_PREFIX + jti; } }
|
3.2. 封装 BlacklistRedisManager(基础设施层)
按照我们的分层架构,黑名单的 Redis 操作应该放在 Manager 层。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/manager/BlacklistRedisManager.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| package com.example.auth.core.manager;
import com.example.auth.core.constant.RedisKeyConstants; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;
import java.util.Date; import java.util.concurrent.TimeUnit;
@Slf4j @Component @RequiredArgsConstructor public class BlacklistRedisManager {
private static final String BLACKLISTED_MARKER = "REVOKED";
private final StringRedisTemplate redisTemplate;
public void add(String jti, Date expiration) { long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;
if (remainingSeconds <= 0) { log.debug("Token 已过期,无需加入黑名单: jti={}", jti); return; }
String key = RedisKeyConstants.buildBlacklistKey(jti); redisTemplate.opsForValue().set(key, BLACKLISTED_MARKER, remainingSeconds, TimeUnit.SECONDS);
log.debug("JTI 已加入黑名单: jti={}, 剩余有效期={}秒", jti, remainingSeconds); }
public void add(String jti, long expireSeconds) { if (expireSeconds <= 0) { log.debug("过期时间无效,无需加入黑名单: jti={}", jti); return; }
String key = RedisKeyConstants.buildBlacklistKey(jti); redisTemplate.opsForValue().set(key, BLACKLISTED_MARKER, expireSeconds, TimeUnit.SECONDS);
log.debug("JTI 已加入黑名单: jti={}, 过期时间={}秒", jti, expireSeconds); }
public boolean exists(String jti) { String key = RedisKeyConstants.buildBlacklistKey(jti); return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } }
|
设计要点:BlacklistRedisManager 只负责 Redis 的存取操作,不依赖 JwtUtil。Token 的解析工作由上层的 AuthService 完成。
3.3. 关键设计细节
为什么要判断 Token 是否已过期?
如果 Token 已经过期,即使不在黑名单中,也无法通过验证。此时将它加入黑名单是浪费 Redis 内存和网络开销。
为什么使用 DateUtil.formatDateTime?
Hutool 的 DateUtil.formatDateTime 可以将 Date 对象格式化为 yyyy-MM-dd HH:mm:ss 格式,方便日志查看。这比
SimpleDateFormat 简洁得多。
第四章. 升级 AuthService(集成黑名单)
4.1. 在 AuthService 中集成黑名单检查
按照我们的分层架构,所有业务逻辑都收敛到 AuthService 中。现在我们升级它,集成黑名单检查和注销功能。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/AuthService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
| package com.example.auth.core.service;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.IdUtil; import com.example.auth.core.config.properties.JwtProperties; import com.example.auth.core.manager.BlacklistRedisManager; import com.example.auth.core.manager.TokenRedisManager; import com.example.auth.core.model.AuthToken; import com.example.auth.core.util.JwtUtil; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service;
import java.util.Date; import java.util.Set;
@Slf4j @Service @RequiredArgsConstructor public class AuthService {
private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; private final TokenRedisManager tokenRedisManager; private final BlacklistRedisManager blacklistRedisManager;
public AuthToken login(Long userId, String username) { log.info("用户登录: userId={}, username={}", userId, username);
String accessToken = jwtUtil.createToken(userId, username, null);
String jti = jwtUtil.getJtiFromToken(accessToken);
String refreshToken = IdUtil.fastSimpleUUID();
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);
log.info("用户登录成功: userId={}, jti={}", userId, jti);
return AuthToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60) .build(); }
public AuthToken refresh(String oldRefreshToken) { log.info("刷新令牌请求");
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);
if (userId == null) { log.warn("Refresh Token 无效或已过期"); throw new RuntimeException("刷新令牌无效或已过期,请重新登录"); }
String username = "User-" + userId;
AuthToken newTokenPair = login(userId, username);
tokenRedisManager.deleteRefreshToken(oldRefreshToken);
log.info("令牌刷新成功: userId={}", userId); return newTokenPair; }
public Claims validateToken(String token) { try { Claims claims = jwtUtil.parseToken(token);
String jti = claims.getId(); if (blacklistRedisManager.exists(jti)) { log.warn("Token 已被注销: jti={}", jti); throw new RuntimeException("Token 已被注销,请重新登录"); }
return claims;
} catch (ExpiredJwtException e) { log.debug("Token 已过期: {}", e.getMessage()); throw new RuntimeException("Token 已过期,请重新登录"); } catch (JwtException e) { log.warn("Token 验证失败: {}", e.getMessage()); throw new RuntimeException("Token 无效"); } }
public boolean isTokenValid(String token) { try { validateToken(token); return true; } catch (Exception e) { return false; } }
public void logout(String accessToken, String refreshToken) { log.info("单设备注销请求");
try { Claims claims = jwtUtil.parseToken(accessToken); String jti = claims.getId(); Date expiration = claims.getExpiration(); Long userId = Long.parseLong(claims.getSubject());
blacklistRedisManager.add(jti, expiration); log.info("Token 已加入黑名单: jti={}, 过期时间={}", jti, DateUtil.formatDateTime(expiration));
tokenRedisManager.deleteRefreshToken(refreshToken);
tokenRedisManager.removeUserDevice(userId, jti);
log.info("单设备注销成功: userId={}, jti={}", userId, jti);
} catch (Exception e) { log.error("单设备注销失败", e); throw new RuntimeException("注销失败", e); } }
public void logoutAll(Long userId) { log.info("全设备注销请求: userId={}", userId);
try { Set<String> jtiSet = tokenRedisManager.getUserOnlineDevices(userId);
if (CollUtil.isEmpty(jtiSet)) { log.info("用户没有在线设备: userId={}", userId); return; }
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; jtiSet.forEach(jti -> blacklistRedisManager.add(jti, maxExpireSeconds));
tokenRedisManager.clearUserDevices(userId);
log.info("全设备注销成功: userId={}, 设备数量={}", userId, jtiSet.size());
} catch (Exception e) { log.error("全设备注销失败: userId={}", userId, e); throw new RuntimeException("全设备注销失败", e); } }
public void kickout(Long userId, String jti) { log.info("踢出设备请求: userId={}, jti={}", userId, jti);
try { long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; blacklistRedisManager.add(jti, maxExpireSeconds);
tokenRedisManager.removeUserDevice(userId, jti);
log.info("踢出设备成功: userId={}, jti={}", userId, jti);
} catch (Exception e) { log.error("踢出设备失败: userId={}, jti={}", userId, jti, e); throw new RuntimeException("踢出设备失败", e); } }
public Long getUserIdFromToken(String token) { Claims claims = validateToken(token); return Long.parseLong(claims.getSubject()); } }
|
4.2. 验证流程图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| ┌─────────────────┐ │ 收到 Token │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 1. 验证签名 │ ◄─── 本地操作,无网络开销 └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 2. 检查过期时间 │ ◄─── 本地操作,无网络开销 └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 3. 检查黑名单 │ ◄─── Redis 查询,O(1) 复杂度 └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 验证通过 │ └─────────────────┘
|
第五章. 升级 AuthController(完整版)
5.1. 完整的 AuthController
现在我们升级 AuthController,添加注销相关的接口。注意:Controller 依然只注入一个 AuthService。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
| package com.example.auth.web.controller;
import com.example.auth.common.model.Result; import com.example.auth.core.model.AuthToken; import com.example.auth.core.service.AuthService; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.Map;
@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController {
private final AuthService authService;
@PostMapping("/login") public Result<AuthToken> login(@RequestParam Long userId, @RequestParam String username) { log.info("收到登录请求: userId={}, username={}", userId, username); AuthToken authToken = authService.login(userId, username); return Result.ok(authToken); }
@PostMapping("/refresh") public Result<AuthToken> refresh(@RequestParam String refreshToken) { log.info("收到刷新令牌请求"); try { AuthToken authToken = authService.refresh(refreshToken); return Result.ok(authToken); } catch (Exception e) { log.error("刷新令牌失败", e); return Result.fail(401, e.getMessage()); } }
@PostMapping("/logout") public Result<Void> logout(@RequestHeader("Authorization") String authHeader, @RequestParam String refreshToken) { log.info("收到注销请求"); try { String accessToken = extractToken(authHeader); authService.logout(accessToken, refreshToken); return Result.ok(); } catch (Exception e) { log.error("注销失败", e); return Result.fail(500, "注销失败:" + e.getMessage()); } }
@PostMapping("/logout/all") public Result<Void> logoutAll(@RequestHeader("Authorization") String authHeader) { log.info("收到全设备注销请求"); try { String accessToken = extractToken(authHeader); Long userId = authService.getUserIdFromToken(accessToken); authService.logoutAll(userId); return Result.ok(); } catch (Exception e) { log.error("全设备注销失败", e); return Result.fail(500, "全设备注销失败:" + e.getMessage()); } }
@GetMapping("/validate") public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String authHeader) { log.info("收到验证令牌请求"); try { String token = extractToken(authHeader); Claims claims = authService.validateToken(token);
Map<String, Object> data = new HashMap<>(); data.put("valid", true); data.put("userId", claims.getSubject()); data.put("username", claims.get("username"));
return Result.ok(data); } catch (Exception e) { log.debug("Token 验证失败: {}", e.getMessage()); Map<String, Object> data = new HashMap<>(); data.put("valid", false); data.put("reason", e.getMessage()); return Result.ok(data); } }
private String extractToken(String authHeader) { if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } return authHeader; } }
|
架构优势:Controller 只注入一个 AuthService,所有的 TokenRedisManager、BlacklistRedisManager 都被隐藏在 AuthService 内部。Controller 不需要知道这些细节。
第六章. 完整测试流程
6.1. 测试单设备注销
步骤 1:登录获取 Token
1
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
|
响应:
1 2 3 4 5 6 7 8 9 10
| { "code": 200, "message": "操作成功", "data": { "accessToken": "eyJhbGciOiJSUzI1NiJ9...", "refreshToken": "a1b2c3d4e5f6...", "expiresIn": 900, "tokenType": "Bearer" } }
|
步骤 2:验证 Token 有效
1 2
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
响应:
1 2 3 4 5 6 7 8 9
| { "code": 200, "message": "操作成功", "data": { "valid": true, "userId": "1001", "username": "admin" } }
|
步骤 3:注销登录
1 2
| curl -X POST "http://localhost:8080/auth/logout?refreshToken=<YOUR_REFRESH_TOKEN>" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
响应:
1 2 3 4 5
| { "code": 200, "message": "操作成功", "data": null }
|
步骤 4:再次验证 Token(应该失败)
1 2
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
响应:
1 2 3 4 5 6 7 8
| { "code": 200, "message": "操作成功", "data": { "valid": false, "reason": "Token 已被注销,请重新登录" } }
|
步骤 5:检查 Redis 数据
1 2 3 4 5 6 7 8 9 10 11
| 127.0.0.1:6379> EXISTS auth:token:black:<JTI> (integer) 1
127.0.0.1:6379> GET auth:token:refresh:<REFRESH_TOKEN> (nil)
127.0.0.1:6379> SMEMBERS auth:user:tokens:1001 (empty array)
|
6.2. 测试全设备注销
步骤 1:模拟多设备登录
1 2 3 4 5 6 7 8
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
|
步骤 2:检查 Redis 中的在线设备
1 2 3 4
| 127.0.0.1:6379> SMEMBERS auth:user:tokens:1001 1) "jti-device-1" 2) "jti-device-2" 3) "jti-device-3"
|
步骤 3:执行全设备注销
1 2
| curl -X POST "http://localhost:8080/auth/logout/all" \ -H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"
|
步骤 4:验证所有设备的 Token 都已失效
1 2 3 4 5 6 7 8 9 10 11
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"
curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <DEVICE_2_ACCESS_TOKEN>"
curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <DEVICE_3_ACCESS_TOKEN>"
|
所有请求都应该返回:
1 2 3 4 5 6 7 8
| { "code": 200, "message": "操作成功", "data": { "valid": false, "reason": "Token 已被注销,请重新登录" } }
|
6.3. 测试黑名单自动过期
步骤 1:修改配置文件,将 Access Token 有效期改为 1 分钟
1 2
| jwt: access-token-expire-minutes: 1
|
步骤 2:登录并立即注销
1 2 3 4 5 6
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
curl -X POST "http://localhost:8080/auth/logout?refreshToken=<YOUR_REFRESH_TOKEN>" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
步骤 3:检查黑名单的 TTL
1 2
| 127.0.0.1:6379> TTL auth:token:black:<JTI> (integer) 58
|
步骤 4:等待 1 分钟后再次检查
1 2
| 127.0.0.1:6379> EXISTS auth:token:black:<JTI> (integer) 0
|
第七章. 本章小结
我们完成了 Token 黑名单机制,实现了主动注销功能,并采用了清晰的分层架构。
核心成果:
| 步骤 | 操作 | 产出 |
|---|
| 1 | 设计分层架构 | Controller → AuthService → Manager |
| 2 | 封装 Manager 层 | TokenRedisManager、BlacklistRedisManager |
| 3 | 封装 Facade 层 | AuthService(统一入口,编排业务流程) |
| 4 | 升级 Controller | 只注入一个 AuthService |
架构依赖关系图(重构后):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| ┌─────────────────────────────────────────────────────────────┐ │ Controller 层 │ │ 只注入一个 AuthService │ └─────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Facade 层 (AuthService) │ │ "大管家",负责编排所有业务流程 │ │ login / refresh / validateToken / logout / logoutAll │ └─────────────────────────┬───────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ JwtUtil │ │TokenRedisManager│ │BlacklistRedisManager│ │ (纯算法工具) │ │ (Token存取) │ │ (黑名单存取) │ └─────────────────┘ └───────────────┘ └─────────────────────┘
|
方法速查:
| 类名 | 方法名 | 作用 |
|---|
| AuthService | login(userId, username) | 登录,创建双令牌 |
| AuthService | refresh(refreshToken) | 刷新令牌 |
| AuthService | validateToken(token) | 验证 Token(含黑名单检查) |
| AuthService | logout(accessToken, refreshToken) | 单设备注销 |
| AuthService | logoutAll(userId) | 全设备注销 |
| AuthService | kickout(userId, jti) | 踢出指定设备 |
| TokenRedisManager | saveRefreshToken(...) | 存储 Refresh Token |
| TokenRedisManager | getUserIdByRefreshToken(...) | 获取用户 ID |
| TokenRedisManager | deleteRefreshToken(...) | 删除 Refresh Token |
| BlacklistRedisManager | add(jti, expiration) | 将 JTI 加入黑名单 |
| BlacklistRedisManager | exists(jti) | 检查 JTI 是否在黑名单中 |
架构优势:
- 职责清晰:Manager 只负责 CRUD,AuthService 负责业务编排
- 依赖简洁:Controller 只注入一个 AuthService,像树状结构而非蜘蛛网
- 易于测试:每一层都可以独立测试
- 易于扩展:新增功能只需在 AuthService 中添加方法
注销场景对比:
| 场景 | 方法 | 说明 |
|---|
| 用户主动退出 | authService.logout(...) | 注销当前设备 |
| 密码修改 | authService.logoutAll(...) | 注销所有设备 |
| 管理员强制下线 | authService.logoutAll(...) | 注销指定用户的所有设备 |
| 远程踢出设备 | authService.kickout(...) | 注销指定设备 |
本章引用链接
在使用 Token 黑名单的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:
架构设计相关
代码实现相关
测试验证相关
SpringBoot3 登录注册系列(六) - 多设备管理与在线状态
第一章. 当前架构的盲点
在上一章中,我们实现了 Token 黑名单机制,用户可以主动注销登录。但现在我们面临一个新的问题:用户根本不知道自己有哪些设备在线。
1.1. 看不见的在线设备
假设你现在打开手机,想查看自己的账号有哪些设备在线。你会发现:
- 没有设备列表:不知道自己有几台设备在线
- 没有登录时间:不知道每个设备是什么时候登录的
- 没有设备信息:不知道是手机还是电脑,是 Chrome 还是 Safari
这就像你的家里有很多把钥匙,但你不知道每把钥匙在谁手里,也不知道谁最近用过。
1.2. 无法管理的设备列表
更严重的问题是:即使你发现了异常登录,也不知道如何处理。
场景一:发现异常登录
你在北京,突然收到一条通知:“您的账号在上海登录”。你确定自己没有去过上海,怀疑账号被盗。但你打开 App,只能看到一个"退出所有设备"的按钮。
点击后,你自己的手机也被踢下线了,必须重新登录。这就像为了抓一个小偷,把整个小区的门都锁上了。
场景二:账号共享泛滥
你的视频网站账号被朋友借用,结果他又分享给了其他人。现在有 10 个人在用你的账号,但你无法限制设备数量。
1.3. 业务场景梳理
在实际业务中,我们需要支持以下设备管理场景:
| 场景 | 用户需求 | 技术实现 |
|---|
| 查看在线设备 | 我想知道有哪些设备在线 | 查询 Redis 中的设备列表 |
| 远程踢出设备 | 我想踢出某个可疑设备 | 将指定设备的 JTI 加入黑名单 |
| 设备数量限制 | 我想限制最多 3 台设备同时在线 | 登录时检查设备数量,超过则踢出最早的设备 |
| 查看登录历史 | 我想知道每个设备的登录时间 | 使用 ZSet 按时间排序存储 |
第二章. 从 Set 到 ZSet 的架构升级
在第四章中,我们使用 Redis 的 Set 来存储用户的在线设备列表。但现在我们发现,Set 有一个致命缺陷:无法排序。
2.1. Set 的局限性
回顾一下我们当前的数据结构:
1 2
| Key: auth:user:tokens:1001 Value: {jti-device-1, jti-device-2, jti-device-3}
|
这个 Set 只能告诉我们"有哪些设备在线",但无法回答:
- 哪个设备是最早登录的?
- 哪个设备是最近登录的?
- 如果要踢出一个设备,应该踢出哪个?
2.2. ZSet 的排序能力
ZSet(Sorted Set)是 Redis 的有序集合每个元素都有一个分数(score),按分数排序。我们可以用登录时间作为分数,这样设备列表就自动按登录时间排序了。
1 2 3 4 5 6
| Key: auth:user:tokens:1001 Value: { jti-device-1: 1735200000, # 2025-12-26 10:00:00 jti-device-2: 1735203600, # 2025-12-26 11:00:00 jti-device-3: 1735207200 # 2025-12-26 12:00:00 }
|
现在我们可以轻松回答:
- 最早登录的设备:
ZRANGE auth:user:tokens:1001 0 0(返回 jti-device-1) - 最近登录的设备:
ZREVRANGE auth:user:tokens:1001 0 0(返回 jti-device-3) - 所有设备按时间排序:
ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES
2.3. 设备信息的存储策略
但是,ZSet 只能存储 JTI 和登录时间,无法存储设备类型、IP、User-Agent 等详细信息。我们需要一个额外的数据结构来存储这些信息。
方案一:使用 Hash
1 2 3 4 5 6 7 8
| Key: auth:device:jti-device-1 Value: { "deviceType": "Mobile", "os": "iOS 17.2", "browser": "Safari", "ip": "192.168.1.100", "loginTime": "2025-12-26 10:00:00" }
|
方案二:使用 String(JSON)
1 2
| Key: auth:device:jti-device-1 Value: "{\"deviceType\":\"Mobile\",\"os\":\"iOS 17.2\",...}"
|
我们选择 方案一(Hash),原因如下:
| 对比维度 | Hash | String(JSON) |
|---|
| 读取单个字段 | ✅ HGET 直接读取 | ❌ 需要反序列化整个 JSON |
| 更新单个字段 | ✅ HSET 直接更新 | ❌ 需要读取、修改、写回 |
| 内存占用 | 较低(Redis 优化) | 较高(JSON 字符串) |
| 可读性 | ✅ Redis 客户端直接查看 | ❌ 需要 JSON 格式化工具 |
第三章. 设备信息模型设计
3.1. DeviceInfo 模型
首先,我们定义一个模型来承载设备信息。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/model/DeviceInfo.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| package com.example.auth.core.model;
import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class DeviceInfo implements Serializable {
private String jti;
private String deviceType;
private String os;
private String browser;
private String ip;
private Long loginTime;
private String loginTimeFormatted; }
|
3.2. 设备信息提取工具类
现在我们需要从 HTTP 请求中提取设备信息。主要来源是:
- User-Agent:包含浏览器、操作系统、设备类型等信息
- IP 地址:需要考虑代理和负载均衡
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/DeviceInfoExtractor.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| package com.example.auth.core.util;
import cn.hutool.core.util.StrUtil; import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgentUtil; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j;
@Slf4j public class DeviceInfoExtractor {
public static String extractDeviceType(HttpServletRequest request) { String userAgentStr = request.getHeader("User-Agent"); if (StrUtil.isBlank(userAgentStr)) { return "Unknown"; }
UserAgent ua = UserAgentUtil.parse(userAgentStr);
if (ua.isMobile()) { return "Mobile"; } else if (ua.isIpad()) { return "Tablet"; } else { return "Desktop"; } }
public static String extractOs(HttpServletRequest request) { String userAgentStr = request.getHeader("User-Agent"); if (StrUtil.isBlank(userAgentStr)) { return "Unknown"; }
UserAgent ua = UserAgentUtil.parse(userAgentStr); return ua.getOs().getName(); }
public static String extractBrowser(HttpServletRequest request) { String userAgentStr = request.getHeader("User-Agent"); if (StrUtil.isBlank(userAgentStr)) { return "Unknown"; }
UserAgent ua = UserAgentUtil.parse(userAgentStr); return ua.getBrowser().getName() + " " + ua.getVersion(); }
public static String extractIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (isValidIp(ip)) { return ip.split(",")[0].trim(); }
ip = request.getHeader("X-Real-IP"); if (isValidIp(ip)) { return ip; }
ip = request.getHeader("Proxy-Client-IP"); if (isValidIp(ip)) { return ip; }
ip = request.getHeader("WL-Proxy-Client-IP"); if (isValidIp(ip)) { return ip; }
ip = request.getRemoteAddr(); return ip; }
private static boolean isValidIp(String ip) { return StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip); } }
|
Hutool 的 UserAgentUtil:Hutool 提供了开箱即用的 User-Agent 解析工具,可以识别常见的浏览器、操作系统、设备类型。
3.3. 关键知识点
为什么要处理 X-Forwarded-For?
在生产环境中,用户的请求通常会经过多层代理:
1
| 用户 → CDN → 负载均衡 → Nginx → Spring Boot
|
如果直接使用 request.getRemoteAddr(),获取到的是 Nginx 的 IP,而不是用户的真实 IP。
X-Forwarded-For 是一个标准的 HTTP 头,用于记录请求经过的所有代理的 IP 地址。格式如下:
1
| X-Forwarded-For: 用户真实IP, 代理1的IP, 代理2的IP
|
我们只需要取第一个 IP,就是用户的真实 IP。
为什么要判断 “unknown”?
某些代理服务器在无法获取真实 IP 时,会将 X-Forwarded-For 设置为 "unknown"。我们需要跳过这种无效值。
第四章. 扩展 Redis 管理器
4.1. 扩展 RedisKeyConstants
首先,我们在 RedisKeyConstants 中添加设备详情相关的 Key 前缀。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| package com.example.auth.core.constant;
public class RedisKeyConstants {
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";
public static final String DEVICE_INFO_PREFIX = "auth:device:";
public static String buildRefreshTokenKey(String refreshToken) { return TOKEN_REFRESH_PREFIX + refreshToken; }
public static String buildUserOnlineKey(Long userId) { return USER_ONLINE_TOKENS_PREFIX + userId; }
public static String buildBlacklistKey(String jti) { return TOKEN_BLACKLIST_PREFIX + jti; }
public static String buildDeviceInfoKey(String jti) { return DEVICE_INFO_PREFIX + jti; } }
|
4.2. 重构 TokenRedisManager(升级为 ZSet)
现在我们需要重构 TokenRedisManager,将 Set 升级为 ZSet,并添加设备详情的存储。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/manager/TokenRedisManager.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
| package com.example.auth.core.manager;
import cn.hutool.core.date.DateUtil; import com.example.auth.core.constant.RedisKeyConstants; import com.example.auth.core.model.DeviceInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;
import java.util.*; import java.util.stream.Collectors;
@Slf4j @Component @RequiredArgsConstructor public class TokenRedisManager {
private final StringRedisTemplate redisTemplate;
public void saveRefreshToken(Long userId, String refreshToken, String jti, long expireSeconds, DeviceInfo deviceInfo) { redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken); connection.stringCommands().setEx( refreshKey.getBytes(), expireSeconds, userId.toString().getBytes() );
String userKey = RedisKeyConstants.buildUserOnlineKey(userId); double score = deviceInfo.getLoginTime().doubleValue(); connection.zSetCommands().zAdd( userKey.getBytes(), score, jti.getBytes() );
connection.keyCommands().expire( userKey.getBytes(), expireSeconds );
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); Map<byte[], byte[]> deviceMap = new HashMap<>(); deviceMap.put("jti".getBytes(), jti.getBytes()); deviceMap.put("deviceType".getBytes(), deviceInfo.getDeviceType().getBytes()); deviceMap.put("os".getBytes(), deviceInfo.getOs().getBytes()); deviceMap.put("browser".getBytes(), deviceInfo.getBrowser().getBytes()); deviceMap.put("ip".getBytes(), deviceInfo.getIp().getBytes()); deviceMap.put("loginTime".getBytes(), deviceInfo.getLoginTime().toString().getBytes()); connection.hashCommands().hMSet(deviceKey.getBytes(), deviceMap);
connection.keyCommands().expire( deviceKey.getBytes(), expireSeconds );
return null; });
log.debug("存储 Refresh Token 和设备信息成功: userId={}, jti={}, 设备类型={}, 有效期={}秒", userId, jti, deviceInfo.getDeviceType(), expireSeconds); }
public Long getUserIdByRefreshToken(String refreshToken) { String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken); String userId = redisTemplate.opsForValue().get(key); return userId == null ? null : Long.parseLong(userId); }
public void deleteRefreshToken(String refreshToken) { String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken); redisTemplate.delete(key); log.debug("删除 Refresh Token: {}", refreshToken); }
public List<DeviceInfo> getUserOnlineDevices(Long userId) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
Set<String> jtiSet = redisTemplate.opsForZSet().reverseRange(userKey, 0, -1);
if (jtiSet == null || jtiSet.isEmpty()) { return Collections.emptyList(); }
return jtiSet.stream() .map(this::getDeviceInfo) .filter(Objects::nonNull) .collect(Collectors.toList()); }
public DeviceInfo getDeviceInfo(String jti) { String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); Map<Object, Object> deviceMap = redisTemplate.opsForHash().entries(deviceKey);
if (deviceMap.isEmpty()) { return null; }
Long loginTime = Long.parseLong((String) deviceMap.get("loginTime"));
return DeviceInfo.builder() .jti((String) deviceMap.get("jti")) .deviceType((String) deviceMap.get("deviceType")) .os((String) deviceMap.get("os")) .browser((String) deviceMap.get("browser")) .ip((String) deviceMap.get("ip")) .loginTime(loginTime) .loginTimeFormatted(DateUtil.formatDateTime(new Date(loginTime * 1000))) .build(); }
public void removeUserDevice(Long userId, String jti) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId); redisTemplate.opsForZSet().remove(userKey, jti);
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); redisTemplate.delete(deviceKey);
log.debug("移除用户设备: userId={}, jti={}", userId, jti); }
public void clearUserDevices(Long userId) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, -1);
if (jtiSet != null && !jtiSet.isEmpty()) { List<String> deviceKeys = jtiSet.stream() .map(RedisKeyConstants::buildDeviceInfoKey) .collect(Collectors.toList()); redisTemplate.delete(deviceKeys); }
redisTemplate.delete(userKey);
log.debug("清空用户所有设备: userId={}", userId); }
public Long getUserDeviceCount(Long userId) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId); return redisTemplate.opsForZSet().zCard(userKey); }
public String getOldestDevice(Long userId) { String userKey = RedisKeyConstants.buildUserOnlineKey(userId); Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, 0); return (jtiSet != null && !jtiSet.isEmpty()) ? jtiSet.iterator().next() : null; } }
|
4.3. 关键设计细节
为什么使用 reverseRange 而不是 range?
reverseRange 是倒序查询,返回的设备列表是按登录时间从新到旧排序的。这样用户看到的设备列表,最上面的是最近登录的设备,符合用户习惯。
为什么要批量删除设备详情?
在 clearUserDevices 方法中,我们需要删除所有设备的详情。如果一个一个删除,会产生大量的网络请求继续完成第六章的内容:
为什么要批量删除设备详情?
在 clearUserDevices 方法中,我们需要删除所有设备的详情。如果一个一个删除,会产生大量的网络请求。使用 redisTemplate.delete(List<String> keys) 可以一次性删除多个 Key,大幅提升性能。
1 2 3 4 5 6 7 8 9 10 11
| for (String jti : jtiSet) { String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti); redisTemplate.delete(deviceKey); }
List<String> deviceKeys = jtiSet.stream() .map(RedisKeyConstants::buildDeviceInfoKey) .collect(Collectors.toList()); redisTemplate.delete(deviceKeys);
|
为什么 ZSet 的 score 使用登录时间?
ZSet 的 score 必须是数值类型。我们使用 Unix 时间戳(秒)作为 score,这样:
- 自动排序:Redis 会自动按 score 排序,无需额外操作
- 范围查询:可以查询"最近 7 天登录的设备"(
ZRANGEBYSCORE) - 性能优化:数值比较比字符串比较快得多
第五章. 升级 AuthService(集成设备管理)
5.1. 在登录时记录设备信息
现在我们需要升级 AuthService 的 login 方法,在登录时记录设备信息。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/AuthService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
| package com.example.auth.core.service;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.IdUtil; import com.example.auth.core.config.properties.JwtProperties; import com.example.auth.core.manager.BlacklistRedisManager; import com.example.auth.core.manager.TokenRedisManager; import com.example.auth.core.model.AuthToken; import com.example.auth.core.model.DeviceInfo; import com.example.auth.core.util.JwtUtil; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service;
import java.util.Date; import java.util.List;
@Slf4j @Service @RequiredArgsConstructor public class AuthService {
private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; private final TokenRedisManager tokenRedisManager; private final BlacklistRedisManager blacklistRedisManager;
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) { log.info("用户登录: userId={}, username={}, 设备类型={}", userId, username, deviceInfo.getDeviceType());
String accessToken = jwtUtil.createToken(userId, username, null);
String jti = jwtUtil.getJtiFromToken(accessToken);
deviceInfo.setJti(jti); deviceInfo.setLoginTime(System.currentTimeMillis() / 1000);
String refreshToken = IdUtil.fastSimpleUUID();
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds, deviceInfo);
log.info("用户登录成功: userId={}, jti={}, IP={}", userId, jti, deviceInfo.getIp());
return AuthToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60) .build(); }
public AuthToken refresh(String oldRefreshToken, DeviceInfo deviceInfo) { log.info("刷新令牌请求");
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);
if (userId == null) { log.warn("Refresh Token 无效或已过期"); throw new RuntimeException("刷新令牌无效或已过期,请重新登录"); }
String username = "User-" + userId;
AuthToken newTokenPair = login(userId, username, deviceInfo);
tokenRedisManager.deleteRefreshToken(oldRefreshToken);
log.info("令牌刷新成功: userId={}", userId); return newTokenPair; }
public Claims validateToken(String token) { try { Claims claims = jwtUtil.parseToken(token);
String jti = claims.getId(); if (blacklistRedisManager.exists(jti)) { log.warn("Token 已被注销: jti={}", jti); throw new RuntimeException("Token 已被注销,请重新登录"); }
return claims;
} catch (ExpiredJwtException e) { log.debug("Token 已过期: {}", e.getMessage()); throw new RuntimeException("Token 已过期,请重新登录"); } catch (JwtException e) { log.warn("Token 验证失败: {}", e.getMessage()); throw new RuntimeException("Token 无效"); } }
public boolean isTokenValid(String token) { try { validateToken(token); return true; } catch (Exception e) { return false; } }
public void logout(String accessToken, String refreshToken) { log.info("单设备注销请求");
try { Claims claims = jwtUtil.parseToken(accessToken); String jti = claims.getId(); Date expiration = claims.getExpiration(); Long userId = Long.parseLong(claims.getSubject());
blacklistRedisManager.add(jti, expiration);
tokenRedisManager.deleteRefreshToken(refreshToken);
tokenRedisManager.removeUserDevice(userId, jti);
log.info("单设备注销成功: userId={}, jti={}", userId, jti);
} catch (Exception e) { log.error("单设备注销失败", e); throw new RuntimeException("注销失败", e); } }
public void logoutAll(Long userId) { log.info("全设备注销请求: userId={}", userId);
try { List<DeviceInfo> devices = tokenRedisManager.getUserOnlineDevices(userId);
if (CollUtil.isEmpty(devices)) { log.info("用户没有在线设备: userId={}", userId); return; }
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; devices.forEach(device -> blacklistRedisManager.add(device.getJti(), maxExpireSeconds));
tokenRedisManager.clearUserDevices(userId);
log.info("全设备注销成功: userId={}, 设备数量={}", userId, devices.size());
} catch (Exception e) { log.error("全设备注销失败: userId={}", userId, e); throw new RuntimeException("全设备注销失败", e); } }
public void kickoutDevice(Long userId, String jti) { log.info("踢出设备请求: userId={}, jti={}", userId, jti);
try { long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; blacklistRedisManager.add(jti, maxExpireSeconds);
tokenRedisManager.removeUserDevice(userId, jti);
log.info("踢出设备成功: userId={}, jti={}", userId, jti);
} catch (Exception e) { log.error("踢出设备失败: userId={}, jti={}", userId, jti, e); throw new RuntimeException("踢出设备失败", e); } }
public List<DeviceInfo> getOnlineDevices(Long userId) { log.info("查询在线设备: userId={}", userId); return tokenRedisManager.getUserOnlineDevices(userId); }
public DeviceInfo getDeviceInfo(String jti) { return tokenRedisManager.getDeviceInfo(jti); }
public Long getUserIdFromToken(String token) { Claims claims = validateToken(token); return Long.parseLong(claims.getSubject()); } }
|
5.2. 关键设计细节
为什么 login 方法需要传入 DeviceInfo?
在之前的版本中,login 方法只需要 userId 和 username。现在我们需要记录设备信息,所以必须传入 DeviceInfo 对象。
但是,DeviceInfo 的提取工作不应该在 Service 层完成,而应该在 Controller 层完成。因为:
- Service 层不应该依赖 HttpServletRequest:Service 层是业务逻辑层,不应该依赖 Web 层的对象
- 便于单元测试:如果 Service 层依赖 HttpServletRequest,单元测试时需要 Mock 这个对象,非常麻烦
为什么 refresh 方法也需要传入 DeviceInfo?
刷新令牌时,我们会生成一对全新的 Token。这意味着会产生一个新的 JTI,需要更新设备信息。
但是,刷新令牌时的设备信息应该和登录时的设备信息保持一致吗?不一定。
假设用户在手机上登录,然后切换到 Wi-Fi 网络,IP 地址变了。此时刷新令牌,应该更新 IP 地址。
所以,我们在刷新令牌时也需要传入最新的设备信息。
第六章. 设备管理接口实现
6.1. 升级 AuthController
现在我们需要升级 AuthController,添加设备管理相关的接口。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
| package com.example.auth.web.controller;
import com.example.auth.common.model.Result; import com.example.auth.core.model.AuthToken; import com.example.auth.core.model.DeviceInfo; import com.example.auth.core.service.AuthService; import com.example.auth.core.util.DeviceInfoExtractor; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.List; import java.util.Map;
@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController {
private final AuthService authService;
@PostMapping("/login") public Result<AuthToken> login(@RequestParam Long userId, @RequestParam String username, HttpServletRequest request) { log.info("收到登录请求: userId={}, username={}", userId, username);
DeviceInfo deviceInfo = buildDeviceInfo(request);
AuthToken authToken = authService.login(userId, username, deviceInfo); return Result.ok(authToken); }
@PostMapping("/refresh") public Result<AuthToken> refresh(@RequestParam String refreshToken, HttpServletRequest request) { log.info("收到刷新令牌请求"); try { DeviceInfo deviceInfo = buildDeviceInfo(request);
AuthToken authToken = authService.refresh(refreshToken, deviceInfo); return Result.ok(authToken); } catch (Exception e) { log.error("刷新令牌失败", e); return Result.fail(401, e.getMessage()); } }
@PostMapping("/logout") public Result<Void> logout(@RequestHeader("Authorization") String authHeader, @RequestParam String refreshToken) { log.info("收到注销请求"); try { String accessToken = extractToken(authHeader); authService.logout(accessToken, refreshToken); return Result.ok(); } catch (Exception e) { log.error("注销失败", e); return Result.fail(500, "注销失败:" + e.getMessage()); } }
@PostMapping("/logout/all") public Result<Void> logoutAll(@RequestHeader("Authorization") String authHeader) { log.info("收到全设备注销请求"); try { String accessToken = extractToken(authHeader); Long userId = authService.getUserIdFromToken(accessToken); authService.logoutAll(userId); return Result.ok(); } catch (Exception e) { log.error("全设备注销失败", e); return Result.fail(500, "全设备注销失败:" + e.getMessage()); } }
@GetMapping("/validate") public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String authHeader) { log.info("收到验证令牌请求"); try { String token = extractToken(authHeader); Claims claims = authService.validateToken(token);
Map<String, Object> data = new HashMap<>(); data.put("valid", true); data.put("userId", claims.getSubject()); data.put("username", claims.get("username"));
return Result.ok(data); } catch (Exception e) { log.debug("Token 验证失败: {}", e.getMessage()); Map<String, Object> data = new HashMap<>(); data.put("valid", false); data.put("reason", e.getMessage()); return Result.ok(data); } }
@GetMapping("/devices") public Result<List<DeviceInfo>> getDevices(@RequestHeader("Authorization") String authHeader) { log.info("收到查询在线设备请求"); try { String accessToken = extractToken(authHeader); Long userId = authService.getUserIdFromToken(accessToken); List<DeviceInfo> devices = authService.getOnlineDevices(userId); return Result.ok(devices); } catch (Exception e) { log.error("查询在线设备失败", e); return Result.fail(500, "查询失败:" + e.getMessage()); } }
@DeleteMapping("/devices/{jti}") public Result<Void> kickoutDevice(@RequestHeader("Authorization") String authHeader, @PathVariable String jti) { log.info("收到踢出设备请求: jti={}", jti); try { String accessToken = extractToken(authHeader); Long userId = authService.getUserIdFromToken(accessToken); authService.kickoutDevice(userId, jti); return Result.ok(); } catch (Exception e) { log.error("踢出设备失败: jti={}", jti, e); return Result.fail(500, "踢出设备失败:" + e.getMessage()); } }
private String extractToken(String authHeader) { if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } return authHeader; }
private DeviceInfo buildDeviceInfo(HttpServletRequest request) { return DeviceInfo.builder() .deviceType(DeviceInfoExtractor.extractDeviceType(request)) .os(DeviceInfoExtractor.extractOs(request)) .browser(DeviceInfoExtractor.extractBrowser(request)) .ip(DeviceInfoExtractor.extractIp(request)) .build(); } }
|
6.2. 接口设计说明
为什么踢出设备使用 DELETE 方法?
RESTful API 的设计原则是:
- GET:查询资源
- POST:创建资源
- PUT:更新资源
- DELETE:删除资源
踢出设备本质上是"删除一个在线会话",所以使用 DELETE 方法更符合语义。
为什么路径是 /devices/{jti} 而不是 /devices/kickout?
RESTful API 的设计原则是:资源用名词,操作用 HTTP 方法。
- ❌
/devices/kickout?jti=xxx(动词 + 参数) - ✅
/devices/{jti}(名词 + DELETE 方法)
第七章. 设备数量限制(顶号逻辑)
7.1. 业务场景
在视频网站、音乐平台等业务中,通常会限制同一账号的最大在线设备数。例如:
- Netflix:标准套餐最多 2 台设备同时观看
- Spotify:免费用户只能 1 台设备在线
- 腾讯视频:VIP 会员最多 5 台设备登录
当用户在第 6 台设备上登录时,系统会自动踢出最早登录的设备。这就是顶号逻辑新设备登录时,自动踢出旧设备。
7.2. 扩展 JwtProperties
首先,我们在配置类中添加设备数量限制的配置。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| package com.example.auth.core.config.properties;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
@Data @Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties {
private String issuer = "auth-service";
private Integer accessTokenExpireMinutes = 15;
private Integer refreshTokenExpireDays = 7;
private Long clockSkewSeconds = 30L;
private String publicKeyResource = "certs/public_key.pem";
private String privateKeyResource = "certs/private_key.pem";
private Integer maxDevices = 0; }
|
更新配置文件。
📄 文件路径:auth-web/src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| server: port: 8080
spring: application: name: auth-service data: redis: host: localhost port: 6379 database: 0 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0
jwt: issuer: pro-auth-service access-token-expire-minutes: 15 refresh-token-expire-days: 7 clock-skew-seconds: 30 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem max-devices: 3
|
7.3. 在 AuthService 中实现顶号逻辑
现在我们需要在 login 方法中添加设备数量检查。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/AuthService.java
在 login 方法的开头添加以下逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) { log.info("用户登录: userId={}, username={}, 设备类型={}", userId, username, deviceInfo.getDeviceType());
Integer maxDevices = jwtProperties.getMaxDevices(); if (maxDevices != null && maxDevices > 0) { Long currentDeviceCount = tokenRedisManager.getUserDeviceCount(userId);
if (currentDeviceCount >= maxDevices) { String oldestJti = tokenRedisManager.getOldestDevice(userId); if (oldestJti != null) { log.info("设备数量达到上限,踢出最早登录的设备: userId={}, jti={}", userId, oldestJti); kickoutDevice(userId, oldestJti); } } }
String accessToken = jwtUtil.createToken(userId, username, null);
}
|
7.4. 顶号流程图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| ┌─────────────────┐ │ 用户登录请求 │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 检查设备数量 │ └────────┬────────┘ │ ├─ 未达上限 ──────────────┐ │ │ └─ 达到上限 │ │ │ ▼ │ ┌─────────────────┐ │ │ 获取最早的设备 │ │ └────────┬────────┘ │ │ │ ▼ │ ┌─────────────────┐ │ │ 踢出该设备 │ │ │ (加入黑名单) │ │ └────────┬────────┘ │ │ │ └────────────────┤ │ ▼ ┌─────────────────┐ │ 生成新的 Token │ └─────────────────┘
|
7.5. 关键设计细节
为什么要先踢出旧设备,再生成新 Token?
如果先生成新 Token,再踢出旧设备,会出现一个短暂的时间窗口:设备数量超过了上限。虽然这个时间窗口很短(几毫秒),但在高并发场景下可能会被利用。
为什么使用 maxDevices > 0 而不是 maxDevices != null?
配置文件中可能会设置 max-devices: 0,表示不限制设备数量。我们需要同时检查 != null 和 > 0。
为什么不使用分布式锁?
你可能会担心:如果用户同时在两台设备上登录,会不会出现竞态条件?
答案是:不会。因为我们的检查和踢出操作都是在同一个方法中完成的,而 Spring 的 Service 方法默认是单线程执行的(除非你手动开启异步)。
即使在高并发场景下,最坏的情况是:两个请求都通过了设备数量检查,导致设备数量暂时超过上限。但下一次登录时,会自动踢出多余的设备,系统会自动恢复到正常状态。
如果你的业务对设备数量有严格要求(如付费会员系统),可以使用 Redis 的分布式锁(Redisson)来保证原子性。
第八章. 完整测试流程
8.1. 测试设备列表查询
步骤 1:模拟多设备登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \ -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML好的,我继续完成第六章的剩余内容:
---
## 第八章. 完整测试流程
### 8.1. 测试设备列表查询
**步骤 1:模拟多设备登录**
```bash # 设备 1 登录(模拟 iPhone) curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \ -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15"
# 设备 2 登录(模拟 Chrome 桌面版) curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"
# 设备 3 登录(模拟 Android) curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \ -H "User-Agent: Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile"
|
每次登录都会返回一对 Token,请保存第一个设备的 accessToken,后续查询设备列表时需要用到。
步骤 2:查询在线设备列表
1 2
| curl -X GET "http://localhost:8080/auth/devices" \ -H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| { "code": 200, "message": "操作成功", "data": [ { "jti": "a1b2c3d4e5f6", "deviceType": "Mobile", "os": "Android", "browser": "Chrome Mobile 120.0.0.0", "ip": "127.0.0.1", "loginTime": 1735280400, "loginTimeFormatted": "2025-12-27 14:00:00" }, { "jti": "f6e5d4c3b2a1", "deviceType": "Desktop", "os": "Windows 10", "browser": "Chrome 120.0.0.0", "ip": "127.0.0.1", "loginTime": 1735280300, "loginTimeFormatted": "2025-12-27 13:58:20" }, { "jti": "1a2b3c4d5e6f", "deviceType": "Mobile", "os": "iOS", "browser": "Safari", "ip": "127.0.0.1", "loginTime": 1735280200, "loginTimeFormatted": "2025-12-27 13:56:40" } ] }
|
观察要点:设备列表按登录时间倒序排列,最新登录的设备在最上面。
步骤 3:检查 Redis 数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| 127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES 1) "1a2b3c4d5e6f" 2) "1735280200" 3) "f6e5d4c3b2a1" 4) "好的,我继续完成第六章的剩余内容:
---
**步骤 3:检查 Redis 数据**
```bash # 查看用户的在线设备列表(ZSet) 127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES 1) "1a2b3c4d5e6f" 2) "1735280200" 3) "f6e5d4c3b2a1" 4) "1735280300" 5) "a1b2c3d4e5f6" 6) "1735280400"
# 查看某个设备的详细信息(Hash) 127.0.0.1:6379> HGETALL auth:device:1a2b3c4d5e6f 1) "jti" 2) "1a2b3c4d5e6f" 3) "deviceType" 4) "Mobile" 5) "os" 6) "iOS" 7) "browser" 8) "Safari" 9) "ip" 10) "127.0.0.1" 11) "loginTime" 12) "1735280200"
|
8.2. 测试远程踢出设备
步骤 1:获取要踢出的设备 JTI
从上一步的设备列表中,选择一个设备的 JTI(如 f6e5d4c3b2a1)。
步骤 2:踢出该设备
1 2
| curl -X DELETE "http://localhost:8080/auth/devices/f6e5d4c3b2a1" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5
| { "code": 200, "message": "操作成功", "data": null }
|
步骤 3:再次查询设备列表(应该少了一个设备)
1 2
| curl -X GET "http://localhost:8080/auth/devices" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| { "code": 200, "message": "操作成功", "data": [ { "jti": "a1b2c3d4e5f6", "deviceType": "Mobile", "os": "Android", "browser": "Chrome Mobile 120.0.0.0", "ip": "127.0.0.1", "loginTime": 1735280400, "loginTimeFormatted": "2025-12-27 14:00:00" }, { "jti": "1a2b3c4d5e6f", "deviceType": "Mobile", "os": "iOS", "browser": "Safari", "ip": "127.0.0.1", "loginTime": 1735280200, "loginTimeFormatted": "2025-12-27 13:56:40" } ] }
|
步骤 4:验证被踢出的设备无法访问
使用被踢出设备的 Access Token 尝试访问:
1 2
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <KICKED_DEVICE_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8
| { "code": 200, "message": "操作成功", "data": { "valid": false, "reason": "Token 已被注销,请重新登录" } }
|
步骤 5:检查 Redis 数据
1 2 3 4 5 6 7 8 9 10 11 12
| 127.0.0.1:6379> EXISTS auth:token:black:f6e5d4c3b2a1 (integer) 1
127.0.0.1:6379> EXISTS auth:device:f6e5d4c3b2a1 (integer) 0
127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1 1) "1a2b3c4d5e6f" 2) "a1b2c3d4e5f6"
|
8.3. 测试设备数量限制(顶号逻辑)
步骤 1:修改配置文件,设置最大设备数为 3
重启应用。
步骤 2:模拟 3 台设备登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (iPhone)"
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0)"
curl -X POST "好的,我继续完成第六章的剩余内容:
---
**步骤 2:模拟 3 台设备登录**
```bash # 设备 1 登录 curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (iPhone)"
# 设备 2 登录 curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0)"
# 设备 3 登录 curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (Macintosh)"
|
保存第一个设备的 Access Token,用于后续查询。
步骤 3:查询设备列表(应该有 3 台设备)
1 2
| curl -X GET "http://localhost:8080/auth/devices" \ -H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| { "code": 200, "message": "操作成功", "data": [ { "jti": "mac-device-jti", "deviceType": "Desktop", "os": "Mac OS X", "browser": "Safari", "ip": "127.0.0.1", "loginTime": 1735281000, "loginTimeFormatted": "2025-12-27 14:10:00" }, { "jti": "windows-device-jti", "deviceType": "Desktop", "os": "Windows 10", "browser": "Chrome", "ip": "127.0.0.1", "loginTime": 1735280800, "loginTimeFormatted": "2025-12-27 14:06:40" }, { "jti": "iphone-device-jti", "deviceType": "Mobile", "os": "iOS", "browser": "Safari", "ip": "127.0.0.1", "loginTime": 1735280600, "loginTimeFormatted": "2025-12-27 14:03:20" } ] }
|
步骤 4:第 4 台设备登录(触发顶号)
1 2
| curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \ -H "User-Agent: Mozilla/5.0 (Linux; Android 14)"
|
观察服务端日志,应该看到:
1 2 3 4
| INFO - 用户登录: userId=2001, username=testuser, 设备类型=Mobile INFO - 设备数量达到上限,踢出最早登录的设备: userId=2001, jti=iphone-device-jti INFO - 踢出设备成功: userId=2001, jti=iphone-device-jti INFO - 用户登录成功: userId=2001, jti=android-device-jti, IP=127.0.0.1
|
步骤 5:再次查询设备列表(应该还是 3 台,但最早的设备被替换了)
1 2
| curl -X GET "http://localhost:8080/auth/devices" \ -H "Authorization: Bearer <NEW_DEVICE_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| { "code": 200, "message": "操作成功", "data": [ { "jti": "android-device-jti", "deviceType": "Mobile", "os": "Android", "browser": "Chrome Mobile", "ip": "127.0.0.1", "loginTime": 1735281200, "loginTimeFormatted": "2025-12-27 14:13:20" }, { "jti": "mac-device-jti", "deviceType": "Desktop", "os": "Mac OS X", "browser": "Safari", "ip": "127.0.0.1", "loginTime": 1735281000, "loginTimeFormatted": "2025-12-27 14:10:00" }, { "jti": "windows-device-jti", "deviceType": "Desktop", "os": "Windows 10", "browser": "Chrome", "ip": "127.0.0.1", "loginTime": 1735280800, "loginTimeFormatted": "2025-12-27 14:06:40" } ] }
|
观察要点:iPhone 设备(最早登录)已经被踢出,Android 设备(最新登录)成功加入。
步骤 6:验证被踢出的设备无法访问
使用 iPhone 设备的 Access Token 尝试访问:
1 2
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <IPHONE_ACCESS_TOKEN>"
|
预期响应:
1 2 3 4 5 6 7 8
| { "code": 200, "message": "操作成功", "data": { "valid": false, "reason": "Token 已被注销,请重新登录" } }
|
8.4. 测试设备信息的准确性
步骤 1:使用真实的浏览器访问
打开浏览器,访问以下 URL(需要先实现一个简单的登录页面,或使用 Postman):
1
| POST http://localhost:8080/auth/login?userId=3001&username=browseruser
|
步骤 2:查询设备列表
1 2
| GET http://localhost:8080/auth/devices Authorization: Bearer <YOUR_ACCESS_TOKEN>
|
预期响应(以 Chrome 为例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "code": 200, "message": "操作成功", "data": [ { "jti": "real-browser-jti", "deviceType": "Desktop", "os": "Windows 10", "browser": "Chrome 120.0.0.0", "ip": "192.168.1.100", "loginTime": 1735281500, "loginTimeFormatted": "2025-12-27 14:18:20" } ] }
|
验证要点:检查 deviceType、os、browser 是否与你的实际环境一致。
第九章. 本章小结
我们完成了多设备管理功能,实现了设备列表查询、远程踢出、设备数量限制等核心能力。
9.1. 本章回顾
本章涵盖了以下核心能力模块:
数据结构升级:将用户在线设备列表从 Set 升级为 ZSet,支持按登录时间排序。同时使用 Hash 存储设备详情,实现了高效的单字段读取和更新。
设备信息提取:封装了 DeviceInfoExtractor 工具类,从 HTTP 请求中提取设备类型、操作系统、浏览器、IP 地址等信息。特别处理了代理和负载均衡场景下的真实 IP 获取。
设备管理接口:提供了查询在线设备列表、远程踢出指定设备的 RESTful API。Controller 层负责提取设备信息,Service 层负责业务编排,Manager 层负责 Redis 操作,职责清晰。
设备数量限制:实现了顶号逻辑,当用户在新设备登录时,如果超过最大设备数限制,自动踢出最早登录的设备。支持通过配置文件灵活调整设备数量上限。
9.2. 核心汇总表
9.3. 方法速查表
| 类名 | 方法名 | 作用 |
|---|
| DeviceInfoExtractor | extractDeviceType(request) | 提取设备类型 |
| DeviceInfoExtractor | extractOs(request) | 提取操作系统 |
| DeviceInfoExtractor | extractBrowser(request) | 提取浏览器 |
| DeviceInfoExtractor | extractIp(request) | 提取真实 IP |
| TokenRedisManager | saveRefreshToken(..., deviceInfo) | 存储 Token 和设备信息 |
| TokenRedisManager | getUserOnlineDevices(userId) | 获取在线设备列表 |
| TokenRedisManager | getDeviceInfo(jti) | 获取设备详情 |
| TokenRedisManager | getUserDeviceCount(userId) | 获取设备数量 |
| TokenRedisManager | getOldestDevice(userId) | 获取最早登录的设备 |
| AuthService | login(userId, username, deviceInfo) | 登录(带设备信息) |
| AuthService | getOnlineDevices(userId) | 查询在线设备 |
| AuthService | kickoutDevice(userId, jti) | 踢出指定设备 |
本章引用链接
在使用多设备管理功能的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:
架构设计相关
代码实现相关
测试验证相关
常见问题
第六章完成!这一章我们实现了完整的多设备管理功能,用户可以查看在线设备、远程踢出可疑设备,系统也可以通过设备数量限制来防止账号共享。
下一章我们将实现登录安全增强功能,包括登录失败次数限制、验证码机制、异地登录检测等,进一步提升系统的安全性。