Spring Security 基于表单的认证和角色权限控制
Spring Security 是基于 Spring 框架提供的一套 Web 应用安全的完整解决方案,核心功能主要是认证和授权。认证主要是判断用户的合法性,主要体现在登录操作,最常用的认证方式是【基于表单的认证】和【基于OAuth2的认证】。授权主要体现在权限控制,也就是控制用户是否能够访问网站的相关资源。
除此之外,Spring Security 还具有 Session 管理、CSRF 跨站攻击防护,各种加密算法等,Spring Security 功能强大的地方主要体现在良好的扩展性,以及容易与其它框架进行集成等等,有关 Spring Security 的详细介绍,请查看官网。
本篇博客主要通过代码的方式介绍 Spring Security 基于表单的认证方式,使用 Mybatis 从数据库中读取用户,使用自定义的 Md5 加密对密码进行验证,使用 Redis 存储 Session 方便网站进行负载均衡部署,登录界面使用了保持登录以及图形验证码,介绍如何在异步线程中获取当前登录的用户信息,如何通过角色和权限控制用户访问网站的资源等等,在博客最后会提供源代码下载。
Spring Security 的官网地址:https://docs.spring.io/spring-security/reference/index.html
1、搭建工程
本篇博客的 demo 涉及内容较多,每个技术点只介绍核心内容,详细内容可下载源代码进行查看和验证运行效果。
搭建一个 SpringBoot 工程,其结构如下:
对于 SpringBoot 来说,默认情况下 resources 下的 static 文件夹中的页面可以直接访问,这里只放了一个登录页面 login.html
config 包下主要是配置类,过滤器、自定义的密码加密类(Spring Security 没有内置 md5 的加密方式)
controller 包下主要是提供登录后跳转的地址,以及通过浏览器访问的一些资源,SecurityController 用于演示权限控制
mapper、pojo、service 分别是数据访问、实体类、业务方法,其中数据访问采用的是 mybatis plus
先看一下项目工程的 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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.jobs</groupId> <artifactId>spring_security_mybatis</artifactId> <version>1.0</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.10</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <scope>compile</scope> </dependency> <!--引入 spring security 依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--导入 mysql 连接依赖--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> <!--导入连接池依赖,生产环境下,连接数据库必然使用连接池--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.16</version> </dependency> <!--导入 mybatis plus 依赖--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.2</version> </dependency> <!--引入 redis 依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--为了使网站能否支持负载均衡,需要把 Session 存储到 redis 中--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.8</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--里面有很多非常实用的工具类--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.4.3</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.7.10</version> </plugin> </plugins> </build> </project>
注意:这里使用的 SpringBoot 版本是 2.7.10 ,已经测试没有问题。如果版本过低的话,有些功能的代码会报错。
对于 Spring Security 来说,其引入的起步依赖是 spring-boot-starter-security
由于本篇博客采用的 2.7.10 版本的 SpringBoot 来说,其内置的 Spring Security 的版本是 5.7.7
然后在看一下 application.yml 配置文件的内容:
server: port: 8888 servlet: session: # 这里可以配置 session 保存时间,默认是 30 分钟 timeout: 30 spring: datasource: # 使用 druid 连接池 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.136.128:3306/security_demo?serverTimeZone=Asia/Shanghai username: root password: root # 配置 redis 连接信息 # 使用 redis 目的是为了将 session 存储在 redis 中,使网站可以负载均衡 redis: host: 192.168.136.128 port: 6379 password: root main: # 控制台日志中不打印 spring 的 logo banner-mode: off mybatis-plus: configuration: # 开启 sql 打印日志,输出的控制台,方便开发过程中查看 sql 执行细节 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: # 日志中不打印 mybatis plus 的 logo 信息 banner: false db-config: # 主键采用数据库的自增长 id 策略 id-type: auto # 配置数据库表的前缀 tb_ 作为前缀,跟实体类上配置的表名进行组合,就是数据库中的表名 table-prefix: tb_
这里已经尽可能把平时比较常用的配置,都使用上了,有关 redis 和 mybatis plus 的使用细节不做过多介绍。
2、前端页面代码
首先看一下登录页面 login.html 的内容:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>登录页面</title> </head> <body> <fieldset> <legend>用户登录</legend> <form id="Form1" action="/login" method="post"> <table> <tr> <td>用户名:</td> <td><input name="username" type="text"></td> </tr> <tr> <td>密码:</td> <td><input name="password" type="password"></td> </tr> <tr> <td>图形码:</td> <td><img src="/getCode"></td> </tr> <tr> <td>输入图形码:</td> <td><input name="imageCode" type="text"></td> </tr> <tr> <td><input type="checkbox" name="remember-me"/>保持登录</td> <td><button type="submit">登录</button></td> </tr> </table> </form> </fieldset> </body> </html>
需要注意的是:对于 Spring Security 来说,用户名和密码的参数名称,默认是 username 和 password ,对于保持登录来说,默认的参数名称是 remember-me,虽然在 Spring Security 中可以配置参数名称,但是一般情况下都使用默认的参数名称。
图形验证码是我们自己添加的功能,输入验证码的参数名称可以随便定义,加入该功能的目的是为了防止暴力破解登录密码。
请求后端的图形验证码的代码在 HomeController 中,具体内容如下:(需要对该资源路径设置匿名访问,下面会介绍)
//获取图形验证码 @GetMapping("/getCode") public void getImageCode(HttpServletResponse response, HttpSession session) throws IOException { //设置响应参数 response.setDateHeader("Expires", 0); response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); response.addHeader("Cache-Control", "post-check=0, pre-check=0"); response.setHeader("Pragma", "no-cache"); response.setContentType("image/jpeg"); //1、通过工具类生成验证码对象(图片数据和验证码信息) LineCaptcha captcha = CaptchaUtil.createLineCaptcha(100, 30, 4, 60); String code = captcha.getCode(); //2、将验证码存入 Session 中 session.setAttribute("IMAGE_CODE", code); //3、通过输出流输出验证码 captcha.write(response.getOutputStream()); }
3、过滤器链配置
对于 Spring Security 来说,最核心的配置就是对过滤器访问链的配置,代码在 SecurityConfig 类中,具体内容如下:
package com.jobs.config; import com.alibaba.fastjson.JSON; import com.jobs.service.MyUserDetailService; import lombok.extern.slf4j.Slf4j; 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.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.session.data.redis.RedisIndexedSessionRepository; import org.springframework.session.security.SpringSessionBackedSessionRegistry; import javax.annotation.PostConstruct; import java.util.HashMap; import java.util.Map; @Slf4j @Configuration //对于该注解,prePostEnabled = true 是默认值,所以可以省略 @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class SecurityConfig { //配置 spring security 的过滤器执行链信息 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests() //允许匿名访问的地址 .antMatchers(getAnonymousUrl()).permitAll() .anyRequest().authenticated() //采用 form 认证方式,登录页是 login.html ,登录成功后跳转到 /index .and().formLogin().loginPage("/login.html") //对于 spring security 来说,默认的验证用户名和密码的地址是 /login,默认注销用户的地址是 /logout .loginProcessingUrl("/login") //由于当前使用的是图形验证码过滤器,因此登录成功和失败,都是执行图形验证码过滤器中的过掉。 //如果不使用图形验证码过滤器的话,就可以使用以下代码配置的登录成功和失败的回调 //登录成功后,跳转到 /index .successHandler((request, response, authentication) -> { response.sendRedirect("/index"); }) //登录失败后,返回给页面失败的原因 .failureHandler((request, response, exception) -> { Map<String, Object> data = new HashMap<>(); data.put("code", -1); data.put("msg", "登录失败"); data.put("data", exception.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(data)); }) .permitAll() //设置允许保持登录,为了方便测试,只保持登录 60 秒,因此 60 秒内关闭浏览器再打开会自动登录 .and().rememberMe().key("myuser").tokenValiditySeconds(60) .and().exceptionHandling() //没有权限访问时,进入该方法 .accessDeniedHandler((request, response, accessDeniedException) -> { Map<String, Object> data = new HashMap<>(); data.put("code", -2); data.put("msg", "访问失败,无权限访问"); data.put("data", accessDeniedException.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(data)); }) //没有登录时,进入该方法 .authenticationEntryPoint((request, response, authException) -> { Map<String, Object> data = new HashMap<>(); data.put("code", -1); data.put("msg", "访问失败,请登录后再访问"); data.put("data", authException.getMessage()); log.info(JSON.toJSONString(data)); //跳转到登录页面 response.sendRedirect("/login.html"); }) //使用图形验证码过滤器,并且比用户名密码验证的顺序要靠前 .and().addFilterAt(imageCodeFilter(), UsernamePasswordAuthenticationFilter.class) //禁用 csrf 防护 .csrf().disable(); //在这里配置 session 存储到 redis 中,这样可以使网站负载均衡 http.sessionManagement().maximumSessions(-1).sessionRegistry(sessionRegistry()); return http.build(); } //图形验证码过滤器设置 public ImageCodeFilter imageCodeFilter() { ImageCodeFilter filter = new ImageCodeFilter(); filter.setAuthenticationManager(authenticationManager()); filter.setAuthenticationSuccessHandler((request, response, authentication) -> { //登录成功后,跳转到 /index response.sendRedirect("/index"); }); filter.setAuthenticationFailureHandler((request, response, exception) -> { Map<String, Object> data = new HashMap<>(); data.put("code", -1); data.put("msg", "登录失败"); data.put("data", exception.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(data)); }); return filter; } //在这里配置允许匿名访问的地址 public String[] getAnonymousUrl() { return new String[]{ "/powertest/all", //测试匿名访问权限的地址 "/getCode" //获取图形验证的地址,需要匿名访问 }; } //下面是配置【验证用户登录的数据来源】和【使用的密码加密方式】--------------------- @Autowired private MyUserDetailService userDetailsService; @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); //采用 md5 密码加密方式 daoAuthenticationProvider.setPasswordEncoder(new MyMd5PasswordEncoder()); return new ProviderManager(daoAuthenticationProvider); } //要想在异步线程中获取当前登录的用户信息,必须将线程策略设置为 inheritable threadlocal //inheritable threadlocal 模式下,会复制父线程中存放的用户信息 @PostConstruct public void setStrategyName() { SecurityContextHolder.setStrategyName( SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); } //下面是【配置 Session 存储到 Redis】--------------------- @Autowired private RedisIndexedSessionRepository sessionRepository; @Bean public SpringSessionBackedSessionRegistry sessionRegistry() { return new SpringSessionBackedSessionRegistry(sessionRepository); } }
3.1、自定义密码加密校验
对于 Spring Security 来说,由于其内置没有 md5 的密码加密类,所以我们自定义了一个 md5 的加密类并配置使用:
package com.jobs.config; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.util.DigestUtils; import org.springframework.util.StringUtils; //自定义的密码加密方式 @Component public class MyMd5PasswordEncoder implements PasswordEncoder { //将密码转换为 md5 字符串 @Override public String encode(CharSequence rawPassword) { if (rawPassword != null) { String pwd = rawPassword.toString().trim(); if (StringUtils.hasLength(pwd)) { return DigestUtils.md5DigestAsHex(pwd.getBytes()); } } return ""; } //将密码转换为 md5 字符串后,与数据库中的密码进行比较,判断是否相同 @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (rawPassword != null) { String pwd = rawPassword.toString().trim(); if (StringUtils.hasLength(pwd)) { String result = DigestUtils.md5DigestAsHex(pwd.getBytes()); return encodedPassword.equals(result); } } return false; } }
然后在 DaoAuthenticationProvider 中通过 setPasswordEncoder 方法配置,让密码采用 md5 加密方式校验。
3.2、图形验证码配置
对于图形验证码来说,我们需要自定义一个过滤器,实现对图形验证码的校验功能:
package com.jobs.config; import cn.hutool.core.util.StrUtil; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; //图形验证码过滤器,用于在登录之前,先判断用户输入的图形验证码是否正确 public class ImageCodeFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String imageCode = (String) request.getSession().getAttribute("IMAGE_CODE"); String input = request.getParameter("imageCode"); //忽略大小写,比较用户输入内容,与图形验证码是否一致 if (!StrUtil.equals(input, imageCode, true)) { throw new InternalAuthenticationServiceException("图形验证码输入错误"); } return super.attemptAuthentication(request, response); } }
然后我们需要设置图形验证码过滤器校验成功和失败的回调方法:
//图形验证码过滤器设置 public ImageCodeFilter imageCodeFilter() { ImageCodeFilter filter = new ImageCodeFilter(); filter.setAuthenticationManager(authenticationManager()); filter.setAuthenticationSuccessHandler((request, response, authentication) -> { //登录成功后,跳转到 /index response.sendRedirect("/index"); }); filter.setAuthenticationFailureHandler((request, response, exception) -> { Map<String, Object> data = new HashMap<>(); data.put("code", -1); data.put("msg", "登录失败"); data.put("data", exception.getMessage()); response.setContentType("application/json;charset=utf-8"); response.getWriter().println(JSON.toJSONString(data)); }); return filter; }
验证码的校验顺序,应该提前于用户名密码的校验,因此需要在过滤器链中,提前验证码过滤器的顺序:
addFilterAt(imageCodeFilter(), UsernamePasswordAuthenticationFilter.class)
3.3、Session 存储到 Redis
对于 Spring Security 来说其 Session 默认是保存在后端服务运行的服务器内存中的,因此如果同时部署了多个后端服务进行负载均衡的话,必须把 Session 保存在相同的地方才行,绝大多数情况下会选择保存在 Redis 中,因此通过以下配置实现该功能:
@Autowired private RedisIndexedSessionRepository sessionRepository; @Bean public SpringSessionBackedSessionRegistry sessionRegistry() { return new SpringSessionBackedSessionRegistry(sessionRepository); }
然后在过滤器链中,进行如下配置即可:【maximumSessions 设置为 -1 表示同一个用户不进行在线人数控制】
http.sessionManagement().maximumSessions(-1).sessionRegistry(sessionRegistry())
3.4、保持登录配置
如果想保持登录的话,只需要在过滤器链中,增加以下配置即可:
rememberMe().key("myuser").tokenValiditySeconds(60)
可以通过 tokenValiditySeconds 设置保持登录的秒数,这样当登录成功后,关闭浏览器,在过期时间内,打开浏览器访问时会自动登录,本博客设置为 60 秒,主要是为了测试,你可以根据实际需要设置具体的保持时长。
3.5、 角色权限控制、匿名访问
对于 SecurityConfig 配置类上的 @EnableMethodSecurity 注解,主要是启用 Spring Security 对网站资源的权限控制。
prePostEnabled 、securedEnabled、jsr250Enabled 支持了很多角色和权限的注解,以及角色权限判断表达式。
我们绝大多数情况下,主要使用的是 @PreAuthorize 注解,表示在访问资源之前进行验证角色和权限是否满足。配合使用的角色权限表达式,主要有 hasAnyRole 和 hasAnyAuthority,用于判断是否拥有角色,是否拥有权限,参数是数组 ,因此可以传入多个角色名称和权限名称。
本篇博客用于权限控制验证的是 SecurityController ,具体内容如下:
package com.jobs.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/powertest") @RestController public class SecurityController { @RequestMapping("/all") public String all() { return "由于该地址被配置在了匿名访问列表中,因此不需要登录也可以访问"; } //需要拥有 read 权限才能访问 @RequestMapping("/read") @PreAuthorize("hasAnyAuthority('read')") public String read() { return "拥有 read 权限,可以访问"; } //需要拥有 exec 权限才能访问 @RequestMapping("/exec") @PreAuthorize("hasAnyAuthority('exec')") public String exec() { return "拥有 exec 权限,可以访问"; } //需要拥有 admin 角色,并且拥有 read 权限才能访问 @RequestMapping("/adminread") @PreAuthorize("hasAnyRole('admin') and hasAuthority('read')") public String adminread() { return "拥有 admin 角色,并且拥有 read 权限,可以访问"; } //需要拥有 root 角色,并且拥有 exec 权限才能访问 @RequestMapping("/rootexec") @PreAuthorize("hasAnyRole('root') and hasAuthority('exec')") public String rootexec() { return "拥有 root 角色,并且拥有 exec 权限,可以访问"; } }
当然如果某些资源,你想要匿名访问,也就是不登录就可以访问的话,首先需要配置匿名访问的资源路径:
//在这里配置允许匿名访问的地址 public String[] getAnonymousUrl() { return new String[]{ "/powertest/all", //测试匿名访问权限的地址 "/getCode" //获取图形验证的地址,需要匿名访问 }; }
然后在过滤链的最开始位置,配置上该数组即可,表示该数组内的所有资源路径可以匿名访问:
http.authorizeHttpRequests().antMatchers(getAnonymousUrl()).permitAll()
4. 基于数据库的用户密码验证
本篇博客使用的 mysql 数据库脚本如下,主要存储的是用户、角色、权限,以及它们的关联关系:
数据库中所有用户的密码,都是数字 123(存储的是采用 md5 加密的字符串)
SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; CREATE DATABASE `security_demo` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci'; -- ---------------------------- -- Table structure for tb_power -- ---------------------------- DROP TABLE IF EXISTS `tb_power`; CREATE TABLE `tb_power` ( `id` int(11) NOT NULL AUTO_INCREMENT, `power_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '权限名称', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tb_power -- ---------------------------- INSERT INTO `tb_power` VALUES (1, 'read'); INSERT INTO `tb_power` VALUES (2, 'write'); INSERT INTO `tb_power` VALUES (3, 'exec'); -- ---------------------------- -- Table structure for tb_role -- ---------------------------- DROP TABLE IF EXISTS `tb_role`; CREATE TABLE `tb_role` ( `id` int(11) NOT NULL AUTO_INCREMENT, `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色名称', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '角色表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tb_role -- ---------------------------- INSERT INTO `tb_role` VALUES (1, 'admin'); INSERT INTO `tb_role` VALUES (2, 'root'); INSERT INTO `tb_role` VALUES (3, 'normal'); -- ---------------------------- -- Table structure for tb_role_power -- ---------------------------- DROP TABLE IF EXISTS `tb_role_power`; CREATE TABLE `tb_role_power` ( `id` int(11) NOT NULL AUTO_INCREMENT, `role_id` int(11) NOT NULL, `power_id` int(11) NOT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `role_id`(`role_id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tb_role_power -- ---------------------------- INSERT INTO `tb_role_power` VALUES (1, 1, 1); INSERT INTO `tb_role_power` VALUES (2, 1, 2); INSERT INTO `tb_role_power` VALUES (3, 2, 1); INSERT INTO `tb_role_power` VALUES (4, 2, 2); INSERT INTO `tb_role_power` VALUES (5, 3, 1); -- ---------------------------- -- Table structure for tb_user -- ---------------------------- DROP TABLE IF EXISTS `tb_user`; CREATE TABLE `tb_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名', `password` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码', `enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用(1 启用,0 禁用)', `remark` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '', PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `username`(`username`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tb_user -- ---------------------------- INSERT INTO `tb_user` VALUES (1, 'jobs', '202cb962ac59075b964b07152d234b70', 1, '乔豆豆'); INSERT INTO `tb_user` VALUES (2, 'ren', '202cb962ac59075b964b07152d234b70', 1, '任肥肥'); INSERT INTO `tb_user` VALUES (3, 'hou', '202cb962ac59075b964b07152d234b70', 1, '侯胖胖'); INSERT INTO `tb_user` VALUES (4, 'lin', '202cb962ac59075b964b07152d234b70', 1, '蔺赞赞'); INSERT INTO `tb_user` VALUES (5, 'yang', '202cb962ac59075b964b07152d234b70', 1, '杨壮壮'); -- ---------------------------- -- Table structure for tb_user_role -- ---------------------------- DROP TABLE IF EXISTS `tb_user_role`; CREATE TABLE `tb_user_role` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NULL DEFAULT NULL COMMENT '用户id', `role_id` int(11) NULL DEFAULT NULL COMMENT '角色id', PRIMARY KEY (`id`) USING BTREE, INDEX `user_id`(`user_id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户所属角色' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of tb_user_role -- ---------------------------- INSERT INTO `tb_user_role` VALUES (1, 1, 1); INSERT INTO `tb_user_role` VALUES (2, 1, 2); INSERT INTO `tb_user_role` VALUES (3, 2, 3); INSERT INTO `tb_user_role` VALUES (4, 3, 3); INSERT INTO `tb_user_role` VALUES (5, 4, 1); INSERT INTO `tb_user_role` VALUES (6, 4, 2); INSERT INTO `tb_user_role` VALUES (7, 5, 3); SET FOREIGN_KEY_CHECKS = 1;
三个访问数据库的 Mapper 代码细节如下:
package com.jobs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.jobs.pojo.MyUser; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; @Mapper public interface UserMapper extends BaseMapper<MyUser> { //根据用户名查找用户信息 @Select("select * from tb_user where username=#{name}") MyUser getUserByName(String name); }
package com.jobs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.jobs.pojo.MyRole; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import java.util.List; @Mapper public interface RoleMapper extends BaseMapper<MyRole> { //根据用户 id 获取该用户的所有角色列表 @Select("select a.id,a.role_name from tb_role as a " + "join tb_user_role as b on a.id=b.role_id where b.user_id=#{uid}") List<MyRole> getRolesByUserId(Integer uid); }
package com.jobs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.jobs.mapper.sql.PowerMapperSQL; import com.jobs.pojo.MyPower; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.SelectProvider; import java.util.List; @Mapper public interface PowerMapper extends BaseMapper<MyPower> { //获取一个或多个角色的权限列表 @SelectProvider(type = PowerMapperSQL.class, method = "getPowerListByRoleIdsSQL") List<MyPower> getPowerListByRoleIds(List<Integer> rids); }
package com.jobs.mapper.sql; import java.util.List; import java.util.stream.Collectors; public class PowerMapperSQL { public String getPowerListByRoleIdsSQL(List<Integer> rids) { StringBuilder sb = new StringBuilder(); sb.append(" select a.id,a.power_name,b.role_id from tb_power as a"); sb.append(" join tb_role_power as b on a.id = b.power_id"); if (rids.size() > 0) { sb.append(" where b.role_id in ("); String idString = String.join(",", rids.stream().distinct().map(s -> s.toString()).collect(Collectors.toSet())); sb.append(idString).append(")"); } else { sb.append(" where 1=2"); } return sb.toString(); } }
三个实体类的细节如下,其中 MyUser 需要实现 UserDetails 接口,这样才能满足 Spring Security 的框架要求:
package com.jobs.pojo; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.*; //自定义的用户实体类 //对应的数据库表名,由于在 yml 配置文件中,配置了表名的前缀 tb_,因此对应的表名是 tb_user @TableName("user") @Data public class MyUser implements UserDetails { //表明该字段是数据库表的主键 @TableId private Integer id; private String username; private String password; private boolean enabled; private String remark; private List<MyRole> roles; //加载当前登录用户的权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { List<SimpleGrantedAuthority> authorities = new ArrayList<>(); if (roles != null && roles.size() > 0) { Set<String> powerNameSet = new HashSet<>(); for (MyRole role : roles) { List<MyPower> powerList = role.getPowers(); if (powerList != null && powerList.size() > 0) { for (MyPower power : powerList) { if (!powerNameSet.contains(power.getPowerName())) { authorities.add(new SimpleGrantedAuthority(power.getPowerName())); powerNameSet.add(power.getPowerName()); } } } //把角色也添加进去,Spring security 要求角色名前增加固定前缀 ROLE_ authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName())); } } return authorities; } @Override public String getPassword() { return this.password; } @Override public String getUsername() { return this.username; } //父类中需要实现的方法,本 demo 用不上 @Override public boolean isAccountNonExpired() { return true; } //父类中需要实现的方法,本 demo 用不上 @Override public boolean isAccountNonLocked() { return true; } //父类中需要实现的方法,本 demo 用不上 @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return this.enabled; } }
package com.jobs.pojo; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.List; //角色实体类 //对应的数据库表名,由于在 yml 配置文件中,配置了表名的前缀 tb_,因此对应的表名是 tb_role @TableName("role") @Data public class MyRole implements Serializable { //表明该字段是数据库表的主键 @TableId private Integer id; //数据库中 tb_role 中的字段是 role_name, //实体类中可以采用 roleName 进行对应 private String roleName; private List<MyPower> powers; }
package com.jobs.pojo; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; //权限实体类 //对应的数据库表名,由于在 yml 配置文件中,配置了表名的前缀 tb_,因此对应的表名是 tb_power @TableName("power") @Data public class MyPower implements Serializable { //表明该字段是数据库表的主键 @TableId private Integer id; //数据库中 tb_power 中的字段是 power_name, //实体类中可以采用 powerName 进行对应 private String powerName; //所属的角色 id,数据库 tb_power 表示不存在该字段 @TableField(exist = false) private Integer roleId; }
需要自定义一个 MyUserDetailService 实现 UserDetailsService ,目的是为了满足 Spring Security 在用户登录时加载用户信息,为后续进行用户名和密码的比对,实现用户认证的功能:
package com.jobs.service; import cn.hutool.core.collection.CollUtil; import com.jobs.mapper.PowerMapper; import com.jobs.mapper.RoleMapper; import com.jobs.mapper.UserMapper; import com.jobs.pojo.MyPower; import com.jobs.pojo.MyRole; import com.jobs.pojo.MyUser; import org.springframework.beans.factory.annotation.Autowired; 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; import java.util.List; //用户登录时,会把用户名传过来,从数据库中查询获取当前要登录的用户信息 @Service public class MyUserDetailService implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired private RoleMapper roleMapper; @Autowired private PowerMapper powerMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { MyUser myUser = userMapper.getUserByName(username); if (myUser != null) { List<MyRole> roleList = roleMapper.getRolesByUserId(myUser.getId()); if (roleList != null && roleList.size() > 0) { //获取角色列表中的所有 id List<Integer> ridList = CollUtil.getFieldValues(roleList, "id", Integer.class); List<MyPower> powerList = powerMapper.getPowerListByRoleIds(ridList); if (powerList != null && powerList.size() > 0) { for (MyRole r : roleList) { List<MyPower> powerFilter = CollUtil.filterNew(powerList, s -> s.getRoleId() == r.getId()); r.setPowers(powerFilter); } } } myUser.setRoles(roleList); return myUser; } else { throw new UsernameNotFoundException("用户不存在"); } } }
对于 Spring Security 框架来说默认采用内存用户模式,我们需要配置成基于我们自己开发的连接数据库获取用户的模式,因此只需要在 SecurityConfig 类中配置如下内容即可:
@Autowired private MyUserDetailService userDetailsService; @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); //采用 md5 密码加密方式 daoAuthenticationProvider.setPasswordEncoder(new MyMd5PasswordEncoder()); return new ProviderManager(daoAuthenticationProvider); }
5. 获取当前登录的用户信息
当用户登录之后,在请求的主线程中可以通过 SecurityContextHolder 中的方法获取当前登录的用户信息:
package com.jobs.controller; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.LineCaptcha; import com.alibaba.fastjson.JSON; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @Slf4j @Controller public class HomeController { //可以通过 SecurityContextHolder.getContext().getAuthentication() 获取当前登录的用户信息 @GetMapping("/index") @ResponseBody public String index() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String result = "index页面获取到的登录用户信息为:" + JSON.toJSONString(authentication); return result; } //可以直接在方法中注入 Authentication 获取当前登录的用户信息 @GetMapping("/auto") @ResponseBody public String Auto(Authentication authentication) { String result = "auto页面获取到的登录用户信息为:" + JSON.toJSONString(authentication); return result; } //要想在异步线程中获取当前登录的用户信息,必须将线程策略设置为 inheritable threadlocal //inheritable threadlocal 模式下,会复制父线程中存放的用户信息 @GetMapping("/async") @ResponseBody public String async() { new Thread(() -> { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String result = "async页面获取到的登录用户信息:" + JSON.toJSONString(authentication); log.info(result); }).start(); return "请从控制台查看,如果将线程策略配置为 inheritable threadlocal 就可以看到登录的用户信息"; } //获取图形验证码 @GetMapping("/getCode") public void getImageCode(HttpServletResponse response, HttpSession session) throws IOException { //设置响应参数 response.setDateHeader("Expires", 0); response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); response.addHeader("Cache-Control", "post-check=0, pre-check=0"); response.setHeader("Pragma", "no-cache"); response.setContentType("image/jpeg"); //1、通过工具类生成验证码对象(图片数据和验证码信息) LineCaptcha captcha = CaptchaUtil.createLineCaptcha(100, 30, 4, 60); String code = captcha.getCode(); //2、将验证码存入 Session 中 session.setAttribute("IMAGE_CODE", code); //3、通过输出流输出验证码 captcha.write(response.getOutputStream()); } }
默认情况下,无法在异步线程中,通过 SecurityContextHolder 获取当前登录的用户信息,如果想要获取的话,需要在 SecurityConfig 中配置以下代码设置线程的策略模式:
//要想在异步线程中获取当前登录的用户信息,必须将线程策略设置为 inheritable threadlocal //inheritable threadlocal 模式下,会复制父线程中存放的用户信息 @PostConstruct public void setStrategyName() { SecurityContextHolder.setStrategyName( SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); }
OK,以上代码就是 Spring Security 基于表单认证的常用技术功能点,所有代码我都测试过,没有问题。
由于涉及的功能技术点太多,这里就不进行截图展示验证效果了,可以下载源代码自行运行验证执行效果。
代码中的注释比较详细,有关 Spring Security 的执行流程和原理可以参考官网或其它相关资料,这里不再赘述。
本篇博客的源代码下载地址:https://files.cnblogs.com/files/blogs/699532/spring_security_mybatis.zip
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
2022-09-17 SpringCloud 使用 Hystrix 实现服务端接口【熔断】