实战!spring Boot security+JWT 前后端分离架构认证登录!

大家好,我是不才陈某 ~

认证、授权是实战项目中必不可少的部分,而 Spring Security 则将作为首选安全组件,因此陈某新开了 《Spring Security 进阶》 这个专栏,写一写从单体架构到OAuth2分布式架构的认证授权。

Spring security 这里就不再过多介绍了,相信大家都用过,也都恐惧过,相比 Shiro 而言,Spring Security 更加重量级,之前的 SSM 项目更多企业都是用的 Shiro,但是 Spring Boot 出来之后,整合 Spring Security 更加方便了,用的企业也就多了。

今天陈某就来介绍一下在前后端分离的项目中如何使用 Spring Security 进行登录认证。文章的目录如下:

前后端分离认证的思路

前后端分离不同于传统的 web 服务,无法使用 session,因此我们采用 JWT 这种无状态机制来生成 token,大致的思路如下:

  1. 客户端调用服务端登录接口,输入用户名、密码登录,登录成功返回两个token,如下:
    1. accessToken:客户端携带这个 token 访问服务端的资源
    2. refreshToken:刷新令牌,一旦 accessToken 过期了,客户端需要使用 refreshToken 重新获取一个 accessToken。因此 refreshToken 的过期时间一般大于 accessToken。
  2. 客户请求头中携带accessToken访问服务端的资源,服务端对accessToken进行鉴定(验签、是否失效....),如果这个accessToken没有问题则放行。
  3. accessToken一旦过期需要客户端携带refreshToken调用刷新令牌的接口重新获取一个新的accessToken

项目搭建

陈某使用的是 Spring Boot 框架,演示项目新建了两个模块,分别是common-basesecurity-authentication-jwt

1、common-base 模块

这是一个抽象出来的公共模块,这个模块主要放一些公用的类,目录如下:

2、security-authentication-jwt 模块

一些需要定制的类,比如 security 的全局配置类、Jwt 登录过滤器的配置类,目录如下:

3、五张表

权限设计根据业务的需求往往有不同的设计,陈某用的RBAC规范,主要涉及到五张表,分别是用户表角色表权限表用户 <-> 角色表角色 <-> 权限表,如下图:

上述几张表的 SQL 会放在案例源码中(这几张表字段为了省事,设计的并不全,自己根据业务逐步拓展即可)

登录认证过滤器

登录接口的逻辑写法有很多种,今天陈某介绍一种使用过滤器的定义的登录接口。

Spring Security 默认的表单登录认证的过滤器是UsernamePasswordAuthenticationFilter,这个过滤器并不适用于前后端分离的架构,因此我们需要自定义一个过滤器。

逻辑很简单,参照UsernamePasswordAuthenticationFilter这个过滤器改造一下,代码如下:

认证成功处理器 AuthenticationSuccessHandler

上述的过滤器接口一旦认证成功,则会调用AuthenticationSuccessHandler进行处理,因此我们可以自定义一个认证成功处理器进行自己的业务处理,代码如下:

陈某仅仅返回了accessTokenrefreshToken,其他的业务逻辑处理自己完善。

认证失败处理器 AuthenticationFailureHandler

同样的,一旦登录失败,比如用户名或者密码错误等等,则会调用AuthenticationFailureHandler进行处理,因此我们需要自定义一个认证失败的处理器,其中根据异常信息返回特定的JSON数据给客户端,代码如下:

逻辑很简单,AuthenticationException有不同的实现类,根据异常的类型返回特定的提示信息即可。

AuthenticationEntryPoint 配置

AuthenticationEntryPoint这个接口当用户未通过认证访问受保护的资源时,将会调用其中的commence()方法进行处理,比如客户端携带的 token 被篡改,因此我们需要自定义一个AuthenticationEntryPoint返回特定的提示信息,代码如下:

AccessDeniedHandler 配置

AccessDeniedHandler这处理器当认证成功的用户访问受保护的资源,但是权限不够,则会进入这个处理器进行处理,我们可以实现这个处理器返回特定的提示信息给客户端,代码如下:

UserDetailsService 配置

UserDetailsService这个类是用来加载用户信息,包括用户名密码权限角色集合.... 其中有一个方法如下:

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

在认证逻辑中 Spring Security 会调用这个方法根据客户端传入的 username 加载该用户的详细信息,这个方法需要完成的逻辑如下:

  • 密码匹配
  • 加载权限、角色集合

我们需要实现这个接口,从数据库加载用户信息,代码如下:

其中的LoginService是根据用户名从数据库中查询出密码、角色、权限,代码如下:

UserDetails这个也是个接口,其中定义了几种方法,都是围绕着用户名密码权限 + 角色集合这三个属性,因此我们可以实现这个类拓展这些字段,SecurityUser代码如下:

拓展UserDetailsService这个类的实现一般涉及到5张表,分别是用户表角色表权限表用户 <-> 角色对应关系表角色 <-> 权限对应关系表,企业中的实现必须遵循RBAC设计规则。这个规则陈某后面会详细介绍。

Token 校验过滤器

客户端请求头携带了 token,服务端肯定是需要针对每次请求解析、校验 token,因此必须定义一个 Token 过滤器,这个过滤器的主要逻辑如下:

  • 从请求头中获取accessToken
  • accessToken解析、验签、校验过期时间
  • 校验成功,将 authentication 存入 ThreadLocal 中,这样方便后续直接获取用户详细信息。

上面只是最基础的一些逻辑,实际开发中还有特定的处理,比如将用户的详细信息放入 Request 属性中、Redis 缓存中,这样能够实现 feign 的令牌中继效果。

校验过滤器的代码如下:

刷新令牌接口

accessToken一旦过期,客户端必须携带着refreshToken重新获取令牌,传统 web 服务是放在 cookie 中,只需要服务端完成刷新,完全做到无感知令牌续期,但是前后端分离架构中必须由客户端拿着refreshToken调接口手动刷新。

代码如下:

主要逻辑很简单,如下:

  • 校验refreshToken
  • 重新生成accessTokenrefreshToken返回给客户端。

注意:实际生产中refreshToken令牌的生成方式、加密算法可以和accessToken不同。

登录认证过滤器接口配置

上述定义了一个认证过滤器JwtAuthenticationLoginFilter,这个是用来登录的过滤器,但是并没有注入加入 Spring Security 的过滤器链中,需要定义配置,代码如下:

/**
 * @author 公号:码猿技术专栏
 * 登录过滤器的配置类
 */
@Configuration
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
<span class="hljs-comment">/**
 * userDetailService
 */</span>
<span class="hljs-meta">@Qualifier("jwtTokenUserDetailsService")</span>
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> UserDetailsService userDetailsService;

<span class="hljs-comment">/**
 * 登录成功处理器
 */</span>
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> LoginAuthenticationSuccessHandler loginAuthenticationSuccessHandler;

<span class="hljs-comment">/**
 * 登录失败处理器
 */</span>
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> LoginAuthenticationFailureHandler loginAuthenticationFailureHandler;

<span class="hljs-comment">/**
 * 加密
 */</span>
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> PasswordEncoder passwordEncoder;

<span class="hljs-comment">/**
 * 将登录接口的过滤器配置到过滤器链中
 * 1. 配置登录成功、失败处理器
 * 2. 配置自定义的userDetailService(从数据库中获取用户数据)
 * 3. 将自定义的过滤器配置到spring security的过滤器链中,配置在UsernamePasswordAuthenticationFilter之前
 * <span class="hljs-doctag">@param</span> http
 */</span>
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">configure</span><span class="hljs-params">(HttpSecurity http)</span> {
    <span class="hljs-type">JwtAuthenticationLoginFilter</span> <span class="hljs-variable">filter</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">JwtAuthenticationLoginFilter</span>();
    filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
    <span class="hljs-comment">//认证成功处理器</span>
    filter.setAuthenticationSuccessHandler(loginAuthenticationSuccessHandler);
    <span class="hljs-comment">//认证失败处理器</span>
    filter.setAuthenticationFailureHandler(loginAuthenticationFailureHandler);
    <span class="hljs-comment">//直接使用DaoAuthenticationProvider</span>
    <span class="hljs-type">DaoAuthenticationProvider</span> <span class="hljs-variable">provider</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DaoAuthenticationProvider</span>();
    <span class="hljs-comment">//设置userDetailService</span>
    provider.setUserDetailsService(userDetailsService);
    <span class="hljs-comment">//设置加密算法</span>
    provider.setPasswordEncoder(passwordEncoder);
    http.authenticationProvider(provider);
    <span class="hljs-comment">//将这个过滤器添加到UsernamePasswordAuthenticationFilter之前执行</span>
    http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
}

}

所有的逻辑都在public void configure(HttpSecurity http)这个方法中,如下:

  • 设置认证成功处理器loginAuthenticationSuccessHandler
  • 设置认证失败处理器loginAuthenticationFailureHandler
  • 设置 userDetailService 的实现类JwtTokenUserDetailsService
  • 设置加密算法 passwordEncoder
  • JwtAuthenticationLoginFilter这个过滤器加入到过滤器链中,直接加入到UsernamePasswordAuthenticationFilter这个过滤器之前。

Spring Security 全局配置

上述仅仅配置了登录过滤器,还需要在全局配置类做一些配置,如下:

  • 应用登录过滤器的配置
  • 将登录接口、令牌刷新接口放行,不需要拦截
  • 配置AuthenticationEntryPointAccessDeniedHandler
  • 禁用 session,前后端分离 +JWT 方式不需要 session
  • 将 token 校验过滤器TokenAuthenticationFilter添加到过滤器链中,放在UsernamePasswordAuthenticationFilter之前。

完整配置如下:

/**
 * @author 公号:码猿技术专栏
 * @EnableGlobalMethodSecurity 开启权限校验的注解
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;
    @Autowired
    private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
    @Autowired
    private RequestAccessDeniedHandler requestAccessDeniedHandler;
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">configure</span><span class="hljs-params">(HttpSecurity http)</span> <span class="hljs-keyword">throws</span> Exception {
    http.formLogin()
            <span class="hljs-comment">//禁用表单登录,前后端分离用不上</span>
            .disable()
            <span class="hljs-comment">//应用登录过滤器的配置,配置分离</span>
            .apply(jwtAuthenticationSecurityConfig)

            .and()
            <span class="hljs-comment">// 设置URL的授权</span>
            .authorizeRequests()
            <span class="hljs-comment">// 这里需要将登录页面放行,permitAll()表示不再拦截,/login 登录的url,/refreshToken刷新token的url</span>
            <span class="hljs-comment">//TODO 此处正常项目中放行的url还有很多,比如swagger相关的url,druid的后台url,一些静态资源</span>
            .antMatchers(   <span class="hljs-string">"/login"</span>,<span class="hljs-string">"/refreshToken"</span>)
            .permitAll()
            <span class="hljs-comment">//hasRole()表示需要指定的角色才能访问资源</span>
            .antMatchers(<span class="hljs-string">"/hello"</span>).hasRole(<span class="hljs-string">"ADMIN"</span>)
            <span class="hljs-comment">// anyRequest() 所有请求   authenticated() 必须被认证</span>
            .anyRequest()
            .authenticated()

            <span class="hljs-comment">//处理异常情况:认证失败和权限不足</span>
            .and()
            .exceptionHandling()
            <span class="hljs-comment">//认证未通过,不允许访问异常处理器</span>
            .authenticationEntryPoint(entryPointUnauthorizedHandler)
            <span class="hljs-comment">//认证通过,但是没权限处理器</span>
            .accessDeniedHandler(requestAccessDeniedHandler)

            .and()
            <span class="hljs-comment">//禁用session,JWT校验不需要session</span>
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            .and()
            <span class="hljs-comment">//将TOKEN校验过滤器配置到过滤器链中,否则不生效,放到UsernamePasswordAuthenticationFilter之前</span>
            .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class)
            <span class="hljs-comment">// 关闭csrf</span>
            .csrf().disable();
}

<span class="hljs-comment">// 自定义的Jwt Token校验过滤器</span>
<span class="hljs-meta">@Bean</span>
<span class="hljs-keyword">public</span> TokenAuthenticationFilter <span class="hljs-title function_">authenticationTokenFilterBean</span><span class="hljs-params">()</span>  {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">TokenAuthenticationFilter</span>();
}

<span class="hljs-comment">/**
 * 加密算法
 * <span class="hljs-doctag">@return</span>
 */</span>
<span class="hljs-meta">@Bean</span>
<span class="hljs-keyword">public</span> PasswordEncoder <span class="hljs-title function_">getPasswordEncoder</span><span class="hljs-params">()</span>{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">BCryptPasswordEncoder</span>();
}

}

注释的很详细了,有不理解的认真看一下。

案例源码已经上传 GitHub,关注公号:码猿技术专栏,回复关键词:9529 获取!

测试

1、首先测试登录接口,postman 访问 http://localhost:2001/security-jwt/login,如下:

可以看到,成功返回了两个 token。

2、请求头不携带 token,直接请求 http://localhost:2001/security-jwt/hello,如下:

可以看到,直接进入了EntryPointUnauthorizedHandler这个处理器。

3、携带 token 访问 http://localhost:2001/security-jwt/hello,如下:

成功访问,token 是有效的。

4、刷新令牌接口测试,携带一个过期的令牌访问如下:

5、刷新令牌接口测试,携带未过期的令牌测试,如下:

可以看到,成功返回了两个新的令牌。

源码追踪

以上一系列的配置完全是参照UsernamePasswordAuthenticationFilter这个过滤器,这个是 web 服务表单登录的方式。

Spring Security 的原理就是一系列的过滤器组成,登录流程也是一样,起初在org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter()方法,进行认证匹配,如下:

attemptAuthentication()这个方法主要作用就是获取客户端传递的 username、password,封装成UsernamePasswordAuthenticationToken交给ProviderManager的进行认证,源码如下:

ProviderManager 主要流程是调用抽象类AbstractUserDetailsAuthenticationProvider#authenticate()方法,如下图:

retrieveUser()方法就是调用 userDetailService 查询用户信息。然后认证,一旦认证成功或者失败,则会调用对应的失败、成功处理器进行处理。

总结

Spring Security 虽然比较重,但是真的好用,尤其是实现 Oauth2.0 规范,非常简单方便。

案例源码已经上传 GitHub,关注公号:码猿技术专栏,回复关键词:9529 获取!

最后说一句(别白嫖,求关注)

陈某每一篇文章都是精心输出,已经写了3 个专栏,整理成PDF,获取方式如下:

  1. 《Spring Cloud 进阶》PDF:关注公号:【码猿技术专栏】回复关键词 Spring Cloud 进阶 获取!
  2. 《Spring Boot 进阶》PDF:关注公号:【码猿技术专栏】回复关键词 Spring Boot 进阶 获取!
  3. 《Mybatis 进阶》PDF:关注公号:【码猿技术专栏】回复关键词 Mybatis 进阶 获取!

如果这篇文章对你有所帮助,或者有所启发的话,帮忙点赞在看转发收藏,你的支持就是我坚持下去的最大动力!