springcloudalibaba微服务实战三十五-退出登录注销jwttoken(代码片段)

飘渺Jam 飘渺Jam     2023-01-03     781

关键词:

大家好,我是飘渺。

有一个看我SpringCloud alibaba系列文章的粉丝私下问我:如何处理jwt失效的问题?修改密码或退出登录后需要将之前的jwt token失效掉,不允许使用旧token登录系统。

我说:很简单呀,咱们直接 无为而治,用户退出或修改密码的时候前端直接删除这个token不就完了吗?后端啥都不用管,啥也不用做。

他说:别闹,你的每篇文章我都给你一键三连。

我当时就被感动了,既然是这样的好读者,我果断答应专门给他写篇文章来分享一下我这个不太成熟的做法,改造一下这个SpringCloud alibaba项目。

在正式开始之前,我们先来回顾一下oauth2中token的相关知识。

知识回顾

众所周知,在 OAuth2 体系中认证通过后返回的令牌信息分为两大类:不透明令牌(opaque tokens)透明令牌(not opaque tokens)。

不透明令牌 是一种无可读性的令牌,一般来说就是一段普通的 UUID 字符串。使用不透明令牌时资源服务不知道这个令牌是什么,代表谁,需要调用认证服务器校验、获取用户信息。使用不透明令牌采用的是 中心化 的架构。

透明令牌 一般指的是我们常说的JWT Token,用户信息保存在 JWT 字符串中,资源服务器自己可以解析令牌不再需要去认证服务器校验令牌。使用JWT是属于 无状态、去中心化 的架构。

一旦我们选择了使用JWT,就需要明确一点:在不借助外力的情况下,让JWT失效的唯一途径就是等token自己过期,无法做到主动让JWT失效。非要让JWT有主动失效的功能只能借助外力,即在服务端存储JWT的状态,在请求时添加判断逻辑,这个与JWT的无状态化、去中心化特性是矛盾的。但是,既然选择了JWT这条路,那就只能接受这个现实。

tips:我们目前项目使用的是JWT Token这种去中心化的架构,并且独立出了一个统一的资源服务器配置模块,详情可见:SpringCloud Alibaba微服务实战三十 | 统一资源服务器配置模块

解决思路

上面说了,要实现JWT的主动失效需要借助外力,在服务端存储JWT的状态,一般使用Redis等高速缓存。而存储JWT状态又分为两种方案:

  1. 白名单机制

    认证通过时,把JWT存到Redis中。注销时,从缓存移除JWT。请求资源添加判断JWT在缓存中是否存在,不存在拒绝访问。这种方式和cookie/session机制中的会话失效删除session基本一致。

  2. 黑名单机制

    注销登录时,缓存JWT至Redis,且缓存有效时间设置为JWT的有效期,请求资源时判断是否存在缓存的黑名单中,存在则拒绝访问。

白名单和黑名单的实现逻辑差不多,黑名单不需每次登录都将JWT缓存,仅仅在某些特殊场景下需要缓存JWT,给服务器带来的压力要远远小于白名单的方式。

我更倾向于使用黑名单机制,有两个原因:

一是会大大节省Redis的存储空间,我们甚至都不需要存储完整的jwt,只需要存储jwt中的唯一id jti即可。

二是我们有独立的资源服务器配置模块,使用白名单的话那就要求所有依赖的业务模块必须加入Redis依赖、添加Redis的配置,不太友好。采用黑名单的话我们只需要在网关层做校验,也就是只要网关和认证服务器添加redis依赖即可。

OK,既然确定了使用黑名单机制,那我们最后再来梳理一下完整的实现流程:

  1. 认证服务器需要添加一个退出登录接口,在退出登录时将jwt 的唯一id jti添加进redis,并设置有效时间为token的剩余时间。
  2. 所有请求需要经过网关,网关层通过Filter添加校验逻辑,解析并判断用户携带的token jti是否在redis中。若存在,说明该token已失效,拒绝访问;否则放行。

梳理好了实现流程,我们开始代码改造。

代码改造

注意,为了不影响阅读,文中展示的代码仅为与此文主题相关的代码,其他无关代码以…代替。

认证服务器改造

  1. 由于需要用到redis存储黑名单,所以需要引入redis依赖
<dependency>
  <groupId>org.springframework.data</groupId>
  <artifactId>spring-data-redis</artifactId>
</dependency>
  1. 修改nacos配置中心auth-service.yml,配置redis相关属性
spring:
  redis:
    host: localhost
    password: xxxxxx
    port: 6379
    timeout: 3000
  1. 编写退出接口,将token插入黑名单
@RestController
@RequestMapping("/token")
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class AuthController 

    private final TokenStore tokenStore;

    private final RedisTemplate<String,String> redisTemplate;
    
    /**
     * 用户退出登录
     * @param authHeader 从请求头获取token
     */
    @DeleteMapping("/logout")
    public ResultData<String> logout(@RequestHeader(value = HttpHeaders.AUTHORIZATION) String authHeader)

        //获取token,去除前缀
        String token = authHeader.replace(OAuth2AccessToken.BEARER_TYPE,"").trim();

        // 解析Token
        OAuth2AccessToken oAuth2AccessToken = tokenStore.readAccessToken(token);

        //token 已过期
        if(oAuth2AccessToken.isExpired())
           return ResultData.fail(ReturnCode.INVALID_TOKEN_OR_EXPIRED.getCode(),ReturnCode.INVALID_TOKEN_OR_EXPIRED.getMessage());
        

        if(StringUtils.isBlank(oAuth2AccessToken.getValue()))
            //访问令牌不合法
            return ResultData.fail(ReturnCode.INVALID_TOKEN.getCode(),ReturnCode.INVALID_TOKEN.getMessage());
        

        OAuth2Authentication oAuth2Authentication = tokenStore.readAuthentication(oAuth2AccessToken);

        String userName = oAuth2Authentication.getName();

        //获取token唯一标识
        String jti = (String) oAuth2AccessToken.getAdditionalInformation().get("jti");

        //获取过期时间
        Date expiration = oAuth2AccessToken.getExpiration();
        long exp = expiration.getTime() / 1000;

        long currentTimeSeconds = System.currentTimeMillis() / 1000;

        //设置token过期时间
        redisTemplate.opsForValue().set(CloudConstant.TOKEN_BLACKLIST_PREFIX + jti, userName, (exp - currentTimeSeconds), TimeUnit.SECONDS);

        return ResultData.success("退出成功");
    


在认证服务器可以通过OAuth2AccessToken直接解析token,对token的有效性做一些基本校验,计算token的剩余有效时间并通过redisTemplate加入redis。

插入黑名单的时候并没有把完整的jwt token直接插入,而是使用了jwt的唯一标识jti,用于节省redis存储空间。

  1. 修改认证服务器配置文件WebSecurityConfig,给退出接口放行
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
//@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter 
	...
    
    /**
     * http安全配置
     * @param http http安全对象
     * @throws Exception http安全异常信息
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception 
        // 加入验证码登陆
        http.apply(smsCodeSecurityConfig);

        http
                .authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
                .and()
                .authorizeRequests().antMatchers("/token/**","/sms/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    

	...

至此认证服务器改造完毕,接下来对网关进行改造。

网关改造

网关层不再引入oauth2相关的依赖,所以就不能使用认证服务器的方法来解析token了,这里我使用的是nimbus-jose-jwt这个工具类。

  1. 引入nimbus-jose-jwt依赖
<dependency>
  <groupId>com.nimbusds</groupId>
  <artifactId>nimbus-jose-jwt</artifactId>
  <version>9.13</version>
</dependency>
  1. 引入Redis 依赖并且配置Redis相关属性

这个已经在认证服务器改造的时候配置过,就不重复了。

  1. 修改网关过滤器GatewayRequestFilter,判断token是否存在于黑名单
@Component
@Order(0)
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class GatewayRequestFilter implements GlobalFilter 

    private final RedisTemplate<String,String> redisTemplate;

    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) 
			...
        //获取请求头的token
        String headerToken = exchange.getRequest().getHeaders().getFirst(CloudConstant.JWT_HEADER_KEY);

        if (StrUtil.isNotEmpty(headerToken)) 
            // 是否在黑名单
            if(isBlack(headerToken))
                throw new HttpServerErrorException(HttpStatus.FORBIDDEN,"该令牌已过期,请重新获取令牌");
            
        
      ...

        return chain.filter(newExchange);
    



    /**
     * 通过redis判断token是否为黑名单
     * @param headerToken 请求头
     * @return boolean
     */
    private boolean isBlack(String headerToken) throws ParseException 
        //todo  移除所有oauth2相关代码,暂时使用 OAuth2AccessToken.BEARER_TYPE 代替
        String token  = headerToken.replace(OAuth2AccessToken.BEARER_TYPE, StrUtil.EMPTY).trim();

        //解析token
        JWSObject jwsObject = JWSObject.parse(token);
        String payload = jwsObject.getPayload().toString();
        JSONObject jsonObject = JSONUtil.parseObj(payload);

        // JWT唯一标识
        String jti = jsonObject.getStr("jti");
        return redisTemplate.hasKey(CloudConstant.TOKEN_BLACKLIST_PREFIX + jti);
    
	...

至此网关层也改造完毕,接下来我们测试一下完整流程。

测试

  1. 执行退出登录

退出登录后在Redis中可以看到黑名单,key为jti,value为登录用户。

  1. 使用过期的token访问其他接口会被网关拦截

小结

一般来说,既然选择了jwt那就要接受jwt这个不允许主动失效的约定,如果考虑到安全性,可以通过缩短jwt有效时间,采用https等手段进行防护。虽然我们通过代码改造让其实现了主动失效的功能,但最终还是失去了jwt 无状态化、去中心化的特性。 当然了,所有的方案都不可能十全十美,主要是看哪种更符合你们的业务场景。

我是飘渺Jam,一名写代码的架构师,做架构的程序员,期待您的转发与关注,咱们下期见!

springcloudalibaba微服务实战三十四-隐私接口禁止外部访问(代码片段)

大家好,我是飘渺!在这个系列文章中曾经介绍过在SpringCloud体系下如何防止前端请求绕过网关直接到达后端微服务,今天我们要反其道而行之,介绍在SpringCloud体系中如何防止内部隐私接口被网关调用。看到这里... 查看详情

springcloudalibaba微服务实战三十七-oauth2自定义登录接口

...给大家送书了~)有不少人私下问我,为什么SpringCloudalibaba实战系列不更新了,主要是因为大部分核心功能都已经讲完了,剩下的基本是属于业务功能开发了,需要根据实际业务扩展。今天更新文章的原因... 查看详情

springcloudalibaba微服务实战三十七-oauth2自定义登录接口(代码片段)

大家好,我是飘渺。有不少人私下问我,为什么SpringCloudalibaba实战系列不更新了,主要是因为大部分核心功能都已经讲完了,剩下的基本是属于业务功能开发了,需要根据实际业务扩展。今天更新文章的原因是粉丝提了个问题:... 查看详情

springcloudalibaba微服务实战三十七-oauth2自定义登录接口(代码片段)

大家好,我是飘渺。有不少人私下问我,为什么SpringCloudalibaba实战系列不更新了,主要是因为大部分核心功能都已经讲完了,剩下的基本是属于业务功能开发了,需要根据实际业务扩展。今天更新文章的原因是粉丝提了个问题:... 查看详情

汇总

个人感觉这是全网比较齐全,写的比较好的SpringCloudalibaba系列教程了,推荐给大家!SpringCloudAlibaba微服务实战一-基础环境准备SpringCloudAlibaba微服务实战二-服务注册SpringCloudAlibaba微服务实战三-服务调用SpringCloudAlibaba微... 查看详情

汇总

个人感觉这是全网比较齐全,写的比较好的SpringCloudalibaba系列教程了,推荐给大家!SpringCloudAlibaba微服务实战一-基础环境准备SpringCloudAlibaba微服务实战二-服务注册SpringCloudAlibaba微服务实战三-服务调用SpringCloudAlibaba微... 查看详情

springcloudalibaba微服务实战教程系列

一、应用系列     Docker安装MySql完整教程、实操 使用到的mysql数据库的安装方案。     Docker安装AlibabaNacos教程  docker安装单实例或集群的Nacos的注册中心方便快速开始。    实现Nacos服... 查看详情

springcloudalibaba微服务实战二-服务注册

导读:在之前一篇文章中我们准备好了基于SpringCloudAlibaba的基础组件,本期主要内容是将所有的服务注册进Nacos,并让account-service和product-service能对外提供基础的增删改查能力。基础框架搭建在你的IDEA中建立一个多模块的项目(... 查看详情

springcloudalibaba微服务实战一-基础环境准备(代码片段)

SpringcloudAibaba现在这么火,我一直想写个基于SpringcloudAlibaba一步一步构建微服务架构的系列博客,终于下定决心从今天开始本系列文章的第一篇-基础环境准备。该系列文章内容主要基于三个微服务:用户服务AccountService,订单服... 查看详情

springcloudalibaba微服务实战二十一-jwt增强

今天内容主要是解决一位粉丝提的问题:如何在jwt中添加用户的额外信息并在资源服务器中获取这些数据。涉及的知识点有以下三个:如何在返回的jwt中添加自定义数据如何在jwt中添加用户的额外数据,比如用户id、手机号码如... 查看详情

springcloudalibaba微服务实战十四-springcloudgateway集成oauth2.0(代码片段)

导读:上篇文章我们已经抽取出了单独的认证服务,本章主要内容是让SpringCloudGateway集成Oauth2。概念部分在网关集成Oauth2.0后,我们的流程架构如上。主要逻辑如下:1、客户端应用通过api网关请求认证服务器获取a... 查看详情

springcloudalibaba微服务实战二十二-整合dubbo

概述在SpringCloud构建的微服务系统中,大多数的开发者使用都是官方提供的Feign组件来进行内部服务通信,这种声明式的HTTP客户端使用起来非常的简洁、方便、优雅,但是有一点,在使用Feign消费服务的时候,相比较Dubbo这种RPC框... 查看详情

微服务实战:选择微服务部署策略

 微服务实战(一):微服务架构的优势与不足微服务实战(二):使用APIGateway微服务实战(三):深入微服务架构的进程间通信微服务实战(四):服务发现的可行方案以及实践案例微服务实践(五):微服务的事件驱动... 查看详情

chrisrichardson微服务实战系列

微服务实战(一):微服务架构的优势与不足微服务实战(二):使用APIGateway微服务实战(三):深入微服务架构的进程间通信微服务实战(四):服务发现的可行方案以及实践案例微服务实践(五):微服务的事件驱动数据... 查看详情

3.20go微服务实战(微服务实战)---日志和监控

第20章 日志和监控20.1 日志实践20.2 指标 20.2.1 指标数据类型 20.2.2 命名约定 20.2.3 存储和查询 20.2.4 Grafana20.3 日志记录 20.3.1 具有关联ID的分布式跟踪 20.3.2 ElasticSearch、Logstash和Kibana 20.3.3 Kibana 20.4 异常    查看详情

3.20go微服务实战(微服务实战)---日志和监控

第20章 日志和监控20.1 日志实践20.2 指标 20.2.1 指标数据类型 20.2.2 命名约定 20.2.3 存储和查询 20.2.4 Grafana20.3 日志记录 20.3.1 具有关联ID的分布式跟踪 20.3.2 ElasticSearch、Logstash和Kibana 20.3.3 Kibana 20.4 异常    查看详情

3.21go微服务实战(微服务实战)---持续交付

第21章 持续交付21.1 持续交付简介 21.1.1 手动部署 21.1.2 持续交付的好处 21.1.3 持续交付面面观 21.1.4 持续交付的过程21.2 容器编排的选项和基础架构21.3 Terraform 21.3.1 提供者 21.3.2 Terraform配置入口点 21.3.3 VPC模块 21.3.... 查看详情

3.21go微服务实战(微服务实战)---持续交付

第21章 持续交付21.1 持续交付简介 21.1.1 手动部署 21.1.2 持续交付的好处 21.1.3 持续交付面面观 21.1.4 持续交付的过程21.2 容器编排的选项和基础架构21.3 Terraform 21.3.1 提供者 21.3.2 Terraform配置入口点 21.3.3 VPC模块 21.3.... 查看详情