Spring Security 基于表单的认证和角色权限控制

发布时间 2023-09-17 19:37:54作者: 乔京飞

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 工程,其结构如下:

image

对于 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 数据库脚本如下,主要存储的是用户、角色、权限,以及它们的关联关系:

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