浅谈Spring Security实现RBAC模型

浅谈Spring Security实现RBAC模型

本文将结合我所做的项目来探讨一下利用Spring Security实现RBAC模型的简要步骤以及利用Spring Security做项目时候发现与改进

🤔️什么是RBAC

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。模型中有几个关键的术语:

  • 用户:系统接口及功能访问的操作者
  • 权限:能够访问某接口或者做某操作的授权资格
  • 角色:具有一类相同操作权限的用户的总称

RBAC权限模型核心授权逻辑如下:

  • 某用户是什么角色?
  • 某角色具有什么权限?
  • 通过角色的权限推导用户的权限

对于一个用户来说,如果直接将用户与权限关联,会有以下问题:

  • 现在用户是Lucifer、Melrose,以后随着人员增加,每一个用户都需要重新授权
  • 或者Lucifer、Melrose离职,需要针对每一个用户进行多种权限的回收

如果给每个用户分配一个角色呢?

  • 一个用户有一个角色
  • 一个角色有多个操作(菜单)权限
  • 一个操作权限可以赋予多个角色

但是在实际的应用系统中,一个用户一个角色远远满足不了需求。如果我们希望一个用户既担任销售角色、又暂时担任副总角色。该怎么做呢?为了增加系统设计的适用性,我们通常设计:

  • 一个用户有一个或多个角色
  • 一个角色包含多个用户
  • 一个角色有多种权限
  • 一个权限可以赋予多个角色

所以,每个角色的权限可以对应为访问某个URL的权利,即所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面访问权限。

✏️权限表的设计

在设计表的时候,可以引入RBAC模型的思想

下面是我做的一个项目中所设计的表,项目链接:https://github.com/Lucifer2u/xm-luciferpro

权限数据库主要包含了五张表,分别是资源表、角色表、用户表、资源角色表、用户角色表,数据库关系模型如下:

p274

  • hr表是用户表,存放了用户的基本信息
  • role是角色表,name字段表示角色的英文名称,按照SpringSecurity的规范,将以ROLE_开始,nameZh字段表示角色的中文名称
  • menu表是一个资源表,该表涉及到的字段有点多,由于我的前端采用了Vue来做,因此当用户登录成功之后,系统将根据用户的角色动态加载需要的模块,所有模块的信息将保存在menu表中,menu表中的path、component、iconCls、keepAlive、requireAuth等字段都是Vue-Router中需要的字段,也就是说menu中的数据到时候会以json的形式返回给前端,再由vue动态更新router,menu中还有一个字段url,表示一个url pattern,即路径匹配规则,假设有一个路径匹配规则为/admin/**,那么当用户在客户端发起一个/admin/user的请求,将被/admin/**拦截到,系统再去查看这个规则对应的角色是哪些,然后再去查看该用户是否具备相应的角色,进而判断该请求是否合法

这样每个人都有相应的角色,然后都需要根据实际的角色权限去访问不同的菜单

🔒动态处理角色和资源的关系

要分析以上问题,我们需要回顾Spring Security的登录流程对整体流程进行了解才能更好的把握此节的内容

Spring Security的登录流程

Spring Security的登录验证流程核心就是过滤器链。

img

  • 贯穿于整个过滤器链始终有一个上下文对象SecurityContext和一个Authentication对象(登录认证的主体)
  • 一旦某一个该主体通过其中某一个过滤器的认证,Authentication对象信息被填充,比如:isAuthenticated=true表示该主体通过验证。
  • 如果该主体通过了所有的过滤器,仍然没有被认证,在整个过滤器链的最后方有一个FilterSecurityInterceptor过滤器(虽然叫Interceptor,但它是名副其实的过滤器,不是拦截器)。判断Authentication对象的认证状态,如果没有通过认证则抛出异常,通过认证则访问后端API。
  • 之后进入响应阶段,FilterSecurityInterceptor抛出的异常被ExceptionTranslationFilter对异常进行相应的处理。比如:用户名密码登录异常,会被引导到登录页重新登陆。
  • 如果是登陆成功且没有任何异常,在请求响应中最后一个过滤器SecurityContextPersistenceFilter中将SecurityContext放入session。下次再进行请求的时候,直接从SecurityContextPersistenceFilter的session中取出认证信息。从而避免多次重复认证。(如果想修改用户信息,可以从这里拿)

SpringSecurity提供了多种登录认证的方式,由多种Filter过滤器来实现,比如:

  • BasicAuthenticationFilter实现的是HttpBasic模式的登录认证
  • UsernamePasswordAuthenticationFilter实现用户名密码的登录认证
  • RememberMeAuthenticationFilter实现登录认证的“记住我”的功能
  • SocialAuthenticationFilter实现社交媒体方式登录认证的处理,如:QQ、微信
  • Oauth2AuthenticationProcessingFilterOauth2ClientAuthenticationProcessingFilter实现Oauth2的鉴权方式

根据我们不同的需求实现及配置,不同的Filter会被加载到应用中。

过滤器登录验证细节

img

构建登录认证主体

如图所示,当用户登陆的时候首先被某一种认证方式的过滤器拦截(以用户名密码登录为例)。如:UsernamePasswordAuthenticationFilter会使用用户名和密码创建一个登录认证凭证:UsernamePasswordAuthenticationToken,进而获取一个Authentication对象,该对象代表身份验证的主体,贯穿于用户认证流程始终。

image-20210808215018034

多种认证方式的管理 ProviderManager

随后使用AuthenticationManager 接口对登录认证主体进行authenticate认证。

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

ProviderManager继承于AuthenticationManager是登录验证的核心类。

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ……
    private List<AuthenticationProvider> providers;
    ……

ProviderManager保管了多个AuthenticationProvider,每一种登录认证方式都可以尝试对登录认证主体进行认证。只要有一种方式被认证成功,Authentication对象就成为被认可的主体。

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;
    boolean supports(Class<?> var1);
}

RememberMeAuthenticationProvider定义了“记住我”功能的登录验证逻辑

DaoAuthenticationProvider加载数据库用户信息,进行用户密码的登录验证

image-20210808220226118

DaoAuthenticationProvider就是我们实现登录的关键,下面详细分析

数据库加载用户信息 DaoAuthenticationProvider

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider 

从源码中发现,需要从数据库获取用户信息的时候,即我们需要加载用户信息进行登录验证的时候,我们需要实现UserDetailsService接口,重写loadUserByUsername方法,参数是用户输入的用户名。返回值是UserDetails

image-20210808220514527

SecurityContext

完成登录认证之后,将认证完成的Authentication对象(authenticate: true, 有授权列表authority list, 和username信息)放入SecurityContext上下文里面。后续的请求就直接从SecurityContextFilter中获得认证主体,从而访问资源。

结合源码讲解登录验证流程

我们就以用户名、密码登录方式为例讲解一下Spring Security的登录认证流程。

img

UsernamePasswordAuthenticationFilter

该过滤器封装用户基本信息(用户名、密码),定义登录表单数据接收相关的信息。如:

  • 默认的表单用户名密码input框name是username、password
  • 默认的处理登录请求路径是/login、使用POST方法

image-20210808222037202

image-20210808222219124

AbstractAuthenticationProcessingFilter的doFilter方法的验证过程

UsernamePasswordAuthenticationFilter继承自抽象类AbstractAuthenticationProcessingFilter,该抽象类定义了验证成功与验证失败的处理方法。

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
      implements ApplicationEventPublisherAware, MessageSourceAware 

image-20210808222657609

验证成功之后的Handler和验证失败之后的handler

AbstractAuthenticationProcessingFilter中定义了验证成功与验证失败的处理Handler。

private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

也就是说当我们需要自定义验证成功或失败的处理方法时,要去实现AuthenticationSuccessHandlerAuthenticationfailureHandler接口

image-20210808222936948

👏回到正题

我们知道如果我们不希望用户、角色、权限信息写死在配置里面。我们应该实现UserDetailsUserDetailsService接口,从而从数据库或者其他的存储上动态的加载这些信息。

UserDetails与UserDetailsService是什么呢?

UserDetailsService接口表达的是如何动态加载UserDetails数据。

  • UserDetailsService接口有一个方法叫做loadUserByUsername,我们实现动态加载用户、角色、权限信息就是通过实现该方法。函数见名知义:通过用户名加载用户。该方法的返回值就是UserDetails
  • UserDetails就是用户信息,即:用户名、密码、该用户所具有的权限。

下面我们来看一下UserDetails接口都有哪些方法。

public interface UserDetails extends Serializable {
    //获取用户的权限集合
    Collection<? extends GrantedAuthority> getAuthorities();

    //获取密码
    String getPassword();

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

    //账号是否没过期
    boolean isAccountNonExpired();

    //账号是否没被锁定
    boolean isAccountNonLocked();

    //密码是否没过期
    boolean isCredentialsNonExpired();

    //账户是否可用
    boolean isEnabled();
}

现在我们明白了,只要我们把这些信息提供给Spring Security,Spring Security就知道怎么做登录验证了,根本不需要我们自己写Controller实现登录验证逻辑。那我们怎么把这些信息提供给Spring Security,用的就是下面的接口方法。

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

现在我们明白了,只要我们把这些信息提供给Spring Security,Spring Security就知道怎么做登录验证了,根本不需要我们自己写Controller实现登录验证逻辑。那我们怎么把这些信息提供给Spring Security,用的就是下面的接口方法。

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

实现UserDetails 接口

public class Hr implements UserDetails {

  String password;  //密码
  String username;  //用户名
  boolean accountNonExpired;   //是否没过期
  boolean accountNonLocked;   //是否没被锁定
  boolean credentialsNonExpired;  //密码是否没过期
  boolean enabled;  //账号是否可用
  Collection<? extends GrantedAuthority> authorities;  //用户的权限集合


  public void setPassword(String password) {
    this.password = password;
  }

  public void setUsername(String username) {
    this.username = username;
  }

  public void setAccountNonExpired(boolean accountNonExpired) {
    this.accountNonExpired = accountNonExpired;
  }

  public void setAccountNonLocked(boolean accountNonLocked) {
    this.accountNonLocked = accountNonLocked;
  }

  public void setCredentialsNonExpired(boolean credentialsNonExpired) {
    this.credentialsNonExpired = credentialsNonExpired;
  }

  public void setEnabled(boolean enabled) {
    this.enabled = enabled;
  }

  public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
    this.authorities = authorities;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public String getUsername() {
    return username;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;   //暂时未用到,直接返回true,表示账户未过期
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;   //暂时未用到,直接返回true,表示账户未被锁定
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;   //暂时未用到,直接返回true,表示账户密码未过期
  }

  @Override
  public boolean isEnabled() {
    return enabled;
  }
}

我们就是写了一个适应于UserDetails的Bean类,所谓的 UserDetails接口实现就是一些get方法。

  • get方法由Spring Security调用,获取认证及鉴权的数据
  • 我们通过set方法或构造函数为 Spring Security 提供UserDetails数据(从数据库查询)。
  • 当enabled的值为false的时候,Spring Security 会自动的禁用该用户,禁止该用户进行系统登录。
  • 通常数据库表sys_user字段要和Hr 属性一一对应,比如username、password、enabled。

目前数据库表里面没有定义accountNonExpired、accountNonLocked、credentialsNonExpired这三个字段,我一般不喜欢搞这么多字段控制用户的登录认证行为,笔者觉得简单点好,一个enabled字段就够了。所以这三个成员变量对应的get方法,直接返回true即可。

另外,UserDetails中还有一个方法叫做getAuthorities,该方法用来获取当前用户所具有的角色,我的角色中有一个roles属性(即role表)用来描述当前用户的角色,因此我的getAuthorities方法的实现如下:

public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>();
    for (Role role : roles) {
        authorities.add(new SimpleGrantedAuthority(role.getName()));
    }
    return authorities;
}

即直接从roles中获取当前用户所具有的角色,构造SimpleGrantedAuthority然后返回即可

SimpleGrantedAuthority简单来说就是存储授予Authentication对象的权限的字符串表示形式。

SimpleGrantedAuthority

在Security中,角色和权限共用GrantedAuthority接口,唯一的不同角色就是多了个前缀”ROLE_”,而且它没有Shiro的那种从属关系,即一个角色包含哪些权限等等。在Security看来角色和权限时一样的,它认证的时候,把所有权限(角色、权限)都取出来,而不是分开验证。

所以,在Security提供的UserDetailsService默认实现JdbcDaoImpl中,角色和权限都存储在auhtorities表中。而不是像Shiro那样,角色有个roles表,权限有个permissions表。以及相关的管理表等等。

public final class SimpleGrantedAuthority implements GrantedAuthority {

   private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

   private final String role;

   public SimpleGrantedAuthority(String role) {
      Assert.hasText(role, "A granted authority textual representation is required");
      this.role = role;
   }

   @Override
   public String getAuthority() {
      return this.role;
   }

   @Override
   public boolean equals(Object obj) {
      if (this == obj) {
         return true;
      }
      if (obj instanceof SimpleGrantedAuthority) {
         return this.role.equals(((SimpleGrantedAuthority) obj).role);
      }
      return false;
   }

   @Override
   public int hashCode() {
      return this.role.hashCode();
   }

   @Override
   public String toString() {
      return this.role;
   }

}

注意,在构建SimpleGrantedAuthority对象的时候,它没有添加任何前缀。所以表示”角色”的权限,在数据库中就带有”ROLE_”前缀了。所以authorities表中的视图可能是这样的。

image-20210809220537827

角色和权限能否分开存储?角色能不能不带”ROLE_”前缀

当然可以分开存储,你可以定义两张表,一张存角色,一张存权限。但是你自定义UserDetailsService的时候,需要保证把这两张表的数据都取出来,放到UserDails的权限集合中。当然你数据库中存储的角色也可以不带”ROLE_”前缀,就像这样。

image-20210809220551920

但是前面说到了,Security才不管你是角色,还是权限。它只比对字符串。

比如它有个表达式hasRole(“ADMIN”)。那它实际上查询的是用户权限集合中是否存在字符串”ROLE_ADMIN”。如果你从角色表中取出用户所拥有的角色时不加上”ROLE_”前缀,那验证的时候就匹配不上了。

所以角色信息存储的时候可以没有”ROLE_”前缀,但是包装成GrantedAuthority对象的时候必须要有。

自定义Service

创建好Hr之后,接下来我们需要创建HrService,用来执行登录等操作,**HrService需要实现UserDetailsService接口,如下:**

@Service
public class HrService implements UserDetailsService {

    @Autowired
    HrMapper hrMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Hr hr = hrMapper.loadUserByUsername(username);
        if (hr == null){
            throw new UsernameNotFoundException("用户名不存在!");
        }

        hr.setRoles(hrMapper.getHrRolesById(hr.getId()));

        return hr;
    }

这里最主要是实现了**UserDetailsService接口中的loadUserByUsername方法**,在执行登录的过程中,这个方法将根据用户名去查找用户,如果用户不存在,则抛出UsernameNotFoundException异常,否则直接将查到的Hr返回。HrMapper用来执行数据库的查询操作。

🔑根据请求地址获取角色

先引入一下SecurityMetadataSource的概念:

SecurityMetadataSource

SecurityMetadataSourceSpring Security的一个概念模型接口。用于表示对受权限保护的”安全对象”的权限设置信息。一个该类对象可以被理解成一个映射表,映射表中的每一项包含如下信息 :

  • 安全对象
  • 安全对象所需权限信息

围绕该映射表,SecurityMetadataSource 定义了如下方法 :

  • Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException;

获取某个受保护的安全对象object的所需要的权限信息,是一组ConfigAttribute对象的集合,如果该安全对象object不被当前SecurityMetadataSource对象支持,则抛出异常IllegalArgumentException
该方法通常配合boolean supports(Class<?> clazz)一起使用,先使用boolean supports(Class<?> clazz)确保安全对象能被当前SecurityMetadataSource支持,然后再调用该方法。

  • Collection<ConfigAttribute> getAllConfigAttributes()

获取该SecurityMetadataSource对象中保存的针对所有安全对象的权限信息的集合。该方法的主要目的是被AbstractSecurityInterceptor用于启动时校验每个ConfigAttribute对象。

  • boolean supports(Class<?> clazz)

这里clazz表示安全对象的类型,该方法用于告知调用者当前SecurityMetadataSource是否支持此类安全对象,只有支持的时候,才能对这类安全对象调用getAttributes方法。

继承关系

SecurityMetadataSource
Spring SecuritySecurityMetadataSource提供了两个子接口 :

  • ```
    MethodSecurityMetadataSource

    
      > 由`Spring Security Core`定义,用于表示安全对象是方法调用(`MethodInvocation`)的安全元数据源。
    
    - ```
      FilterInvocationSecurityMetadataSource

    Spring Security Web定义,用于表示安全对象是Web请求(FilterInvocation)的安全元数据源。

FilterInvocationSecurityMetadataSource

一般情况下,我们如果需要自定义权限拦截,则需要涉及到FilterInvocationSecurityMetadataSource这个接口了。

这里有个坑爹的地方。如果用户未登录,但是已经设置了拦截白名单的URL,仍然会进入到权限验证里面来。起初,我以为不会进来,但后来跟踪源代码发现,还是会进来。只是此时的身份是一个匿名用户。其默认的实现为DefaultFilterInvocationSecurityMetadataSource

spring security的认证和权限流程,大概就是有多个过滤器,一步步调用filter chain。它的身份认证其实是始于访问资源开始。如果一个用户已登录,那么访问受保护的资源,则会校验该用户是否有权限访问。如果没有权限,则会调用权限拒绝的处理器进行处理。如果有权限,则能顺利访问该资源;

一个用户未登录情况下,也即匿名用户,访问受保护的资源时,spring security会首先检查该资源是否需要权限,如果需要权限,然后再检查,该资源是否是白名单里面。如果是白名单,也能正常访问。如果是受保护的资源,则会提示该用户需要登录。

也即,当一个匿名用户,访问受保护的资源时,就会提示该用户需要登录。

所以说,在FilterInvocationSecurityMetadataSource中默认的实现类DefaultFilterInvocationSecurityMetadataSource的主要功能就是通过当前的请求地址,获取该地址需要的用户角色

我们可以参考这个实现类,自己也定义一个FilterInvocationSecurityMetadataSource,如下

主要工作为:

  • 从数据源中加载ConfigAttributeSecurityMetadataSource资源器中
  • 重写getAttributes()加载ConfigAttributeAccessDecisionManager.decide()授权决策做准备。
@Component
public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    MenuService menuService;
    //路径匹配
    AntPathMatcher antPathMatcher = new AntPathMatcher();
    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        //获取请求的地址
        String requestUrl = ((FilterInvocation) object).getRequestUrl();
        List<Menu> menus = menuService.getAllMenusWithRole();
        for (Menu menu : menus) {
            if (antPathMatcher.match(menu.getUrl(), requestUrl)) {
                List<Role> roles = menu.getRoles();

                String[] str = new String[roles.size()];
                for (int i = 0; i < roles.size(); i++) {
                    str[i] = roles.get(i).getName();
                }
                return SecurityConfig.createList(str);
            }
        }
        //没有匹配上的资源,都是登录访问
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return false;
    }
}
  • 一开始注入了MenuServiceMenuService的作用是用来查询数据库中url pattern和role的对应关系( menuService.getAllMenu()后面可用缓存来存),查询结果是一个List集合,集合中是Menu类,Menu类有两个核心属性,一个是url pattern,即匹配规则(比如/admin/**),还有一个是List,即这种规则的路径需要哪些角色才能访问。
  • 我们可以从getAttributes(Object o)方法的参数o中提取出当前的请求url,然后将这个请求url和数据库中查询出来的所有url pattern一一对照,看符合哪一个url pattern,然后就获取到该url pattern所对应的角色,当然这个角色可能有多个,所以遍历角色,最后利用SecurityConfig.createList方法来创建一个角色集合。
  • 第二步的操作中,涉及到一个优先级问题,比如我的地址是/employee/basic/hello,这个地址既能被/employee/**匹配,也能被/employee/basic/**匹配,这就要求我们从数据库查询的时候对数据进行排序,将/employee/basic/**类型的url pattern放在集合的前面去比较。
  • 如果getAttributes(Object o)方法返回null的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录。但是在我的整个业务中,并不存在这样的请求,我这里的要求是,所有未匹配到的路径,都是认证(登录)后可访问,因此我在这里返回一个ROLE_LOGIN的角色,这种角色在我的角色数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色
  • 如果地址是/login_p,这个是登录页,不需要任何角色即可访问,直接返回null。
  • getAttributes(Object o)方法返回的集合最终会来到AccessDecisionManager类中,接下来我们再来看AccessDecisionManager

🔑 检查角色是否满足匹配

先引入一下AccessDecisionManager的概念:

AccessDecisionManager

img

AccessDecisionManager 顾名思义,访问决策管理器。即做出最终的访问控制(授权)决定。

而常用的 AccessDecisionManager 有三个,这里我就使用最简单的一个AffirmativeBased中的思想,这是Spring Security 框架默认的 AccessDecisionManager只要任一 AccessDecisionVoter 返回肯定的结果,便授予访问权限

自定义CustomUrlDecisionManager类实现AccessDecisionManager接口,如下:

@Component
public class CustomUrlDecisionManager implements AccessDecisionManager {

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        for (ConfigAttribute configAttribute : configAttributes) {
            //当前请求需要的权限
            String needRole = configAttribute.getAttribute();
            //如果是匿名用户,抛异常
            if ("ROLE_LOGIN".equals(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken){
                    throw new AccessDeniedException("尚未登录,请登录!");
                } else {
                    return;
                }
            }
            //当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                //需要的角色能被检测到
                if (authority.getAuthority().equals(needRole)){
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足,请返回!");
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return false;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return false;
    }
}
  • decide方法接收三个参数,其中第一个参数中保存了当前登录用户的角色信息,第三个参数则是CustomFilterInvocationSecurityMetadataSource中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个)
  • 如果当前请求需要的权限为ROLE_LOGIN则表示登录即可访问,和角色没有关系,此时我需要判断authentication是不是AnonymousAuthenticationToken的一个实例,如果是,则表示当前用户没有登录,没有登录就抛一个BadCredentialsException异常,登录了就直接返回,则这个请求将被成功执行。
  • 遍历collection,同时查看当前用户的角色列表中是否具备需要的权限,如果具备就直接返回,否则就抛异常。
  • 这里涉及到一个all和any的问题:假设当前用户具备角色A、角色B,当前请求需要角色B、角色C,那么是要当前用户要包含所有请求角色才算授权成功还是只要包含一个就算授权成功?我这里采用了第二种方案,即只要包含一个即可

到目前为止,获取角色和判断角色是否满足匹配到部分已经分析结束,下面开始分析登录的验证和自定义登录过滤器的具体流程

🔍登录的验证

下面就要针对于登录进行更深一步的说明,登录验证涉及到账号、密码、与验证码

PasswordEncoder

PasswordEncoder 是Spring Scurity框架内处理密码加密与校验的接口。

package org.springframework.security.crypto.password;

public interface PasswordEncoder {
   String encode(CharSequence rawPassword);

   boolean matches(CharSequence rawPassword, String encodedPassword);

   default boolean upgradeEncoding(String encodedPassword) {
      return false;
   }
}

这个接口有三个方法

  • encode方法接受的参数是原始密码字符串,返回值是经过加密之后的hash值,hash值是不能被逆向解密的。这个方法通常在为系统添加用户,或者用户注册的时候使用。
  • matches方法是用来校验用户输入密码rawPassword,和加密后的hash值encodedPassword是否匹配。如果能够匹配返回true,表示用户输入的密码rawPassword是正确的,反之返回fasle。也就是说虽然这个hash值不能被逆向解密,但是可以判断是否和原始密码匹配。这个方法通常在用户登录的时候进行用户输入密码的正确性校验。
  • upgradeEncoding设计的用意是,判断当前的密码是否需要升级。也就是是否需要重新加密?需要的话返回true,不需要的话返回fasle。默认实现是返回false。

BCryptPasswordEncoder 作为Spring Security推荐使用的 PasswordEncoder实现类 ,可以实现对密码的自动加密加盐,(盐是值即使相同的明文,生成的新的加密字符串都是不一样的),这样可以避免像在Shiro中那样我们自己配置密码的盐,而 BCryptPasswordEncoder 就是 PasswordEncoder 接口的实现类,只需要提供 BCryptPasswordEncoder 这个 Bean 的实例即可,SpringSecurity中使用BCryptPasswordEncoder的具体流程如下:

BCryptPasswordEncoder 使用 BCrypt 强哈希函数,开发者在使用时可以选择提供 strength 和 SecureRandom 实例。strength 越大,密钥的迭代次数越多,密钥迭代次数为 2^strength。strength 取值在 4~31 之间,默认为 10。

在用户注册时,我们需要对密码进行处理,处理方式如下:

public int hrReg(String username, String password) {
    //如果用户名存在,返回错误
    if (hrMapper.loadUserByUsername(username) != null) {
        return -1;
    }
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    String encode = encoder.encode(password);
    return hrMapper.hrReg(username, encode);
}

用户将密码从前端传来之后,通过调用 BCryptPasswordEncoder 实例中的 encode 方法对密码进行加密处理,加密完成后将密文存入数据库。

$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm

BCrypt加密后的密码有三个部分,由 $分隔:

  1. “2a”表示 BCrypt 算法版本
  2. “10”表示算法的强度
  3. “zt6dUMTjNSyzINTGyiAglu”部分实际上是随机生成的盐。通常来说前 22 个字符是盐,剩余部分是纯文本的实际哈希值。

BCrypt算法生成长度为 60 的字符串,因此我们需要确保密码将存储在可以容纳密码的数据库列中。

当用户注册成功之后,存在数据库中的密码就像下面这样:

p283

假如明文都是 123。配置完成后,使用 admin/123 或者 sang/123 就可以实现登录。

密码加密处理之后,登录时候也要对密码进行处理,修改SecurityConfig类的configure(AuthenticationManagerBuilder auth)方法,改为下面这样即可:

@Bean
PasswordEncoder passwordEncoder(){
  return new BCryptPasswordEncoder();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(hrService);
}

验证码的校验

验证码的生成接口十分多,这里不再提供,这个工具类很常见,网上也有很多,就是画一个简单的验证码,通过流将验证码写到前端页面,提供验证码的 Controller 如下:

@GetMapping("/verifyCode")
public void verifyCode(HttpServletRequest request, HttpServletResponse resp) throws IOException {
  	//获取实例
    VerificationCode code = new VerificationCode();
    BufferedImage image = code.getImage();
    String text = code.getText();
    HttpSession session = request.getSession(true);
    session.setAttribute("verify_code", text);
    VerificationCode.output(image,resp.getOutputStream());
}

同时需要在Spring Security配置类中配置不拦截此接口

@Override
public void configure(WebSecurity web) throws Exception {
    //防止访问登录页面死循环,不用进入Security拦截
    web.ignoring().antMatchers("/css/**", "/js/**", "/index.html", "/img/**", "/fonts/**", "/favicon.ico", "/verifyCode");
}

因为涉及到自定义登陆逻辑,所以需要自定义登陆过滤器,这里先不说明实现过滤器的详细流程,等到下面讲到JSON登陆的时候,统一处理

需要注意的是,过滤器自定义完成后,也需要在配置类中注入,这里才能能完成整个流程的配置。

实现JSON格式登陆

前后端分离这样的开发架构下,前后端的交互都是通过 JSON 来进行,无论登录成功还是失败,都不会有什么服务端跳转或者客户端跳转之类。

登录成功了,服务端就返回一段登录成功的提示 JSON 给前端,前端收到之后,该跳转该展示,由前端自己决定,就和后端没有关系了。

登录失败了,服务端就返回一段登录失败的提示 JSON 给前端,前端收到之后,该跳转该展示,由前端自己决定,也和后端没有关系了。

所以为了统一,我们需要在登录的时候,也实现JSON交互,但是我们首先得明白一个前提, 在使用 SpringSecurity 中,默认的登录数据是通过 key/value 的形式来传递的,默认情况下不支持 JSON格式的登录数据,如果有这种需求,就需要自己来解决,所以我们需要自定义过滤器

首先大家知道,用户登录的用户名/密码是在 UsernamePasswordAuthenticationFilter 类中处理的,具体的处理代码如下:

public Authentication attemptAuthentication(HttpServletRequest request,
		HttpServletResponse response) throws AuthenticationException {
	String username = obtainUsername(request);
	String password = obtainPassword(request);
    //省略
}
protected String obtainPassword(HttpServletRequest request) {
	return request.getParameter(passwordParameter);
}
protected String obtainUsername(HttpServletRequest request) {
	return request.getParameter(usernameParameter);
}

从这段代码中,我们就可以看出来为什么 Spring Security 默认是通过 key/value 的形式来传递登录参数,因为它处理的方式就是 request.getParameter

所以我们要定义成 JSON 的,思路很简单,就是自定义来定义一个过滤器代替 UsernamePasswordAuthenticationFilter,然后在获取参数的时候,换一种方式就行了。

所以我们需要模仿源代码中的此部分来个性化定制:

image-20210809163840081

这里有一个额外的点需要注意,就是现在还有验证码的功能,所以如果自定义过滤器,要连同验证码一起处理掉。

接下来我们来自定义一个过滤器代替 UsernamePasswordAuthenticationFilter ,如下:

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
  	//需要重写的方法
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
      	//此部分逻辑不变
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
      	//拿到正确的验证码
        String verify_code = (String) request.getSession().getAttribute("verify_code");
      //判断是Key/Value还是JSON传递
        if (request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().contains(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
          	//封装传来的数据
            Map<String, String> loginData = new HashMap<>();
            try {
                loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            } catch (IOException e) {
            }finally {
              	//浏览器传过来输入的
                String code = loginData.get("code");
                checkCode(response, code, verify_code);
            }
            String username = loginData.get(getUsernameParameter());
            String password = loginData.get(getPasswordParameter());
            if (username == null) {
                username = "";
            }
            if (password == null) {
                password = "";
            }
            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    username, password);
            setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        } 
      //Key/Value直接用父类处理即可
      else {
            checkCode(response, request.getParameter("code"), verify_code);
            return super.attemptAuthentication(request, response);
        }
    }	
		//校验输入的和正确生成的是否匹配
    public void checkCode(HttpServletResponse resp, String code, String verify_code) {
        if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) {
            //验证码不正确
            throw new AuthenticationServiceException("验证码不正确");
        }
    }
}

这段逻辑我们基本上是模仿官方提供的 UsernamePasswordAuthenticationFilter 来写的,稍微解释下:

  1. 首先登录请求肯定是 POST,如果不是 POST ,直接抛出异常,后面的也不处理了。
  2. 因为要在这里处理验证码,所以第二步从 session 中把已经下发过的验证码的值拿出来。
  3. 接下来通过 contentType 来判断当前请求是否通过 JSON 来传递参数,如果是通过 JSON 传递参数,则按照 JSON 的方式解析,如果不是,则调用 super.attemptAuthentication 方法,进入父类的处理逻辑中,也就是说,我们自定义的这个类,既支持 JSON 形式传递参数,也支持 key/value 形式传递参数。
  4. 如果是 JSON 形式的数据,我们就通过读取 request 中的 I/O 流,将 JSON 映射到一个 Map 上。
  5. 从 Map 中取出 code,先去判断验证码是否正确,如果验证码有错,则直接抛出异常
  6. 接下来从 Map 中取出 username 和 password,构造 UsernamePasswordAuthenticationToken 对象并作校验。

接下来就是在Spring Security配置类中配置此过滤器即可

Spring Security配置类

基础注入

结合以上所讲的,就能在配置类中整合所有需要用到的校验类,首先在Spring Security配置类中配置基础的信息

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
		//UserDetial的校验
    @Autowired
    HrService hrService;
  
		//根据请求地址获取角色的校验
    @Autowired
    CustomFilterInvocationSecurityMetadataSource customFilterInvocationSecurityMetadataSource;
		//检查角色的校验
    @Autowired
    CustomUrlDecisionManager customUrlDecisionManager;
		//密码的校验
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
		//密码加密处理后配置
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(hrService);
    }

登陆成功失败的回调

接下来就需要针对刚定义好的过滤器进一步配置

@Bean
LoginFilter loginFilter() throws Exception {
    LoginFilter loginFilter = new LoginFilter();
  	//成功回调
    loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                Hr hr = (Hr) authentication.getPrincipal();
                hr.setPassword(null);
                RespBean ok = RespBean.ok("登录成功!", hr);
                String s = new ObjectMapper().writeValueAsString(ok);
                out.write(s);
                out.flush();
                out.close();
            }
    );
  //失败回调
    loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                RespBean respBean = RespBean.error(exception.getMessage());
                if (exception instanceof LockedException) {
                    respBean.setMsg("账户被锁定,请联系管理员!");
                } else if (exception instanceof CredentialsExpiredException) {
                    respBean.setMsg("密码过期,请联系管理员!");
                } else if (exception instanceof AccountExpiredException) {
                    respBean.setMsg("账户过期,请联系管理员!");
                } else if (exception instanceof DisabledException) {
                    respBean.setMsg("账户被禁用,请联系管理员!");
                } else if (exception instanceof BadCredentialsException) {
                    respBean.setMsg("用户名或者密码输入错误,请重新输入!");
                }
                out.write(new ObjectMapper().writeValueAsString(respBean));
                out.flush();
                out.close();
            }
    );

AuthenticationSuccessHandler方法有三个参数,分别是:

  • HttpServletRequest
  • HttpServletResponse
  • Authentication

有了前两个参数,我们就可以在这里随心所欲的返回数据了。利用 HttpServletRequest 我们可以做服务端跳转,利用 HttpServletResponse 我们可以做客户端跳转,当然,也可以返回 JSON 数据。

第三个 Authentication 参数则保存了我们刚刚登录成功的用户信息。

同理,失败的回调也是三个参数,前两个就不用说了,第三个是一个 Exception,对于登录失败,会有不同的原因,Exception 中则保存了登录失败的原因,我们可以将之通过 JSON 返回到前端。

异常捕获

这里我还挨个去识别了一下异常的类型,根据不同的异常类型,我们可以给用户一个更加明确的提示,但是有一个需要注意的点:

当用户登录时,用户名或者密码输入错误,我们一般只给一个模糊的提示,即用户名或者密码输入错误,请重新输入,而不会给一个明确的诸如“用户名输入错误”或“密码输入错误”这样精确的提示,但是对于很多不懂行的新手小伙伴,他可能就会给一个明确的错误提示,这会给系统带来风险。

但是使用了 Spring Security 这样的安全管理框架之后,即使你是一个新手,也不会犯这样的错误。

在 Spring Security 中,用户名查找失败对应的异常是:

  • UsernameNotFoundException

密码匹配失败对应的异常是:

  • BadCredentialsException

但是我们在登录失败的回调中,却总是看不到 UsernameNotFoundException 异常,无论用户名还是密码输入错误,抛出的异常都是 BadCredentialsException

public Authentication authenticate(Authentication authentication)
		throws AuthenticationException {
	try {
		user = retrieveUser(username,
				(UsernamePasswordAuthenticationToken) authentication);
	}
	catch (UsernameNotFoundException notFound) {
		logger.debug("User '" + username + "' not found");
		if (hideUserNotFoundExceptions) {
			throw new BadCredentialsException(messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.badCredentials",
					"Bad credentials"));
		}
		else {
			throw notFound;
		}
	}
}

从这段代码中,我们看出,在查找用户时,如果抛出了 UsernameNotFoundException,这个异常会被捕获,捕获之后,如果 hideUserNotFoundExceptions 属性的值为 true,就抛出一个 BadCredentialsException。相当于将 UsernameNotFoundException 异常隐藏了,而默认情况下,hideUserNotFoundExceptions 的值就为 true。

登陆未验证

在前后端分离中,如果用户没有登录就访问一个需要认证后才能访问的页面,这个时候,我们不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,前端收到提示之后,再自行决定页面跳转。

要解决这个问题,就涉及到 Spring Security 中的一个接口 AuthenticationEntryPoint ,该接口有一个实现类:LoginUrlAuthenticationEntryPoint ,该类中有一个方法 commence,如下:

/**
 * Performs the redirect (or forward) to the login form URL.
 */
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) {
	String redirectUrl = null;
	if (useForward) {
		if (forceHttps && "http".equals(request.getScheme())) {
			redirectUrl = buildHttpsRedirectUrlForRequest(request);
		}
		if (redirectUrl == null) {
			String loginForm = determineUrlToUseForThisRequest(request, response,
					authException);
			if (logger.isDebugEnabled()) {
				logger.debug("Server side forward to: " + loginForm);
			}
			RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
			dispatcher.forward(request, response);
			return;
		}
	}
	else {
		redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
	}
	redirectStrategy.sendRedirect(request, response, redirectUrl);
}

首先我们从这个方法的注释中就可以看出,这个方法是用来决定到底是要重定向还是要 forward,通过 Debug 追踪,我们发现默认情况下 useForward 的值为 false,所以请求走进了重定向。

那么我们解决问题的思路很简单,直接重写这个方法,在方法中返回 JSON 即可,不再做重定向操作,具体配置如下:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
				.csrf().disable().exceptionHandling()
				//没有认证时,在这里处理结果,不要重定向
				.authenticationEntryPoint((req, resp, authException) -> {
            resp.setContentType("application/json;charset=utf-8");
            resp.setStatus(401);
            PrintWriter out = resp.getWriter();
            RespBean respBean = RespBean.error("访问失败!");
            if (authException instanceof InsufficientAuthenticationException) {
                respBean.setMsg("请求失败,请联系管理员!");
            }
            out.write(new ObjectMapper().writeValueAsString(respBean));
            out.flush();
            out.close();
        }
);

在 Spring Security 的配置中加上自定义的 AuthenticationEntryPoint 处理方法,该方法中直接返回相应的 JSON 提示即可。这样,如果用户再去直接访问一个需要认证之后才可以访问的请求,就不会发生重定向操作了,服务端会直接给浏览器一个 JSON 提示,浏览器收到 JSON 之后,该干嘛干嘛。

注销登录

注销登录我们前面说过,按照前面的配置,注销登录之后,系统自动跳转到登录页面,这也是不合适的,如果是前后端分离项目,注销登录成功后返回 JSON 即可,配置如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
        .logout()
        .logoutSuccessHandler((req, resp, authentication) -> {
         resp.setContentType("application/json;charset=utf-8");
         PrintWriter out = resp.getWriter();
         out.write(new ObjectMapper().writeValueAsString(RespBean.ok("注销成功!")));
         out.flush();
         out.close();
         }
         )
         .permitAll()
}

LoginFilter的配置

在自定义JSON过滤器后,LoginFilter也需要相应的配置到安全配置类中

@Bean
LoginFilter loginFilter() throws Exception {
	...
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/doLogin");
}

当我们代替了 UsernamePasswordAuthenticationFilter 之后,原本在 SecurityConfig#configure方法中关于 form 表单的配置就会失效,那些失效的属性,都可以在配置 LoginFilter 实例的时候配置。另外记得配置一个 AuthenticationManager,根据 WebSecurityConfigurerAdapter 中提供的配置即可。

image-20210809192541708

FilterProcessUrl 则可以根据实际情况配置,如果不配置,默认的就是 /login

image-20210809192208902

最后,我们用自定义的 LoginFilter 实例代替 UsernamePasswordAuthenticationFilter,调用 addFilterAt 方法完成替换操作

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        ...
        //省略
    http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}

上面基本就已经实现了基于RBAC的登陆流程,这里附上整体的配置类代码:

**
 * @author lucifer
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    HrService hrService;

    @Autowired
    CustomFilterInvocationSecurityMetadataSource customFilterInvocationSecurityMetadataSource;

    @Autowired
    CustomUrlDecisionManager customUrlDecisionManager;

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(hrService);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //防止访问登录页面死循环,不用进入Security拦截
        web.ignoring().antMatchers("/css/**", "/js/**", "/index.html", "/img/**", "/fonts/**", "/favicon.ico", "/verifyCode");
    }

    @Bean
    LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    Hr hr = (Hr) authentication.getPrincipal();
                    hr.setPassword(null);
                    RespBean ok = RespBean.ok("登录成功!", hr);
                    String s = new ObjectMapper().writeValueAsString(ok);
                    out.write(s);
                    out.flush();
                    out.close();
                }
        );
        loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
                    response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    RespBean respBean = RespBean.error(exception.getMessage());
                    if (exception instanceof LockedException) {
                        respBean.setMsg("账户被锁定,请联系管理员!");
                    } else if (exception instanceof CredentialsExpiredException) {
                        respBean.setMsg("密码过期,请联系管理员!");
                    } else if (exception instanceof AccountExpiredException) {
                        respBean.setMsg("账户过期,请联系管理员!");
                    } else if (exception instanceof DisabledException) {
                        respBean.setMsg("账户被禁用,请联系管理员!");
                    } else if (exception instanceof BadCredentialsException) {
                        respBean.setMsg("用户名或者密码输入错误,请重新输入!");
                    }
                    out.write(new ObjectMapper().writeValueAsString(respBean));
                    out.flush();
                    out.close();
                }
        );
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        loginFilter.setFilterProcessesUrl("/doLogin");
        return loginFilter;
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setAccessDecisionManager(customUrlDecisionManager);
                        object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource);
                        return object;
                    }
                })
                .and()
                .logout()
                .logoutSuccessHandler((req, resp, authentication) -> {
                            resp.setContentType("application/json;charset=utf-8");
                            PrintWriter out = resp.getWriter();
                            out.write(new ObjectMapper().writeValueAsString(RespBean.ok("注销成功!")));
                            out.flush();
                            out.close();
                        }
                )
                .permitAll()
                .and()
                .csrf().disable().exceptionHandling()
                //没有认证时,在这里处理结果,不要重定向
                .authenticationEntryPoint((req, resp, authException) -> {
                            resp.setContentType("application/json;charset=utf-8");
                            resp.setStatus(401);
                            PrintWriter out = resp.getWriter();
                            RespBean respBean = RespBean.error("访问失败!");
                            if (authException instanceof InsufficientAuthenticationException) {
                                respBean.setMsg("请求失败,请联系管理员!");
                            }
                            out.write(new ObjectMapper().writeValueAsString(respBean));
                            out.flush();
                            out.close();
                        }
                );
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

http.authorizeRequests()

可以发现在配置中的开头有这样的配置,这个是干什么的?

这就涉及到 Spring Security 中过滤器链的配置问题了

结合动态处理角色和资源的关系一起看

从过滤器开始

Spring Security 中一共提供了 32 个过滤器,其中默认使用的有 15 个,

在一个 Web 项目中,请求流程大概如下图所示:

image-20210809220745044

请求从客户端发起(例如浏览器),然后穿过层层 Filter,最终来到 Servlet 上,被 Servlet 所处理。

那么,Spring Security 中默认的 15 个过滤器就是这样嵌套在 Client 和 Servlet 之间吗?

不是的!

上图中的 Filter 我们可以称之为 Web Filter,Spring Security 中的 Filter 我们可以称之为 Security Filter,它们之间的关系如下图:

image-20210809220801972

可以看到,Spring Security Filter 并不是直接嵌入到 Web Filter 中的,而是通过 FilterChainProxy 来统一管理 Spring Security Filter,FilterChainProxy 本身则通过 Spring 提供的 DelegatingFilterProxy 代理过滤器嵌入到 Web Filter 之中。

DelegatingFilterProxy 很多小伙伴应该比较熟悉,在 Spring 中手工整合 Spring Session、Shiro 等工具时都离不开它,现在用了 Spring Boot,很多事情 Spring Boot 帮我们做了,所以有时候会感觉 DelegatingFilterProxy 的存在感有所降低,实际上它一直都在。

多个过滤器链

上面和大家介绍的是单个过滤器链,实际上,在 Spring Security 中,可能存在多个过滤器链。

有人会问,下面这种配置是不是就是多个过滤器链?

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/admin/**").hasRole("admin")
            .antMatchers("/user/**").hasRole("user")
            .anyRequest().authenticated()
            ...
            .csrf().disable();
}

这样的配置相信大家都见过,但是这并不是多个过滤器链,这是一个过滤器链。因为不管是 /admin/** 还是 /user/** ,走过的过滤器都是一样的,只是不同的路径判断条件不一样而已。

如果系统存在多个过滤器链,多个过滤器链会在 FilterChainProxy 中进行划分,如下图:

image-20210809220817060

可以看到,当请求到达 FilterChainProxy 之后,FilterChainProxy 会根据请求的路径,将请求转发到不同的 Spring Security Filters 上面去,不同的 Spring Security Filters 对应了不同的过滤器,也就是不同的请求将经过不同的过滤器。

回到问题

最后,我们在回到一开始的问题。

首先,http.authorizeRequests() 配置并非总在第一行出现,如果只有一个过滤器链,他总是在第一行出现,表示该过滤器链的拦截规则是 /**请求只有先被过滤器链拦截下来,接下来才会进入到不同的 Security Filters 中进行处理),如果存在多个过滤器链,就不一定了。

    @Configuration
    @Order(1)
    static class DefaultWebSecurityConfig extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.antMatcher("/foo/**")
                    .authorizeRequests()
                    .anyRequest().hasRole("admin")
                    .and()
                    .csrf().disable();
        }
    }

    @Configuration
    @Order(2)
    static class DefaultWebSecurityConfig2 extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.antMatcher("/bar/**")
                    .authorizeRequests()
                    .anyRequest().hasRole("user")
                    .and()
                    .formLogin()
                    .permitAll()
                    .and()
                    .csrf().disable();
        }
    }
}
  1. 注意在静态内部类里边,我没有使用 http.authorizeRequests() 开始,http.authorizeRequests() 配置表示该过滤器链过滤的路径是 /**。在静态内部类里边,我是用了 http.antMatcher("/bar/**") 开启配置,表示将当前过滤器链的拦截范围限定在 /bar/**
  2. 当存在多个过滤器链的时候,必然会有一个优先级的问题,所以每一个过滤器链的配置类上通过 @Order(2) 注解来标记优先级。

仅仅从字面意思来理解,authorizeRequests() 方法的返回值是 ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry,ExpressionUrlAuthorizationConfigurer 可以为多组不同的 RequestMatcher 配置不同的权限规则,就是大家看到的 .antMatchers("/admin/**").hasRole("admin").antMatchers("/user/**").hasRole("user")

对于配置中的一些细节(为什么要用这些来实现),将在下面用源码来进行分析,感兴趣的可以了解下

🔧加餐:源码剖析Spring Security

本文基本上参考搬运了松哥的文章,松哥对Spring Security理解的十分透彻,推荐去学习,附上网站:http://www.javaboy.org

本部分主要针对HttpSecurity、SecurityConfigurer、AuthenticationManagerBuilder、WebSecurityConfigurerAdapter四部分进行源码分析,其他关键的类已在前面的部分做了必要的阐述

👀HttpSecurity

此节建议结合登陆的验证中的配置类来看

HttpSecurity 也是 Spring Security 中的重要一环。我们平时所做的大部分 Spring Security 配置也都是基于 HttpSecurity 来配置的。比如,刚才我们配置类中的configure就使用到了

@Override
    protected void configure(HttpSecurity http) throws Exception

首先我们来看下 HttpSecurity 的继承关系图:

image-20210809220933162

可以看到,HttpSecurity 继承自 AbstractConfiguredSecurityBuilder,同时实现了 SecurityBuilder HttpSecurityBuilder 两个接口。

我们来看下 HttpSecurity 的定义:

public final class HttpSecurity extends
  AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
  implements SecurityBuilder<DefaultSecurityFilterChain>,
  HttpSecurityBuilder<HttpSecurity> {
        //...
}

这里每一个类都带有泛型,看得人有点眼花缭乱。

我把这个泛型类拿出来和大家讲一下,小伙伴们就明白了。

泛型主要是两个,DefaultSecurityFilterChainHttpSecurityHttpSecurity 就不用说了,这是我们今天的主角,那么 DefaultSecurityFilterChain 是干嘛的?

这我们就得从 SecurityFilterChain 说起了。

SecurityFilterChain

先来看定义:

public interface SecurityFilterChain {
 boolean matches(HttpServletRequest request);
 List<Filter> getFilters();
}

SecurityFilterChain 其实就是我们平时所说的 Spring Security 中的过滤器链,它里边定义了两个方法,一个是 matches 方法用来匹配请求,另外一个 getFilters 方法返回一个 List 集合,集合中放着 Filter 对象,当一个请求到来时,用 matches 方法去比较请求是否和当前链吻合,如果吻合,就返回 getFilters 方法中的过滤器,那么当前请求会逐个经过 List 集合中的过滤器。

SecurityFilterChain 接口只有一个实现类,那就是 DefaultSecurityFilterChain那么从上面的介绍中,大家可以看到,DefaultSecurityFilterChain 其实就相当于是 Spring Security 中的过滤器链,一个 DefaultSecurityFilterChain 代表一个过滤器链,如果系统中存在多个过滤器链,则会存在多个 DefaultSecurityFilterChain 对象。

接下来我们把 HttpSecurity 的这几个父类捋一捋。

⚠️SecurityBuilder

public interface SecurityBuilder<O> {
 O build() throws Exception;
}

SecurityBuilder 就是用来构建过滤器链的,在 HttpSecurity 实现 SecurityBuilder 时,传入的泛型就是 DefaultSecurityFilterChain,所以 SecurityBuilder#build 方法的功能很明确,就是用来构建一个过滤器链出来。

HttpSecurityBuilder

HttpSecurityBuilder 看名字就是用来构建 HttpSecurity 的。不过它也只是一个接口,具体的实现在 HttpSecurity 中,接口定义如下:

public interface HttpSecurityBuilder<H extends HttpSecurityBuilder<H>> extends
  SecurityBuilder<DefaultSecurityFilterChain> {
 <C extends SecurityConfigurer<DefaultSecurityFilterChain, H>> C getConfigurer(
   Class<C> clazz);
 <C extends SecurityConfigurer<DefaultSecurityFilterChain, H>> C removeConfigurer(
   Class<C> clazz);
 <C> void setSharedObject(Class<C> sharedType, C object);
 <C> C getSharedObject(Class<C> sharedType);
 H authenticationProvider(AuthenticationProvider authenticationProvider);
 H userDetailsService(UserDetailsService userDetailsService) throws Exception;
 H addFilterAfter(Filter filter, Class<? extends Filter> afterFilter);
 H addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter);
 H addFilter(Filter filter);
}

这里的方法比较简单:

  1. getConfigurer 获取一个配置对象。Spring Security 过滤器链中的所有过滤器对象都是由 xxxConfigure 来进行配置的,这里就是获取这个 xxxConfigure 对象。
  2. removeConfigurer 移除一个配置对象。
  3. setSharedObject/getSharedObject 配置/获取由多个 SecurityConfigurer 共享的对象。
  4. authenticationProvider 方法表示配置验证器。
  5. userDetailsService 配置数据源接口。
  6. addFilterAfter 在某一个过滤器之前添加过滤器。
  7. addFilterBefore 在某一个过滤器之后添加过滤器。
  8. addFilter 添加一个过滤器,该过滤器必须是现有过滤器链中某一个过滤器或者其扩展。

这便是 HttpSecurityBuilder 中的功能,这些接口在HttpSecurity中都将得到实现。

AbstractSecurityBuilder

AbstractSecurityBuilder 类实现了 SecurityBuilder 接口,该类中主要做了一件事,就是确保整个构建只被构建一次。

public abstract class AbstractSecurityBuilder<O> implements SecurityBuilder<O> {
 private AtomicBoolean building = new AtomicBoolean();
 private O object;
 public final O build() throws Exception {
  if (this.building.compareAndSet(false, true)) {
   this.object = doBuild();
   return this.object;
  }
  throw new AlreadyBuiltException("This object has already been built");
 }
 public final O getObject() {
  if (!this.building.get()) {
   throw new IllegalStateException("This object has not been built");
  }
  return this.object;
 }
 protected abstract O doBuild() throws Exception;
}

可以看到,这里重新定义了 build 方法,并设置 build 方法为 final 类型,无法被重写,在 build 方法中,通过 AtomicBoolean 实现该方法只被调用一次。具体的构建逻辑则定义了新的抽象方法 doBuild,将来在实现类中通过 doBuild 方法定义构建逻辑。

AbstractConfiguredSecurityBuilder

AbstractSecurityBuilder 方法的实现类就是 AbstractConfiguredSecurityBuilder

AbstractConfiguredSecurityBuilder 中所做的事情就比较多了,我们分别来看。

首先 AbstractConfiguredSecurityBuilder 中定义了一个枚举类,将整个构建过程分为 5 种状态,也可以理解为构建过程生命周期的五个阶段,如下:

private enum BuildState {
 UNBUILT(0),
 INITIALIZING(1),
 CONFIGURING(2),
 BUILDING(3),
 BUILT(4);
 private final int order;
 BuildState(int order) {
  this.order = order;
 }
 public boolean isInitializing() {
  return INITIALIZING.order == order;
 }
 public boolean isConfigured() {
  return order >= CONFIGURING.order;
 }
}

五种状态分别是 UNBUILT、INITIALIZING、CONFIGURING、BUILDING 以及 BUILT。另外还提供了两个判断方法,isInitializing 判断是否正在初始化,isConfigured 表示是否已经配置完毕。

AbstractConfiguredSecurityBuilder 中的方法比较多,在这里列出来两个关键的方法和大家分析:

  • 第一个就是这个 add 方法,这相当于是在收集所有的配置类。将所有的 xxxConfigure 收集起来存储到 configurers 中,将来再统一初始化并配置,configurers 本身是一个 LinkedHashMap ,key 是配置类的 class,value 是一个集合,集合里边放着 xxxConfigure 配置类。当需要对这些配置类进行集中配置的时候,会通过 getConfigurers 方法获取配置类,这个获取过程就是把 LinkedHashMap 中的 value 拿出来,放到一个集合中返回。
  • AbstractSecurityBuilder 类中,过滤器的构建被转移到 doBuild 方法上面了,不过在 AbstractSecurityBuilder 中只是定义了抽象的 doBuild 方法,具体的实现在AbstractConfiguredSecurityBuilder。doBuild 方法就是一边更新状态,进行进行初始化。

回到主题HttpSecurity

HttpSecurity 做的事情,就是进行各种各样的 xxxConfigurer 配置。

public CorsConfigurer<HttpSecurity> cors() throws Exception {
 return getOrApply(new CorsConfigurer<>());
}
public CsrfConfigurer<HttpSecurity> csrf() throws Exception {
 ApplicationContext context = getContext();
 return getOrApply(new CsrfConfigurer<>(context));
}
public ExceptionHandlingConfigurer<HttpSecurity> exceptionHandling() throws Exception {
 return getOrApply(new ExceptionHandlingConfigurer<>());
}

HttpSecurity 中有大量类似的方法,过滤器链中的过滤器就是这样一个一个配置的。我就不一一介绍了。

每个配置方法的结尾都会来一句 getOrApply,这个是干嘛的?

private <C extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> C getOrApply(
  C configurer) throws Exception {
 C existingConfig = (C) getConfigurer(configurer.getClass());
 if (existingConfig != null) {
  return existingConfig;
 }
 return apply(configurer);
}

getConfigurer 方法是在它的父类 AbstractConfiguredSecurityBuilder 中定义的,目的就是去查看当前这个 xxxConfigurer 是否已经配置过了。

如果当前 xxxConfigurer 已经配置过了,则直接返回,否则调用 apply 方法,这个 apply 方法最终会调用到 AbstractConfiguredSecurityBuilder#add 方法,将当前配置 configurer 收集起来。

HttpSecurity 中还有一个 addFilter 方法(上面也用到的):

public HttpSecurity addFilter(Filter filter) {
 Class<? extends Filter> filterClass = filter.getClass();
 if (!comparator.isRegistered(filterClass)) {
  throw new IllegalArgumentException(
    "The Filter class "
      + filterClass.getName()
      + " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
 }
 this.filters.add(filter);
 return this;
}

这个 addFilter 方法的作用,主要是在各个 xxxConfigurer 进行配置的时候,会调用到这个方法,(xxxConfigurer 就是用来配置过滤器的),把 Filter 都添加到 fitlers 变量中。

最终在 HttpSecurityperformBuild 方法中,构建出来一个过滤器链:

@Override
protected DefaultSecurityFilterChain performBuild() {
 filters.sort(comparator);
 return new DefaultSecurityFilterChain(requestMatcher, filters);
}

先给过滤器排序,然后构造 DefaultSecurityFilterChain 对象。

👀SecurityConfigurer

此节建议结合登陆的验证中的配置类来看

SecurityConfigurer 在 Spring Security 中是一个非常重要的角色。Spring Security 过滤器链中的每一个过滤器,都是通过 xxxConfigurer 来进行配置的(上面配置类中重写的configure就是这里的SecurityConfigurer),而这些 xxxConfigurer 实际上都是 SecurityConfigurer 的实现。

SecurityConfigurer 本身是一个接口,我们来看下:

public interface SecurityConfigurer<O, B extends SecurityBuilder<O>> {

 void init(B builder) throws Exception;

 void configure(B builder) throws Exception;
}

可以看到,SecurityConfigurer 中主要是两个方法,init 和 configure。

init 就是一个初始化方法。而 configure 则是一个配置方法。这里只是规范了方法的定义,具体的实现则在不同的实现类中。

需要注意的是这两个方法的参数类型都是一个泛型 B,也就是 SecurityBuilder 的子类,关于 SecurityBuilder ,它是用来构建过滤器链的。

SecurityConfigurer 有三个实现类:

  • SecurityConfigurerAdapter
  • GlobalAuthenticationConfigurerAdapter
  • WebSecurityConfigurer

SecurityConfigurerAdapter

SecurityConfigurerAdapter 实现了 SecurityConfigurer 接口,我们所使用的大部分的 xxxConfigurer 也都是 SecurityConfigurerAdapter 的子类。

SecurityConfigurerAdapter SecurityConfigurer 的基础上,还扩展出来了几个非常好用的方法,我们一起来看下:

public abstract class SecurityConfigurerAdapter<O, B extends SecurityBuilder<O>>
  implements SecurityConfigurer<O, B> {
 private B securityBuilder;

 private CompositeObjectPostProcessor objectPostProcessor = new CompositeObjectPostProcessor();

 public void init(B builder) throws Exception {
 }

 public void configure(B builder) throws Exception {
 }

 public B and() {
  return getBuilder();
 }
 protected final B getBuilder() {
  if (securityBuilder == null) {
   throw new IllegalStateException("securityBuilder cannot be null");
  }
  return securityBuilder;
 }
 @SuppressWarnings("unchecked")
 protected <T> T postProcess(T object) {
  return (T) this.objectPostProcessor.postProcess(object);
 }
 public void addObjectPostProcessor(ObjectPostProcessor<?> objectPostProcessor) {
  this.objectPostProcessor.addObjectPostProcessor(objectPostProcessor);
 }
 public void setBuilder(B builder) {
  this.securityBuilder = builder;
 }
 private static final class CompositeObjectPostProcessor implements
   ObjectPostProcessor<Object> {
  private List<ObjectPostProcessor<?>> postProcessors = new ArrayList<>();

  @SuppressWarnings({ "rawtypes", "unchecked" })
  public Object postProcess(Object object) {
   for (ObjectPostProcessor opp : postProcessors) {
    Class<?> oppClass = opp.getClass();
    Class<?> oppType = GenericTypeResolver.resolveTypeArgument(oppClass,
      ObjectPostProcessor.class);
    if (oppType == null || oppType.isAssignableFrom(object.getClass())) {
     object = opp.postProcess(object);
    }
   }
   return object;
  }
  private boolean addObjectPostProcessor(
    ObjectPostProcessor<?> objectPostProcessor) {
   boolean result = this.postProcessors.add(objectPostProcessor);
   postProcessors.sort(AnnotationAwareOrderComparator.INSTANCE);
   return result;
  }
 }
}
  • CompositeObjectPostProcessor 首先一开始声明了一个 CompositeObjectPostProcessor 实例,CompositeObjectPostProcessorObjectPostProcessor 的一个实现,ObjectPostProcessor 本身是一个后置处理器,该后置处理器默认有两个实现,AutowireBeanFactoryObjectPostProcessorCompositeObjectPostProcessor:
    • 其中 AutowireBeanFactoryObjectPostProcessor 主要是利用了 AutowireCapableBeanFactory 对 Bean 进行手动注册,因为在 Spring Security 中,很多对象都是手动 new 出来的,这些 new 出来的对象和容器没有任何关系,利用 AutowireCapableBeanFactory 可以将这些手动 new 出来的对象注入到容器中,而 AutowireBeanFactoryObjectPostProcessor 的主要作用就是完成这件事
    • CompositeObjectPostProcessor 则是一个复合的对象处理器,里边维护了一个 List 集合,这个 List 集合中,大部分情况下只存储一条数据,那就是 AutowireBeanFactoryObjectPostProcessor,用来完成对象注入到容器的操作,如果用户自己手动调用了 addObjectPostProcessor 方法,那么 CompositeObjectPostProcessor 集合中维护的数据就会多出来一条,在 CompositeObjectPostProcessor#postProcess 方法中,会遍历集合中的所有 ObjectPostProcessor,挨个调用其 postProcess 方法对对象进行后置处理。
  • and 方法,该方法返回值是一个 securityBuildersecurityBuilder 实际上就是 HttpSecurity,我们在 HttpSecurity 中去配置不同的过滤器时,可以使用 and 方法进行链式配置,就是因为这里定义了 and 方法并返回了 securityBuilder 实例。

这便是 SecurityConfigurerAdapter 的主要功能,后面大部分的 xxxConfigurer 都是基于此类来实现的。

SecurityConfigurerAdapter 的实现主要也是三大类:

  • UserDetailsAwareConfigurer
  • AbstractHttpConfigurer
  • LdapAuthenticationProviderConfigurer

考虑到 LDAP 现在使用很少,所以这里我来和大家重点介绍下前两个。

UserDetailsAwareConfigurer

这个配置类看名字大概就知道这是用来配置用户类的。

image-20210809221022916

AbstractDaoAuthenticationConfigurer

AbstractDaoAuthenticationConfigurer 中所做的事情比较简单,主要是构造了一个默认的 DaoAuthenticationProvider,并为其配置 PasswordEncoder 和 UserDetailsService。

UserDetailsServiceConfigurer

UserDetailsServiceConfigurer 重写了 AbstractDaoAuthenticationConfigurer 中的 configure 方法,在 configure 方法执行之前加入了 initUserDetailsService 方法,以方便开发展按照自己的方式去初始化 UserDetailsService。不过这里的 initUserDetailsService 方法是空方法。

UserDetailsManagerConfigurer

UserDetailsManagerConfigurer 中实现了 UserDetailsServiceConfigurer 中定义的 initUserDetailsService 方法,具体的实现逻辑就是将 UserDetailsBuilder 所构建出来的 UserDetails 以及提前准备好的 UserDetails 中的用户存储到 UserDetailsService 中。

该类同时添加了 withUser 方法用来添加用户,同时还增加了一个 UserDetailsBuilder 用来构建用户,这些逻辑都比较简单,可以自行查看。

JdbcUserDetailsManagerConfigurer

JdbcUserDetailsManagerConfigurer 在父类的基础上补充了 DataSource 对象,同时还提供了相应的数据库查询方法。

InMemoryUserDetailsManagerConfigurer

InMemoryUserDetailsManagerConfigurer 在父类的基础上重写了构造方法,将父类中的 UserDetailsService 实例定义为 InMemoryUserDetailsManager。

DaoAuthenticationConfigurer

DaoAuthenticationConfigurer 继承自 AbstractDaoAuthenticationConfigurer,只是在构造方法中修改了一下 userDetailsService 而已。

⚠️AbstractHttpConfigurer

AbstractHttpConfigurer 这一派中的东西非常多,我们所有的过滤器配置,都是它的子类,我们来看下都有哪些类?

image-20210809221037144

AbstractHttpConfigurer 继承自 SecurityConfigurerAdapter,并增加了两个方法,disable 和 withObjectPostProcessor:

public abstract class AbstractHttpConfigurer<T extends AbstractHttpConfigurer<T, B>, B extends HttpSecurityBuilder<B>>
  extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, B> {

 /**
  * Disables the {@link AbstractHttpConfigurer} by removing it. After doing so a fresh
  * version of the configuration can be applied.
  *
  * @return the {@link HttpSecurityBuilder} for additional customizations
  */
 @SuppressWarnings("unchecked")
 public B disable() {
  getBuilder().removeConfigurer(getClass());
  return getBuilder();
 }

 @SuppressWarnings("unchecked")
 public T withObjectPostProcessor(ObjectPostProcessor<?> objectPostProcessor) {
  addObjectPostProcessor(objectPostProcessor);
  return (T) this;
 }
}

这两个方法之前都有给大家介绍过,disable 基本上是大家的老熟人了,我们常用的 .csrf().disable() 就是出自这里,那么从这里我们也可以看到 disable 的实现原理,就是从 getBuilder 中移除相关的 xxxConfigurer,getBuilder 方法获取到的实际上就是 HttpSecurity,所以移除掉 xxxConfigurer 实际上就是从过滤器链中移除掉某一个过滤器,例如 .csrf().disable() 就是移除掉处理 csrf 的过滤器。

另一个增加的方法是 withObjectPostProcessor,这是为配置类添加手动添加后置处理器的。在 AbstractHttpConfigurer 的父类中其实有一个类似的方法就是 addObjectPostProcessor,但是 addObjectPostProcessor 只是一个添加方法,返回值为 void,而 withObjectPostProcessor 的返回值是当前配置类,也就是 xxxConfigurer,所以如果使用 withObjectPostProcessor 的话,可以使用链式配置,事实上,上面的项目配置使用的也都是 withObjectPostProcessor 方法(当然,你也可以使用 addObjectPostProcessor,最终效果是一样的)。

⚠️AbstractAuthenticationFilterConfigurer

AbstractAuthenticationFilterConfigurer 类的功能比较多,源码也是相当相当长。不过我们只需要抓住两点即可,init 方法和 configure 方法,因为这两个方法是所有 xxxConfigurer 的灵魂。

@Override
public void init(B http) throws Exception {
 updateAuthenticationDefaults();
 updateAccessDefaults(http);
 registerDefaultAuthenticationEntryPoint(http);
}

init 方法主要干了三件事:

  1. updateAuthenticationDefaults 主要是配置了登录处理地址,失败跳转地址,注销成功跳转地址。
  2. updateAccessDefaults 方法主要是对 loginPage、loginProcessingUrl、failureUrl 进行 permitAll 设置(如果用户配置了 permitAll 的话)。
  3. registerDefaultAuthenticationEntryPoint 则是注册异常的处理器。

再来看 configure 方法:

@Override
public void configure(B http) throws Exception {
 PortMapper portMapper = http.getSharedObject(PortMapper.class);
 if (portMapper != null) {
  authenticationEntryPoint.setPortMapper(portMapper);
 }
 RequestCache requestCache = http.getSharedObject(RequestCache.class);
 if (requestCache != null) {
  this.defaultSuccessHandler.setRequestCache(requestCache);
 }
 authFilter.setAuthenticationManager(http
   .getSharedObject(AuthenticationManager.class));
 authFilter.setAuthenticationSuccessHandler(successHandler);
 authFilter.setAuthenticationFailureHandler(failureHandler);
 if (authenticationDetailsSource != null) {
  authFilter.setAuthenticationDetailsSource(authenticationDetailsSource);
 }
 SessionAuthenticationStrategy sessionAuthenticationStrategy = http
   .getSharedObject(SessionAuthenticationStrategy.class);
 if (sessionAuthenticationStrategy != null) {
  authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
 }
 RememberMeServices rememberMeServices = http
   .getSharedObject(RememberMeServices.class);
 if (rememberMeServices != null) {
  authFilter.setRememberMeServices(rememberMeServices);
 }
 F filter = postProcess(authFilter);
 http.addFilter(filter);
}

configure 中的逻辑就很简答了,构建各种各样的回调函数设置给 authFilter,authFilter 再去 postProcess 中走一圈注册到 Spring 容器中,最后再把 authFilter 添加到过滤器链中。

这便是 AbstractAuthenticationFilterConfigurer 的主要功能。需要提醒大家的是,我们日常配置的,如:

  • loginPage
  • loginProcessingUrl
  • permitAll
  • defaultSuccessUrl
  • failureUrl

等方法都是在这里定义的。

⚠️FormLoginConfigurer

FormLoginConfigurer 在定义是,明确了 AbstractAuthenticationFilterConfigurer 中的泛型是 UsernamePasswordAuthenticationFilter,也就是我们这里最终要配置的过滤是 UsernamePasswordAuthenticationFilter

FormLoginConfigurer 重写了 init 方法,配置了一下默认的登录页面。其他的基本上都是从父类来的,未做太多改变。

另外我们日常配置的很多东西也是来自这里:

image-20210809221049974

这就是 FormLoginConfigurer 这个配置类,FormLoginConfigurer 对应的过滤器是 UsernamePasswordAuthenticationFilter,可以自行分析其他的 xxxConfigurer,每一个 xxxConfigurer 都对应了一个 不同的 Filter。

GlobalAuthenticationConfigurerAdapter

GlobalAuthenticationConfigurerAdapter 看名字就知道是一个跟全局配置有关的东西,它本身实现了 SecurityConfigurerAdapter 接口,但是并未对方法做具体的实现,只是将泛型具体化了:

@Order(100)
public abstract class GlobalAuthenticationConfigurerAdapter implements
  SecurityConfigurer<AuthenticationManager, AuthenticationManagerBuilder> {

 public void init(AuthenticationManagerBuilder auth) throws Exception {
 }

 public void configure(AuthenticationManagerBuilder auth) throws Exception {
 }
}

可以看到,SecurityConfigurer 中的泛型,现在明确成了 AuthenticationManagerAuthenticationManagerBuilder。所以 GlobalAuthenticationConfigurerAdapter 的实现类将来主要是和配置 AuthenticationManager 有关。当然也包括默认的用户名密码也是由它的实现类来进行配置的。

我们在 Spring Security 中使用的 AuthenticationManager 其实可以分为两种,一种是局部的,另一种是全局的,这里主要是全局的配置。

WebSecurityConfigurer

还有一个实现类就是 WebSecurityConfigurer,这个可能有的小伙伴比较陌生,其实他就是我们天天用的 WebSecurityConfigurerAdapter 的父接口。

所以 WebSecurityConfigurer 的作用就很明确了,用户扩展用户自定义的配置。

SecurityConfigurer 默认主要是这三个实现,考虑到大多数的过滤器配置都是通过 SecurityConfigurerAdapter 进行扩展的,因此我们今天就通过这条线进行展开。

👀AuthenticationManagerBuilder

此节建议结合检查角色是否满足匹配来看

前面和大家分享了 SecurityBuilder 以及它的一个重要实现 HttpSecurity,在 SecurityBuilder 的实现类里边,还有一个重要的分支,那就是 AuthenticationManagerBuilderAuthenticationManagerBuilder 看名字就知道是用来构建 AuthenticationManager 的,所以我们就来看一看 AuthenticationManager 到底是怎么构建的。

初步理解

在 Spring Security 中,用来处理身份认证的类是 AuthenticationManager,我们也称之为认证管理器。也是我们上面检查角色是否满足匹配的重要一环

AuthenticationManager 中规范了 Spring Security 的过滤器要如何执行身份认证,并在身份认证成功后返回一个经过认证的 Authentication 对象。AuthenticationManager 是一个接口,我们可以自定义它的实现,但是通常我们使用更多的是系统提供的 ProviderManager

ProviderManager

ProviderManager 是的最常用的 AuthenticationManager 实现类。

ProviderManager 管理了一个 AuthenticationProvider 列表,每个 AuthenticationProvider 都是一个认证器,不同的 AuthenticationProvider 用来处理不同的 Authentication 对象的认证。一次完整的身份认证流程可能会经过多个 AuthenticationProvider

ProviderManager 相当于代理了多个 AuthenticationProvider,他们的关系如下图:

image-20210809210416369

AuthenticationProvider

AuthenticationProvider 定义了 Spring Security 中的验证逻辑,我们来看下 AuthenticationProvider 的定义:

public interface AuthenticationProvider {
	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;
	boolean supports(Class<?> authentication);
}

可以看到,AuthenticationProvider 中就两个方法:

  • authenticate 方法用来做验证,就是验证用户身份。
  • supports 则用来判断当前的 AuthenticationProvider 是否支持对应的 Authentication

在一次完整的认证中,可能包含多个 AuthenticationProvider,而这多个 AuthenticationProvider 则由 ProviderManager 进行统一管理,具体我会再出一篇文章来分析Spring Security 登录流程。

最常用的 AuthenticationProvider 实现类是 DaoAuthenticationProvider

Parent

每一个 ProviderManager 管理多个 AuthenticationProvider,同时每一个 ProviderManager 都可以配置一个 parent,如果当前的 ProviderManager 中认证失败了,还可以去它的 parent 中继续执行认证,所谓的 parent 实例,一般也是 ProviderManager,也就是 ProviderManager 的 parent 还是 ProviderManager。可以参考如下架构图:

image-20210809210931119

从上面的分析中大家可以看出,AuthenticationManager 的初始化会分为两块,一个全局的 AuthenticationManager,也就是 parent,另一个则是局部的 AuthenticationManager。先给大家一个结论,一个系统中,我们可以配置多个 HttpSecurity,而每一个HttpSecurity都有一个对应的 AuthenticationManager 实例(局部 AuthenticationManager),这些局部的 AuthenticationManager 实例都有一个共同的 parent,那就是全局的 AuthenticationManager

为什么每一个 HttpSecurity 都要绑定一个 AuthenticationManager?

因为在同一个系统中,我们可以回配置多个 HttpSecurity,也就是多个不同的过滤器链,既然有多个过滤器链,每一个请求到来的时候,它需要进入到某一个过滤器链中去处理,每一个过滤器链中又会涉及到 AuthenticationProvider 的管理,不同过滤器链中的 AuthenticationProvider 肯定是各自管理最为合适,也就是不同的过滤器链中都有一个绑定的 AuthenticationManager,即每一个 HttpSecurity 都要绑定一个 AuthenticationManager

👀WebSecurityConfigurerAdapter

我们配置中继承的就是WebSecurityConfigurerAdapter,需要重点关注

我们的自定义的SecurityConfig都是继承自 WebSecurityConfigurerAdapter 来实现的,首先看一张 WebSecurityConfigurerAdapter 的继承关系图:

img

在这层继承关系中,有两个非常重要的类:

  • SecurityBuilder

  • SecurityConfigurer

上面已经分析了这两个类,下面着重分析其他的

WebSecurityConfigurer

WebSecurityConfigurer 其实是一个空接口,但是它里边约束了一些泛型,如下:

public interface WebSecurityConfigurer<T extends SecurityBuilder<Filter>> extends
		SecurityConfigurer<Filter, T> {

}

这里边的泛型很关键,这关乎到 WebSecurityConfigurer 的目的是啥!

  1. SecurityBuilder 中的泛型 Filter,表示SecurityBuilder最终的目的是为了构建一个 Filter 对象出来。
  2. SecurityConfigurer 中两个泛型,第一个表示的含义也是 SecurityBuilder 最终构建的对象。

同时这里还定义了新的泛型 T,T 需要继承自 SecurityBuilder,根据 WebSecurityConfigurerAdapter 中的定义,我们可以知道,T 就是 WebSecurity,我们也大概能猜出 WebSecurity 就是 SecurityBuilder 的子类。

所以 WebSecurityConfigurer 的目的我们可以理解为就是为了配置 WebSecurity

WebSecurity

我们来看下 WebSecurity 的定义:

public final class WebSecurity extends
		AbstractConfiguredSecurityBuilder<Filter, WebSecurity> implements
		SecurityBuilder<Filter>, ApplicationContextAware {
}

没错,确实是这样!WebSecurity 继承自 AbstractConfiguredSecurityBuilder<Filter, WebSecurity> 同时实现了 SecurityBuilder 接口。

WebSecurity 的这些接口和继承类,上面的HttpSecurity中有分析,这里就不重复分析了

SecurityBuilder

SecurityBuilder 就是用来构建过滤器链的,在 HttpSecurity 实现 SecurityBuilder 时,传入的泛型就是 DefaultSecurityFilterChain,所以 SecurityBuilder#build 方法的功能很明确,就是用来构建一个过滤器链出来,但是那个过滤器链是 Spring Security 中的。在 WebSecurityConfigurerAdapter 中定义的泛型是 SecurityBuilder,所以最终构建的是一个普通 Filter,其实就是 FilterChainProxy,关于 FilterChainProxy ,可以参考http.authorizeRequests()中的说明。

WebSecurity 的核心逻辑集中在 performBuild 构建方法上,我们一起来看下:

@Override
protected Filter performBuild() throws Exception {
	Assert.state(
			!securityFilterChainBuilders.isEmpty(),
			() -> "At least one SecurityBuilder<? extends SecurityFilterChain> needs to be specified. "
					+ "Typically this done by adding a @Configuration that extends WebSecurityConfigurerAdapter. "
					+ "More advanced users can invoke "
					+ WebSecurity.class.getSimpleName()
					+ ".addSecurityFilterChainBuilder directly");
	int chainSize = ignoredRequests.size() + securityFilterChainBuilders.size();
	List<SecurityFilterChain> securityFilterChains = new ArrayList<>(
			chainSize);
	for (RequestMatcher ignoredRequest : ignoredRequests) {
		securityFilterChains.add(new DefaultSecurityFilterChain(ignoredRequest));
	}
	for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : securityFilterChainBuilders) {
		securityFilterChains.add(securityFilterChainBuilder.build());
	}
	FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
	if (httpFirewall != null) {
		filterChainProxy.setFirewall(httpFirewall);
	}
	filterChainProxy.afterPropertiesSet();
	Filter result = filterChainProxy;
	if (debugEnabled) {
		logger.warn("\n\n"
				+ "********************************************************************\n"
				+ "**********        Security debugging is enabled.       *************\n"
				+ "**********    This may include sensitive information.  *************\n"
				+ "**********      Do not use in a production system!     *************\n"
				+ "********************************************************************\n\n");
		result = new DebugFilter(filterChainProxy);
	}
	postBuildAction.run();
	return result;
}

先来说一句,这里的 performBuild 方法只有一个功能,那就是构建 FilterChainProxy

把握住了这条主线,我们再来看方法的实现就很容易了。

  1. 首先统计过滤器链的总条数,总条数包括两个方面,一个是 ignoredRequests,这是忽略的请求,通过 WebSecurity 配置的忽略请求,另一个则是 securityFilterChainBuilders,也就是我们通过 HttpSecurity 配置的过滤器链,有几个就算几个。

    前端静态资源放行时,可以直接不走 Spring Security 过滤器链,像下面这样:

    >@Override
    >public void configure(WebSecurity web) throws Exception {
     web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico");
    >}

    后端的接口要额外放行,就需要仔细考虑场景了,不过一般来说,不建议使用上面这种方式,建议下面这种方式

    >http.authorizeRequests()
         .antMatchers("/hello").permitAll()
         .anyRequest().authenticated()

    因为,如果我们暴露登录接口的时候,使用了前面提到的第一种方式,没有走 Spring Security,过滤器链,则在登录成功后,就不会将登录用户信息存入 session 中,进而导致后来的请求都无法获取到登录用户信息(后来的请求在系统眼里也都是未认证的请求)

    或者如果你的登录请求正常,走了 Spring Security 过滤器链,但是后来的 A 请求没走过滤器链(采用前面提到的第一种方式放行),那么 A 请求中,也是无法通过 SecurityContextHolder 获取到登录用户信息的,因为它一开始没经过 SecurityContextPersistenceFilter 过滤器链。

  2. 创建 securityFilterChains 集合,并且遍历上面提到的两种类型的过滤器链,并将过滤器链放入 securityFilterChains 集合中。

  3. HttpSecurity介绍过,HttpSecurity 构建出来的过滤器链对象就是 DefaultSecurityFilterChain,所以可以直接将 build 结果放入 securityFilterChains 中,而 ignoredRequests 中保存的则需要重构一下才可以存入 securityFilterChains

  4. securityFilterChains 中有数据之后,接下来创建一个 FilterChainProxy

  5. 给新建的FilterChainProxy配置上防火墙

  6. 最后我们返回的就是 FilterChainProxy 的实例。

从这段分析中,我们可以看出来 WebSecurity HttpSecurity 的区别:

  1. HttpSecurity 目的是构建过滤器链,一个 HttpSecurity 对象构建一条过滤器链,一个过滤器链中有 N 个过滤器,HttpSecurity所做的事情实际上就是在配置这 N 个过滤器。
  2. WebSecurity 目的是构建 FilterChainProxy,一个 FilterChainProxy 中包含有多个过滤器链和一个 Firewall。

回到WebSecurityConfigurerAdapter

最后我们再来看 WebSecurityConfigurerAdapter,由于 WebSecurityConfigurer 只是一个空接口,WebSecurityConfigurerAdapter 就是针对这个空接口提供一个具体的实现,最终目的还是为了方便你配置 WebSecurity

WebSecurityConfigurerAdapter 中的方法比较多,但是根据我们前面的分析,提纲挈领的方法就两个,一个是 init,还有一个 configure(WebSecurity web),其他方法都是为这两个方法服务的。那我们就来看下这两个方法,先看 init 方法:

public void init(final WebSecurity web) throws Exception {
	final HttpSecurity http = getHttp();
	web.addSecurityFilterChainBuilder(http).postBuildAction(() -> {
		FilterSecurityInterceptor securityInterceptor = http
				.getSharedObject(FilterSecurityInterceptor.class);
		web.securityInterceptor(securityInterceptor);
	});
}
protected final HttpSecurity getHttp() throws Exception {
	if (http != null) {
		return http;
	}
	AuthenticationEventPublisher eventPublisher = getAuthenticationEventPublisher();
	localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher);
	AuthenticationManager authenticationManager = authenticationManager();
	authenticationBuilder.parentAuthenticationManager(authenticationManager);
	Map<Class<?>, Object> sharedObjects = createSharedObjects();
	http = new HttpSecurity(objectPostProcessor, authenticationBuilder,
			sharedObjects);
	if (!disableDefaults) {
		// @formatter:off
		http
			.csrf().and()
			.addFilter(new WebAsyncManagerIntegrationFilter())
			.exceptionHandling().and()
			.headers().and()
			.sessionManagement().and()
			.securityContext().and()
			.requestCache().and()
			.anonymous().and()
			.servletApi().and()
			.apply(new DefaultLoginPageConfigurer<>()).and()
			.logout();
		// @formatter:on
		ClassLoader classLoader = this.context.getClassLoader();
		List<AbstractHttpConfigurer> defaultHttpConfigurers =
				SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader);
		for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
			http.apply(configurer);
		}
	}
	configure(http);
	return http;
}
protected void configure(HttpSecurity http) throws Exception {
	logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
	http
		.authorizeRequests()
			.anyRequest().authenticated()
			.and()
		.formLogin().and()
		.httpBasic();
}

init 方法可以算是这里的入口方法了:首先调用 getHttp 方法进行 HttpSecurity 的初始化。HttpSecurity 的初始化,实际上就是配置了一堆默认的过滤器,配置完成后,最终还调用了 configure(http) 方法,该方法又配置了一些拦截器,不过在实际开发中,我们经常会重写 configure(http) 方法,HttpSecurity 配置完成后,再将 HttpSecurity 放入 WebSecurity 中,保存在 WebSecuritysecurityFilterChainBuilders 集合里,具体参见上面的HttpSecurity部分

configure(WebSecurity web) 方法实际上是一个空方法,我们在实际开发中可能会重写该方法:

@Override
public void configure(WebSecurity web) throws Exception {
    //防止访问登录页面死循环,不用进入Security拦截
    web.ignoring().antMatchers("/css/**", "/js/**", "/index.html", "/img/**", "/fonts/**", "/favicon.ico", "/verifyCode");
}