乐趣区

关于java:SpringSecutityJWT认证流程解析-segmentfault新人第一弹

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

楔子

本文适宜: 对 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 的权限去抛出异样,而后由异样处理器去解决这些异样。

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

如上图,一个申请想要拜访到 API 就会以从左到右的模式通过蓝线框框外面的过滤器,其中绿色局部是咱们本篇次要讲的负责认证的过滤器,蓝色局部负责异样解决,橙色局部则是负责受权。

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

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

  • 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)
            throws AuthenticationException {

        // 校验未认证的 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;}
            }

        }

        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???? 申请带上 token????JWT 过滤器拦挡???? 校验 token???? 将从缓存中查出来的对象放到上下文中

这样之后,咱们认证的逻辑就算实现了。

4. ???? 代码优化

认证和 JWT 过滤器实现后,这个 JWT 的我的项目其实就能够跑起来了,能够实现咱们想要的成果,如果想让程序更强壮,咱们还须要再加一些辅助性能,让代码更敌对。

1. 认证失败处理器

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

2. 权限有余处理器

当用户自身权限不满足所拜访 API 须要的权限时,触发这个处理器,返回一个权限有余的后果。

3. 退出办法

用户退出个别就是革除掉上下文对象和缓存就行了,你也能够做一下附加操作,这两步是必须的。

4. token 刷新

JWT 的我的项目 token 刷新也是必不可少的,这里刷新 token 的次要办法放在了 token 工具类外面,刷新完了把缓存重载一遍就行了,因为缓存是有有效期的,从新 put 能够重置生效工夫。

后记

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

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

也有教程做过滤器的时候是间接继承UsernamePasswordAuthenticationFilter,这样的办法也是可行的,不过咱们理解了整体的运行流程之后你就晓得没必要这样做,不须要去继承 XXX,只有写个过滤器而后放在那个地位就能够了。

好了,认证篇完结后,下篇就是动静鉴权了,这是我在思否的第一篇文,我的第一次常识输入,心愿大家继续关注。

你们的每个点赞珍藏与评论都是对我常识输入的莫大必定,如果有文中有什么谬误或者疑点或者对我的指教都能够在评论区下方留言,一起探讨。

我是耳朵,一个始终想做常识输入的人,下期见。

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

退出移动版