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