SpringSecurity+JWT认证流程解析

纸上得来终觉浅,觉知此事要躬行。

楔子

本文适合: 对Spring Security有一点了解或者跑过简单demo但是对整体运行流程不明白的同学,对SpringSecurity有兴趣的也可以当作你们的入门教程,示例代码中也有很多注释。

本文代码: 码云地址GitHub地址

大家在做系统的时候,一般做的第一个模块就是认证与授权模块,因为这是一个系统的入口,也是一个系统最重要最基础的一环,在认证与授权服务设计搭建好了之后,剩下的模块才得以安全访问。

市面上一般做认证授权的框架就是shiroSpring Security,也有大部分公司选择自己研制。出于之前看过很多Spring Security的入门教程,但都觉得讲的不是太好,所以我这两天在自己鼓捣Spring Security的时候萌生了分享一下的想法,希望可以帮助到有兴趣的人。

Spring Security框架我们主要用它就是解决一个认证授权功能,所以我的文章主要会分为两部分:

  • 第一部分认证(本篇)
  • 第二部分授权(放在下一篇)

我会为大家用一个Spring Security + JWT + 缓存的一个demo来展现我要讲的东西,毕竟脑子的东西要体现在具体事物上才可以更直观的让大家去了解去认识。

学习一件新事物的时候,我推荐使用自顶向下的学习方法,这样可以更好的认识新事物,而不是盲人摸象。

:只涉及到用户认证授权不涉及oauth2之类的第三方授权。

1. 📖SpringSecurity的工作流程


想上手 Spring Security 一定要先了解它的工作流程,因为它不像工具包一样,拿来即用,必须要对它有一定的了解,再根据它的用法进行自定义操作。

我们可以先来看看它的工作流程:

Spring Security的官方文档上有这么一句话:

Spring Security’s web infrastructure is based entirely on standard servlet filters.

Spring Security 的web基础是Filters。

这句话展示了Spring Security的设计思想:即通过一层层的Filters来对web请求做处理。

放到真实的Spring Security中,用文字表述的话可以这样说:

一个web请求会经过一条过滤器链,在经过过滤器链的过程中会完成认证与授权,如果中间发现这条请求未认证或者未授权,会根据被保护API的权限去抛出异常,然后由异常处理器去处理这些异常。

用图片表述的话可以这样画,这是我在百度找到的一张图片:

 

image.png
如上图,一个请求想要访问到API就会以从左到右的形式经过蓝线框框里面的过滤器,其中绿色部分是我们本篇主要讲的负责认证的过滤器,蓝色部分负责异常处理,橙色部分则是负责授权。

 

图中的这两个绿色过滤器我们今天不会去说,因为这是Spring Security对form表单认证和Basic认证内置的两个Filter,而我们的demo是JWT认证方式所以用不上。

如果你用过Spring Security就应该知道配置中有两个叫formLoginhttpBasic的配置项,在配置中打开了它俩就对应着打开了上面的过滤器。

image.png

 

  • formLogin对应着你form表单认证方式,即UsernamePasswordAuthenticationFilter。
  • httpBasic对应着Basic认证方式,即BasicAuthenticationFilter。

换言之,你配置了这两种认证方式,过滤器链中才会加入它们,否则它们是不会被加到过滤器链中去的。

因为Spring Security自带的过滤器中是没有针对JWT这种认证方式的,所以我们的demo中会写一个JWT的认证过滤器,然后放在绿色的位置进行认证工作。

2. 📝SpringSecurity的重要概念


知道了Spring Security的大致工作流程之后,我们还需要知道一些非常重要的概念也可以说是组件:

  • SecurityContext:上下文对象,Authentication对象会放在里面。
  • SecurityContextHolder:用于拿到上下文对象的静态工具类。
  • Authentication:认证接口,定义了认证对象的数据形式。
  • AuthenticationManager:用于校验Authentication,返回一个认证完成后的Authentication对象。

1.SecurityContext

上下文对象,认证后的数据就放在这里面,接口定义如下:

 
复制代码
public interface SecurityContext extends Serializable {
 // 获取Authentication对象
 Authentication getAuthentication();

 // 放入Authentication对象
 void setAuthentication(Authentication authentication);
}

这个接口里面只有两个方法,其主要作用就是get or set Authentication

2. SecurityContextHolder

 
复制代码
public class SecurityContextHolder {

 public static void clearContext() {
  strategy.clearContext();
 }

 public static SecurityContext getContext() {
  return strategy.getContext();
 }
    
    public static void setContext(SecurityContext context) {
  strategy.setContext(context);
 }

}

可以说是SecurityContext的工具类,用于get or set or clear SecurityContext,默认会把数据都存储到当前线程中。

3. Authentication

 
复制代码
public interface Authentication extends Principal, Serializable {
 
 Collection<? extends GrantedAuthority> getAuthorities();
 Object getCredentials();
 Object getDetails();
 Object getPrincipal();
 boolean isAuthenticated();
 void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

这几个方法效果如下:

  • getAuthorities: 获取用户权限,一般情况下获取到的是用户的角色信息
  • getCredentials: 获取证明用户认证的信息,通常情况下获取到的是密码等信息。
  • getDetails: 获取用户的额外信息,(这部分信息可以是我们的用户表中的信息)。
  • getPrincipal: 获取用户身份信息,在未认证的情况下获取到的是用户名,在已认证的情况下获取到的是 UserDetails。
  • isAuthenticated: 获取当前 Authentication 是否已认证。
  • setAuthenticated: 设置当前 Authentication 是否已认证(true or false)。


Authentication只是定义了一种在SpringSecurity进行认证过的数据的数据形式应该是怎么样的,要有权限,要有密码,要有身份信息,要有额外信息。

4. AuthenticationManager

 
复制代码
public interface AuthenticationManager {
 // 认证方法
 Authentication authenticate(Authentication authentication)
   throws AuthenticationException;
}

AuthenticationManager定义了一个认证方法,它将一个未认证的Authentication传入,返回一个已认证的Authentication,默认使用的实现类为:ProviderManager。

接下来大家可以构思一下如何将这四个部分,串联起来,构成Spring Security进行认证的流程:

1. 👉先是一个请求带着身份信息进来
2. 👉经过AuthenticationManager的认证,
3. 👉再通过SecurityContextHolder获取SecurityContext
4. 👉最后将认证后的信息放入到SecurityContext

3. 📃代码前的准备工作


真正开始讲诉我们的认证代码之前,我们首先需要导入必要的依赖,数据库相关的依赖可以自行选择什么JDBC框架,我这里用的是国人二次开发的myabtis-plus

 
复制代码
    <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
        
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.0</version>
        </dependency>
        
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>


接着,我们需要定义几个必须的组件。

由于我用的Spring-Boot是2.X所以必须要我们自己定义一个加密器:

1. 定义加密器Bean

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

这个Bean是不必可少的,Spring Security在认证操作时会使用我们定义的这个加密器,如果没有则会出现异常。

2. 定义AuthenticationManager

 
复制代码
@Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

这里将Spring Security自带的authenticationManager声明成Bean,声明它的作用是用它帮我们进行认证操作,调用这个Bean的authenticate方法会由Spring Security自动帮我们做认证。

3. 实现UserDetailsService

 
复制代码
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserService userService;
    @Autowired
    private RoleInfoService roleInfoService;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("开始登陆验证,用户名为: {}",s);

        // 根据用户名验证用户
        QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
        queryWrapper.lambda().eq(UserInfo::getLoginAccount,s);
        UserInfo userInfo = userService.getOne(queryWrapper);
        if (userInfo == null) {
            throw new UsernameNotFoundException("用户名不存在,登陆失败。");
        }

        // 构建UserDetail对象
        UserDetail userDetail = new UserDetail();
        userDetail.setUserInfo(userInfo);
        List<RoleInfo> roleInfoList = roleInfoService.listRoleByUserId(userInfo.getUserId());
        userDetail.setRoleInfoList(roleInfoList);
        return userDetail;
    }
}

实现UserDetailsService的抽象方法并返回一个UserDetails对象,认证过程中SpringSecurity会调用这个方法访问数据库进行对用户的搜索,逻辑什么都可以自定义,无论是从数据库中还是从缓存中,但是我们需要将我们查询出来的用户信息和权限信息组装成一个UserDetails返回。

UserDetails 也是一个定义了数据形式的接口,用于保存我们从数据库中查出来的数据,其功能主要是验证账号状态和获取权限,具体实现可以查阅我仓库的代码

4. TokenUtil

由于我们是JWT的认证模式,所以我们也需要一个帮我们操作Token的工具类,一般来说它具有以下三个方法就够了:

  • 创建token
  • 验证token
  • 反解析token中的信息


在下文我的代码里面,JwtProvider充当了Token工具类的角色,具体实现可以查阅我仓库的代码

4. ✍代码中的具体实现


有了前面的讲解之后,大家应该都知道用SpringSecurity做JWT认证需要我们自己写一个过滤器来做JWT的校验,然后将这个过滤器放到绿色部分。

在我们编写这个过滤器之前,我们还需要进行一个认证操作,因为我们要先访问认证接口拿到token,才能把token放到请求头上,进行接下来请求。

如果你不太明白,不要紧,先接着往下看我会在这节结束再次梳理一下。

1. 认证方法

访问一个系统,一般最先访问的是认证方法,这里我写了最简略的认证需要的几个步骤,因为实际系统中我们还要写登录记录啊,前台密码解密啊这些操作。

 
复制代码
@Override
    public ApiResult login(String loginAccount, String password) {
        // 1 创建UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken usernameAuthentication = new UsernamePasswordAuthenticationToken(loginAccount, password);
        // 2 认证
        Authentication authentication = this.authenticationManager.authenticate(usernameAuthentication);
        // 3 保存认证信息
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 4 生成自定义token
        UserDetail userDetail = (UserDetail) authentication.getPrincipal();
        AccessToken accessToken = jwtProvider.createToken((UserDetails) authentication.getPrincipal());

        // 5 放入缓存
        caffeineCache.put(CacheName.USER, userDetail.getUsername(), userDetail);
        return ApiResult.ok(accessToken);
    }

这里一共五个步骤,大概只有前四步是比较陌生的:

  1. 传入用户名和密码创建了一个UsernamePasswordAuthenticationToken对象,这是我们前面说过的Authentication的实现类,传入用户名和密码做构造参数,这个对象就是我们创建出来的未认证的Authentication对象。
  2. 使用我们先前已经声明过的Bean-authenticationManager调用它的authenticate方法进行认证,返回一个认证完成的Authentication对象。
  3. 认证完成没有出现异常,就会走到第三步,使用SecurityContextHolder获取SecurityContext之后,将认证完成之后的Authentication对象,放入上下文对象。
  4. Authentication对象中拿到我们的UserDetails对象,之前我们说过,认证后的Authentication对象调用它的getPrincipal()方法就可以拿到我们先前数据库查询后组装出来的UserDetails对象,然后创建token。
  5. UserDetails对象放入缓存中,方便后面过滤器使用。


这样的话就算完成了,感觉上很简单,因为主要认证操作都会由authenticationManager.authenticate()帮我们完成。



接下来我们可以看看源码,从中窥得Spring Security是如何帮我们做这个认证的(省略了一部分):

 
复制代码
// AbstractUserDetailsAuthenticationProvider

public Authentication authenticate(Authentication authentication){

  // 校验未认证的Authentication对象里面有没有用户名
  String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
    : authentication.getName();  
    
     boolean cacheWasUsed = true;
     // 从缓存中去查用户名为XXX的对象
  UserDetails user = this.userCache.getUserFromCache(username);

     // 如果没有就进入到这个方法
  if (user == null) {
   cacheWasUsed = false;

   try {
                // 调用我们重写UserDetailsService的loadUserByUsername方法
                // 拿到我们自己组装好的UserDetails对象
    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;
    }
   }

   Assert.notNull(user,
     "retrieveUser returned null - a violation of the interface contract");
  }
    
    try {
         // 校验账号是否禁用
   preAuthenticationChecks.check(user);
         // 校验数据库查出来的密码,和我们传入的密码是否一致
   additionalAuthenticationChecks(user,
     (UsernamePasswordAuthenticationToken) authentication);
  }


}

看了源码之后你会发现和我们平常写的一样,其主要逻辑也是查数据库然后对比密码。

登录之后效果如下:

image.png

我们返回token之后,下次请求其他API的时候就要在请求头中带上这个token,都按照JWT的标准来做就可以。

 

2. JWT过滤器

有了token之后,我们要把过滤器放在过滤器链中,用于解析token,因为我们没有session,所以我们每次去辨别这是哪个用户的请求的时候,都是根据请求中的token来解析出来当前是哪个用户。

所以我们需要一个过滤器去拦截所有请求,前文我们也说过,这个过滤器我们会放在绿色部分用来替代UsernamePasswordAuthenticationFilter,所以我们新建一个JwtAuthenticationTokenFilter,然后将它注册为Bean,并在编写配置文件的时候需要加上这个:

 
复制代码
@Bean
    public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
        return new JwtAuthenticationTokenFilter();
    }

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(jwtAuthenticationTokenFilter(),
                        UsernamePasswordAuthenticationFilter.class);
    }

addFilterBefore的语义是添加一个Filter到XXXFilter之前,放在这里就是把JwtAuthenticationTokenFilter放在UsernamePasswordAuthenticationFilter之前,因为filter的执行也是有顺序的,我们必须要把我们的filter放在过滤器链中绿色的部分才会起到自动认证的效果。

接下来我们可以看看JwtAuthenticationTokenFilter的具体实现了:

 
复制代码
@Override
    protected void doFilterInternal(@NotNull HttpServletRequest request,
                                    @NotNull HttpServletResponse response,
                                    @NotNull FilterChain chain) throws ServletException, IOException {
        log.info("JWT过滤器通过校验请求头token进行自动登录...");

        // 拿到Authorization请求头内的信息
        String authToken = jwtProvider.getToken(request);

        // 判断一下内容是否为空且是否为(Bearer )开头
        if (StrUtil.isNotEmpty(authToken) && authToken.startsWith(jwtProperties.getTokenPrefix())) {
            // 去掉token前缀(Bearer ),拿到真实token
            authToken = authToken.substring(jwtProperties.getTokenPrefix().length());

            // 拿到token里面的登录账号
            String loginAccount = jwtProvider.getSubjectFromToken(authToken);

            if (StrUtil.isNotEmpty(loginAccount) && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 缓存里查询用户,不存在需要重新登陆。
                UserDetail userDetails = caffeineCache.get(CacheName.USER, loginAccount, UserDetail.class);

                // 拿到用户信息后验证用户信息与token
                if (userDetails != null && jwtProvider.validateToken(authToken, userDetails)) {

                    // 组装authentication对象,构造参数是Principal Credentials 与 Authorities
                    // 后面的拦截器里面会用到 grantedAuthorities 方法
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());

                    // 将authentication信息放入到上下文对象中
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    log.info("JWT过滤器通过校验请求头token自动登录成功, user : {}", userDetails.getUsername());
                }
            }
        }

        chain.doFilter(request, response);
    }

代码里步骤虽然说的很详细了,但是可能因为代码过长不利于阅读,我还是简单说说,也可以直接去仓库查看源码

  1. 拿到Authorization请求头对应的token信息
  2. 去掉token的头部(Bearer )
  3. 解析token,拿到我们放在里面的登陆账号
  4. 因为我们之前登陆过,所以我们直接从缓存里面拿我们的UserDetail信息即可
  5. 查看是否UserDetail为null,以及查看token是否过期,UserDetail用户名与token中的是否一直。
  6. 组装一个authentication对象,把它放在上下文对象中,这样后面的过滤器看到我们上下文对象中有authentication对象,就相当于我们已经认证过了。

这样的话,每一个带有正确token的请求进来之后,都会找到它的账号信息,并放在上下文对象中,我们可以使用SecurityContextHolder很方便的拿到上下文对象中的Authentication对象。

完成之后,启动我们的demo,可以看到过滤器链中有以下过滤器,其中我们自定义的是第5个:

image.png

 

🐱‍🏍就酱,我们登录完了之后获取到的账号信息与角色信息我们都会放到缓存中,当带着token的请求来到时,我们就把它从缓存中拿出来,再次放到上下文对象中去。

结合认证方法,我们的逻辑链就变成了:

登录👉拿到token👉请求带上token👉JWT过滤器拦截👉校验token👉将从缓存中查出来的对象放到上下文中

这样之后,我们认证的逻辑就算完成了。

4. 💡代码优化


认证和JWT过滤器完成后,这个JWT的项目其实就可以跑起来了,可以实现我们想要的效果,如果想让程序更健壮,我们还需要再加一些辅助功能,让代码更友好。

1. 认证失败处理器

 

image.png
当用户未登录或者token解析失败时会触发这个处理器,返回一个非法访问的结果。

image.png

 

2. 权限不足处理器

 

image.png
当用户本身权限不满足所访问API需要的权限时,触发这个处理器,返回一个权限不足的结果。

image.png

 

3. 退出方法

 

image.png
用户退出一般就是清除掉上下文对象和缓存就行了,你也可以做一下附加操作,这两步是必须的。

 

4. token刷新

 

image.png
JWT的项目token刷新也是必不可少的,这里刷新token的主要方法放在了token工具类里面,刷新完了把缓存重载一遍就行了,因为缓存是有有效期的,重新put可以重置失效时间。

 

后记


这篇文我从上周日就开始构思了,为了能讲的老妪能解,修修改改了几遍才发出来。

Spring Security的上手的确有点难度,在我第一次去了解它的时候看的是尚硅谷的教程,那个视频的讲师拿它和Thymeleaf结合,这就导致网上也有很多博客去讲Spring Security的时候也是这种方式,而没有去关注前后端分离。

也有教程做过滤器的时候是直接继承UsernamePasswordAuthenticationFilter,这样的方法也是可行的,不过我们了解了整体的运行流程之后你就知道没必要这样做,不需要去继承XXX,只要写个过滤器然后放在那个位置就可以了。


好了,认证篇结束后,下篇就是动态鉴权了,这是我在掘金的第一篇文,我的第一次知识输出,希望大家持续关注。

你们的每个点赞收藏与评论都是对我知识输出的莫大肯定,如果有文中有什么错误或者疑点或者对我的指教都可以在评论区下方留言,一起讨论。

我是耳朵,一个一直想做知识输出的人,下期见。

本文代码:码云地址GitHub地址

如果不能谈情说爱,我们可以自怜自爱。

楔子

上一篇文我们讲过了SpringSecurity的认证流程,相信大家认真读过了之后一定会对SpringSecurity的认证流程已经明白个七八分了,本期是我们如约而至的动态鉴权篇,看这篇并不需要一定要弄懂上篇的知识,因为讲述的重点并不相同,你可以将这两篇看成两个独立的章节,从中撷取自己需要的部分。

祝有好收获。

本文代码: 码云地址GitHub地址

1. 📖SpringSecurity的鉴权原理

上一篇文我们讲认证的时候曾经放了一个图,就是下图:

 

image.png

 

整个认证的过程其实一直在围绕图中过滤链的绿色部分,而我们今天要说的动态鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor

1. FilterSecurityInterceptor

想知道怎么动态鉴权首先我们要搞明白SpringSecurity的鉴权逻辑,从上图中我们也可以看出:FilterSecurityInterceptor是这个过滤链的最后一环,而认证之后就是鉴权,所以我们的FilterSecurityInterceptor主要是负责鉴权这部分。

一个请求完成了认证,且没有抛出异常之后就会到达FilterSecurityInterceptor所负责的鉴权部分,也就是说鉴权的入口就在FilterSecurityInterceptor

我们先来看看FilterSecurityInterceptor的定义和主要方法:

 
复制代码
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
  Filter {

            public void doFilter(ServletRequest request, ServletResponse response,
                    FilterChain chain) throws IOException, ServletException {
                FilterInvocation fi = new FilterInvocation(request, response, chain);
                invoke(fi);
            }
}

上文代码可以看出FilterSecurityInterceptor是实现了抽象类AbstractSecurityInterceptor的一个实现类,这个AbstractSecurityInterceptor中预先写好了一段很重要的代码(后面会说到)。

FilterSecurityInterceptor的主要方法是doFilter方法,过滤器的特性大家应该都知道,请求过来之后会执行这个doFilter方法,FilterSecurityInterceptordoFilter方法出奇的简单,总共只有两行:

第一行是创建了一个FilterInvocation对象,这个FilterInvocation对象你可以当作它封装了request,它的主要工作就是拿请求里面的信息,比如请求的URI。

第二行就调用了自身的invoke方法,并将FilterInvocation对象传入。

所以我们主要逻辑肯定是在这个invoke方法里面了,我们来打开看看:

 
复制代码
public void invoke(FilterInvocation fi) throws IOException, ServletException {
        if ((fi.getRequest() != null)
                && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
                && observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        else {
            // first time this request being called, so perform security checking
            if (fi.getRequest() != null && observeOncePerRequest) {
                fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
            }

            // 进入鉴权
            InterceptorStatusToken token = super.beforeInvocation(fi);

            try {
                fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
            }
            finally {
                super.finallyInvocation(token);
            }

            super.afterInvocation(token, null);
        }
    }

invoke方法中只有一个if-else,一般都是不满足if中的那三个条件的,然后执行逻辑会来到else

else的代码也可以概括为两部分:

  1. 调用了super.beforeInvocation(fi)
  2. 调用完之后过滤器继续往下走。

第二步可以不看,每个过滤器都有这么一步,所以我们主要看super.beforeInvocation(fi),前文我已经说过, FilterSecurityInterceptor实现了抽象类AbstractSecurityInterceptor, 所以这个里super其实指的就是AbstractSecurityInterceptor, 那这段代码其实调用了AbstractSecurityInterceptor.beforeInvocation(fi), 前文我说过AbstractSecurityInterceptor中有一段很重要的代码就是这一段, 那我们继续来看这个beforeInvocation(fi)方法的源码:

 
复制代码
protected InterceptorStatusToken beforeInvocation(Object object) {
        Assert.notNull(object, "Object was null");
        final boolean debug = logger.isDebugEnabled();

        if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
            throw new IllegalArgumentException(
                    "Security invocation attempted for object "
                            + object.getClass().getName()
                            + " but AbstractSecurityInterceptor only configured to support secure objects of type: "
                            + getSecureObjectClass());
        }

        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
                .getAttributes(object);

        Authentication authenticated = authenticateIfRequired();

        try {
            // 鉴权需要调用的接口
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException accessDeniedException) {
            publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
                    accessDeniedException));

            throw accessDeniedException;
        }

    }

源码较长,这里我精简了中间的一部分,这段代码大致可以分为三步:

  1. 拿到了一个Collection<ConfigAttribute>对象,这个对象是一个List,其实里面就是我们在配置文件中配置的过滤规则。
  2. 拿到了Authentication,这里是调用authenticateIfRequired方法拿到了,其实里面还是通过SecurityContextHolder拿到的,上一篇文章我讲过如何拿取。
  3. 调用了accessDecisionManager.decide(authenticated, object, attributes),前两步都是对decide方法做参数的准备,第三步才是正式去到鉴权的逻辑,既然这里面才是真正鉴权的逻辑,那也就是说鉴权其实是accessDecisionManager在做。

2. AccessDecisionManager

前面通过源码我们看到了鉴权的真正处理者:AccessDecisionManager,是不是觉得一层接着一层,就像套娃一样,别急,下面还有。先来看看源码接口定义:

 
复制代码
public interface AccessDecisionManager {

    // 主要鉴权方法
    void decide(Authentication authentication, Object object,
                Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
            InsufficientAuthenticationException;

    boolean supports(ConfigAttribute attribute);

    boolean supports(Class<?> clazz);
}

AccessDecisionManager是一个接口,它声明了三个方法,除了第一个鉴权方法以外,还有两个是辅助性的方法,其作用都是甄别 decide方法中参数的有效性。

那既然是一个接口,上文中所调用的肯定是他的实现类了,我们来看看这个接口的结构树:

image.png

从图中我们可以看到它主要有三个实现类,分别代表了三种不同的鉴权逻辑:

  • AffirmativeBased:一票通过,只要有一票通过就算通过,默认是它。
  • UnanimousBased:一票反对,只要有一票反对就不能通过。
  • ConsensusBased:少数票服从多数票。

这里的表述为什么要用票呢?因为在实现类里面采用了委托的形式,将请求委托给投票器,每个投票器拿着这个请求根据自身的逻辑来计算出能不能通过然后进行投票,所以会有上面的表述。

也就是说这三个实现类,其实还不是真正判断请求能不能通过的类,真正判断请求是否通过的是投票器,然后实现类把投票器的结果综合起来来决定到底能不能通过。

刚刚已经说过,实现类把投票器的结果综合起来进行决定,也就是说投票器可以放入多个,每个实现类里的投票器数量取决于构造的时候放入了多少投票器,我们可以看看默认的AffirmativeBased的源码。

 
复制代码
public class AffirmativeBased extends AbstractAccessDecisionManager {

    public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
        super(decisionVoters);
    }

    // 拿到所有的投票器,循环遍历进行投票
    public void decide(Authentication authentication, Object object,
                       Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
        int deny = 0;

        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, configAttributes);

            if (logger.isDebugEnabled()) {
                logger.debug("Voter: " + voter + ", returned: " + result);
            }

            switch (result) {
                case AccessDecisionVoter.ACCESS_GRANTED:
                    return;

                case AccessDecisionVoter.ACCESS_DENIED:
                    deny++;

                    break;

                default:
                    break;
            }
        }

        if (deny > 0) {
            throw new AccessDeniedException(messages.getMessage(
                    "AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }

        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
}

AffirmativeBased的构造是传入投票器List,其主要鉴权逻辑交给投票器去判断,投票器返回不同的数字代表不同的结果,然后AffirmativeBased根据自身一票通过的策略决定放行还是抛出异常。

AffirmativeBased默认传入的构造器只有一个->WebExpressionVoter,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果。

所以SpringSecurity默认的鉴权逻辑就是根据配置文件中的配置进行鉴权,这是符合我们现有认知的。

2. ✍动态鉴权实现

通过上面一步步的讲述,我想你也应该理解了SpringSecurity到底是什么实现鉴权的,那我们想要做到动态的给予某个角色不同的访问权限应该怎么做呢?

既然是动态鉴权了,那我们的权限URI肯定是放在数据库中了,我们要做的就是实时的在数据库中去读取不同角色对应的权限然后与当前登录的用户做个比较。

那我们要做到这一步可以想些方案,比如:

  • 直接重写一个AccessDecisionManager,将它用作默认的AccessDecisionManager,并在里面直接写好鉴权逻辑。
  • 再比如重写一个投票器,将它放到默认的AccessDecisionManager里面,和之前一样用投票器鉴权。
  • 我看网上还有些博客直接去做FilterSecurityInterceptor的改动。

我一向喜欢小而美的方式,少做改动,所以这里演示的代码将以第二种方案为基础,稍加改造。

那么我们需要写一个新的投票器,在这个投票器里面拿到当前用户的角色,使其和当前请求所需要的角色做个对比。

单单是这样还不够,因为我们可能在配置文件中也配置的有一些放行的权限,比如登录URI就是放行的,所以我们还需要继续使用我们上文所提到的WebExpressionVoter,也就是说我要自定义权限+配置文件双行的模式,所以我们的AccessDecisionManager里面就会有两个投票器:WebExpressionVoter和自定义的投票器。

紧接着我们还需要考虑去使用什么样的投票策略,这里我使用的是UnanimousBased一票反对策略,而没有使用默认的一票通过策略,因为在我们的配置中配置了除了登录请求以外的其他请求都是需要认证的,这个逻辑会被WebExpressionVoter处理,如果使用了一票通过策略,那我们去访问被保护的API的时候,WebExpressionVoter发现当前请求认证了,就直接投了赞成票,且因为是一票通过策略,这个请求就走不到我们自定义的投票器了。

注:你也可以不用配置文件中的配置,将你的自定义权限配置都放在数据库中,然后统一交给一个投票器来处理。

1. 重新构造AccessDecisionManager

那我们可以放手去做了,首先重新构造AccessDecisionManager, 因为投票器是系统启动的时候自动添加进去的,所以我们想多加入一个构造器必须自己重新构建AccessDecisionManager,然后将它放到配置中去。

而且我们的投票策略已经改变了,要由AffirmativeBased换成UnanimousBased,所以这一步是必不可少的。

并且我们还要自定义一个投票器起来,将它注册成Bean,AccessDecisionProcessor就是我们需要自定义的投票器。

 
复制代码
@Bean
    public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
        return new AccessDecisionProcessor();
    }

@Bean
    public AccessDecisionManager accessDecisionManager() {
        // 构造一个新的AccessDecisionManager 放入两个投票器
        List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
        return new UnanimousBased(decisionVoters);
    }

定义完AccessDecisionManager之后,我们将它放入启动配置:

 
复制代码
@Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                // 放行所有OPTIONS请求
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                // 放行登录方法
                .antMatchers("/api/auth/login").permitAll()
                // 其他请求都需要认证后才能访问
                .anyRequest().authenticated()
                // 使用自定义的 accessDecisionManager
                .accessDecisionManager(accessDecisionManager())
                .and()
                // 添加未登录与权限不足异常处理器
                .exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler())
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .and()
                // 将自定义的JWT过滤器放到过滤链中
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class)
                // 打开Spring Security的跨域
                .cors()
                .and()
                // 关闭CSRF
                .csrf().disable()
                // 关闭Session机制
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

这样之后,SpringSecurity里面的AccessDecisionManager就会被替换成我们自定义的AccessDecisionManager了。

2. 自定义鉴权实现

上文配置中放入了两个投票器,其中第二个投票器就是我们需要创建的投票器,我起名为AccessDecisionProcessor

投票其也是有一个接口规范的,我们只需要实现这个AccessDecisionVoter接口就行了,然后实现它的方法。

 
复制代码
@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {
    @Autowired
    private Cache caffeineCache;

    @Override
    public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert object != null;

        // 拿到当前请求uri
        String requestUrl = object.getRequestUrl();
        String method = object.getRequest().getMethod();
        log.debug("进入自定义鉴权投票器,URI : {} {}", method, requestUrl);

        String key = requestUrl + ":" + method;
        // 如果没有缓存中没有此权限也就是未保护此API,弃权
        PermissionInfoBO permission = caffeineCache.get(CacheName.PERMISSION, key, PermissionInfoBO.class);
        if (permission == null) {
            return ACCESS_ABSTAIN;
        }

        // 拿到当前用户所具有的权限
        List<String> roles = ((UserDetail) authentication.getPrincipal()).getRoles();
        if (roles.contains(permission.getRoleCode())) {
            return ACCESS_GRANTED;
        }else{
            return ACCESS_DENIED;
        }
    }

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

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}

大致逻辑是这样:我们以URI+METHOD为key去缓存中查找权限相关的信息,如果没有找到此URI,则证明这个URI没有被保护,投票器可以直接弃权。

如果找到了这个URI相关权限信息,则用其与用户自带的角色信息做一个对比,根据对比结果返回ACCESS_GRANTEDACCESS_DENIED

当然这样做有一个前提,那就是我在系统启动的时候就把URI权限数据都放到缓存中了,系统一般在启动的时候都会把热点数据放入缓存中,以提高系统的访问效率。

 
复制代码
@Component
public class InitProcessor {
    @Autowired
    private PermissionService permissionService;
    @Autowired
    private Cache caffeineCache;

    @PostConstruct
    public void init() {
        List<PermissionInfoBO> permissionInfoList = permissionService.listPermissionInfoBO();
        permissionInfoList.forEach(permissionInfo -> {
            caffeineCache.put(CacheName.PERMISSION, permissionInfo.getPermissionUri() + ":" + permissionInfo.getPermissionMethod(), permissionInfo);
        });
    }
}

这里我考虑到权限URI可能非常多,所以将权限URI作为key放到缓存中,因为一般缓存中通过key读取数据的速度是O(1),所以这样会非常快。

鉴权的逻辑到底如何处理,其实是开发者自己来定义的,要根据系统需求和数据库表设计进行综合考量,这里只是给出一个思路。

如果你一时没有理解上面权限URI做key的思路的话,我可以再举一个简单的例子:

比如你也可以拿到当前用户的角色,查到这个角色下的所有能访问的URI,然后比较当前请求的URI,有一致的则证明当前用户的角色下包含了这个URI的权限所以可以放行,没有一致的则证明不够权限不能放行。

这种方式的话去比较URI的时候可能会遇到这样的问题:我当前角色权限是/api/user/**,而我请求的URI是/user/get/1,这种Ant风格的权限定义方式,可以用一个工具类来进行比较:

 
复制代码
@Test
 public void match() {
  AntPathMatcher antPathMatcher = new AntPathMatcher();
  // true
  System.out.println(antPathMatcher.match("/user/**", "/user/get/1"));
 }

这是我是为了测试直接new了一个AntPathMatcher,实际中你可以将它注册成Bean,注入到AccessDecisionProcessor中进行使用。

它也可以比较RESTFUL风格的URI,比如:

 
复制代码
@Test
 public void match() {
  AntPathMatcher antPathMatcher = new AntPathMatcher();
  // true
  System.out.println(antPathMatcher.match("/user/{id}", "/user/1"));
 }

在面对真正的系统的时候,往往是根据系统设计进行组合使用这些工具类和设计思想。

ACCESS_GRANTEDACCESS_DENIEDACCESS_ABSTAINAccessDecisionVoter接口中带有的常量。

后记

好了,上面就是这期的所有内容了,我从周日就开始肝了。

我写文章啊,一般要写三遍:

  • 第一遍是初稿,把思路里面已有的梳理之后转化成文字。

  • 第二遍是查漏补缺,看看有哪些原来的思路里面遗漏的地方可以补上。

  • 第三遍就是对语言结构的重新整理。

经此三遍之后,我才敢发,所以认证和授权分成两篇了,一是可以分开写,二是写到一块很费时间,我又是第一次写文,不敢设太大的目标。

这就好比你第一次背单词就告诉自己一天要背1000个,最后当然背不下来,然后就会自己责怪自己,最终陷入循环。

初期设立太大的目标往往会适得其反,前期一定要挑一些自己力所能及的,先尝到完成的喜悦,再慢慢加大难度,这个道理是很多做事的道理。

这篇结束后SpringSecurity的认证与授权就都完成了,希望大家有所收获。

上一篇SpringSecurity的认证流程,大家也可以再回顾一下。

下一篇的话还没想好,估计会写一点开发时候常遇到的通用工具或配置的问题,放松放松,oauth2的东西也有打算,不知道oauth2的东西有人看吗。

如果觉得写的还不错的话,可以抬一手帮我点个赞哈,毕竟我也需要升级啊🚀

你们的每个点赞收藏与评论都是对我知识输出的莫大肯定,如果有文中有什么错误或者疑点或者对我的指教都可以在评论区下方留言,一起讨论。

我是耳朵,一个一直想做知识输出的人,下期见。

本文代码:码云地址GitHub地址

别辜负生命,别辜负自己。

楔子

前面两期我讲了SpringSecurity认证流程SpringSecurity鉴权流程,今天是第三期,是SpringSecurity的收尾工作,讲SpringSecurity的启动流程。

就像很多电影拍火了之后其续作往往是前作的前期故事一样,我这个第三期要讲的SpringSecurity启动流程也是不择不扣的"前期故事",它能帮助你真正认清SpringSecurity的整体全貌。

在之前的文章里,在说到SpringSecurity中的过滤器链的时候,往往是把它作为一个概念了解的,就是我们只是知道有这么个东西,也知道它到底是干什么用的,但是我们却不知道这个过滤器链是由什么类什么时候去怎么样创建出来的。

今天这期就是要了解SpringSecurity的自动配置到底帮我们做了什么,它是如何把过滤器链给创建出来的,又是在默认配置的时候怎么加入了我们的自定义配置。

祝有好收获(边赞边看,法力无限)。

1. 📚EnableWebSecurity

我们先来看看我们一般是如何使用SpringSecurity的。

我们用SpringSecurity的时候都会先新建一个SpringSecurity相关的配置类,用它继承WebSecurityConfigurerAdapter,然后打上注解@EnableWebSecurity,然后我们就可以通过重写 WebSecurityConfigurerAdapter里面的方法来完成我们自己的自定义配置。

就像这样:

 
复制代码
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

    }

}

我们已经知道,继承WebSecurityConfigurerAdapter是为了重写配置,那这个注解是做了什么呢?

从它的名字@EnableWebSecurity我们可以大概猜出来,它就是那个帮我们自动配置了SpringSecurity好心人

 
复制代码
@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = { java.lang.annotation.ElementType.TYPE })
@Documented
@Import({ WebSecurityConfiguration.class,
        SpringWebMvcImportSelector.class,
        OAuth2ImportSelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {

    /**
     * Controls debugging support for Spring Security. Default is false.
     * @return if true, enables debug support with Spring Security
     */
    boolean debug() default false;
}

emmm,我猜大家应该有注解相关的知识吧,ok,既然你们都有注解相关的知识,我就直接讲了。

这个@EnableWebSecurity中有两个地方是比较重要的:

  • 一是@Import注解导入了三个类,这三个类中的后两个是SpringSecurity为了兼容性做的一些东西,兼容SpringMVC,兼容SpringSecurityOAuth2,我们主要看的其实是第一个类,导入这个类代表了加载了这个类里面的内容。

  • 二是@EnableGlobalAuthentication这个注解,@EnableWebSecurity大家还没搞明白呢,您这又来一个,这个注解呢,其作用也是加载了一个配置类-AuthenticationConfiguration,看它的名字大家也可应该知道它加载的类是什么相关的了吧,没错就是AuthenticationManager相关的配置类,这个我们可以以后再说。

综上所述,@EnableWebSecurity可以说是帮我们自动加载了两个配置类:WebSecurityConfigurationAuthenticationConfiguration(@EnableGlobalAuthentication注解加载了这个配置类)。

其中WebSecurityConfiguration是帮助我们建立了过滤器链的配置类,而AuthenticationConfiguration则是为我们注入AuthenticationManager相关的配置类,我们今天主要讲的是WebSecurityConfiguration

2. 📖源码概览

既然讲的是WebSecurityConfiguration,我们照例先把源码给大家看看,精简了一下无关紧要的:

 
复制代码
@Configuration(proxyBeanMethods = false)
public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware {
    private WebSecurity webSecurity;

    private Boolean debugEnabled;

    private List<SecurityConfigurer<Filter, WebSecurity>> webSecurityConfigurers;

    private ClassLoader beanClassLoader;

    @Autowired(required = false)
    private ObjectPostProcessor<Object> objectObjectPostProcessor;

    
    @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = webSecurityConfigurers != null
                && !webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            webSecurity.apply(adapter);
        }
        return webSecurity.build();
    }


    @Autowired(required = false)
    public void setFilterChainProxySecurityConfigurer(
            ObjectPostProcessor<Object> objectPostProcessor,
            @Value("#{@autowiredWebSecurityConfigurersIgnoreParents.getWebSecurityConfigurers()}") List<SecurityConfigurer<Filter, WebSecurity>> webSecurityConfigurers)
            throws Exception {
        webSecurity = objectPostProcessor
                .postProcess(new WebSecurity(objectPostProcessor));
        if (debugEnabled != null) {
            webSecurity.debug(debugEnabled);
        }

        webSecurityConfigurers.sort(AnnotationAwareOrderComparator.INSTANCE);

        Integer previousOrder = null;
        Object previousConfig = null;
        for (SecurityConfigurer<Filter, WebSecurity> config : webSecurityConfigurers) {
            Integer order = AnnotationAwareOrderComparator.lookupOrder(config);
            if (previousOrder != null && previousOrder.equals(order)) {
                throw new IllegalStateException(
                        "@Order on WebSecurityConfigurers must be unique. Order of "
                                + order + " was already used on " + previousConfig + ", so it cannot be used on "
                                + config + " too.");
            }
            previousOrder = order;
            previousConfig = config;
        }
        for (SecurityConfigurer<Filter, WebSecurity> webSecurityConfigurer : webSecurityConfigurers) {
            webSecurity.apply(webSecurityConfigurer);
        }
        this.webSecurityConfigurers = webSecurityConfigurers;
    }
    
}

如代码所示,首先WebSecurityConfiguration是个配置类,类上面打了@Configuration注解,这个注解的作用大家还知道吧,在这里就是把这个类中所有带@Bean注解的Bean给实例化一下。

这个类里面比较重要的就两个方法:springSecurityFilterChainsetFilterChainProxySecurityConfigurer

springSecurityFilterChain方法上打了@Bean注解,任谁也能看出来就是这个方法创建了springSecurityFilterChain,但是先别着急,我们不能先看这个方法,虽然它在上面。

3. 📄SetFilterChainProxySecurityConfigurer

我们要先看下面的这个方法:setFilterChainProxySecurityConfigurer,为啥呢?

为啥呢?

因为它是@Autowired注解,所以它要比springSecurityFilterChain方法优先执行,从系统加载的顺序来看,我们需要先看它。

@Autowired在这里的作用是为这个方法自动注入所需要的两个参数,我们先来看看这两个参数:

  • 参数objectPostProcessor是为了创建WebSecurity实例而注入进来的,先了解一下即可。

  • 参数webSecurityConfigurers是一个List,它实际上是所有WebSecurityConfigurerAdapter的子类,那如果我们定义了自定义的配置类,其实就是把我们的配置也读取到了。

    这里其实有点难懂为什么参数中SecurityConfigurer<Filter, WebSecurity>这个类型可以拿到WebSecurityConfigurerAdapter的子类?

    因为WebSecurityConfigurerAdapter实现了WebSecurityConfigurer<WebSecurity>接口,而WebSecurityConfigurer<WebSecurity>又继承了SecurityConfigurer<Filter, T>,经过一层实现,一层继承关系之后,WebSecurityConfigurerAdapter终于成为了SecurityConfigurer的子类。

    而参数中SecurityConfigurer<Filter, WebSecurity>中的两个泛型参数其实是起到了一个过滤的作用,仔细查看我们的WebSecurityConfigurerAdapter的实现与继承关系,你可以发现我们的WebSecurityConfigurerAdapter正好是这种类型。

ok,说完了参数,我觉得我们可以看看代码了:

 
复制代码
@Autowired(required = false)
public void setFilterChainProxySecurityConfigurer(
        ObjectPostProcessor<Object> objectPostProcessor,
        @Value("#{@autowiredWebSecurityConfigurersIgnoreParents.getWebSecurityConfigurers()}") List<SecurityConfigurer<Filter, WebSecurity>> webSecurityConfigurers)
        throws Exception {

    // 创建一个webSecurity实例
    webSecurity = objectPostProcessor
            .postProcess(new WebSecurity(objectPostProcessor));
    if (debugEnabled != null) {
        webSecurity.debug(debugEnabled);
    }

    // 根据order排序
    webSecurityConfigurers.sort(AnnotationAwareOrderComparator.INSTANCE);

    Integer previousOrder = null;
    Object previousConfig = null;
    for (SecurityConfigurer<Filter, WebSecurity> config : webSecurityConfigurers) {
        Integer order = AnnotationAwareOrderComparator.lookupOrder(config);
        if (previousOrder != null && previousOrder.equals(order)) {
            throw new IllegalStateException(
                    "@Order on WebSecurityConfigurers must be unique. Order of "
                            + order + " was already used on " + previousConfig + ", so it cannot be used on "
                            + config + " too.");
        }
        previousOrder = order;
        previousConfig = config;
    }

    // 保存配置
    for (SecurityConfigurer<Filter, WebSecurity> webSecurityConfigurer : webSecurityConfigurers) {
        webSecurity.apply(webSecurityConfigurer);
    }

    // 成员变量初始化
    this.webSecurityConfigurers = webSecurityConfigurers;
}

根据我们的注释,这段代码做的事情可以分为以为几步:

  1. 创建了一个webSecurity实例,并且赋值给成员变量。

  2. 紧接着对webSecurityConfigurers通过order进行排序,order是加载顺序。

  3. 进行判断是否有相同order的配置类,如果出现将会直接报错。

  4. 保存配置,将其放入webSecurity的成员变量中。

大家可以将这些直接理解为成员变量的初始化,和加载我们的配置类配置即可,因为后面的操作都是围绕它初始化的webSecurity实例和我们加载的配置类信息来做的。

这些东西还可以拆出来一步步的来讲,但是这样的话真是一篇文章写不完,我也没有那么大的精力能够事无巨细的写出来,我只挑选这条痕迹清晰的主脉络来讲,如果大家看完能明白它的一个加载顺序其实就挺好了。

就像Spring的面试题会问SpringBean的加载顺序,SpringMVC则会问SpringMVC一个请求的运行过程一样。

全部弄得明明白白,必须要精研源码,在初期,我们只要知道它的一条主脉络,在之后的使用中,哪出了问题你可以直接去定位到可能是哪有问题,这样就已经很好了,学习是一个循环渐进的过程。

4. 📃SpringSecurityFilterChain

初始化完变量,加载完配置,我们要开始创建过滤器链了,所以先走setFilterChainProxySecurityConfigurer是有原因的,如果我们不把我们的自定义配置加载进来,创建过滤器链的时候怎么知道哪些过滤器需要哪些过滤器不需要。

 
复制代码
@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = webSecurityConfigurers != null
                && !webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            webSecurity.apply(adapter);
        }
        return webSecurity.build();
    }

springSecurityFilterChain方法逻辑就很简单了,如果我们没加载自定义的配置类,它就替我们加载一个默认的配置类,然后调用这个build方法。

看到这熟悉的方法名称,你就应该知道这是建造者模式,不管它什么模式,既然调用了,我们点进去就是了。

 
复制代码
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");
    }

build()方法是webSecurity的父类AbstractSecurityBuilder中的方法,这个方法又调用了doBuild()方法。

 
复制代码
@Override
protected final O doBuild() throws Exception {
    synchronized (configurers) {
        buildState = AbstractConfiguredSecurityBuilder.BuildState.INITIALIZING;
        
        // 空方法
        beforeInit();
        // 调用init方法
        init();

        buildState = AbstractConfiguredSecurityBuilder.BuildState.CONFIGURING;

        // 空方法
        beforeConfigure();
        // 调用configure方法
        configure();

        buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILDING;

        // 调用performBuild
        O result = performBuild();

        buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILT;

        return result;
    }
}

通过我的注释可以看到beforeInit()beforeConfigure()都是空方法, 实际有用的只有init()configure()performBuild()方法。

我们先来看看init()configure()方法。

 
复制代码
private void init() throws Exception {
        Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();

        for (SecurityConfigurer<O, B> configurer : configurers) {
            configurer.init((B) this);
        }

        for (SecurityConfigurer<O, B> configurer : configurersAddedInInitializing) {
            configurer.init((B) this);
        }
    }

private void configure() throws Exception {
        Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();

        for (SecurityConfigurer<O, B> configurer : configurers) {
            configurer.configure((B) this);
        }
    }

源码中可以看到都是先获取到我们的配置类信息,然后循环调用配置类自己的init()configure()方法。

前面说过,我们的配置类是继承了WebSecurityConfigurerAdapter的子类,而WebSecurityConfigurerAdapter又是SecurityConfigurer的子类,所有SecurityConfigurer的子类都需要实现init()configure()方法。

所以这里的init()configure()方法其实就是调用WebSecurityConfigurerAdapter自己重写的init()configure()方法。

其中WebSecurityConfigurerAdapter中的configure()方法是一个空方法,所以我们只需要去看WebSecurityConfigurerAdapter中的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);
        });
    }

这里也可以分为两步:

  1. 执行了getHttp()方法,这里面初始化加入了很多过滤器。

  2. HttpSecurity放入WebSecurity,将FilterSecurityInterceptor放入WebSecurity,就是我们鉴权那章讲过的FilterSecurityInterceptor

那我们主要看第一步getHttp()方法:

 
复制代码
protected final HttpSecurity getHttp() throws Exception {
        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;
    }

getHttp()方法里面http调用的那一堆方法都是一个个过滤器,第一个csrf()很明显就是防止CSRF攻击的过滤器,下面还有很多,这就是SpringSecurity默认会加入过滤器链的那些过滤器了。

其次,还有一个重点就是倒数第二行代码,我也加上了注释,我们一般在我们自定义的配置类中重写的就是这个方法,所以我们的自定义配置就是在这里生效的。

所以在初始化的过程中,这个方法会先加载自己默认的配置然后再加载我们重写的配置,这样两者结合起来,就变成了我们看到的默认配置。(如果我们不重写configure(http)方法,它也会一点点的默认配置,大家可以去看源码,看了就明白了。)

init()configure()(空方法)结束之后,就是调用performBuild()方法。

 
复制代码
protected Filter performBuild() throws Exception {

    int chainSize = ignoredRequests.size() + securityFilterChainBuilders.size();

    List<SecurityFilterChain> securityFilterChains = new ArrayList<>(
            chainSize);

    for (RequestMatcher ignoredRequest : ignoredRequests) {
        securityFilterChains.add(new DefaultSecurityFilterChain(ignoredRequest));
    }

    // 调用securityFilterChainBuilder的build()方法
    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;
    
    postBuildAction.run();
    return result;
    }

这个方法主要需要看的是调用securityFilterChainBuilderbuild()方法,这个securityFilterChainBuilder是我们在init()方法中add的那个,所以这里的securityFilterChainBuilder其实就是HttpSecurity,所以这里其实是调用了HttpSecuritybulid()方法。

又来了,WebSecuritybulid()方法还没说完,先来了一下HttpSecuritybulid()方法。

HttpSecuritybulid()方法进程和之前的一样,也是先init()然后configure()最后performBuild()方法,值得一提的是在HttpSecurityperformBuild()方法里面,会对过滤器链中的过滤器进行排序:

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

HttpSecuritybulid()方法执行完了之后将DefaultSecurityFilterChain返回给WebSecurityperformBuil()方法,performBuil()方法再将其转换为FilterChainProxy,最后WebSecurityperformBuil()方法执行结束,返回一个Filter注入成为name="springSecurityFilterChain"Bean

经过以上这些步骤之后,springSecurityFilterChain方法执行完毕,我们的过滤器链就创建完成了,SpringSecurity也可以跑起来了。

后记

看到这的话,其实你已经很有耐性了,但可能还觉得云里雾里的,因为SpringSecurity(Spring大家族)这种工程化极高的项目项目都是各种设计模式和编码思想满天飞,看不懂的时候只能说这什么玩意,看得懂的时候又该膜拜这是艺术啊。

这些东西它不容易看懂但是比较解耦容易扩展,像一条线下来的代码就容易看懂但是不容易扩展了,福祸相依。

而且这么多名称相近的类名,各种继承抽象,要好好理解下来的确没那么容易,这篇其实想给这个SpringSecurity来个收尾,逼着自己写的,我这个人喜欢有始有终,这段东西也的确复杂,接下来的几篇打算写几个实用的有意思的也轻松的放松一下。

如果你对SpringSecurity源码有兴趣可以跟着来我这个文章,点开你自己的源码点一点,看一看,加油。

自从上篇征文发了之后,感觉多了很多前端的关注者,掘金果然还是前端多啊,没事,虽然我不怎么写前端,说不定哪天改行了呢哈哈。

我也不藏着掖着,其实我现在是写后端的,我对前端呢只能说是略懂略懂,不过无聊了也可以来看看我的文章,点点赞刷刷阅读干干啥的👍,说不定某一天突然看懂了某篇文还前端劝退后端入行,加油了大家。

别辜负生命,别辜负自己。

你们的每个点赞收藏与评论都是对我知识输出的莫大肯定,如果有文中有什么错误或者疑点或者对我的指教都可以在评论区下方留言,一起讨论。

我是耳朵,一个一直想做知识输出的伪文艺程序员,下期见。

「和耳朵」SpringSecurity是如何代理过滤器链的? - 掘金 (juejin.cn)

从去年的七月份开始我陆陆续续写了三篇SpringSecurity相关的文章,幸得各位掘友捧场,反响还算不错,这也给了我极大的信心和动力,本来是打算继续写下去做成一个SpringSecurity系列,后来因为换工作的缘故,导致暂时搁置了,今天开始继续更新相关内容,希望大家多多支持👍。

今天的主题是:SpringSecurity如何进行过滤器链代理?

1. 外部容器与内嵌容器

在前文SpringSecurity自动配置这一章我们已经说过SpringSecurity会初始化一个名为springSecurityFilterChain的Bean,我们的请求实际就会被这个Bean进行处理。

但是SpringSecurity不会直接拿这个Bean进行处理,而是通过一个Filter进行代理,在这个Filter内部调用了这个Bean进行doFilter处理(实际是不是一层代理,而是多层)。

那我们其实可以将流程简化为下图:

看起来很简单,Spring只需要注册一个Filter再调用过滤器链就行了,但其实Spring不会直接注册一个Filter,它会把这个Filter注册成为一个Bean交给容器管理,再注册到Filter中。

这里涉及到一个问题,我们传统的注册Filter是直接定义Filter,而要把一个Filter先注册成Bean再注册到Filter中无疑会做些额外的事,所以Spring就提供了两个接口:

  • WebApplicationInitializer - 打包为war包使用外部容器时使用
  • ServletContextInitializer - 打包为jar包使用内嵌容器时使用

这两个接口的作用都是可以在Servlet容器启动后注册自定义的Filter/Listener并交给Bean工厂管理,唯一不同的就是在不同外部环境下会选取不同的生效接口。

一般来说,框架中会同时包含这两种接口的实现,保证在不同外部环境下都有一致的表现,比如SpringSecurity中就具有以下两个实现:

  • AbstractSecurityWebApplicationInitializer - 外部容器处理类
  • DelegatingFilterProxyRegistrationBean - 内嵌容器处理类

我们今天的内容也是以这两个实现类为入口进行深入了解,不过上文也说到了他们仅仅是注册Filter的入口不一样,注册Filter之后的后续代理流程还是一模一样的。

2. 内嵌容器方式注册Filter

现在都是微服务大行其道的时候了,一般来说我们的应用都是通过内嵌容器的方式进行运行,所以我们先来说说内嵌容器运行时的整个启动链路。

我们知道在SpringBoot中很重要的一个特性就是自动配置,在org.springframework.boot.autoconfigure.security包下放着SpringSecurity的自动配置类,我们着重来看SecurityFilterAutoConfiguration,这里面只有一个方法:

 
java
复制代码
    public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(SecurityProperties securityProperties) {
        DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean("springSecurityFilterChain", new ServletRegistrationBean[0]);
        registration.setOrder(securityProperties.getFilter().getOrder());
        registration.setDispatcherTypes(this.getDispatcherTypes(securityProperties));
        return registration;
    }

通过这个方法的方法名:securityFilterChainRegistration我们可以看出来该方法的作用就是为了注册过滤器链,同时我们看它的方法内容可以看到它创建了一个上文中提到过的DelegatingFilterProxyRegistrationBean的实例,传入的参数就是过滤器链的名称,由于此类是ServletContextInitializer的实现类,所以它最终会通过它的getFilter()方法创建一个Filter实例。

 
java
复制代码
    public DelegatingFilterProxy getFilter() {
        return new DelegatingFilterProxy(this.targetBeanName, this.getWebApplicationContext()) {
            protected void initFilterBean() throws ServletException {
            }
        };
    }

这个DelegatingFilterProxy就是创建出的Filter实例,我们可以再深一步的看看它的实例化过程:

 
java
复制代码
    public DelegatingFilterProxyRegistrationBean(String targetBeanName, ServletRegistrationBean<?>... servletRegistrationBeans) {
        super(servletRegistrationBeans);
        Assert.hasLength(targetBeanName, "TargetBeanName must not be null or empty");
        this.targetBeanName = targetBeanName;
        this.setName(targetBeanName);
    }

实例化的过程比较简单,就是将我们要代理的Bean的名字放在targetBeanName属性上面,然后这个Filter就相当于在我们容器的Filter链中了。

这其实这个时候我们要代理的Bean的实例还没被放到这个代理类中,目前这个代理类中只有targetBeanName属性。

 
java
复制代码
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Filter delegateToUse = this.delegate;
        if (delegateToUse == null) {
            synchronized(this.delegateMonitor) {
                delegateToUse = this.delegate;
                if (delegateToUse == null) {
                    WebApplicationContext wac = this.findWebApplicationContext();
                    if (wac == null) {
                        throw new IllegalStateException("No WebApplicationContext found: no ContextLoaderListener or DispatcherServlet registered?");
                    }

                    delegateToUse = this.initDelegate(wac);
                }

                this.delegate = delegateToUse;
            }
        }

        this.invokeDelegate(delegateToUse, request, response, filterChain);
    }

代理Bean最终的实例化是在第一次请求到达,调用doFilter方法时,判断之后发现目前并没有被代理对象,就会调用initDelegate方法:

 
java
复制代码
    protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
        String targetBeanName = this.getTargetBeanName();
        Assert.state(targetBeanName != null, "No target bean name set");
        Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);
        if (this.isTargetFilterLifecycle()) {
            delegate.init(this.getFilterConfig());
        }

        return delegate;
    }

这个过程也很简单,就是从Bean工厂中找到这个Bean,然后赋值给代理对象。

最后,将会调用invokeDelegate方法,调用被代理对象的doFilter方法:

 
java
复制代码
    protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        delegate.doFilter(request, response, filterChain);
    }

其实到这就已经和之前的文章内容串联起来了,剩下的就可以交给代理Bean去做了,这个代理Bean就是我们在SpringSecurity自动配置这一章讲到过的springSecurityFilterChain

然而其实我们可以继续深入,在那一章讲过这个Bean的类型是FilterChainProxy,我们可以看看它内部的doFilter又到底是怎么做的?过滤器链真的是一个链条吗?FilterChainProxy是直接代理这个链条吗?

 
java
复制代码
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
        if (!clearContext) {
            this.doFilterInternal(request, response, chain);
        } else {
            try {
                request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
                this.doFilterInternal(request, response, chain);
            } catch (RequestRejectedException var9) {
                this.requestRejectedHandler.handle((HttpServletRequest)request, (HttpServletResponse)response, var9);
            } finally {
                SecurityContextHolder.clearContext();
                request.removeAttribute(FILTER_APPLIED);
            }

        }
    }

这是FilterChainProxy的doFilter方法,上来就是一个if-else,但是他们会调用同一个核心方法doFilterInternal

 
java
复制代码
    private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);
        HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response);
        List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);
        if (filters != null && filters.size() != 0) {
            if (logger.isDebugEnabled()) {
                logger.debug(LogMessage.of(() -> {
                    return "Securing " + requestLine(firewallRequest);
                }));
            }

            FilterChainProxy.VirtualFilterChain virtualFilterChain = new FilterChainProxy.VirtualFilterChain(firewallRequest, chain, filters);
            virtualFilterChain.doFilter(firewallRequest, firewallResponse);
        } else {
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.of(() -> {
                    return "No security for " + requestLine(firewallRequest);
                }));
            }

            firewallRequest.reset();
            chain.doFilter(firewallRequest, firewallResponse);
        }
    }

在这个方法中会根据当前的请求去匹配能匹配到的Filters,具体操作在getFilters方法中,它的作用就是拿到FilterChainProxy的filterChains成员变量中的所有Filter,然后进行匹配,将匹配到的Filter封装成一个List。

乍听一下filterChains你可能已经不知道了,我们来看看它的类型:SecurityFilterChain,没错这是一个类,不要和前面的Bean搞混了,如果之前的文章你还记得那么可以知道这其实是由我们的自定义配置构建出的一个类,里面放了我们自定义相关的Filter。

如果我们有多个自定义配置文件就会有多个这样的对象,他们共同组成了filterChains。

如果能匹配到对应的Filter那么它会组成一个新的类:FilterChainProxy.VirtualFilterChain,这是一个内部类,然后进行doFilter操作,所以我们平常所说的过滤器链实际上是这个,它是真正起效果的那个类。

为了防止大家这一段看不懂、不理解、我特地画了一个图来形象的表示整个链路,只要对照我这个图再根据相关代码,相信可以很快的理解这一段的链路:

3. 外部容器方式注册Filter

刚说完了内嵌容器的方式,再来说说外部容器的方式,我在文章开头也说过,其实就是实现类的不同,注册完Bean后面的流程都是一样的,我先放个流程图给大家看看:

大家可以发现,其实只有最上面的两块发生了变化,所以如果上一节的内容好好看的话这一节的很快就能理解。

外部容器的情况下SpringSecurity的入口在AbstractSecurityWebApplicationInitializer,我再来重述一下它的作用:

  1. 继承了WebApplicationInitializer
  2. Servlet容器启动后会调用其实现类的onStartup方法,在这里可以进行Filter的注册

在这个类中它通过调用了insertSpringSecurityFilterChain方法进行Filter的注册:

 
java
复制代码
    private void insertSpringSecurityFilterChain(ServletContext servletContext) {
        String filterName = "springSecurityFilterChain";
        DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(filterName);
        String contextAttribute = this.getWebApplicationContextAttribute();
        if (contextAttribute != null) {
            springSecurityFilterChain.setContextAttribute(contextAttribute);
        }

        this.registerFilter(servletContext, true, filterName, springSecurityFilterChain);
    }

这个方法其实也就是实例化了DelegatingFilterProxy,后续的流程都是一样了~~~

4. 结语

这三篇加上本篇一共四篇,大致已经将SpringSecurity的轮廓和关键流程勾勒出来了,工作中其实这么多内容也够了,我们公司就是用的SpringSecurity作为认证鉴权框架,相关代码链路也都能看懂😄

🏷️日后会支持输出更多后端内容,欢迎大家点赞收藏👏

 

Spring Security + JWT + Swagger2 登录验证小结 - 掘金 (juejin.cn)

Spring Security + JWT + Swagger2 登录验证一套流程

主要是三个框架的集成配置,以及各个独立的配置(主要是 JWT + Security 的登录验证)。

流程:

  • 构建 Spring Boot 基本项目,准备数据库表 User —— 用于存放登录实体类信息。
  • 配置 Security 和 Swagger2 环境,确保没有什么问题。
  • 构建 RespBean——公共返回实体类JwtTokenUtil——JWT token 工具类User——登录实体类
  • 让 User 实现 UserDetails 接口,重写部分方法。
  • 配置 Security 实现重写 UserDetailsService 方法,以及 PasswordEncoder——密码凭证器 并加上 @Bean 注解。这两个主要用于设置 Security 的认证。
  • 构建 jwtAuthenticationTokenFilter 类——自定义 JWT Token 拦截器,并在 SecurityConfig 的授权方法中添加此拦截器。
  • 在 Swagger2Config 配置类中,配置有关 Security 的 Token 认证。
  • 启动项目查看代码是否准确。

1. 构建 Spring Boot 基本项目,准备数据库——User

项目子模块:authority-security,父模块已引入 Spring boot 依赖 2.3.0

1.1 导入依赖

 
xml
复制代码
<dependencies>
    <!-- web 依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- lombok 依赖 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- mysql 依赖 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- mybatis-plus 依赖 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.3.1.tmp</version>
    </dependency>

    <!-- swagger2 依赖 -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.7.0</version>
    </dependency>

    <!-- swagger 第三方 UI 依赖 -->
    <dependency>
        <groupId>com.github.xiaoymin</groupId>
        <artifactId>swagger-bootstrap-ui</artifactId>
        <version>1.9.6</version>
    </dependency>

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

    <!-- JWT 依赖 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>

    <!-- commons-pool2 对象池依赖 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
</dependencies>

构建数据库表:user

 
sql
复制代码
create table user(
	id int primary key auto_increment,
	username varchar not null,
	password varchar not null,
	info varchar(200),
	enabled tinyint(1) default 1
)

insert into user values(default,"admin","$2a$10$Himwt.wu3MPOLnNQ9YUH8O2quxgi7bMuomiNeFsVKRay87.qG5dgy","管理员 info ...",default)

username:admin;password:123

配置 application.yml 文件参数:

 
yaml
复制代码
server:
  port: 8082

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/dbtest16?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: admin
    password: admin
    hikari:
      # 连接池名字
      pool-name: DateHikari
      # 最小空闲连接数
      minimum-idle: 5
      # 空闲连接存活最大事件,默认10分钟(600000)
      idle-timeout: 180000
      # 最大连接数:默认 10
      maximum-pool-size: 10
      # 从连接池返回的连接自动提交
      auto-commit: true
      # 连接最大存活时间,0 表示永久存活,默认 1800000(30 min)
      max-lifetime: 1800000
      # 连接超时事件 30 s
      connection-timeout: 30000
      # 测试连接是否可用的查询语句
      connection-test-query: SELECT 1

# MP 配置
mybatis-plus:
  # 配置 Mapper 映射文件
  mapper-locations: classpath*:/mapper/*Mapper.xml
  # 实体类的别名包
  type-aliases-package: com.cnda.pojo
  configuration:
    # 自动驼峰命名
    map-underscore-to-camel-case: false

# MyBatis 的 SQL 打印是方法接口所在的包
logging:
  level:
    com.cnda.mapper: debug

# JWT 配置
jwt:
  # JWT 存储的请求头
  tokenHeader: Authorization
  # JWT 加密使用的密钥
  secret: test-cnda-secret
  # JWT 的有效时间 (60*60*24)
  expiration: 604800
  # JWT 负载中拿到开头 规定
  tokenHead: Bearer

User 实体类代码:

 
java
复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    private Integer id;
    private String username;
    private String password;
    private String info;
    private Boolean enabled;
}

2. 配置 Security 和 Swagger2 的配置

先配置好这两个确保没有什么问题,因为重点是 JWT,这两个配置比较简单,当搭配了 JWT 之后,Swagger2 也需要与两者集成一些配置,这个后面再说,现在只配置基本设置。

2.1 配置 SecurityConfig

 
java
复制代码
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(
                "/hello",
            	// 下面是对静态资源以及 swagger2 UI 的放行。
            	"/css/**",
                "/js/**",
                "/img/**",
                "/index.html",
                "favicon.ico",
                "/doc.html",
                "/webjars/**",
                "/swagger-resources/**",
                "/v2/api-docs/**",
                "/ws/**"
        );
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
    }
}

上面使用 WebSecurity 放行了 /hello 请求,在 LoginController 中。

 
java
复制代码
@RestController
public class LoginController {

    @RequestMapping("/hello")
    public String hello(){
        return "Hello Word!";
    }
}

这意味除了 localhost:8082/hello 会被放行,其他请求都会被 Security 拦截重定向到 /login(这个请求 Security 内部已经实现了包括相关页面)。

2.2 配置 Swagger2Config

 
java
复制代码
@Configuration
@EnableSwagger2
public class Swagger2Config {
    @Bean
    public Docket docket(){
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo()) // 配置 apiInfo
                .select() // 选择那些路径和api会生成document
                .apis(RequestHandlerSelectors.basePackage("com.cnda.controller")) // // 对哪些 api进行监控,RequestHandlerSelectors.basePackage 基于包扫描
                .paths(PathSelectors.any()) // 对所有路径进行监控
                .build();

    }

    private ApiInfo apiInfo(){
        return new ApiInfoBuilder()
                .title("在线接口文档")
                .description("在线接口文档")
                .contact(new Contact("cnda","http://localhost:8082/doc.html","xxx@xxx.com"))
                .build();
    }
}

运行效果:

image-20230305095856050

修改一下 Rustful 风格,并加了一个 /hello1 请求,不放行,打印内容相同。

image-20230305100904394

image-20230305100944517

可以看到 Security 和 Swagger2 基本配置完成。

3. 构建 JWT 工具类、公共响应对象

JWT 工具类主要用于生成 JWT,判断 JWT 是否有效,刷新 JWT 等方法。

公共响应对象——RespBean,返回的都已 JSON 格式返回。

3.1 JwtUtil

 
java
复制代码
@Component
public class JwtUtil {
    // 准备两个存放在荷载的内容
    private static final String CLAIM_KEY_SUB = "sub";
    private static final String CLAIM_KEY_CREATE = "ibt";


    // 提取 application.yml 中 JWT 的参数:
    // 1. expiration Long
    @Value("${jwt.expiration}")
    private Long expiration;

    // 2. secret String
    @Value("${jwt.secret}")
    private String secret; // 密钥

    // 根据用户名构建 token
    public String foundJWT(UserDetails userDetails) {
        String username = userDetails.getUsername();
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_SUB, username);
        claims.put(CLAIM_KEY_CREATE, new Date());
        return foundJWT(claims);
    }

    // 根据荷载 map 构建 token
    private String foundJWT(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(getExpiration()) // 过期时间
                .signWith(SignatureAlgorithm.HS512, secret) // 设置签名算法和密钥
                .compact();
    }

    // 判断 token 是否有效
    public boolean validateToken(String token,UserDetails userDetails){
        // 从 token 中获取 username 与 userDetails 中的username 对比
        String username = getUsernameInToken(token);
        // 判断 username 是否一致以及 token 是否过期
        return username.equals(userDetails.getUsername()) && !isExpired(token);
    }


    // 判断 token 是否过期
    // true 过期 false 没过期
    private boolean isExpired(String token) {
        Date expiration = getClaimsInToken(token).getExpiration();
        return expiration.before(new Date());
    }
    // 从 token 中提取荷载信息
    public Claims getClaimsInToken(String token){
        Claims claims = null;

        try {
            claims = Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        }catch (Exception e){
            e.printStackTrace();
        }
        return claims;
    }

    // 从 token 中提取用户名信息
    public String getUsernameInToken(String token){
        String username;
        try {
            username = getClaimsInToken(token).getSubject();
        }catch (Exception e){
            username = null;
        }
        return username;
    }
    // token 是否能刷新
    public boolean tokenCanRef(String token){
        return !isExpired(token); // 有效地 token 才能被刷新
    }
    // 刷新 token
    public String refToken(String token){
        Claims claimsInToken = getClaimsInToken(token);
        claimsInToken.put(CLAIM_KEY_CREATE,new Date());
        return foundJWT(claimsInToken);
    }

    // 设置过期时间
    private Date getExpiration() {
        return new Date(System.currentTimeMillis() + expiration * 1000);
    }
}

3.2 RespBean 公共返回对象

 
java
复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RespBean {
    private long code;
    private String message;
    private Object obj;

    /**
     * 返回响应结果
     */
    private static RespBean result(long code, String message, Object obj) {
        return new RespBean(code, message, obj);
    }
    /*
       返回成功响应
        */
    public static RespBean success(String message) {
        return result(200, message, null);
    }

    /*
    返回成功响应以及数据体
     */
    public static RespBean success(String message, Object obj) {
        return result(200, message, obj);
    }

    /*
    返回错误响应
     */
    public static RespBean error(String message) {
        return result(500, message, null);
    }
}

4. 让 User 实体类实现 UserDetails 的方法成为 Security 验证的用户核心主体

由于 Security 框架的性质,自定义授权和认证时,一般情况下会自定义 UserDetails。

 
java
复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    private Integer id;
    private String username;
    private String password;
    private String info;
    private Boolean enabled;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { // 权限角色
        return null;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

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

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

    @Override
    public boolean isEnabled() { // 这里数据库实现了该字段,直接用即可
        return this.enabled;
    }
}

5. 重写 UserDetailsServer 和 PasswordEncoder

5.1 重写 UserDetailsServer

这个类就只有一个方法:

loadUserByUsername(UserDetails details),该方法用于根据用户名加载用户信息,用作于 Security 的后续认证,同时也可以用一个类去实现该接口,这里为了方便,同时也是 Lambda 表达式。

注意:这里的 UserMapper 没有代码展示了,就一个根据用户名查询用户信息的 SQL。

 
java
复制代码
@Resource
private UserMapper mapper;

@Bean
@Override
protected UserDetailsService userDetailsService() {
    return username -> {
        User user = mapper.find(username);
        if (user!=null){
            return user;
        }
        throw new UsernameNotFoundException("用户名或密码不正确");
    };
}

5.2 PasswordEncoder——密码凭证器

这个类主要用于验证表单提交的密码是否和 重写之后的 UserDetailsServer 得到的 UserDetails 中的加密密码一致。

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

5.3 配置到 SecurityConfig 的认证中

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

6. 配置 JWT 的拦截器

 
java
复制代码
public class JwtTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService service;


    @Value("${jwt.tokenHeader}")
    private String tokenHeader;

    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        // 获取请求头中的指定的值
        String headerToken = httpServletRequest.getHeader(tokenHeader);
        // 保证 header中的 token 不为 null,且以指定字串开头——Bearer
        if (headerToken!=null && headerToken.startsWith(tokenHead)){
            // 截取有效 token
            String jwtToken = headerToken.substring(tokenHead.length());
            String username = jwtUtil.getUsernameInToken(jwtToken);
            // 判断 UserDetails 中的用户主体是否为null
            if (username!=null && SecurityContextHolder.getContext().getAuthentication() == null){
                // SecurityContextHolder.getContext().getAuthentication() == null 代表着此时 Security 中没有登录的用户主体
                // 此时可以使用有效地 jwtToken 进行用户认证
                UserDetails userDetails = service.loadUserByUsername(username);
                // 判断 token 是否有效
                if (jwtUtil.validateToken(jwtToken,userDetails)){
                    // 如果有效则使用 token 中的信息进行登录
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
                    // 根据请求设置 Details,包含了部分请求信息和主体信息。具体效果不清楚...坑
                    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                    // 将 authenticationToken 设置到 SecurityContext 中
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);

                }
            }

        }
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

6.1 将 JWT 拦截器设置到 SecurityConfig 的授权方法中。

 
java
复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
    // 由于我们使用的是 JWT 令牌的形式来验证用户,所以可以将 csrf 防御关闭
    // JWT 能有效防止 csrf 攻击,强行使用 csrf 可能导致令牌泄露
    http.csrf()
        .disable()
        // 基于 token,不需要使用 Session 了
        .sessionManagement() // Session 管理
        // 管理 Session 创建策略
        //    ALWAYS, 总是创建HttpSession
        //    NEVER, 只会在需要时创建一个HttpSession
        //    IF_REQUIRED, 不会创建HttpSession,但如果它已经存在,将可以使用HttpSession
        //    STATELESS; 永远不会创建HttpSession,它不会使用HttpSession来获取SecurityContext
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeRequests() // 授权请求
        // 除了上面的请求,其他所有请求都需要认证
        .anyRequest()
        .authenticated()
        .and()
        // 禁止缓存
        .headers()
        .cacheControl();

    // 自定义拦截器 JWT 过滤器
    http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); // 将过滤器按照一定顺序加入过滤器链。

}

@Bean
public JwtTokenFilter jwtTokenFilter() {
    return new JwtTokenFilter();
}

7. 完善 LoginController 请求,运行项目。

LoginController

 
java
复制代码
@RestController
public class LoginController {
    @Autowired
    private UserService service;

    @GetMapping("/hello")
    public String hello(){
        return "Hello Word!";
    }
    @GetMapping("/hello1")
    public String hello1(){
        return "Hello1 Word!";
    }

    @PostMapping("/login")
    public RespBean loginUser(@RequestBody User user, HttpServletRequest request){
        return service.login(user.getUsername(),user.getPassword(),request);
    }
}

UserService,使用的时 MVC 模式,所以只展示实现类:

 
java
复制代码
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private JwtUtil jwtUtil;

    @Value("${jwt.tokenHead}")
    private String tokenHead;



    @Override
    public RespBean login(String username, String password, HttpServletRequest request) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        if (userDetails==null || !passwordEncoder.matches(password,userDetails.getPassword())){
            return RespBean.error("用户名或密码错误!");
        }
        if (!userDetails.isEnabled()){
            return RespBean.error("用户状态异常!");
        }

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
        String jwt = jwtUtil.foundJWT(userDetails);
        SecurityContextHolder.getContext().setAuthentication(token);
        Map<String,String> msg = new HashMap<>();
        msg.put("tokenHead",tokenHead);
        msg.put("token", jwt);
        return RespBean.success("登录成功!",msg);
    }
}

7.1 完善 Swagger2Config 配置

由于 JWT 的加入,所以 Swagger2 的方法请求也是需要带入 JWT 令牌,提供了 Security 的全局认证。

只展示了修改的部分。

 
java
复制代码
@Bean
public Docket docket(){
    return new Docket(DocumentationType.SWAGGER_2)
        .apiInfo(apiInfo()) // 配置 apiInfo
        .select() // 选择那些路径和api会生成document
        .apis(RequestHandlerSelectors.basePackage("com.cnda.controller")) // // 对哪些 api进行监控,RequestHandlerSelectors.basePackage 基于包扫描
        .paths(PathSelectors.any()) // 对所有路径进行监控
        .build()
        // 添加和 Security 相关的配置。
        .securityContexts(securityContexts())
        .securitySchemes(securitySchemes());
}

// 以下方法相对于给 Swagger 添加了一个在 Security 的全局授权,并且以正则的形式设置了授权的请求 url

/**
     * securityContexts
     * 请求体内容
     */
private List<SecurityContext> securityContexts(){
    List<SecurityContext> securityContexts = new ArrayList<>();
    securityContexts.add(getContextByPath("/hello/.*"));
    return securityContexts;
}

// 通过正则表达式来设置哪些路径
// 通过 Path 获取到对应的 SecurityContext
private SecurityContext getContextByPath(String pathRegex) {
    return SecurityContext.builder()
        .securityReferences(defaultAuth())
        .forPaths(PathSelectors.regex(pathRegex)) // 按照 String 的 matches 方法进行匹配
        .build();
}

/**
     * 配置默认的全局鉴权策略;其中返回的 SecurityReference 中,reference 即为 ApiKey 对象里面的name,保持一致才能开启全局鉴权
     * @return SecurityReference
     */
private List<SecurityReference> defaultAuth() {
    List<SecurityReference> references = new ArrayList<>();
    // scope 参数:
    AuthorizationScope authorizationScope = new AuthorizationScope("global","accessEverything");
    AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
    authorizationScopes[0] = authorizationScope;
    references.add(new SecurityReference("Authorization",authorizationScopes));
    return references;

}


/**
     * securitySchemes
     * 安全体方案
     */
private List<SecurityScheme> securitySchemes(){
    List<SecurityScheme> apiKeys = new ArrayList<>();
    // 设置请求头信息
    apiKeys.add(new ApiKey("Authorization","Authorization","Header"));
    return apiKeys;
}

修改的部分直接 CV 大法即可。

7.2 运行项目查看效果:

image-20230305113336813

可以看到利用 Swagger2 的调试,返回 JWT Token 令牌成功!

 
json
复制代码
{
  "code": 200,
  "message": "登录成功!",
  "obj": {
    "tokenHead": "Bearer",
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlidCI6MTY3Nzk4NzIwNjgyMSwiZXhwIjoxNjc4NTkyMDA2fQ.p_GUqevx8gvCK2txxeEX-RQFm69yDCxCYNlZbeHgVIizSUDO6gaT3a2jGXvzXqofH2uxkQBgN4WfeSIlGydiNA"
  }
}

将令牌设置到 Swagger2 中

image-20230305113500682

这样之前的 /hello1 就可以请求成功了:

image-20230305113540793

说明 Swagger2 设置 JWT 也成功了,每次发送请求,头部都会携带 JWT 令牌。

总结

还是对 Security 不太熟悉,Swagger2 的配置比较固定

JWT 主要也是两个点:

  • JWT Token Utile 工具类,主要用于管理 JWT 令牌。
  • JWT Token Filter JWT 拦截器,这个就是 Security 和 JWT 的集成了,以及请求发来的时候解析 JWT 从而完成免登录这一操作。
posted @ 2024-01-23 16:35  CharyGao  阅读(914)  评论(0编辑  收藏  举报