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);
  }

注册阶段

  1. 用户输入明文密码。
  2. 后端生成随机盐(Salt)。
  3. 将密码 + 盐进行哈希计算。
  4. 只存哈希结果(BCrypt 会将盐和算法参数一起编码在字符串里)。

登录阶段

  1. 从数据库取出该用户的哈希密文。
  2. 后端使用相同的算法,尝试用输入的明文和密文进行匹配。
  3. 匹配成功则授权。

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接口

unlogin

结果:访问失败,未登录

2)普通用户登录

login

结果:登录成功

3)再次访问/page

login_page

结果:访问成功

2、访问ADMIN角色接口测试

1)普通用户直接访问/get接口

user_role

结果:权限不足

2)ADMIN登录

admin_login

结果:ADMIN登录成功

3)再次访问ADMIN接口/get

admin_role

结果:访问成功!

posted @ 2026-01-06 19:34  旧色染新烟  阅读(217)  评论(1)    收藏  举报