关于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地址

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理