程序员scholar 程序员scholar
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
npm

(进入注册为作者充电)

  • SpringSecurity

    • Spring Security是什么
    • 认证与授权的基本概念
    • Spring Security 的默认配置
    • SpringSecurity的默认登录页
    • Spring Security 的 Filter 机制
    • HttpSecurity 与自定义登录页面
    • Spring Security 的核心组件与扩展
    • Spring Security 中的用户认证与角色管理
    • Spring Security 的授权机制与安全表达式
    • Security 的 Session 与 Token 管理
    • Spring Security 集成第三方登录
    • Spring Security 集成 QQ 登录与 JWT 认证
    • Spring Security 登录认证源码
    • Spring Security - JWT认证实战
      • 1. 准备工作
        • 1.1 添加依赖(pom.xml)
        • 1.2 Redis 配置类
        • 1.3 JWT 工具类
        • 1.4 Redis 工具类
        • 1. Redis 连接工具类
        • 2. Redis 对象序列化
        • 3. Redis 缓存工具类
        • 1.5 web工具类
        • 1.6 实体类
        • 1.7 响应类
      • 2. JWT登录实战
        • 2.1 数据库校验用户
        • 1.准备工作
        • 2. 定义 Mapper 接口
        • 3. 配置 Mapper 扫描
        • 4. 测试 MyBatis Plus 是否正常工作
        • 2.2 核心代码
        • 1. 自定义 `UserDetailsService` 实现类
        • 2. 自定义 `UserDetails` 实现类
        • 3. 密码加密存储模式更改
        • 4. 自定义登录接口
        • 1. 创建登录控制器 LoginController
        • 2. 创建登录服务接口 LoginService
        • 3. 实现登录服务逻辑 LoginServiceImpl
        • 4. 配置Spring Security 配置类
        • 5. 自定义认证过滤器
        • 1. 创建自定义JWT认证过滤器
        • 2. 配置类添加JWT认证过滤器
        • 6. 退出登录功能
        • 7. 自定义失败处理器
        • 1. 自定义AccessDeniedHandler(授权失败处理器)
        • 2. 自定义AuthenticationEntryPoint(认证失败处理器)
        • 3. 修改SecurityConfig类配置异常处理器
        • 4. 测试自定义失败处理器
    • Spring Security - JWT授权实战
    • Spring Security 异常处理与自定义逻辑
  • SpringSecurity
  • SpringSecurity
scholar
2024-12-28
目录

Spring Security - JWT认证实战

# Spring Security - JWT登录实战

前言

Spring Security 结合 JWT 登录认证的过程就是先放行登录接口,然后在登录接口手动进行Spring Security的认证并获取用户信息,然后将用户信息存入Redis,基于jwt加密Redis的key作为token返回给前端,前端下次访问其他未放行的接口的时候就会带着这个token,那我们这个时候定义了一个jwt的过滤器,将这个过滤器放在默认的登录认证过滤器(UsernamePasswordAuthenticationFilter)之前,这样我在这个jwt过滤器里面如果能基于token从Redis里面获取到这个用户的信息,我们就可以在拿着这个用户的信息手动进行认证,如果认证通过,将认证信息设置到SecurityContextHolder中,SecurityContextHolder 作为存储当前认证状态的全局上下文,后续过滤器会检查 SecurityContextHolder 是否已经包含认证信息,如果有就不会重复进行认证,就会 跳过后的登录认证过滤器会直接进入授权阶段,然后调用控制器(controller层)处理业务逻辑。

流程简述:

  1. 登录时,用户提交用户名和密码,服务端完成认证后生成 JWT 并存储用户信息到 Redis,同时将 JWT 返回给前端。
  2. 在后续的请求中,前端携带 JWT,服务端通过 JWT 过滤器验证 Token 并从 Redis 中获取用户信息。
  3. 如果认证通过,将认证信息设置到 SecurityContextHolder,后续过滤器跳过登录认证逻辑,直接进入授权阶段,最终由控制器处理业务逻辑。

如果token不存在或者已失效,导致SecurityContextHolder 中并没有设置用户认证信息,后续会进入自定义的失败处理器。

# 1. 准备工作

项目目录结构:

SecurityTest
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── example
│   │   │           └── securitytest
│   │   │               ├── config
│   │   │               │   └── RedisConfig.java
│   │   │               ├── domain
│   │   │               │   ├── ResponseResult.java
│   │   │               │   └── User.java
│   │   │               ├── utils
│   │   │               │   ├── FastJsonRedisSerializer.java
│   │   │               │   ├── JwtUtil.java
│   │   │               │   ├── RedisCache.java
│   │   │               │   ├── RedisUtils.java
│   │   │               │   └── WebUtils.java
│   │   │               └── SecurityTestApplication.java
│   │   └── resources
│   │       ├── static
│   │       ├── templates
│   │       └── application.properties
│   └── test
├── .gitignore
├── 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

# 1.1 添加依赖(pom.xml)

        <!-- Spring Boot 安全功能的starter包,用于web应用的安全控制 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Spring Boot Web功能的starter包,提供web应用的基本功能 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Lombok,提供简单的代码生成工具,减少样板代码,设置为可选依赖 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- Spring Boot的测试starter包,用于单元测试和集成测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Spring Security的测试包,用于安全测试 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Redis的starter包,用于集成Redis作为缓存或持久化方案 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- FastJSON,一个Java语言编写的高性能功能完备的JSON库 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.33</version>
        </dependency>
        <!-- JWT(JSON Web Token)的库,用于生成和解析JWT -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
        <!-- JAXB API,用于XML和Java对象之间的绑定 -->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.1</version>
        </dependency>
        <!-- MyBatis Plus的Spring Boot starter,用于简化MyBatis的使用 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <!-- MySQL连接器,用于连接和操作MySQL数据库 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.29</version>
        </dependency>
        <!-- Spring Boot的测试starter包,重复项,可能用于不同目的 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
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

# 1.2 Redis 配置类

@Configuration  // 标识这是一个配置类
public class RedisConfig {

    // 定义 RedisTemplate Bean
    @Bean
    @SuppressWarnings(value = {"unchecked", "rawtypes"})  // 抑制类型检查警告
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);  // 设置连接工厂,连接 Redis 数据库

        // 自定义 FastJsonRedisSerializer 用于对象序列化和反序列化
        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 设置 RedisTemplate 的 key 和 value 序列化方式
        // 使用 StringRedisSerializer 来序列化 Redis 的 key 值
        template.setKeySerializer(new StringRedisSerializer());
        
        // 使用 FastJsonRedisSerializer 来序列化 Redis 的 value 值
        template.setValueSerializer(serializer);
        
        // 对于 Hash 类型的 Redis 数据结构,key 使用 StringRedisSerializer,value 使用 FastJsonRedisSerializer
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        // 完成属性设置
        template.afterPropertiesSet();
        return template;  // 返回 RedisTemplate 实例
    }
}
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

# 1.3 JWT 工具类

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
 * JWT工具类
 * 用于生成、解析和管理JWT(JSON Web Token)
 */
public class JwtUtil {
    // Token 有效期(默认为1小时,单位:毫秒)
    public static final Long JWT_TTL = 60 * 60 * 1000L;

    // 签名密钥明文(用于生成加密密钥)
    public static final String JWT_KEY = "sangeng";

    /**
     * 生成唯一的 UUID
     *
     * @return UUID 字符串
     */
    public static String getUUID() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    /**
     * 创建 JWT(使用默认的有效期)
     *
     * @param subject token 中存储的数据(通常是用户标识信息)
     * @return 生成的 JWT 字符串
     */
    public static String createJWT(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID()); // 设置过期时间
        return builder.compact();
    }

    /**
     * 创建 JWT(指定有效期)
     *
     * @param subject   token 中存储的数据(通常是用户标识信息)
     * @param ttlMillis token 的有效期(毫秒)
     * @return 生成的 JWT 字符串
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID()); // 设置过期时间
        return builder.compact();
    }

    /**
     * 创建 JWT(指定 ID、有效期和存储数据)
     *
     * @param id        token 的唯一标识
     * @param subject   token 中存储的数据
     * @param ttlMillis token 的有效期(毫秒)
     * @return 生成的 JWT 字符串
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id); // 设置过期时间
        return builder.compact();
    }

    /**
     * 获取 JwtBuilder(生成 Token 的核心对象)
     *
     * @param subject   token 中存储的数据
     * @param ttlMillis token 的有效期(毫秒)
     * @param uuid      token 的唯一标识
     * @return JwtBuilder 对象
     */
    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 签名算法
        SecretKey secretKey = generalKey(); // 加密密钥
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis); // 当前时间

        if (ttlMillis == null) {
            ttlMillis = JwtUtil.JWT_TTL; // 如果未指定有效期,使用默认值
        }
        long expMillis = nowMillis + ttlMillis; // 计算过期时间
        Date expDate = new Date(expMillis); // 过期时间

        return Jwts.builder()
                .setId(uuid) // 设置唯一 ID
                .setSubject(subject) // 设置主题(可以是 JSON 数据)
                .setIssuer("sg") // 设置签发者
                .setIssuedAt(now) // 设置签发时间
                .signWith(signatureAlgorithm, secretKey) // 使用 HS256 签名算法和密钥
                .setExpiration(expDate); // 设置过期时间
    }

    /**
     * 解析 JWT
     *
     * @param jwt token 字符串
     * @return 解析后的 Claims(包含 token 中存储的数据)
     * @throws Exception 如果解析失败,抛出异常
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey(); // 获取密钥
        return Jwts.parser()
                .setSigningKey(secretKey) // 设置签名密钥
                .parseClaimsJws(jwt) // 解析 JWT
                .getBody(); // 获取 Claims(存储的内容)
    }

    /**
     * 生成加密后的密钥 SecretKey
     *
     * @return SecretKey 对象
     */
    public static SecretKey generalKey() {
        // 使用 Base64 解码密钥明文
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        // 根据 AES 算法生成加密密钥
        return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    }

    public static void main(String[] args) throws Exception {
        // 示例:解析一个示例 JWT
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
        Claims claims = parseJWT(token); // 解析 JWT
        System.out.println(claims); // 打印解析结果
    }
}
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

# 1.4 Redis 工具类

# 1. Redis 连接工具类

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * RedisUtils 工具类
 * 用于启动 Redis 服务器和通过 CLI 登录 Redis 服务器。
 */
public class RedisUtils {

    /**
     * 启动 Redis 服务器
     * 方法通过运行 Redis 的启动命令,启动本地 Redis 服务器。
     */
    public static void startRedisServer() {
        try {
            // 定义 Redis 服务器启动命令
            String command = "C:\\develop1\\Redis-x64-3.2.100\\redis-server.exe C:\\develop1\\Redis-x64-3.2.100\\redis.windows.conf";

            // 使用 Runtime 执行命令,启动 Redis 服务器
            Process process = Runtime.getRuntime().exec(command);

            // 获取启动过程中输出的信息
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));

            // 逐行读取输出内容并打印到控制台
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line); // 打印启动信息
            }
        } catch (IOException e) {
            // 如果发生异常,则打印异常信息
            e.printStackTrace();
        }
    }

    /**
     * 使用 Redis CLI 登录 Redis 服务器
     * 
     * @param host     Redis 服务器地址(如 localhost)
     * @param port     Redis 服务器端口(如 6379)
     * @param password Redis 服务器密码
     */
    public static void loginRedisCli(String host, int port, String password) {
        try {
            // 定义 Redis CLI 登录命令
            String command = "redis-cli.exe -h " + host + " -p " + port + " -a " + password;

            // 使用 Runtime 执行登录命令
            Process process = Runtime.getRuntime().exec(command);

            // 获取登录过程中的输出信息
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));

            // 逐行读取输出内容并打印到控制台
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line); // 打印登录信息
            }
        } catch (IOException e) {
            // 如果发生异常,则打印异常信息
            e.printStackTrace();
        }
    }

    /**
     * 主方法:测试启动 Redis 服务器和登录到 Redis 服务器的功能
     */
    public static void main(String[] args) {
        // 启动 Redis 服务器
        startRedisServer();

        // 登录到 Redis 服务器
        loginRedisCli("localhost", 6379, "123456");
    }
}
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

# 2. Redis 对象序列化

  • 自定义的 FastJsonRedisSerializer 主要是为了提供一个对象序列化与反序列化的方式,使得在与 Redis 进行数据交换时,能够灵活且高效地将 Java 对象转化为 Redis 可存储的数据格式(如 JSON),同时保证能够从 Redis 中恢复出原始对象
package com.scholar.securitytest.utils;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.nio.charset.Charset;

// 自定义 FastJson 序列化工具类,用于 Redis 中的对象序列化
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
    // 默认字符集 UTF-8
    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
    
    // 用于指定目标类类型
    private Class<T> clazz;

    // 静态初始化,开启 FastJson 自动类型支持
    static {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    // 构造函数,传入目标类类型
    public FastJsonRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }

    // 序列化方法,将对象转换为字节数组
    @Override
    public byte[] serialize(T t) throws SerializationException {
        if (t == null) {
            return new byte[0];  // 如果对象为空,返回空字节数组
        }
        // 使用 FastJson 将对象转换为 JSON 字符串并返回字节数组
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    // 反序列化方法,将字节数组转换为对象
    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length <= 0) {
            return null;  // 如果字节数组为空,返回空对象
        }
        // 将字节数组转换为字符串,然后使用 FastJson 将字符串解析成对象
        String str = new String(bytes, DEFAULT_CHARSET);
        return JSON.parseObject(str, clazz);
    }

    // 用于获取目标类的 Java 类型信息
    protected JavaType getJavaType(Class<?> clazz) {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}
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

# 3. Redis 缓存工具类

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Redis 缓存工具类
 * 提供常用的 Redis 操作方法,包括对字符串、列表、集合、哈希等数据类型的缓存操作。
 */
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {

    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本对象(如 Integer、String、实体类等)
     *
     * @param key   缓存的键
     * @param value 缓存的值
     * @param <T>   对象类型
     */
    public <T> void setCacheObject(final String key, final T value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本对象并设置有效期
     *
     * @param key      缓存的键
     * @param value    缓存的值
     * @param timeout  有效期
     * @param timeUnit 时间单位
     * @param <T>      对象类型
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置缓存键的有效期
     *
     * @param key     缓存的键
     * @param timeout 有效期(秒)
     * @return 是否设置成功
     */
    public boolean expire(final String key, final long timeout) {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置缓存键的有效期(带时间单位)
     *
     * @param key     缓存的键
     * @param timeout 有效期
     * @param unit    时间单位
     * @return 是否设置成功
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获取缓存的基本对象
     *
     * @param key 缓存的键
     * @param <T> 返回值类型
     * @return 缓存对象
     */
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个缓存对象
     *
     * @param key 缓存的键
     * @return 是否删除成功
     */
    public boolean deleteObject(final String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 删除多个缓存对象
     *
     * @param collection 缓存键集合
     * @return 删除的数量
     */
    public long deleteObject(final Collection collection) {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存 List 数据
     *
     * @param key      缓存的键
     * @param dataList 缓存的列表数据
     * @param <T>      列表元素类型
     * @return 缓存成功的数量
     */
    public <T> long setCacheList(final String key, final List<T> dataList) {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获取缓存的 List 数据
     *
     * @param key 缓存的键
     * @param <T> 列表元素类型
     * @return 缓存的 List 数据
     */
    public <T> List<T> getCacheList(final String key) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存 Set 数据
     *
     * @param key     缓存的键
     * @param dataSet 缓存的 Set 数据
     * @param <T>     Set 元素类型
     * @return 缓存操作对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        for (T value : dataSet) {
            setOperation.add(value);
        }
        return setOperation;
    }

    /**
     * 获取缓存的 Set 数据
     *
     * @param key 缓存的键
     * @param <T> Set 元素类型
     * @return 缓存的 Set 数据
     */
    public <T> Set<T> getCacheSet(final String key) {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存 Map 数据
     *
     * @param key     缓存的键
     * @param dataMap 缓存的 Map 数据
     * @param <T>     Map 值的类型
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获取缓存的 Map 数据
     *
     * @param key 缓存的键
     * @param <T> Map 值的类型
     * @return 缓存的 Map 数据
     */
    public <T> Map<String, T> getCacheMap(final String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 向 Hash 中添加数据
     *
     * @param key   缓存的键
     * @param hKey  Hash 的键
     * @param value Hash 的值
     * @param <T>   值的类型
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value) {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取 Hash 中的单个值
     *
     * @param key  缓存的键
     * @param hKey Hash 的键
     * @param <T>  值的类型
     * @return Hash 的值
     */
    public <T> T getCacheMapValue(final String key, final String hKey) {
        return redisTemplate.opsForHash().get(key, hKey);
    }

    /**
     * 删除 Hash 中的某个值
     *
     * @param key  缓存的键
     * @param hKey Hash 的键
     */
    public void delCacheMapValue(final String key, final String hKey) {
        redisTemplate.opsForHash().delete(key, hKey);
    }

    /**
     * 获取 Hash 中的多个值
     *
     * @param key   缓存的键
     * @param hKeys Hash 的键集合
     * @param <T>   值的类型
     * @return Hash 中的多个值
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 根据前缀获取缓存的键列表
     *
     * @param pattern 键的前缀
     * @return 匹配的键列表
     */
    public Collection<String> keys(final String pattern) {
        return redisTemplate.keys(pattern);
    }
}
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

# 1.5 web工具类

package com.scholar.securitytest.utils;

import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

/**
 * Web 工具类
 * 提供 Web 开发中的常用功能,主要用于将字符串渲染到客户端。
 */
public class WebUtils {

    /**
     * 将字符串渲染到客户端
     * 
     * 该方法通过设置 HTTP 响应的状态码、内容类型和字符编码,
     * 将指定的字符串直接写入响应体中,返回给前端。
     *
     * @param response HttpServletResponse 对象,用于向客户端发送响应
     * @param string   待渲染的字符串(通常是 JSON 格式的字符串)
     * @return null 始终返回 null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try {
            // 设置响应状态码为 200(OK)
            response.setStatus(200);

            // 设置响应内容类型为 JSON
            response.setContentType("application/json");

            // 设置字符编码为 UTF-8,防止中文乱码
            response.setCharacterEncoding("utf-8");

            // 将字符串写入到响应体中,返回给客户端
            response.getWriter().print(string);
        } catch (IOException e) {
            // 如果发生 I/O 异常,打印堆栈信息以便调试
            e.printStackTrace();
        }

        // 返回 null 作为默认值
        return null;
    }
}
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

# 1.6 实体类


/**
 * <p>
 * 用户表
 * </p>
 *
 * @author scholar
 * @since 2024-05-07
 */
@TableName("sys_user")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 用户名
     */
    private String userName;

    /**
     * 昵称
     */
    private String nickName;

    /**
     * 密码
     */
    private String password;

    /**
     * 用户类型:0代表普通用户,1代表管理员
     */
    private String type;

    /**
     * 账号状态(0正常 1停用)
     */
    private String status;

    /**
     * 邮箱
     */
    private String email;

    /**
     * 手机号
     */
    private String phonenumber;

    /**
     * 用户性别(0男,1女,2未知)
     */
    private String sex;

    /**
     * 头像
     */
    private String avatar;

    /**
     * 创建人的用户id
     */
    private Long createBy;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新人
     */
    private Long updateBy;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    /**
     * 删除标志(0代表未删除,1代表已删除)
     */
    private Integer delFlag;


}

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

# 1.7 响应类

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
    /**
     * 状态码
     */
    private Integer code;
    /**
     * 提示信息,如果有错误时,前端可以获取该字段进行提示
     */
    private String msg;
    /**
     * 查询到的结果数据,
     */
    private T data;

    public ResponseResult(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public ResponseResult(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
}

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

# 2. JWT登录实战

# 2.1 数据库校验用户

为了让 Spring Security 使用我们自定义的用户数据源(例如从数据库中查询用户信息),我们需要实现一个自定义的 UserDetailsService。

# 1.准备工作

1.1 数据库表设计

创建一个名为 sys_user 的表,用于存储用户信息。

CREATE TABLE `sys_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `type` char(1) DEFAULT '0' COMMENT '用户类型:0代表普通用户,1代表管理员',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `create_by` bigint DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

1.2 引入依赖

在项目的 pom.xml 中引入以下依赖,用于数据库操作。

<!-- MyBatis Plus Starter -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.5</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.29</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12

1.3 配置数据库信息

在 application.yml 中配置数据库连接信息。

server:
  port: 8888

spring:
  application:
    name: SecurityTest
  datasource:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/security_test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: 183193
  data:
      redis:
        host: localhost
        port: 8891
        password: 183193
        database: 10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 2. 定义 Mapper 接口

使用 MyBatis Plus 提供的 BaseMapper 接口定义一个 UserMapper,用于与数据库交互。

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.scholar.securitytest.domain.User;

// 继承 BaseMapper 提供基础的 CRUD 操作
public interface UserMapper extends BaseMapper<User> {}
1
2
3
4
5

# 3. 配置 Mapper 扫描

在 Spring Boot 启动类中,配置 MyBatis Plus 的 Mapper 扫描路径。

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.sangeng.mapper") // 配置 Mapper 扫描路径
public class SecurityTestApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityTestApplication.class, args);
    }
}
1
2
3
4
5
6
7
8
9
10
11

# 4. 测试 MyBatis Plus 是否正常工作

编写一个测试用例,验证 MyBatis Plus 能够正常从数据库查询数据。

import com.sangeng.domain.User;
import com.sangeng.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest
class SecurityTestApplicationTests {

    @Autowired
    private UserMapper userMapper;

    @Test
    void contextLoads() {
        // 查询所有用户
        List<User> users = userMapper.selectList(null);

        // 打印用户信息
        users.forEach(System.out::println);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 2.2 核心代码

思路分析:

Spring Security 默认从内存中查找用户名和密码,并通过 UserDetailsService 的默认实现类获取 UserDetails 对象,交由 DaoAuthenticationProvider 校验。

但是在实际场景中:

  1. 用户信息存储在数据库中。
  2. 用户信息不仅包含用户名和密码,还可能包含其他字段(如权限、角色等)。

因此,需要:

  1. 自定义 UserDetailsService,从数据库中查询用户信息并封装成 UserDetails。
  2. 自定义 UserDetails 实现类,扩展用户信息。
  3. 替换默认的密码加密器,使用更安全的 BCryptPasswordEncoder。

# 1. 自定义 UserDetailsService 实现类

为什么需要自定义 UserDetailsService

Spring Security 提供了默认的 InMemoryUserDetailsManager 实现,这种实现只能从内存中加载用户信息,不适合实际项目中的需求。

实际需求:

  • 用户信息通常存储在数据库中。
  • 用户表不仅包含用户名和密码,还可能有角色、权限、状态等信息。
  • 默认的 InMemoryUserDetailsManager 无法满足动态查询数据库的需求。

解决方案:

我们需要自己实现 UserDetailsService,在 loadUserByUsername 方法中:

  1. 编写逻辑从数据库中查询用户信息。
  2. 将查询结果封装为一个实现了 UserDetails 的对象,返回给 Spring Security。

在 com.sangeng.service 包下创建 UserDetailsServiceImpl。

@Service // 声明为服务层组件
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper; // 注入 UserMapper,用于与数据库交互

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询数据库中的用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserName, username);
        User user = userMapper.selectOne(queryWrapper);

        // 如果用户不存在,抛出异常
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户名或密码错误");
        }

        // TODO: 如果需要权限信息,可在此处查询并封装

        // 将用户信息封装为自定义的 UserDetails 实现类
        return new LoginUser(user);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

说明:

  • UserMapper 是 MyBatis Plus 自动生成的基础 Mapper,用于操作数据库。
  • loadUserByUsername 方法是 Spring Security 在认证过程中调用的核心方法。

注意:

  • {noop} 表示密码未加密,仅用于测试。生产环境需使用加密后的密码。
  • 如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如:

image-20240510150930053

这样登陆的时候就可以用libai作为用户名,123456作为密码来登陆了。


# 2. 自定义 UserDetails 实现类

为什么需要扩展 UserDetails

Spring Security 的 UserDetails 接口只定义了认证所需的基础字段,如用户名、密码、权限等:

public interface UserDetails {
    Collection<? extends GrantedAuthority> getAuthorities(); // 用户权限
    String getPassword(); // 用户密码
    String getUsername(); // 用户名
    boolean isAccountNonExpired(); // 账户是否未过期
    boolean isAccountNonLocked(); // 账户是否未锁定
    boolean isCredentialsNonExpired(); // 凭证是否未过期
    boolean isEnabled(); // 账户是否启用
}
1
2
3
4
5
6
7
8
9

限制:

  • 如果用户信息中包含自定义字段(例如:email、phoneNumber、departmentId),UserDetails 本身无法满足需求。

解决方案:

  • 创建一个自定义的类(如 LoginUser),实现 UserDetails 接口。
  • 在这个类中添加额外的字段来存储其他用户信息。

在 com.sangeng.domain 包下创建 LoginUser 类。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user; // 封装数据库中的用户信息

    // 获取用户权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // TODO: 如果有权限信息,在此处返回用户的权限集合
        return Collections.emptyList(); // 暂时返回空集合
    }

    // 获取密码
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    // 获取用户名
    @Override
    public String getUsername() {
        return user.getUserName();
    }

    // 判断账户是否未过期
    @Override
    public boolean isAccountNonExpired() {
        return true; // 返回 true 表示账户未过期
    }

    // 判断账户是否未锁定
    @Override
    public boolean isAccountNonLocked() {
        return true; // 返回 true 表示账户未锁定
    }

    // 判断凭证是否未过期
    @Override
    public boolean isCredentialsNonExpired() {
        return true; // 返回 true 表示凭证未过期
    }

    // 判断账户是否可用
    @Override
    public boolean isEnabled() {
        return true; // 返回 true 表示账户可用
    }
}
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

说明:

  • LoginUser 是自定义的 UserDetails 实现类,用于扩展用户信息。
  • 权限信息(getAuthorities 方法)目前返回空集合,后续可根据需要添加。

# 3. 密码加密存储模式更改

在实际的项目中,我们不应将密码明文存储在数据库中。为了提升系统的安全性,通常会对密码进行加密存储。Spring Security 默认支持几种密码加密方式,但我们一般会选择使用 BCryptPasswordEncoder 作为密码加密算法,因为它具备较好的安全性。

默认情况下,PasswordEncoder 要求数据库中存储的密码格式为:{id}password,其中 {id} 表示加密算法的标识符(如 {bcrypt})。Spring Security 会根据密码前缀的标识符来判断使用哪种加密算法来验证密码。但是,为了避免使用这种方式存储密码,我们可以自定义密码加密器。

  • Spring Security 配置类不再继承 WebSecurityConfigurerAdapter(这个类已经在 Spring Security 5.x 版本中废弃),而是通过 @EnableWebSecurity 注解开启自定义的安全配置,并使用 @Bean 注解注入自定义的 PasswordEncoder。
  • 在 com.sangeng.config 包下创建 SecurityConfig 配置类。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration // 声明此类为 Spring 配置类
@EnableWebSecurity // 启用 Spring Security 功能
public class SecurityConfig {

    /**
     * 配置密码加密器,Spring Security 将使用 BCryptPasswordEnoder 来加密和验证密码
     * 
     * @return BCryptPasswordEncoder 实例
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 返回 BCryptPasswordEncoder 加密器实例
        return new BCryptPasswordEncoder();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

测试密码加密:

在测试类中验证 BCryptPasswordEncoder 的加密和匹配功能。

@SpringBootTest
public class PasswordEncoderTest {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Test
    public void testBCryptPasswordEncoder() {
        // 加密密码
        String rawPassword = "123456";
        String encodedPassword = passwordEncoder.encode(rawPassword);
        System.out.println("加密后的密码:" + encodedPassword);

        // 验证密码
        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("密码匹配结果:" + matches);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 4. 自定义登录接口

为什么要自定义登录接口?

在 Spring Security 的默认认证流程中,用户通过表单提交的用户名和密码由 UsernamePasswordAuthenticationFilter 处理。认证成功后,用户信息会被封装到 Authentication 对象中,并存储到 SecurityContextHolder 中。默认流程会通过页面跳转来处理后续逻辑。

但在实际开发中,通常需要实现以下功能:

  1. 返回 JSON 格式数据:
    • 前端通常需要认证成功后接收 JSON 格式的响应(例如包含 JWT 的 Token),而非页面跳转。
  2. 支持分布式架构:
    • 在分布式系统中,认证成功后用户信息需要存储到分布式缓存(如 Redis)中,方便后续微服务间的用户认证和授权。

因此,我们需要自定义登录接口,以满足以下需求:

  • 调用 ProviderManager 执行用户认证。
  • 认证通过后,生成 JWT 并返回给前端。
  • 将用户信息存储到 Redis 中,为后续操作提供支持。

步骤概述

  1. 定义登录接口:创建一个/user/login接口,用户可以通过该接口提交用户名和密码进行登录。
  2. Spring Security配置:配置Spring Security,放行/user/login接口,避免认证拦截。其他请求需进行认证。
  3. 认证与JWT生成:通过AuthenticationManager进行用户认证,并在认证成功后生成JWT令牌。
  4. 用户信息缓存:使用Redis缓存用户信息,存储用户ID及认证信息,以便后续请求验证。

代码实现

# 1. 创建登录控制器 LoginController

该控制器负责接收前端的登录请求,调用登录服务进行用户认证。

@RestController
public class LoginController {

    @Autowired
    private LoginService loginService;

    // 登录接口,接收用户名和密码,进行认证
    @PostMapping("/user/login")
    public ResponseResult login(@RequestBody User user) {
        return loginService.login(user);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
  • @PostMapping("/user/login"):接收前端传递的登录请求。
  • @RequestBody:将请求体中的JSON数据自动转换为User对象。
  • 调用loginService.login(user)处理登录逻辑。
# 2. 创建登录服务接口 LoginService

定义登录服务接口,提供登录方法。

public interface LoginService {
    ResponseResult login(User user);
}
1
2
3
# 3. 实现登录服务逻辑 LoginServiceImpl

实现LoginService接口,进行用户认证,生成JWT并将用户信息存入Redis缓存。

@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;  // 用于进行用户认证
    @Autowired
    private RedisCache redisCache;  // 用于存储用户信息到Redis

    @Override
    public ResponseResult login(User user) {

        // 1. 封装Authentication对象,用于携带用户名和密码进行认证
        UsernamePasswordAuthenticationToken authenticationToken = 
                new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        // 2. 通过AuthenticationManager的authenticate方法进行认证,他会调用
        Authentication authenticated = authenticationManager.authenticate(authenticationToken);

        // 3. 获取认证后的用户信息
        LoginUser loginUser = (LoginUser) authenticated.getPrincipal();
        String userId = loginUser.getUser().getId().toString();  // 获取用户ID

        // 4. 认证通过后生成JWT令牌
        String jwt = JwtUtil.createJWT(userId);

        // 5. 将用户信息存入Redis缓存,key为"userId"
        redisCache.setCacheObject("login:" + userId, loginUser);

        // 6. 返回包含JWT的响应
        HashMap<Object, Object> response = new HashMap<>();
        response.put("token", jwt);
        return new ResponseResult(200, "登录成功", response);
    }
}
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

详细解析:

  • UsernamePasswordAuthenticationToken:封装用户的用户名和密码,传递给Spring Security进行认证。
  • AuthenticationManager.authenticate():通过认证管理器执行认证过程,验证用户名和密码。
  • JwtUtil.createJWT(userId):使用userId生成一个JWT令牌。
  • redisCache.setCacheObject("login:" + userId, loginUser):将用户信息缓存到Redis,loginUser对象存储了认证后的用户信息,以供后续请求使用。
# 4. 配置Spring Security 配置类

在Spring Security的配置类中,我们需要定义AuthenticationManager、配置过滤链并放行登录接口/user/login。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;

@Configuration
@EnableWebSecurity  // 开启Spring Security功能
public class SecurityConfig {

    @Autowired
    AuthenticationConfiguration authenticationConfiguration;  // 注入AuthenticationConfiguration以获取AuthenticationManager

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();  // 使用BCrypt加密算法
    }

    // 配置AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();  // 获取AuthenticationManager
    }

    /**
     * 配置Spring Security的过滤链。
     *
     * @param http 用于构建安全配置的HttpSecurity对象。
     * @return 返回配置好的SecurityFilterChain对象。
     * @throws Exception 如果配置过程中发生错误,则抛出异常。
     */
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())  // 禁用CSRF保护
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 设置无状态会话
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/user/login").anonymous()  // 放行登录接口,允许匿名访问
                    .anyRequest().authenticated())  // 其他接口需要身份认证
                .cors(cors -> cors.configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues())); // 配置CORS

        // 返回配置好的过滤链
        return http.build();
    }
}
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

详细解析:

  • @EnableWebSecurity:启用Spring Security的功能。
  • AuthenticationManager authenticationManagerBean():通过authenticationConfiguration.getAuthenticationManager()获取AuthenticationManager,该对象负责认证用户信息。
  • SecurityFilterChain:配置HTTP请求的访问规则:
    • .csrf().disable():禁用CSRF保护,通常对于API接口来说,我们不需要CSRF防护。
    • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS):设置为无状态的会话管理,不使用会话存储认证信息。
    • .authorizeHttpRequests():定义URL访问控制规则:
      • requestMatchers("/user/login").anonymous():登录接口可以匿名访问。
      • anyRequest().authenticated():其他接口必须经过认证才能访问。

注意事项

测试

你可以使用Postman等工具测试登录接口。成功登录后,会返回JWT令牌,如下图所示:

image-20240510160632441

在上面的图中,token是返回给前端的JWT令牌,客户端可以将其保存起来,后续请求时带上这个token。

# 5. 自定义认证过滤器

自定义 JWT 过滤器的主要目的是 解析客户端传递的 JWT,并根据解析结果 设置认证信息到 Spring Security 的上下文 (SecurityContextHolder) 中,从而让后续的安全过滤器能够识别用户的身份和权限。

步骤概述

  1. 创建自定义过滤器:实现JWT认证逻辑,从请求头中获取token,解析token,验证用户信息并将其设置到SecurityContextHolder。
  2. 配置Spring Security:将JWT认证过滤器添加到Spring Security的过滤器链中,确保所有请求(除了登录)都经过JWT认证。

代码实现

# 1. 创建自定义JWT认证过滤器
@Component
// OncePerRequestFilter 保证每次请求该过滤器的 doFilterInternal 方法只执行一次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    RedisCache redisCache;  // 用于从Redis中获取用户信息

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

        // 1. 在请求头中获取token
        String token = request.getHeader("token");

        // 如果token为空,直接放行,SecurityContextHolder中没有用户信息,后续的过滤器会进行处理
        if (!StringUtils.hasText(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        String subject;
        try {
            // 2. 解析token,获取用户id(subject)
            Claims claims = JwtUtil.parseJWT(token);  // 解析JWT
            subject = claims.getSubject();  // 获取subject(即用户ID)
        } catch (Exception e) {
            // 解析失败,抛出异常
            throw new RuntimeException("token非法");
        }

        // 3. 使用用户id(subject)从Redis中获取用户信息
        String redisKey = "login:" + subject;  // Redis中的key格式为 "login:userId"
        LoginUser loginUser = redisCache.getCacheObject(redisKey);  // 从Redis中获取LoginUser对象

        if (Objects.isNull(loginUser)) {
            // 如果Redis中没有找到用户信息,抛出异常表示用户未登录
            throw new RuntimeException("用户未登录");
        }

        // 4. 如果用户信息存在,将其封装为Authentication对象并设置到SecurityContextHolder中
        UsernamePasswordAuthenticationToken authenticationToken = 
                new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);  // 设置认证信息

        // 5. 放行请求
        filterChain.doFilter(request, response);
    }
}
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

详细解析:

  • 获取请求头中的token:
    • 从HTTP请求头中获取token,通常token存储在请求的Authorization或token头中。
  • 解析token:
    • 使用JwtUtil.parseJWT(token)方法解析JWT令牌,提取其中的用户信息(例如:subject即用户ID)。
  • 从Redis中获取用户信息:
    • 使用RedisCache从Redis中根据用户ID获取LoginUser对象。Redis缓存中存储了登录用户的信息,缓存的key通常为"login:" + userId。
  • 封装Authentication对象:
    • 使用UsernamePasswordAuthenticationToken来创建一个Authentication对象,并将其存储到SecurityContextHolder中。Spring Security会使用这个对象来进行后续的权限验证。
  • 放行请求:
    • 通过filterChain.doFilter(request, response)放行请求,让后续的过滤器继续执行。
# 2. 配置类添加JWT认证过滤器

为了使自定义的认证过滤器生效,我们需要将其添加到Spring Security的过滤器链中。以下是完整的配置类:

import com.scholar.securitytest.utils.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;

@Configuration
@EnableWebSecurity  // 开启Spring Security功能
public class SecurityConfig {

    @Autowired
    private AuthenticationConfiguration authenticationConfiguration;  // 注入AuthenticationConfiguration以获取AuthenticationManager

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();  // 使用BCrypt加密算法
    }

    // 配置AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();  // 获取AuthenticationManager
    }

    /**
     * 配置Spring Security的过滤链。
     *
     * @param http 用于构建安全配置的HttpSecurity对象。
     * @return 返回配置好的SecurityFilterChain对象。
     * @throws Exception 如果配置过程中发生错误,则抛出异常。
     */
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())  // 禁用CSRF保护
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 设置无状态会话
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/user/login").anonymous()  // 放行登录接口,允许匿名访问
                    .anyRequest().authenticated())  // 其他接口需要身份认证
                .cors(cors -> cors.configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues())) // 配置CORS
                // 添加JWT认证过滤器,确保在UsernamePasswordAuthenticationFilter之前执行
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 返回配置好的过滤链
        return http.build();
    }
}
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

详细解析:

  • 配置密码编码器:

    • passwordEncoder()方法使用BCryptPasswordEncoder加密算法配置密码编码器。Spring Security会在用户认证时使用这个编码器来验证密码。
  • 配置AuthenticationManager:

    • authenticationManagerBean()方法返回AuthenticationManager,这个对象用于进行用户认证。
  • 配置过滤链:SecurityFilterChain方法配置Spring Security的安全策略:

  • .csrf().disable():禁用CSRF保护。

    • .sessionCreationPolicy(SessionCreationPolicy.STATELESS):设置会话为无状态,所有认证信息通过JWT进行传递。
  • .authorizeHttpRequests():配置请求权限:

    • requestMatchers("/user/login").anonymous():/user/login路径允许匿名访问。
    • anyRequest().authenticated():其他请求必须经过认证。
  • .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class):将自定义的JWT认证过滤器添加到过滤器链中,确保它在UsernamePasswordAuthenticationFilter之前执行。

测试

  1. 登录:用户通过/user/login接口提交用户名和密码进行登录,成功后会获得一个JWT令牌。
  2. 访问保护接口:用户在请求其他需要认证的接口时,需要将JWT令牌放入请求头中(例如:Authorization: Bearer <JWT_TOKEN>)。
  3. 过滤器验证:JWT认证过滤器会拦截请求,验证token是否合法,解析出用户信息并将其设置到SecurityContextHolder中。

如果token合法且Redis中有用户信息,Spring Security将允许用户访问请求的资源。否则,系统会抛出异常并拒绝访问。

# 6. 退出登录功能

在实现了登录功能后,通常还需要支持用户退出登录。退出登录主要是删除用户的认证信息和缓存数据,使得该用户无法再访问需要身份认证的资源。Spring Security中,退出登录的过程主要包括以下几个步骤:

  1. 获取当前认证信息:从SecurityContextHolder中获取当前已认证的用户信息。
  2. 删除Redis缓存:从Redis中删除当前用户的缓存信息,确保用户信息不再有效。
  3. 返回响应:返回退出成功的响应。

代码实现

  1. LoginServiceImpl中的退出登录逻辑
@Override
public ResponseResult logout() {
    // 获取SecurityContextHolder中的用户认证信息
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();  // 获取当前认证的用户
    Long userId = loginUser.getUser().getId();  // 获取当前用户的ID

    // 删除redis中的用户信息,缓存失效
    redisCache.deleteObject("login:" + userId);  // 删除Redis中的用户缓存

    // 返回退出成功的响应
    return new ResponseResult(200, "退出成功");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. LoginController中的退出登录接口
@PostMapping("/user/logout")
public ResponseResult logout() {
    System.out.println("开始登出");
    return loginService.logout();  // 调用服务层的退出登录方法
}
1
2
3
4
5

# 7. 自定义失败处理器

在Spring Security中,当认证或授权失败时,系统会通过ExceptionTranslationFilter捕获异常并进行处理。默认情况下,Spring Security会返回标准的错误信息。为了统一处理并让前端能够根据相同的格式处理错误,我们可以自定义AuthenticationEntryPoint和AccessDeniedHandler来分别处理认证失败和授权失败的异常。

# 1. 自定义AccessDeniedHandler(授权失败处理器)

当用户尝试访问一个需要特定权限的资源时,如果没有足够权限,Spring Security会抛出AccessDeniedException,我们可以通过自定义AccessDeniedHandler来返回统一的错误响应。

触发流程

  1. 请求进入过滤器链并通过认证过滤器,SecurityContextHolder 中设置了用户认证信息。
  2. 在授权阶段,Spring Security 使用 AccessDecisionManager 检查用户权限是否满足资源的访问要求。
  3. 如果权限不足,Spring Security 抛出 AccessDeniedException。
  4. ExceptionTranslationFilter 捕获 AccessDeniedException,调用配置的 AccessDeniedHandler 的 handle 方法。
  5. AccessDeniedHandlerImpl 返回自定义的 403 响应和错误信息。
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {

        // 创建统一的响应对象
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);  // 将响应对象转为JSON格式
        WebUtils.renderString(response, json);  // 返回响应
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
# 2. 自定义AuthenticationEntryPoint(认证失败处理器)

当用户未认证或者认证失败时,Spring Security会抛出AuthenticationException。我们可以通过自定义AuthenticationEntryPoint来返回一个统一的错误响应。

触发流程

  1. 请求进入过滤器链,经过认证相关的过滤器(如 JwtAuthenticationTokenFilter)。
  2. 如果认证失败(如 token 不存在或无效),Spring Security 抛出 AuthenticationException。
  3. ExceptionTranslationFilter 捕获 AuthenticationException,调用配置的 AuthenticationEntryPoint 的 commence 方法。
  4. AuthenticationEntryPointImpl 返回自定义的 401 响应和错误信息。
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        // 创建统一的响应对象
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录");
        String json = JSON.toJSONString(result);  // 将响应对象转为JSON格式
        WebUtils.renderString(response, json);  // 返回响应
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
# 3. 修改SecurityConfig类配置异常处理器

为了让自定义的失败处理器生效,我们需要在Spring Security的配置中添加自定义的AccessDeniedHandler和AuthenticationEntryPoint。

@Configuration  // 配置类
@EnableWebSecurity  // 开启Spring Security的功能
public class SecurityConfig {

    @Autowired
    AuthenticationConfiguration authenticationConfiguration;  // 获取AuthenticationManager

    @Autowired
    JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;  // 注入JWT认证过滤器

    @Autowired
    AccessDeniedHandlerImpl accessDeniedHandler;  // 注入自定义的AccessDeniedHandler

    @Autowired
    AuthenticationEntryPointImpl authenticationEntryPoint;  // 注入自定义的AuthenticationEntryPoint

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();  // 使用BCrypt加密算法
    }

    // 配置AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();  // 获取AuthenticationManager
    }

    /**
     * 配置Spring Security的过滤链。
     *
     * @param http 用于构建安全配置的HttpSecurity对象。
     * @return 返回配置好的SecurityFilterChain对象。
     * @throws Exception 如果配置过程中发生错误,则抛出异常。
     */
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())  // 禁用CSRF保护
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 设置为无状态会话
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/user/login").anonymous()  // 放行登录接口,允许匿名访问
                    .anyRequest().authenticated())  // 其他请求必须经过认证
                .cors(cors -> cors.configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues())) // 配置CORS
                // 添加JWT认证过滤器,确保在UsernamePasswordAuthenticationFilter之前执行
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
                // 配置异常处理
                .exceptionHandling(exception -> exception
                    .accessDeniedHandler(accessDeniedHandler)  // 配置授权失败处理器
                    .authenticationEntryPoint(authenticationEntryPoint));  // 配置认证失败处理器

        // 返回配置好的安全过滤链
        return http.build();
    }
}
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
# 4. 测试自定义失败处理器
  1. 正常登录

用户通过/user/login接口提交用户名和密码进行登录,成功后会返回一个JWT令牌,表示用户认证通过。

正常登录

  1. 访问需要认证的接口

用户使用获得的JWT令牌访问需要认证的接口,如果令牌有效,返回相应数据;如果令牌无效或过期,会触发认证失败处理器,返回统一的错误信息。

访问接口

  1. 退出登录

用户调用/user/logout接口进行退出登录,成功后,用户的认证信息会被清除,Redis中的缓存也会被删除。

退出登录

  1. 再次访问接口

退出登录后,用户再次访问需要认证的接口时,会触发认证失败处理器,返回认证失败的统一响应。

再次访问接口

编辑此页 (opens new window)
上次更新: 2024/12/29, 23:35:48
Spring Security 登录认证源码
Spring Security - JWT授权实战

← Spring Security 登录认证源码 Spring Security - JWT授权实战→

Theme by Vdoing | Copyright © 2019-2025 程序员scholar
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式