介绍 SpringSecurity

简述 SpringSecurity

Spring Security 是 Spring 应用程序的安全框架,它提供了认证、授权、访问控制等安全性功能。在 Spring Boot 应用程序中使用 Spring Security,可以方便地进行安全性配置。

Spring Security最初是一个独立的项目,名为Acegi Security,于2004年首次发布。Acegi Security是针对Spring框架的安全框架,旨在为Java企业应用程序提供基于认证和授权的安全功能。

2010年,Spring Security成为Spring项目的一部分,并开始在Spring框架的生态系统中广泛应用。随着时间的推移,Spring Security逐渐成为Java企业应用程序中最受欢迎的安全框架之一,用于保护Web应用程序、REST API和基于Spring的微服务架构。

在最新版本的Spring Security 5中,该框架已经得到了大量改进和更新,以支持最新的安全标准和技术,如OAuth 2.0、OpenID Connect和WebFlux。

SpringSecurity 的主要功能说明

一般Web应用关于安全方面的两个主要区域是“认证”和“授权”(或者访问控制)。

  • 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。通俗的说就是系统认为用户是否能登录。
  • 授权:经过认证后判断当前用户是否有权限进行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对某一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。通俗点讲就是系统判断用户是否有权限去做某些事情

认证和授权也是 SpringSecurity 作为安全框架的核心功能。

项目源码

本文的项目地址:点击这里查看项目源码

入门案例

项目工程路径

首先,我们创建一个 SpringBoot 项目,创建项目应该来说是比较简单的,这里就略过了。下面说下简单的搭建,那就接着向下看,最后的项目工程路径是这样子的:
入门工程项目结构

第一步:加载依赖

这里主要是看下 POM 文件引入哪些依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
        <version>3.1.0</version>
     </dependency>

      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
      </dependency>

      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-security</artifactId>
      </dependency>

      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-test</artifactId>
          <scope>test</scope>
      </dependency>

      <dependency>
          <groupId>org.projectlombok</groupId>
          <artifactId>lombok</artifactId>
          <version>1.18.26</version>
          <optional>true</optional>
      </dependency>

</dependencies>

第二步:创建核心的配置类

WebSecurityConfigurerAdapter 类是 Spring Security 的核心配置类,相关的权限过滤,访问地址等等都是通过这里来配置的。使用Spring Security 就离不开这个类的实现。现在呢,我们就先有一个眼熟,后面我们会一点点的深入去看是如何整合的。

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
 * WebSecurityConfigurerAdapter就是Security的核心配置类,一般我们要用Security都会涉及到这个类,一般就是继承这个类,重写方法。
 *
 * @Author wuq
 * @Date 2021-7-16
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单登录
        http.formLogin()                    // 启用默认登录界面
                .failureUrl("/login?fail")     // 登录失败返回 Url
                .defaultSuccessUrl("/index")  // 登录成功跳转 URL,他会自动去根路径static文件夹下寻找login.html
                .failureForwardUrl("/fail")
                .permitAll();                   // 登录页面全部权限可以访问

        super.configure(http);
    }
}

第三步:增加controller

我这里增加controller是为了方便于看到跳转的效果
**

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @RequestMapping("index")
    public String index(){
        return "index";
    }
}

第三步:启动程序

在启动程序的时候,会发现控制台中输出了这么串密码,每次启动程序的时候,这里的密码都是不一样的
启动时会出现一串密码
访问 localhost:8080/index,这里访问的时候会自动跳转到下面的登录页面,这个并不是我们自己设置的登录页,而是 Spring Security 自己带有的登录界面,输入用户名 user (下面会介绍是怎么来的) 以及上面的密码串,就可以登录了
登录页面
登录成功之后就会跳转到下面的页面了
登录之后跳转到index
当我们访问 localhost:8080/logout 就会出现下面的界面,这个页面也是 Spring Security 自带的界面
退出登录

小结

界面跳转说明

由于我们在 WebSecurityConfig 配置了相关的界面跳转路径,所以就会实现登录之后,自动跳转到指定的页面上面。

通过上面的界面跳转就会发现,我们可以通过简单的配置就能实现请求的拦截以及,登录之后页面跳转了。

密码生成说明

在启动程序的时候会发现有这么一串密码出来,那他是怎么生成的呢?

2023-09-12 13:57:01.237  INFO 9228 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 8026a963-4e4d-4fa2-b988-b28dcf264c33

这里我们看下上面的控制台打印,会看到有一个类 UserDetailsServiceAutoConfiguration ,的确输出的位置就是这里,这个类是自动化配置用户相关的信息的。我们访问下这个自动装配的类
源码位置
我们就继续看下是怎么调用的,通过源码可以看到这里是在生成 InMemoryUserDetailsManager bean 的时候会去调用,并且这里使用了 @ConditionalOnMissingBean 注解
调用路径
然后在这里我们看到这里是有一个 User 类,我们就去看下这个User
User 类
设置密码

看到这里,就应该清楚了这个密码是怎么生成了的,并且也知道了这个还可以去配置对应的用户名和密码:

spring.security.user.name=admin
spring.security.user.password=123456

重点内容扫盲

对于接下来需要做的深入学习之前,我们先对两块的知识点扫盲一下

重要的Filter

Spring Security采用责任链的设计模式,它有一条很长的过滤器链。通过不同的过滤器处理相应的业务流程,如登录认证、权限过滤等。

  1. org.springframework.security.web.context.SecurityContextPersistenceFilter:SecurityContextPersistenceFilter 主要是使用 SecurityContextRepository 在session中保存或更新一个SecurityContext,并将 SecurityContext 给以后的过滤器使用,来为后续filter建立所需的上下文。SecurityContext 中存储了当前用户的认证以及权限信息。

  2. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter:此过滤器用于集成SecurityContext到Spring异步执行机制中的 WebAsyncManager

  3. org.springframework.security.web.header.HeaderWriterFilter:向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制

  4. org.springframework.security.web.csrf.CsrfFilter:csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含,则报错。起到防止csrf攻击的效果。

  5. org.springframework.security.web.authentication.logout.LogoutFilter:匹配 URL为/logout的请求,实现用户退出,清除认证信息。

  6. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter:认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。

  7. org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter:如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。

  8. org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter:由此过滤器可以生产一个默认的退出登录页面

  9. org.springframework.security.web.authentication.www.BasicAuthenticationFilter:此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。

  10. org.springframework.security.web.savedrequest.RequestCacheAwareFilter:通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest

  11. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter:针对ServletRequest进行了一次包装,使得request具有更加丰富的API

  12. org.springframework.security.web.authentication.AnonymousAuthenticationFilter:当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。
    spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。

  13. org.springframework.security.web.session.SessionManagementFilter:SecurityContextRepository限制同一用户开启多个会话的数量

  14. org.springframework.security.web.access.ExceptionTranslationFilter:异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常

  15. org.springframework.security.web.access.intercept.FilterSecurityInterceptor:获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。

PasswordEncoder 接口

关于 PasswordEncoder 接口,PasswordEncoder 主要负责的就是密码和 主题信息业务类返回的密码进行比对的时候,所要使用的加密方式。

// 表示把参数按照特定的解析规则进行解析
String encode(CharSequence rawPassword);

// 表示验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回 true;如果不匹配,则返回 false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码。
boolean matches(CharSequence rawPassword, String encodedPassword);

// 表示如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回false。默认返回 false。
default boolean upgradeEncoding(String encodedPassword){
	return false; 
}

接着,我们看下对应的接口实现类
PasswordEncoder 实现类
我们这里就主要介绍下 BCryptPasswordEncoder 密码解析器,这个也是官方推荐使用的,

BCryptPasswordEncoder 是 Spring Security 框架提供的一种密码加密方式。它使用 bcrypt 算法对密码进行加密,该算法是一种非常安全可靠的密码加密算法。

使用 BCryptPasswordEncoder 加密用户的密码时,首先会生成一个随机“盐”(salt),并将盐值和原始密码一同进行加密。因为每个用户的盐值都是随机生成的,即使两个用户的密码相同,加密后的结果也是不同的,这样大大增加了密码破解的难度。

举一个实际的例子:密码是 123456 加密后成了 a 存到了数据库,这时候登录前端传的还是 123456 密码,然后进行加密,加密后的密文会发现根本不是a,是b,但是a和b两个密文通过加密算法提供的对比方法,在对比的时候是相等的。

具体实例

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class BCryptTest {
    public static void main(String[] args) {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

        // 对密码进行加密
        String pwd = bCryptPasswordEncoder.encode("123456");
        // 输出加密之后的字符串
        System.out.println("加密之后数据:\t"+pwd);

        // 使用 bCryptPasswordEncoder 的匹对方法
        boolean result = bCryptPasswordEncoder.matches("123456", pwd);
        // 打印比较结果
        System.out.println("比较结果:\t"+result);
    }
}

控制台输出

加密之后数据:	$2a$10$n.U/yTVF8c9mjMsPUv0fruekmbvfxAhZhcq0ymOWa/qMwr3P7LxQa
比较结果:	true

对于 BCryptPasswordEncoder 的使用,在实际项目上面,我们是会将用户的密码使用 BCrypt 加密之后保存到数据库中,然后登录的时候会将明文的密码加密之后与数据库中的密文进行对比。

UserDetailsService 接口

在上面介绍密码是如何生成的时候,有讲到 UserDetailsServiceAutoConfiguration 类,在上面的注解上面就有出现过他的身影。通过这里的 @ConditionalOnMissingBean 可以看出来,当我们没有自己的登录逻辑时(就像上面的入门示例一样),就会默认的走到这个地方来。
UserDetailsServiceAutoConfiguration
对于这个接口呢,我感觉是需要重点需要了解的,这个是涉及到我们登录的时候校验用的。也就是说 Spring Security 就是通过这个来校验登录用户信息的。 我们具体应该怎么写代码呢?这由于他是一个接口,我们实现这个接口,然后写入我们自己的逻辑就好,对应的源码如下:

package org.springframework.security.core.userdetails;

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

对于这个loadUserByUsername 具体的操作有点儿奇怪,我们这个先看整个验证流程,这里需要特别说明下:

第一步:我们根据 username 查询数据库对应的用户是否存在。
第二步:将数据库中查询的用户信息(账号+密码)封装到 UserDetail 对象中,作为方法的返回值。
第三步,将第二步中数据库中的密码与前端出入的明文密码加密之后对比,验证身份。

UserDetailsService 中loadUserByUsername 就做的事情是第二步。我们通常的情况下,是直接判断用户名和密码,看看是否能登录成功,这里和我们自己写的登录逻辑并不一样。

用户示例

import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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;

@Service
public class LoginService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws
            UsernameNotFoundException {
        // 1.根据username查询数据库,判断用户名是否存在
       
        // 2.将数据库当中查出来的username和pwd封装到user对象当中返回 第三个参数表示权限
        return new User(username, pwd,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin,"));
    }
}

UserDetail
在上面出现了 UserDetail 以及一个 User类这里就再说明下他们是啥,UserDetail 是一个接口,具体的源码如下:

public interface UserDetails extends Serializable {
	// 表示获取登录用户所有权限
    Collection<? extends GrantedAuthority> getAuthorities();

	// 表示获取密码
    String getPassword();

	// 表示获取用户名
    String getUsername();

	// 表示判断账户是否过期
    boolean isAccountNonExpired();

	// 表示判断账户是否被锁定
    boolean isAccountNonLocked();

	// 表示凭证{密码}是否过期
    boolean isCredentialsNonExpired();

	// 表示当前用户是否可用
    boolean isEnabled();
}

在Spring Security 中有一个 User (不是上面文章中 SecurityProperties 的内部类 User ) 作为 UserDetails 实现,在项目上面是新建一个类实现这个接口或者直接使用这个 User 都是可以的:
User类
具体的验证调用验证的逻辑如下,这里是先将一部分源码贴出来给大伙看下,知道是怎么调用的,后面会通过示例讲到
调用验证

深入学习案例

这里呢,我们需要深入学习下 Spring Security 的登录验证了,我们先看下是怎么实现web 登录校验

先通过一个实际的例子来混个脸熟,这里呢,我把对应的源码放入到了这里:Github 项目工程地址点击这里

基础验证案列

下面最后搭建的工程项目接口是下面这样子的:
项目工程结构

第一步:加载依赖

我们这里使用 MybatisPlus 作为持久化框架,简化我们的查询。另外呢,这里需要注意的是mybatis-plus在springboot并没有版本管理,所以我们需要指定mybatis-plus版本,不然就报错。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
        <version>3.1.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.26</version>
        <optional>true</optional>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.1.2</version>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>
第二步:初始化 SQL

初始化 SQL,这里我是用 MySQL,在虚拟机上面搭建的 docker 容器,如果是需要搭建的话,可以看下我之前写这个文章:docker 中安装 MySQL 以及使用

create table users
(
    id       bigint primary key auto_increment,
    username varchar(20) unique not null,
    password varchar(100)
);
-- 密码 123456 使用了BCrypt加密
insert into users
values (1, 'admin', '$2a$10$ZglYem2Zs8E4ETbLwaiA4OjXaTZX9w8wJ7x8LZdpGisdtI9VlIfvO');
-- 密码 123456
insert into users
values (2, 'user', '$2a$10$ZglYem2Zs8E4ETbLwaiA4OjXaTZX9w8wJ7x8LZdpGisdtI9VlIfvO');

create table role
(
    id   bigint primary key auto_increment,
    name varchar(20)
);
insert into role
values (1, '管理员');
insert into role
values (2, '普通用户');

create table role_user
(
    uid bigint,
    rid bigint
);
insert into role_user
values (1, 1);
insert into role_user
values (2, 2);

create table menu
(
    id         bigint primary key auto_increment,
    name       varchar(20),
    url        varchar(100),
    parentid   bigint,
    permission varchar(20)
);
insert into menu
values (1, '系统管理', '', 0, 'menu:system');
insert into menu
values (2, '用户管理', '', 0, 'menu:user');

create table role_menu
(
    mid bigint,
    rid bigint
);
insert into role_menu
values (1, 1);
insert into role_menu
values (2, 1);
insert into role_menu
values (2, 2);

CREATE TABLE `persistent_logins`
(
    `username`  VARCHAR(64) NOT NULL,
    `series`    VARCHAR(64) NOT NULL,
    `token`     VARCHAR(64) NOT NULL,
    `last_used` TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`series`)
) ENGINE = INNODB DEFAULT CHARSET = utf8;
第三步: 添加配置

配置文件 application.yml

# DataSource Config
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.152.129:3306/study?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF8
    username: admin
    password: 123456

# 日志打印
# 日志打印
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

Spring Security 配置

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    // 注入 PasswordEncoder 类到 spring 容器中
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //  表单登录
        http.formLogin() // 表单登录
                .defaultSuccessUrl("/index")  //  登录成功之后跳转到哪个 url
                .failureForwardUrl("/fail")   //  登录失败之后跳转到哪个 url
                .and()
                .authorizeRequests() // 认证配置
                .anyRequest() // 任何请求
                .authenticated(); // 都需要身份验证

        // 关闭 csrf
        http.csrf().disable();
    }
}

Mapper 的扫码注解
由于我们使用了 mybatis plus 所以我们需要给 mapper 添加一个扫码的注解,在启动类上面添加就好

@SpringBootApplication
@MapperScan("com.demo.security.mapper")
public class SecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}
第四步:添加实体类以及对应的 Mapper

User 实体类以及对应的 Mapper,这个就是我们的用户,等下验证的时候,就是会去查询这个User 表中的数据

Users类

import lombok.Data;

@Data
public class Users {
    private Long id;
    private String username;
    private String password;
}

UsersMapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.demo.security.entity.Users;

public interface UsersMapper extends BaseMapper<Users> {
}
第五步:增加 controller 进行访问验证
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class IndexController {

    @GetMapping("index")
    @ResponseBody
    public String index() {
        System.out.println("1111111111111");
        return "success";
    }

    @PostMapping("fail")
    @ResponseBody
    public String fail() {
        return "fail";
    }

}
第六步:添加自定义的验证逻辑

这个验证类的实现比较重要,这里也需要重点啰嗦下,上面在知识点扫盲中有讲到 UserDetailsService 这个接口,这个类就主要做了两件事请:

  • 第一:根据前端传入的用户名去查询数据库中是否存在对应的账户
  • 第二:将数据库中查询到的用户,放入到 User 对象中返回
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.demo.security.entity.Users;
import com.demo.security.mapper.UsersMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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;


/**
 * 根据账号查询用户密码,顺便判断账户是否存在。
 * 将从数据库查询出来的账号密码,放到 User 对象当中并返回。
 *
 * UserDetailsService 接口:主要作用就是返回主体,并且主体当中会携带授权(授权这个权可以是菜单权限,也可以是角色权限)。
 */
@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UsersMapper usersMapper;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        QueryWrapper<Users> wrapper = new QueryWrapper();
        wrapper.eq("username", s);
        Users users = usersMapper.selectOne(wrapper);

        if (users == null) {
            throw new UsernameNotFoundException("用户名不存在!");
        }
        System.out.println(users);
        // 这里就是在构建权限,这里的权限可以是菜单权限也可以是角色权限
        List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
        return new User(users.getUsername(), users.getPassword(), auths);
    }
}

验证

验证一:异常访问

直接访问 localhost:8080/index 会发现访问的时候就会自动跳转到 localhost:8080/login 地址上面去,这里是因为没有权限就无法访问

验证二:正常登录

访问 localhost:8080/login ,输入用户名admin 和密码 123456,就会进入到下面页面
登录成功

验证三:异常登录

访问 localhost:8080/login ,输入用户名admin 然后输入错误的密码,再次登录
登录失败调整

基于角色的访问控制

前面我们已经添加了一个用户实体类,现在我们来继续增加角色和菜单相应的实体类

项目结构

我们这个地方修改之后的项目工程结构如下
项目结构

添加菜单和角色实体类以及Mapper 文件

Menu类

import lombok.Data;

@Data
public class Menu {
    private Long id;
    private String name;
    private String url;
    private Long parentId;
    private String permission;
}

Role类

import lombok.Data;

@Data
public class Role {
    private Long id;
    private String name;
}

对应的 Mapper 文件以及 xml 查询

import com.demo.security.entity.Menu;
import com.demo.security.entity.Role;

import java.util.List;

/**
 * @author wuq
 * @Time 2023-9-6 17:04
 * @Description
 */
public interface UserInfoMapper {

    /**
     * 根据用户 Id 查询用户角色
     *
     * @param userId
     * @return
     */
    List<Role> selectRoleByUserId(Long userId);

    /**
     * 根据用户 Id 查询菜单
     *
     * @param userId
     * @return
     */
    List<Menu> selectMenuByUserId(Long userId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wq.security.mapper.UserInfoMapper">

    <!--根据用户 Id 查询角色信息-->
    <select id="selectRoleByUserId" resultType="com.wq.security.entity.Role">
        SELECT r.id,
               r.NAME
        FROM role r
                 INNER JOIN role_user ru ON ru.rid = r.id
        WHERE ru.uid = #{0}
    </select>
    <!--根据用户 Id 查询权限信息-->
    <select id="selectMenuByUserId" resultType="com.wq.security.entity.Menu">
        SELECT m.id,
               m.NAME,
               m.url,
               m.parentid,
               m.permission
        FROM menu m
                 INNER JOIN role_menu rm ON m.id = rm.mid
                 INNER JOIN role r ON r.id = rm.rid
                 INNER JOIN role_user ru ON r.id = ru.rid
        WHERE ru.uid = #{0}
    </select>
</mapper>
调整 MyUserDetailsService 实现

这里呢,我们将上面的源码修改下,增加权限相关的查询,前面是写死了权限,我们现在是将权限信息从数据库中查询出来再返回,按照下面的源码看,权限是封装到了 List<GrantedAuthority> 集合中。

注意:在实际开发当中,我们可能涉及不到某些接口必须用哪个角色才能访问的场景,而只是利用角色来分配菜单,然后给用户再分配角色。
如果要是这样的话,我们只需要根据用户id来关联查询角色表,再根据拥有的角色查询出来所拥有的菜单权限即可。就不需要像下面一样,还查询出来角色,把角色也放到了List当中。

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.demo.security.entity.Menu;
import com.demo.security.entity.Role;
import com.demo.security.entity.Users;
import com.demo.security.mapper.UserInfoMapper;
import com.demo.security.mapper.UsersMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.ArrayList;
import java.util.List;


/**
 * 根据账号查询用户密码,顺便判断账户是否存在。
 * 将从数据库查询出来的账号密码,放到 User 对象当中并返回。
 *
 * UserDetailsService 接口:主要作用就是返回主体,并且主体当中会携带授权(授权这个权可以是菜单权限,也可以是角色权限)。
 */
@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UsersMapper usersMapper;
    @Autowired
    private UserInfoMapper userInfoMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        QueryWrapper<Users> wrapper = new QueryWrapper();
        wrapper.eq("username", s);
        Users users = usersMapper.selectOne(wrapper);

        if (users == null) {
            throw new UsernameNotFoundException("用户名不存在!");
        }
        // 获取用户角色、菜单列表
        List<Role> roles = userInfoMapper.selectRoleByUserId(users.getId());
        List<Menu> menus = userInfoMapper.selectMenuByUserId(users.getId());

        // 声明一个集合List<GrantedAuthority>, 将角色和菜单权限都加入进去
        List<GrantedAuthority> grantedAuthorityList = new ArrayList<>();
        // 处理角色
        for (Role role:roles){
            // 这个地方品拼接的 "ROLE_" 不能删除
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_" + role.getName());
            grantedAuthorityList.add(simpleGrantedAuthority);
        }
        // 处理权限
        for (Menu menu:menus){
            grantedAuthorityList.add(new SimpleGrantedAuthority(menu.getPermission()));
        }
        return new User(users.getUsername(), users.getPassword(), grantedAuthorityList);
    }
}

另外这里需要注意下,在权限拼接的时候 ROLE_ 不能删除,这个后面会讲到

SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_" + role.getName());
添加访问接口

IndexController.java 中增加下面内容

@GetMapping("findAll")
@ResponseBody
public String findAll() {
    return "findAll";
}

@GetMapping("find")
@ResponseBody
public String find() {
    return "find";
}
修改 Security 配置

下面是增加了

  • .antMatchers("/findAll").hasRole("管理员") 需要管理员权限才能访问
  • .antMatchers("/find").hasAuthority("menu:user") 需要用户具备 menu:user 这个接口的许可,才可以访问,这里的许可就是指的是菜单中的 permission 字段,如果是权限是按钮级别的控制,那么对应的接口就需要有一个唯一的 permission 许可
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    // 注入 PasswordEncoder 类到 spring 容器中
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 表单登录
        http.formLogin()
                //  登录成功之后跳转到哪个 url
                .defaultSuccessUrl("/index").permitAll()
                //  登录失败之后跳转到哪个 url
                .failureForwardUrl("/fail").permitAll();
        // 身份验证
        http.authorizeRequests()
                // // 需要用户带有管理员角色才可以访问/findAll接口
                .antMatchers("/findAll").hasRole("管理员")
                .antMatchers("/find").hasRole("管理员")
                // 需要用户具备menu:user这个接口的许可,才可以访问
                .antMatchers("/find").hasAuthority("menu:user")
                // 任何请求都需要认证
                .anyRequest().authenticated();
        // 关闭 csrf
        http.csrf().disable();
    }
}
hasRole 的源码相关说明

通过编译器,我们点击进去看 hasRole的源码时,会找到 ExpressionUrlAuthorizationConfigurer 这个类,先看下 hasRole() 最后的校验逻辑,下面都出现这个 rolePrefix 的前缀

private static String hasAnyRole(String rolePrefix, String... authorities) {
        String anyAuthorities = StringUtils.arrayToDelimitedString(authorities, "','" + rolePrefix);
        return "hasAnyRole('" + rolePrefix + anyAuthorities + "')";
    }

    private static String hasRole(String rolePrefix, String role) {
        Assert.notNull(role, "role cannot be null");
        Assert.isTrue(rolePrefix.isEmpty() || !role.startsWith(rolePrefix), () -> {
            return "role should not start with '" + rolePrefix + "' since it is automatically inserted. Got '" + role + "'";
        });
        return "hasRole('" + rolePrefix + role + "')";
    }

那我们就全局搜索下吧,看下这个是怎么来的,看到下面就应该知道了,如果没有特殊配置的话,就走的默认前缀ROLE_
rolePrefix
在Spring Security中,可以使用GrantedAuthorityDefaults来为所有的授权授予对象指定默认的前缀。默认的前缀为ROLE_,可以使用rolePrefix属性为其指定不同的前缀,那我们具体怎么修改这个前缀呢?

WebSecurityConfigurerAdapterconfigure(HttpSecurity http)方法中,可以使用以下代码来设置GrantedAuthorityDefaults的前缀:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 设置授权授予对象的默认前缀
        http.authorizeRequests().mvcMatchers("/admin/**").hasRole("ADMIN");

        // 使用自定义的前缀
        http.authorizeRequests().mvcMatchers("/user/**").hasAuthority("CUSTOMER");
    }

    @Bean
    GrantedAuthorityDefaults grantedAuthorityDefaults() {
        return new GrantedAuthorityDefaults("CUSTOMER_");
    }
}

在示例中,grantedAuthorityDefaults()方法返回了GrantedAuthorityDefaults对象,其构造函数中传入了一个自定义的前缀CUSTOMER_。然后,在configure(HttpSecurity http)方法中,使用hasAuthority("CUSTOMER")指定了使用自定义前缀的授权授予对象。

也可以使用默认的前缀ROLE_,只需要在grantedAuthorityDefaults()方法中不传入任何参数即可。

验证

前面的验证环节中验证过的,这里我就不再赘述了,直接做新的验证

验证一:使用普通用户登录验证

访问 localhost:8080/login 输入用户名user 和密码 123456 ,登录成功之后再去访问下 findAll接口,由于user并没有访问权限,所以这个地方会报错
user访问findAll
接下来,我们访问下 find 接口,由于我们有配置菜单的接口许可,所以这里可以访问(这里其实就是说明了,只要有一个条件满足就可以访问了)
user 访问 find

验证二:使用管理员账户登录验证

访问 localhost:8080/login 输入用户名admin 和密码 123456 ,登录成功之后再去访问下 findAll接口与 find 接口,都是可以访问的,这里就不贴图来说明了,大伙可以自己去试试就好

自定义界面访问

看到这里感觉真是不容易啊,我们还没有结束,继续向下看吧,界面添加的位置在这里
添加界面

添加登录界面

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<form action="/user/login" method="post">
    <div>
        <span>用户名:</span><input type="text" name="username">
    </div>

    <div>
        <span>密码:</span> <input type="password" name="password">
    </div>

    <div>
        <input type="submit" value="login"/>
    </div>
</form>
</body>
</html>
修改 Security 的配置

SecurityConfig.java 具体源码下的注释都比较清楚了,大伙就看下就好了

@Override
    protected void configure(HttpSecurity http) throws Exception {
        //  表单登录
        http.formLogin()
                // 修改默认的登录页为login.html,他会自动去根路径static文件夹下寻找login.html
                .loginPage("/login.html")
                // 设置登录接口地址,这个接口不是真实存在的,还是用的security给我们提供的,之所以要有这个配置,是login.html当中form表单提交的地址我们设置的是这个
                .loginProcessingUrl("/user/login")
                // 登录成功之后跳转的 url
                .defaultSuccessUrl("/index")
                // 登录失败之后跳转的 url
                .failureForwardUrl("/fail")
                // permitAll中文意思是许可所有的:所有的都遵循上面的配置的意思
                .permitAll();

        //  身份认证
        http.authorizeRequests()
                // 该路由不需要身份认证
                .antMatchers("/user/login", "/login.html").permitAll()
                // 需要用户带有管理员权限
                .antMatchers("/findAll").hasRole("管理员")
                .antMatchers("/find").hasRole("管理员")
                // 需要用户具备这个接口的权限
                .antMatchers("/find").hasAuthority("menu:user")
                // 任何请求都需要认证
                .anyRequest().authenticated();
        // 关闭 csrf
        http.csrf().disable();
    }

验证

登录页面验证

访问 localhost:8080/login 时,会发现自动跳转到下面的页面中,输入用户名user 和密码 123456 是可以登录进去的
login.html
登录之后界面跳转
登录成功

自定义错误页面

添加错误页面

这里我们添加一个新页面 unauth.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>没有权限</title>
</head>
<body>
    <h1>没有权限</h1>
</body>
</html>
修改 Security 的配置
// 设置没有权限访问跳转自定义页面
http.exceptionHandling().accessDeniedPage("/unauth.html");

添加错误配置

验证

无访问权限验证

访问 localhost:8080/login 输入用户名user 和密码 123456 ,登录成功之后再去访问下 findAll接口,由于user并没有访问权限
没有权限

自定义主页

这里我们是增加一个 home.html 方便于登录之后跳转,以及界面退出使用

添加主页

home.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<dvi>
    登录成功
</dvi>

<div>
    <a href="/logout">退出</a>
</div>

</body>
</html>
修改 Security 的配置
// 退出,这里的/logout的请求是和前端的接口约定,是security给我们提供的,退出成功后跳转登录页/login.html
http.logout().logoutUrl("/logout").logoutSuccessUrl("/login.html").permitAll();

增加配置

验证

验证跳转主页

访问 localhost:8080/login 输入用户名user 和密码 123456 ,再次点击退出就到主页了。
home页面

remember-me 功能介绍

简单点理解就是这样子的,当我们登录到系统,关闭掉网页,再次访问系统的接口是可以访问的。一般情况下,如果我们把网页关闭或者浏览器关闭了,即使是后端程序服务重启了,这个时候就需要重新登录。有这个 remember-me 功能,就可以不用重新登录了。

具体是怎么做的呢?其实这个是将我们的请求相关的参数持久化到了数据库中去了。这里我就直接开始说怎么做了,以及相关的报错,大伙了解下就好

修改 Security 配置

我们增加 remember-me 的配置

@Autowired
private DataSource dataSource;

@Autowired
private MyUserDetailsService myUserDetailsService;

@Bean
public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);
    return jdbcTokenRepository;
}

// 注入 PasswordEncoder 类到 spring 容器中
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

remember-me 配置

修改Login页面

修改页面增加 remember-me 复选框,这里的 name="remember-me" 是不能修改的,可以理解为Spring Security 默认的

<input type="checkbox" name="remember-me">自动登录
创建表结构
CREATE TABLE `persistent_logins` (
	`username` VARCHAR ( 64 ) NOT NULL,
	`series` VARCHAR ( 64 ) NOT NULL,
	`token` VARCHAR ( 64 ) NOT NULL,
	`last_used` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY ( `series` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8;

如果不创建表结构会出现什么问题呢?我这边先不创建表结构,直接启动程序

org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)]; nested exception is java.sql.SQLSyntaxErrorException: Table 'study.persistent_logins' doesn't exist
    at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:235)
    at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72)
    at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1443)
    at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:633)
    at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:862)
    at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:917)
    at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:927)

这里我们看下源码,我们是新增这部分的配置,然后这里出现了一个类 JdbcTokenRepositoryImpl, 并且我们还将 dataSource 传入进去了,我们就看下这个类

@Bean
public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    jdbcTokenRepository.setDataSource(dataSource);
    return jdbcTokenRepository;
}

JdbcTokenRepositoryImpl类
在这类中,就已经将这个表的相关操作全部都集成进去了,所以看到这里就明白了。

验证

验证 remember-me 功能

我们使用 admin账号之后,将浏览器页面关闭掉,依然可以访问到 findAll 接口。
remember-me 登录

原理分析

流程图,百度上面查到的地址在这里
百度找的图片

在登录成功之后,前端在浏览器上面写入了一部分信息到 cookies 中了
前端浏览器中的 cookies
开启 RememberMe 后,RememberMeAuthenticationFilter 过滤器就会被激活,我们可以看看这个过滤器的doFilter方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
		// 查看是否有SecurityContextHolder中是否有认证信息
		if (SecurityContextHolder.getContext().getAuthentication() == null) {
			// 没有认证信息,因此尝试rememberMe认证,这也是核心方法
			Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);

			if (rememberMeAuth != null) {
				// rememberMeAuth不为null则表示自动登录成功,现在需要对key进行校验
				try {
					rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

					// 认证走到这一步就说明成功了,因为失败会抛异常
					// 将身份信息存储到SecurityContextHolder
					SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
					
					// 发布认证成功事件
					onSuccessfulAuthentication(request, response, rememberMeAuth);

					if (logger.isDebugEnabled()) {
						logger.debug("SecurityContextHolder populated with remember-me token: '"
								+ SecurityContextHolder.getContext().getAuthentication()
								+ "'");
					}

					// Fire event
					if (this.eventPublisher != null) {
						eventPublisher
								.publishEvent(new InteractiveAuthenticationSuccessEvent(
										SecurityContextHolder.getContext()
												.getAuthentication(), this.getClass()));
					}

					if (successHandler != null) {
						successHandler.onAuthenticationSuccess(request, response,
								rememberMeAuth);

						return;
					}

				}
				catch (AuthenticationException authenticationException) {
					if (logger.isDebugEnabled()) {
						logger.debug(
								"SecurityContextHolder not populated with remember-me token, as "
										+ "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
										+ rememberMeAuth
										+ "'; invalidating remember-me token",
								authenticationException);
					}
					
					// 登录失败,使用该方法处理失败回调
					rememberMeServices.loginFail(request, response);

					// 发布登录失败事件
					onUnsuccessfulAuthentication(request, response,authenticationException);
				}
			}
			// 过滤器放行
			chain.doFilter(request, response);
		}
		else {
		    // 如果SecurityContextHolder中有认证信息,说明已经认证过了,则打印日志并直接放行
			if (logger.isDebugEnabled()) {
				logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			}

			chain.doFilter(request, response);
		}
	}

从上面可以知道核心部分是在 rememberMeServices.autoLogin() ,最后我们找下具体的执行逻辑
在这里插入图片描述

注解使用

由于在实际开发中,当接口越来越多之后,如果都在Spring Security 的配置类中增加配置,那么就会越来越难维护,在 Spring Security 中也提供了注解来处理这样子的问题。这里我就介绍常用一部分,另外的大家可以去搜索下,问题不大。

Spring Security默认是禁用注解的,要想开启注解,要在继承 WebSecurityConfigurerAdapter 的类加 @EnableMethodSecurity 注解

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)

prePostEnabled = true

  • @PreAuthorize 和 @PostAuthorize 两个注解会生效

securedEnabled =true

  • @Secured 会生效

另外还有一个 jsr250Enabled = true 这个是 java 提供的配置,这个就不在这里说了,其实都不算特别复杂的。

我们先回顾下之前的配置

// 需要用户带有管理员角色才可以访问/findAll接口
.antMatchers("/findAll").hasRole("管理员")
.antMatchers("/find").hasAuthority("menu:user")
@Secured注解

需要开启配置

@EnableGlobalMethodSecurity(securedEnabled=true)

具体使用如下,我们可以在 Controller 上面去增加一个注解就可以了,当然前缀 ROLE_ 不能少!!!
在这里插入图片描述

这个也可以使用在方法上面:

@Secured({"ROLE_管理员","ROLE_普通用户"})
@GetMapping("find")
@ResponseBody
public String find() {
    return "find";
}

这个就是和 .antMatchers("/findAll").hasRole("管理员") 等效

@PreAuthorize

@PreAuthorize:注解适合进入方法前的权限验证, 可以将登录用户的 roles/permissions 参数传到方法中。

需要开启配置

@EnableGlobalMethodSecurity(prePostEnabled = true)
@GetMapping("index")
@ResponseBody
@PreAuthorize("hasAnyAuthority('menu:system')")
public String index() {
    System.out.println("1111111111111");
    return "success";
}

这个就是和 .antMatchers("/find").hasAuthority("menu:user") 等效。

@PostAuthorize

@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证,适合验证带有返回值
的权限。配置都是差不多的,这里就不多说了。

@EnableGlobalMethodSecurity(prePostEnabled = true)
@GetMapping("index")
@ResponseBody
@PostAuthorize("hasAnyAuthority('menu:system')")
public String index() {
    System.out.println("1111111111111");
    return "success";
}

总结

看到这里了,应该是对 Spring Security 大致的用法是怎么来的就有一个清晰的了解了,在项目开发中看到上面,应该是应对一般的开发工作是没有问题了的。

另外呢,使用 SpringBoot Security + Jwt + Redis + Mybatis Plus 做为登录校验可以点击看下项目工程源码

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐