写点什么

SpringSecurity+JWT 认证流程解析

发布于: 2021 年 04 月 30 日
SpringSecurity+JWT认证流程解析

楔子

本文适合: 对 Spring Security 有一点了解或者跑过简单 demo 但是对整体运行流程不明白的同学,对 SpringSecurity 有兴趣的也可以当作你们的入门教程,示例代码中也有很多注释。大家在做系统的时候,一般做的第一个模块就是认证与授权模块,因为这是一个系统的入口,也是一个系统最重要最基础的一环,在认证与授权服务设计搭建好了之后,剩下的模块才得以安全访问。市面上一般做认证授权的框架就是 shiro 和 Spring 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 的权限去抛出异常,然后由异常处理器去处理这些异常。

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


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

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

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


  • 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); }

}复制代码
复制代码

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


我们返回 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 个:


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

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

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

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

4. 代码优化

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

1. 认证失败处理器


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

2. 权限不足处理器


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


3. 退出方法


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

4. token 刷新

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

后记

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

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

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

好了,认证篇结束后,下篇就是动态鉴权了,这是我在掘金的第一篇文,我的第一次知识输出,希望大家持续关注。 你们的每个点赞收藏与评论都是对我知识输出的莫大肯定,如果有文中有什么错误或者疑点或者对我的指教都可以在评论区下方留言,一起讨论。

原文链接:https://juejin.cn/post/6846687598442708999

最后,小编还给大家整理了一份面试大全 有需要的添加小助理 vx:mxzFAFAFA 来领取~



发布于: 2021 年 04 月 30 日阅读数: 29
用户头像

领取文章中资料添加小助理vx:mxzFAFAFA 2021.02.05 加入

Java架构大数据每天分享干货!

评论

发布
暂无评论
SpringSecurity+JWT认证流程解析