SpringBoot4.0.1+SpringSecurity6.2+Redis实现单点登录
一、组件版本
主要组件:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 25 | 支持SpringBoot4 |
| SpringBoot | 4.x | 新版本 |
| SpringSecurity | 6.2 | 支持SpringBoot4 |
| Redis | 8.4 | 存储session |
| MySQL | 8.0 | 配置库 |
工程POM:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.chaiys</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot 4.0.1 and JDK25</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>4.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>3.5.15</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-jsqlparser -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.15</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.20.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
</project>
二、编码实现
1、数据库配置
这里我们使用MySQL作为配置库,使用Redis作为Session存储(支持重启后Session保持),application.properties配置:
spring.application.name=demo
server.servlet.context-path=/demo
# support virtual thread
spring.threads.virtual.enabled=true
# active file
spring.profiles.active=dev
# init
spring.mvc.servlet.load-on-startup=10
spring.main.allow-bean-definition-overriding=true
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
spring.servlet.multipart.max-file-size=-1
spring.servlet.multipart.max-request-size=-1
spring.mvc.static-path-pattern=/**
spring.mvc.view.suffix=.html
spring.web.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,file:www/
spring.web.resources.add-mappings=true
# Database Configuration
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.pool-name=hikari-jdbc-pool
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.validation-timeout=3000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.connection-timeout=60000
spring.datasource.hikari.connection-test-query=SELECT 1
# MybatisPlus Configuration
mybatis-plus.mapper-locations=classpath*:mybatis/mapper/*.xml
mybatis-plus.configuration.map-underscore-to-camel-case=true
mybatis-plus.configuration.cache-enabled=false
#logging.level.com.chaiys.demo.dao.mapper=debug
#logging.level.org.apache.ibatis=debug
# output sql to standard out console
# mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
# Logs Configuration
logging.config=classpath:logback-spring.xml
# Security Session
server.servlet.session.timeout=30d
server.servlet.session.cookie.max-age=30d
spring.session.redis.namespace=demo:session
application-dev.properties配置:
server.port=8080
# Database
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/chaiys?useUnicode=true&characterEncoding=utf8
spring.datasource.username=root
spring.datasource.password=
# Redis
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
spring.data.redis.password=
spring.data.redis.database=0
数据库创建测试用到的表:
create table demo (
id bigint primary key,
name varchar(200),
age int,
salary decimal(10,2),
create_time datetime
);
create table users (
id bigint primary key,
name varchar(200),
password varchar(200),
role varchar(200),
create_time datetime
);
Demo造数据:
import random
import time
import mysql.connector
from mysql.connector import Error
from datetime import datetime, timedelta
import logging
import uuid
class MySQLDataGenerator:
def __init__(self, host='localhost', database='test_db',
user='root', password='password', port=3306):
"""
初始化MySQL连接配置
Args:
host: MySQL主机地址
database: 数据库名
user: 用户名
password: 密码
port: 端口
"""
self.host = host
self.database = database
self.user = user
self.password = password
self.port = port
self.connection = None
# 配置日志
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
self.logger = logging.getLogger(__name__)
def connect(self):
"""连接到MySQL数据库"""
try:
self.connection = mysql.connector.connect(
host=self.host,
database=self.database,
user=self.user,
password=self.password,
port=self.port
)
if self.connection.is_connected():
self.logger.info(f"成功连接到MySQL数据库: {self.database}")
return True
except Error as e:
self.logger.error(f"连接失败: {e}")
return False
def disconnect(self):
"""断开数据库连接"""
if self.connection and self.connection.is_connected():
self.connection.close()
self.logger.info("数据库连接已关闭")
def generate_name(self):
"""生成随机姓名"""
first_names = ['张', '王', '李', '赵', '刘', '陈', '杨', '黄', '周', '吴',
'徐', '孙', '胡', '朱', '高', '林', '何', '郭', '马', '罗',
'郑', '梁', '谢', '宋', '唐', '许', '韩', '冯', '邓', '曹',
'彭', '曾', '肖', '田', '董', '潘', '袁', '于', '蒋', '蔡',
'余', '杜', '叶', '程', '苏', '魏', '吕', '丁', '任', '沈',
'欧阳', '司马', '上官', '诸葛', '东方', '慕容', '司徒']
last_names = ['伟', '强', '磊', '军', '勇', '杰', '涛', '明', '超', '刚',
'鹏', '龙', '浩', '宇', '飞', '斌', '峰', '阳', '坤', '帅',
'文', '建', '国', '宏', '伟', '俊', '翔', '鑫', '瑞', '哲',
'志', '博', '诚', '毅', '睿', '轩', '浩宇', '子轩', '浩然',
'芳', '娜', '秀英', '敏', '静', '丽', '艳', '娟', '秀兰', '霞',
'红', '文', '云', '婷', '雪', '慧', '琳', '颖', '倩', '洁',
'瑶', '欣', '悦', '雅', '雯', '雨', '萌', '思', '佳', '怡',
'婉', '莹', '馨', '薇', '蕾', '诗', '涵', '梦', '羽', '若']
return random.choice(first_names) + random.choice(last_names)
def generate_create_time(self, days_back=365):
"""生成随机创建时间(最近days_back天内)"""
now = datetime.now()
random_days = random.randint(0, days_back)
random_hours = random.randint(0, 23)
random_minutes = random.randint(0, 59)
random_seconds = random.randint(0, 59)
return now - timedelta(days=random_days,
hours=random_hours,
minutes=random_minutes,
seconds=random_seconds)
def generate_single_record(self, record_id):
"""生成单条记录数据"""
return {
'id': record_id,
'bizId': str(uuid.uuid4()).replace('-', ''),
'name': self.generate_name(),
'age': random.randint(18, 65),
'salary': round(random.uniform(3000.00, 50000.00), 2),
'create_time': self.generate_create_time()
}
def insert_batch(self, start_id=1, batch_size=1000):
"""
批量插入数据
Args:
start_id: 起始ID
batch_size: 批量插入的记录数
Returns:
成功插入的记录数
"""
if not self.connection or not self.connection.is_connected():
self.logger.error("数据库未连接")
return 0
insert_sql = """
INSERT INTO demo (id, name, age, salary, create_time)
VALUES (%s, %s, %s, %s, %s)
"""
try:
cursor = self.connection.cursor()
# 生成数据
records = []
for i in range(batch_size):
record = self.generate_single_record(start_id + i)
records.append((
record['id'],
record['name'],
record['age'],
record['salary'],
record['create_time']
))
# 批量插入
start_time = time.time()
cursor.executemany(insert_sql, records)
self.connection.commit()
elapsed_time = time.time() - start_time
self.logger.info(f"成功插入 {cursor.rowcount} 条记录,耗时: {elapsed_time:.2f} 秒")
return cursor.rowcount
except Error as e:
self.connection.rollback()
self.logger.error(f"批量插入失败: {e}")
return 0
def main():
"""主函数"""
# 配置数据库连接参数(请根据实际情况修改)
config = {
'host': 'localhost',
'database': 'chaiys', # 数据库名
'user': 'root', # 用户名
'password': 'xxxxxx', # 你的密码
'port': 3306
}
# 创建数据生成器实例
generator = MySQLDataGenerator(**config)
try:
# 连接数据库
if not generator.connect():
return
# 插入数据
print("=" * 50)
print("开始生成测试数据")
print("=" * 50)
# 批量插入数据,可以调整批次大小
total_inserted = 0
# 插入100批数据
batches = 100
# 每批2000条
batch_size = 2000
for batch_num in range(1, batches + 1):
print(f"\n第 {batch_num}/{batches} 批数据生成中...")
inserted = generator.insert_batch(
start_id=total_inserted + 1,
batch_size=batch_size
)
total_inserted += inserted
# 小批次插入后稍作延迟,避免数据库压力过大
time.sleep(0.5)
print(f"\n所有批次完成!总共插入 {total_inserted} 条记录")
# 显示表信息
print("\n" + "=" * 50)
except Exception as e:
generator.logger.error(f"程序运行异常: {e}")
finally:
# 断开数据库连接
generator.disconnect()
if __name__ == "__main__":
# 直接运行主函数,连接数据库并插入数据
main()
User造数(密码123456):
insert into users values(1,'chaiys','$2a$10$M7tqdf8GC3cqQUGdeMg.b.sF4GLjrqNd1C4iOfTpSiQHDYQRJlSLm', 'USER',now());
insert into users values(2,'admin','$2a$10$M7tqdf8GC3cqQUGdeMg.b.sF4GLjrqNd1C4iOfTpSiQHDYQRJlSLm', 'ADMIN',now());
直接使用BCrypt加密:
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// 1. 加密(每次运行结果都不同,因为盐是随机的)
String rawPassword = "123456";
String encodedPassword = encoder.encode(rawPassword);
System.out.println("存储到数据库的密文: " + encodedPassword);
// 2. 匹配验证
boolean isMatch = encoder.matches(rawPassword, encodedPassword);
System.out.println("密码是否正确: " + isMatch);
}
注册阶段
- 用户输入明文密码。
- 后端生成随机盐(Salt)。
- 将密码 + 盐进行哈希计算。
- 只存哈希结果(BCrypt 会将盐和算法参数一起编码在字符串里)。
登录阶段
- 从数据库取出该用户的哈希密文。
- 后端使用相同的算法,尝试用输入的明文和密文进行匹配。
- 匹配成功则授权。
2、Secutiry配置
直接使用SpringSecurity配置实现登录、登出、拦截:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.session.FlushMode;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启注解控制权限,如 @PreAuthorize
@EnableRedisHttpSession(
flushMode = FlushMode.ON_SAVE,
redisNamespace = "${spring.session.redis.namespace:demo:session}"
)
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // 后端分离项目通常禁用 CSRF
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/login").permitAll() // 白名单
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 角色权限
.anyRequest().authenticated() // 其他所有请求需登录
)
.formLogin(form -> form
.loginProcessingUrl("/login")
.successHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=utf-8");
resp.setCharacterEncoding("UTF-8");
resp.getWriter().write("{\"code\":200,\"message\":\"登录成功\"}");
})
.failureHandler((req, resp, ex) -> {
resp.setStatus(401);
resp.setContentType("application/json;charset=utf-8");
resp.setCharacterEncoding("UTF-8");
resp.getWriter().write("{\"code\":401,\"message\":\"账号或密码错误\"}");
})
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=utf-8");
resp.setCharacterEncoding("UTF-8");
resp.getWriter().write("{\"code\":200,\"message\":\"注销成功\"}");
})
)
.exceptionHandling(ex -> ex
// 未登录时的处理
.authenticationEntryPoint((req, resp, authEx) -> {
resp.setContentType("application/json;charset=utf-8");
resp.setCharacterEncoding("UTF-8");
resp.setStatus(401);
resp.getWriter().write("未登录,请先登录");
})
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 使用强哈希加密
return new BCryptPasswordEncoder();
}
}
- EnableWebSecurity:开启Spring Security
- EnableRedisHttpSession:开启Redis缓存Session配置,并在注解内传递参数
- EnableMethodSecurity:开启注解控制权限,支持角色
- Spring Security内部实现了/login接口,我们只需要实现UserDetailsService
3、用户查询
UserDetailsService实现:
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.chaiys.demo.dao.dataobject.Users;
import com.chaiys.demo.dao.mapper.UsersMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {
@Resource
private UsersMapper usersMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Users::getName, username);
Users user = usersMapper.selectOne(queryWrapper);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return org.springframework.security.core.userdetails.User
.withUsername(user.getName())
.password(user.getPassword()) // 数据库存的是加密后的密文
.roles(user.getRole()) // 例如 "ADMIN" 或 "USER"
.build();
}
}
- UsersMapper查询users表获取用户
4、Demo接口
测试接口:
import com.chaiys.demo.api.pojo.ResultPageVO;
import com.chaiys.demo.api.pojo.ResultVO;
import com.chaiys.demo.api.pojo.dto.DemoDTO;
import com.chaiys.demo.api.pojo.dto.ReqDemoPageQueryDTO;
import com.chaiys.demo.api.pojo.vo.DemoVO;
import com.chaiys.demo.api.pojo.vo.ReqDemoPageQueryVO;
import com.chaiys.demo.service.DemoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/demo")
public class DemoController {
@Autowired
private DemoService demoService;
@PostMapping("/page")
public ResultPageVO<DemoVO> page(@RequestBody ReqDemoPageQueryVO req) {
ReqDemoPageQueryDTO reqDTO = new ReqDemoPageQueryDTO();
BeanUtils.copyProperties(req, reqDTO);
ResultPageVO<DemoDTO> pageVO = demoService.page(reqDTO);
List<DemoVO> demoVOS = new ArrayList<>();
for (DemoDTO demoDTO : pageVO.getData()) {
DemoVO demoVO = new DemoVO();
BeanUtils.copyProperties(demoDTO, demoVO);
demoVOS.add(demoVO);
}
return ResultPageVO.success(pageVO, demoVOS);
}
@GetMapping("/get")
@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色能访问
public ResultVO<DemoVO> get(@RequestParam Long id) {
DemoDTO demoDTO = demoService.getById(id);
DemoVO demoVO = new DemoVO();
BeanUtils.copyProperties(demoDTO, demoVO);
return ResultVO.success(demoVO);
}
}
- page接口普通角色能访问
- get接口必须ADMIN角色才能访问
三、运行测试
1、访问Demo接口测试
1)未登录直接访问/page接口

结果:访问失败,未登录
2)普通用户登录

结果:登录成功
3)再次访问/page

结果:访问成功
2、访问ADMIN角色接口测试
1)普通用户直接访问/get接口

结果:权限不足
2)ADMIN登录

结果:ADMIN登录成功
3)再次访问ADMIN接口/get

结果:访问成功!
浙公网安备 33010602011771号