SpringBoot整合SpringSecurity

文章部分图片来源网络,侵删,感谢

看完本文你可以了解到的知识点:

  • SpringSecurity是什么,如何配置以及认证流程
  • 如何基于Token(JWT)进行认证
  • 如何对用户进行鉴权,基于RBAC模型

介绍

SpringSecurity 是基于Spring开发WEB服务认证授权框架,它的核心功能包括:

  • 用户认证(你是谁)
  • 资源授权(你能做什么)
  • 防止CSRF跨站请求,session攻击等

安装

<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>
    <version>2.4.0</version>
</dependency>
复制代码

其实当你引入依赖以后,什么配置都不需要做,你的项目就已经被Spring Security管理了,默认使用的是Http basic模式,当你启动项目你可以在控制台看到这行message,这是Spring Security生成的随机密码,每次启动都会重新生成。

此时,如果你再去访问api接口,那么会自动跳转login请求并弹出这个页面:

这是SpringSecurity帮我们做的,默认的username为user,Password也就是控制台的随机密码,输入即可访问到我们的api接口。当然我们也可以自定义username和password,只需要在application.yml中这样配置:

spring:
  security:
    user:
      name: admin
      password: 123456
复制代码

原理

刚刚我们已经了解了Spring SecurityHttp Basic模式的,这是最基本最简单的一种模式,它的原理是将用户名和密码通过Base64进行加密,然后在请求头中通过Authorization传递,服务器在BasicAuthenticationFilter过滤器中对加密字符串进行解析校验然后返回结果,这种方式非常的不安全,而且用户只能在配置文件中定义,并且不可以定制登录页面,所以基本上使用的场景不多。(用的不多还说半天?这不是先熟悉一下SpringSecurity嘛!),除了Http Basic模式以外还提供了formLogin模式,但是由于目前基本都是前后端分离,所以基于Session的认证我在这里就不写了,本文主要还是基于Token方式认证,也就是不使用这两种模式。

在学习如何使用之前,我们需要先了解一下SpringSecurity的流程,了解了流程方便后面入手。

核心组件

SpringSecurity有几个核心的组件需要我们先了解:

  • Authentication:认证主体,存储详细的用户信息
  • SecurityContext:存储认证主体
  • SecurityContextHolder:构建SecurityContext,它是线程安全的,使用ThreadLocal管理。

也就是说用户经过认证以后,我们可以在程序中通过这行代码来获取用户的信息。

SecurityContextHolder.getContext().getAuthentication()
复制代码

上面提到了BasicAuthenticationFilter,那么它是什么。

SpringSecurity认证的核心是过滤器链,整个认证授权过程其实就是在过滤器中完成的

图片来源网络(侵删)

  • 当用户请求Api时,首先会经过一系列的认证过滤器(图中第二部分)进行认证,如果某个过滤器认证通过,那么会设置Authentication对象。
    • 以上的认证过滤器并不是全部,并且我们可以自定义过滤器
  • 最后一个过滤器是FilterSecurityInterceptor,它会去判断用户有没有认证,如果已认证,则可以访问Api
  • 如果经过所有的过滤器用户都还没有认证,那么FilterSecurityInterceptor会抛出异常,然后由ExceptionTranslationFilter捕获对异常进行处理,处理方式比如跳转到登录页面或者响应JSON

配置类

SpringSecurity怎么知道哪些api需要认证才能访问,哪些用户可以认证通过,并且有权限的呢?自然是需要我们告诉SpringSecurity,所以需要创建一个SpringSecurity的配置类,继承WebSecurityConfigurerAdapter,重写三个方法,用于配置用户、资源权限规则等等。先了解即可。

@EnableWebSecurity(debug = true) //开启 debug开启可以查看详细的日志
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public WebSecurityConfig(UserDetailsServiceImpl userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    //配置资源权限规则
    @Override
    protected void configure(HttpSecurity http) throws Exception {

    }

    //配置用户
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       
    }
  
    //忽略静态资源,不会参与认证
    @Override
    public void configure(WebSecurity web) throws Exception {
    }


    //密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
           return new BCryptPasswordEncoder();
    }

}
复制代码

密码编码器

先说点题外话,关于密码的发展史,一开始是使用明文进行存储密码(想起了2011年CSDN几百万用户密码泄漏事件),后来开始使用单向的hash算法(MD5、SHA256),单向hash代表hash后的值不能被反向解析,但是这种方式有很多缺点,比如同样的值经过hash后得到的值是相同的,会有hash碰撞,计算速度很快。基于这三个缺点,黑客们可以对20位以内的数字+字母的随机组合生成一个字典表,解密的时候搜索一下就可以得到原始值了。再之后呢就是对密码“加盐”,对于用户的每个密码添加全球唯一的值,这样就没有现成的字典表使用使用。

但是目前来说,最好的办法是使用慢一点的算法比如Bcrypt,而不是MD5这样快速的算法。制作8位密码长度的MD5字典表需要5个月,而BCrypt需要几十年。而且BCrypt的盐值是经过多次计算的。

SpringSecurity提供了密码编码器PasswordEncoder,并且提供了BCryptPasswordEncoder实现类。用户注册时使用BCrypt进行加密,当登录时会使用用户输入的明文密码进行匹配。我们需要将它注入到Spring容器中作为全局Bean。

@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}
复制代码

但是有一种场景,就是可能开发的项目很久了,一开始是使用的MD5算法,那现在如果想使用BCrypt该怎么解决呢。也就是如果对于老用户MD5同样可以匹配成功,新用户BCrypt可以匹配成功,如果老用户修改密码再使用BCrypt算法进行加密,相当于一个替换的过程,SpringSecurity5提供了DelegatingPasswordEncoder,它可以定义密码编码器的集合。

@Bean
public PasswordEncoder passwordEncoder() {
    String idForDefault = "bcrypt"; //默认编码
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put(idForDefault, new BCryptPasswordEncoder());
    encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
    return new DelegatingPasswordEncoder(idForDefault, encoders);
}
复制代码

但是这种情况的话我们数据库中的密码应该使用{算法}密码的形式存储,比如:

{bcrypt}$2a$10$G9URrvDzboJXt4fOawOohOD2QbtnniwVD9IEmxNrbHvPtoCnPIXSq
{MD5}4xAGajgDhbSnUgAZ+5jbJOKNwAbJ3fPMwNsaIohE2Ko=
复制代码


因为当我们配置了多密码编码器,SpringSecurity加密后的密码是这个形式的,不然无法匹配。

定制化数据库

SpringSecurity的用户来源可以是内存或者数据库(SpringSecurity支持硬编码配置用户信息),但是实际中基本上我们的用户是在数据库中的,所以我们需要一种方式可以将数据库的用户加载进来,然后SpringSecurity根据这些用户进行认证匹配。SpringSecurity提供了UserDetailsUserDetailsService,前者就是用户信息,后者则是用于加载UserDetails,也就是去查询数据库返回UserDetails。

UserDetails

该实体对应的就是数据库中的用户表,包含用户名、密码、邮箱等等信息(这些都是用户表中的字段),以及一个authorities权限集合,这个用户拥有哪些权限。而数据库中会有一个角色表和中间表。

@Entity
@Getter
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean enabled; // 账户是否可用

    @ManyToMany
    @Fetch(value = FetchMode.JOIN)
    @JoinTable(name = "user_role",
            joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")}, //关联
            inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")}  //反向指Role
    )
    private Set<Role> authorities; //权限集合

    //以下三个字段都是与账户是否可用有关,如果不需要设置为true即可,一般来说只用enable就好了。
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

}
复制代码
@Entity
@Getter
public class Role implements GrantedAuthority {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "role_name")
    private String authority;
}
复制代码

UserDetailsService

UserDetailsService是用于加载用户信息,它只有一个方法,返回上面定义的UserDetails即可。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepo userRepo;

    @Override
    public User loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepo.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("未找到用户名为" + username + "的用户"));
    }
}
复制代码
@Repository
public interface UserRepo extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
复制代码

SecurityConfig

定义好了UserDetailsUserDetailsService之后,我们还需要在SecurityConfig中进行配置,告诉SpringSecurity使用该方式加载用户信息。

@Autowired
private UserDetailsServiceImpl userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
}
复制代码

认证流程

以用户名和密码登录举例:

  • 根据用户名和密码构建一个UsernamePasswordAuthenticationToken对象,它继承了Authentication
  • 然后将Authentication对象交给过滤器链去认证
  • 通过AuthenticationManager的实现类Providers去认证
  • 如果是去数据库加载数据源会通过DaoAuthenticationProvider进行认证,会调用UserDetailsService加载用户的信息
  • 如果认证失败,那么Authetication对象会设置为false,FilterSecurityInterceptor会抛出异常。

最后

本文参考内容:

Spring Security + OAuth2 精讲
多场景打造企业级认证与授权