likes
comments
collection
share

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

作者站长头像
站长
· 阅读数 30

大家好,我是 杰哥

前几次文章,我们分别介绍了 Spring SecurityJWT,并通过入门案例,分别了解到 Spring SecurityJWT 实现认证的方式。

Spring Security 是一个声明式的安全访问控制的框架,而 JWT 则是信息传输的一种开放标准,是一种跨域解决通用方案

业内也常常将两者结合,也就是说使用 Spring Security 框架时,采用 JWT 替代 Session 来实现分布式环境下的认证功能

Spring Security:Spring Boot(十一):Spring  Security 实现权限控制

JWT : 告别 session,还是这个认证方案优秀!

具体该如何结合呢?我们今天就一起来探索一番

一 理论优先

(一) 重要角色认识

首先要知道这么几个事情,实战中会使用到哦~

  • SecurityContextSpring Security 的上下文对象,Authentication认证对象会放在里面,若用户未认证,则 Authentication。对象的内容为 null

  • SecurityContextHolder:用于拿到上下文对象的静态工具类,使用方法 SecurityContextHolder.getContext()就可以获取到SecurityContext 对象,

  • Authentication:认证接口,定义了认证对象的数据形式,比如用户名密码

  • AuthenticationManager:认证管理器。这个接口规范了 Spring Security 的过滤器要如何执行身份认证,并在身份认证成功后返回一个经过认证的 Authentication 对象

(二)Spring Security 的过滤器链

之前我们介绍 Spring Security 的时候,曾经提到过 Spring Security 采用的是责任链的设计模式,它有一条很长的过滤器链来实现各个环节的控制逻辑

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

preview

如上图,每个 WEB 请求,会经过这么一条过滤器链,在经过过滤器的过程中会完成认证与授权。如果中间发现这个请求未认证或者未授权,就会抛出异常

Spring Security 的过滤器链有十几个,我们本篇主要讲的是 Spring Security 采用JWT实现认证方案,所以只关注以下几个:

  • SecurityContextPersistenceFilter,在新版本中已经被废弃,推荐使用 SecurityContextHolderFilter 。在运行应用程序的其余部分之前,SecurityContextHolderFilterSecurityContextRepository 加载 SecurityContext 并将其设置在 SecurityContextHolder

  • UsernamePasswordAuthenticationFilter 是针对使用用户名和密码进行身份认证而定制化的一个过滤器,如果将原本默认的认证方案变更为 JWT,那么就需要定义一个过滤器,并配置在其前面将认证信息存储至上下文之后,再进行认证

  • FilterSecurityInterceptor 会根据 SecurityContextHolder 中存储的用户信息来决定其是否有权限,从而决定是否允许访问

二 实战

既然 JWT 只是不同于 Session 的另一种认证方案,那么,我们的实战,也将在 Spring Security demo   的基础上,将原本的 Session 机制,更换为 JWT 而已

(一)Spring Security 配置类

Spring Security  配置类 - SecurityConfig 如下

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

Spring Security demo  的基础上,为了将认证方案更改为JWT  ,做了两个地方的改动

1 认证过滤器配置

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry
                authorizeRequests = http.csrf().disable().authorizeRequests();
//         1.查询到所有的权限
        List<Permission> allPermission = permissionMapper.findAllPermission();
//         2.分别添加权限规则
        allPermission.forEach((p -> {
            authorizeRequests.antMatchers(p.getUrl()).hasAnyAuthority(p.getName()) ;
        }));

        authorizeRequests.and()
                // 配置为 Spring Security 不创建使用 session
               .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
               .and()
               .authorizeRequests()
                .antMatchers("/**").fullyAuthenticated()
                //不用验证登录接口
               .antMatchers("/authenticate").permitAll()
               .anyRequest().authenticated();
        //配置认证过滤器 jwtAuthenticationFilter
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

这里其实在之前 Spring Security 项目的基础上,增加了以下两项的配置

1)配置为 Spring Security 不创建使用 session

2)配置认证过滤器 jwtAuthenticationFilter

可以顺便复习一下:

SecurityFilterChain Bean 实际上是在新版本(2.7.0 版本) Spring Security 中,用来定义各个资源能够被拥有哪些权限的用户所访问的规则

如资源 /user/common 可以分别被具有 admincommon 权限的用户所访问,而 /user/admin 则只可以被具有 admin 权限的用户所访问

2 配置认证接口的放行规则

/**
 * 资源放行配置
 * @return
 */
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
    return web -> {
        web.ignoring().antMatchers("/hello");
        web.ignoring().antMatchers("/login");
        //登录接口放行
        web.ignoring().antMatchers("/authenticate");
        web.ignoring().antMatchers("/css/**", "/js/**");
    };
}

登录接口 /authenticate 是用来获取 token 的,它本身当然无法携带认证信息,所以这里配置我们后续所定义的登录接口 /authenticate 不用被校验

(二)JWT 认证过滤器

用于每次请求的拦截处理。验证 token ,并将验证之后的token对应的用户信息存储至上下文

/**
 * JWT 认证过滤器
 */
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Resource
    private JwtService jwtService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            //1-获取 token
            String token = request.getHeader("Authorization");
            if (StrUtil.isBlank(token)) {
                //放行,会自动执行后面的过滤器
                logger.info("请求头不含 JWT token 或者 token 的值为空,调用下个过滤器");
                filterChain.doFilter(request,response);
                return;
            }
            //2-获取认证信息
            Authentication authentication = jwtService.getAuthentication(token);
            //3-设置用户验证对象至上下文
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (MalformedJwtException | ExpiredJwtException | UnsupportedJwtException e) {
            SecurityContextHolder.getContext().setAuthentication(null);
        } finally{
            filterChain.doFilter(request, response);
        }
    }

}

这里通过继承 OncePerRequestFilter 类,对于客户端的请求进行拦截处理,并确保在一次请求中只通过一次过滤,避免重复执行

过滤器中的逻辑为:

1 获取 token。首先从客户端的请求头中获取 token 的值,并做非空校验

2 获取认证对象。调用 JwtServicegetAuthentication() 方法得到 Authentication 对象

进入 JwtServicegetAuthentication() 方法

/**
     * 获取认证过的token对应的信息,包括用户以及用户对应的权限
     * @param token
     * @return
     */
    public Authentication getAuthentication(String token) {
        // 1-根据 token 和秘钥,解析出 JWT 的 claims 对象
        Claims claims =
                Jwts.parser()
                        .setSigningKey(KEY)
                        .parseClaimsJws(token)
                        .getBody();
        // 2-获取权限信息,并转换为集合类型
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());
        //3-得到用户对象 principal
        User principal = new User(claims.getSubject(), "", authorities);
         //4-得到认证 token 对象(UsernamePasswordAuthenticationToken 实现了 Authentication 接口,表示是通过用户名密码认证过的 token 认证信息)
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);

    }

1)得到 Claims 对象

根据 token 和秘钥,解析出 Claims 对象,这个里面就存储了我们的 JWTpayload 中各个参数值。debug 可以看到,这个里面实际上是我们在创建 token 时,存进去的数据:用户的用户名 sub、权限列表 auth 以及过期时间 exp

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

2)得到权限列表 authorities 获取权限信息,并转换为集合类型(生成 token 时,是以字符串的形式存储的)

3)将认证过后的这个用户对象配置到 Security 上下文:SecurityContextHolder

后续的 FilterSecurityInterceptor 会根据 SecurityContextHolder 中存储的用户信息来决定其是否有权限,从而决定是否允许访问,请求结束之后会清除掉该用户信息

一般利用 ThreadLocal 机制来保存每个使用者的 SecurityContext,就避免了多线程并发导致信息错乱等问题了

(三)登录接口-生成 token

@RestController
@CrossOrigin
public class JwtAuthenticationController {

    @Resource
    private UserService<User> userService;

    @Resource
    private JwtService jwtService;

    /**
     * 登录接口 - 用于生成 token
     * @param username 用户名
     * @param password 密码
     * @return
     * @throws Exception
     */
    @PostMapping("/authenticate")
    public ResponseResult login(String username, String password) throws Exception {
        //1-校验用户名密码是否为空
        if(StrUtil.isBlank(username) || StrUtil.isBlank(password)){
            throw new Exception("用户名或密码不能为空!");
        }

        // 2-根据用户查询用户是否存在
        User user = userService.findByUsername(username);
        if (user == null){
            throw new Exception("用户名或密码有误!");
        }
        //3-验证用户名密码
        password = MD5Util.md5slat(password);
        if (!password.equalsIgnoreCase(user.getPassword())){
            throw new Exception("用户名或密码有误!");
        }

        UserVo userVo =  UserVo.builder().build();
        userVo.setId(user.getId());
        userVo.setUsername(username);
        userVo.setPassword(password);
        //4- 生成 token
        String token = jwtService.createToken(userVo);
        userVo.setToken(token);
        userVo.setRefreshToken(UUID.randomUUID().toString());
        return new ResponseResult(userVo);
    }
}

这里,就与上一篇中的登录接口的逻辑一样了,也是以下四个步骤:

1 校验用户名密码是否为空

2 根据用户名查询用户是否存在

3 验证用户名密码是否正确。(需要提前通过 MD5Util.md5slat(password) 方法得到加密之后的密码,写入表 User 中的密码)

比如,我的密码是 123456,首先采用  MD5Util.md5slat(123456) 生成一个秘钥

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

运行 main 方法,输出的秘钥为:53b432e61314e00bcc99287af9537dc4

填写在 user 表中

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

**4 生成 token。**调用 jwtService.createToken() 方法生成 token ,并返回 UserVo 对象

那么,token 是怎样生成的呢?

进入 jwtService.createToken()  方法

/**
 * 创建 token
 * @param userVo 用户对象
 * @return token
 */
public String createToken(UserVo userVo) {
    String authorities = userVo.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    long now = (new Date()).getTime();

    return Jwts.builder()
            .setSubject(userVo.getUsername())
            .claim(AUTHORITIES_KEY, authorities)
            .setExpiration(new Date(now + TOKEN_EXSPIRE_TIME))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
}

采用 Jwts.builder() 创建一个JwtBuilder构建器,然后分别配置用户的用户名 sub、权限列表 auth 以及过期时间 exp,指定加密算法,调用compact() 方法得到一个字符串,即生成了一个token字符串

(四)资源接口

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/common")
    public String common() {
        return "hello~ common";
    }

    @GetMapping("/admin")
    public String admin() {
        return "hello~ admin";
    }

}

分别定义 /user/common/user/admin 两个 API,作为用户的权限,用于测试不同用户的权限

这里只列出了该项目的主要类,此外,还分别需要创建用于密码加密的 MD5Util 工具类,UserUserVoResponseResult 等实体类, 用于项目本身的功能完善

三 测试

需要说明一下,本次的项目是在 Spring Security 文章里面的 demo(**github.com/helemile/Sp… Session 变更为 JWT 认证方案而已,原先的实现逻辑完全没有变化

所以,RBAC 权限控制表:userroleuser_rolepermissionrole_permission 表中的信息完全一样(除了密码采用了 MD5 加盐的方式加密以外)

即:共存在两个用户:adminuser,他们所对应的权限列表分别为:

user 用户: common 权限,可以访问的资源列表:/user/common

admin 用户: admin 权限,可以访问的资源列表: /user/admin/user/common

(一)user 用户

1 调用登录接口,获取 tokenuser 用户)

使用 user 账号进行登录,该账号只有 common 权限

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

登录之后,成功返回当前用户的信息,包括用户名,密码,token 以及权限列表 authorities

2 不携带 token,直接访问资源 /user/common

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

接口返回 403-禁止访问

3 携带 token,访问资源 /user/common

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

访问成功

4 携带 token,访问资源 /user/admin

当然,由于 user 用户只有 common 权限,所以如果访问 /user/admin 资源,也会返回 403

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

若换成 admin 用户登录呢?

(二)admin 用户

1 调用登录接口,获取 tokenadmin 用户)

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

2 访问 /user/admin 资源

Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!

成功访问了

也就是说,我们的目的就达到了:成功将 Spring Security 的认证方案替换成了 JWT,实现了Sprinfg SecurityJWT 的结合

四 总结

今天我们讲了:

1 Spring Security 的几个重要类或接口:SecurityContextSecurityContextHolder``Authentication以及 AuthenticationManager

2 Spring Security 的过滤器链说明

3 实战实现 Spring SecurityJWT 认证方案的集成,共同实现了分布式系统的认证功能

关于用户的权限列表 authorities 如何获取这个问题,网上的很多实战例子是在每次过滤器解析时,根据用户名去数据库查询一次,得到权限列表。这样的效果其实跟 Session 差不多了,因为同样每次请求都要请求一次持久层呢~

我们这里实战 demo 的思路是将用户以及用户的权限列表 authorities 作为生成 token 的其中一个数据项,从而在过滤器中解析完 token 即可获得,从而存储至认证对象:Authentication 中,而不用专门从数据库中获取一次,这种设计还是相对比较巧妙的

如果你还有更好的方案,欢迎留言交流哦~

文章演示代码地址:github.com/helemile/Sp…

嗯,就这样。每天学习一点,时间会见证你的强大~

欢迎大家关注我们的公众号【青梅主码】,一起持续性学习吧~

往期精彩回顾

总结复盘

架构设计读书笔记与感悟总结

带领新人团队的沉淀总结

复盘篇:问题解决经验总结复盘

网络篇

网络篇(四):《图解 TCP/IP》读书笔记

网络篇(一):《趣谈网络协议》读书笔记(一)

事务篇章

事务篇(四):Spring事务并发问题解决

事务篇(三):分享一个隐性事务失效场景

事务篇(一):毕业三年,你真的学会事务了吗?

Docker篇章

Docker篇(六):Docker Compose如何管理多个容器?

Docker篇(二):Docker实战,命令解析

Docker篇(一):为什么要用Docker?

..........

SpringCloud篇章

Spring Cloud(十三):Feign居然这么强大?

Spring Cloud(十):消息中心篇-Kafka经典面试题,你都会吗?

Spring Cloud(九):注册中心选型篇-四种注册中心特点超全总结

Spring Cloud(四):公司内部,关于Eureka和zookeeper的一场辩论赛

..........

Spring Boot篇章

Spring Boot(十二):陌生又熟悉的 OAuth2.0 协议,实际上每个人都在用

Spring Boot(七):你不能不知道的Mybatis缓存机制!

Spring Boot(六):那些好用的数据库连接池们

Spring Boot(四):让人又爱又恨的JPA

SpringBoot(一):特性概览

..........

翻译

[译]用 Mint 这门强大的语言来创建一个 Web 应用

【译】基于 50 万个浏览器指纹的新发现

使用 CSS 提升页面渲染速度

WebTransport 会在不久的将来取代 WebRTC 吗?

.........

职业、生活感悟

你有没有想过,旅行的意义是什么?

程序员的职业规划

灵魂拷问:人生最重要的是什么?

如何高效学习一个新技术?

如何让自己更坦然地度过一天?

..........

转载自:https://juejin.cn/post/7130041010016485407
评论
请登录