【SpringCloud Gateway】SpringCloud各微服务之间用户登录信息共享的实现思路——gateway网关token校验以及向微服务发送请求携带token
作者:mmseoamin日期:2023-12-18

        最近在学习SpringCloud项目时,想到了一些问题,各个微服务分别部署在不同的服务上,由naocs作为注册中心实现负载均衡,彼此之间通过Feign相互调用通信,信息同步并不像单体项目那样方便,传统单体项目的登录验证方式似乎在SpringCloud中不能满足项目的需求。那么当用户完成登录后,各微服务该如何确认用户的登录状态呢?

        下面有几种实现思路:

  • 统一认证中心:建立一个单独的认证中心,例如使用Spring Security或者基于OAuth的认证服务。每个微服务都需要将用户的登录请求导向认证中心,认证中心负责验证用户身份。认证中心可以颁发访问令牌,微服务通过访问令牌进行鉴权。
  • JWT (JSON Web Tokens):使用JWT来实现身份验证和授权。认证中心颁发包含用户信息的JWT令牌,微服务在收到请求时验证JWT令牌的有效性,并提取其中的用户信息。这样,用户信息可以在不同微服务之间共享。
  • 消息队列:使用消息队列(如RabbitMQ、Kafka)来在微服务之间传递用户登录信息。当用户登录或注销时,认证中心可以发布消息,其他微服务订阅这些消息以更新用户状态。
  • 分布式缓存:使用分布式缓存(如Redis)来存储用户登录信息。当用户登录时,在认证中心用户信息缓存到Redis中,其他微服务可以查询Redis以获取用户信息。

            这里为大家提供一种较为简单的方式:使用Redis分布式缓存储存用户登录信息。在gateway微服务中配置过滤器,在过滤器中获取到达网关的请求所携带的token信息,如果token为空或token对应的key在Redis中不存在,向用户返回401 UNAUTHORIZED的状态码;如果token验证正确,便刷新Redis中对应key的TTL,并继续向负载均衡的请求地址发送请求且携带相应的token信息。在接收请求的微服务中编写拦截器,在拦截器中获取token并通过Redis拿取对应的用户信息。

            gateway中的过滤器代码实现:

    @Order(1)
    @Configuration
    public class GlobalFilterConfig implements GlobalFilter {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        
        @Override
        public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            String token = exchange.getRequest().getHeaders().getFirst(GET_TOKEN);
            if (token == null || token.isEmpty()) {
                return unAuthorize(exchange);
                
            }
            Map map = stringRedisTemplate.opsForHash().entries(LOGIN_TOKEN_KEY + token);
            if (map.isEmpty()) {
                return unAuthorize(exchange);
                
            }
            
            // 刷新TTL
            stringRedisTemplate.expire(LOGIN_TOKEN_KEY + token, 30, TimeUnit.MINUTES);
            
            //把新的 exchange放回到过滤链
            ServerHttpRequest request = exchange.getRequest().mutate().header(GATEWAY_TOKEN, token).build();
            ServerWebExchange newExchange = exchange.mutate().request(request).build();
            return chain.filter(newExchange);
            
        }
        
        // 返回未登录的自定义错误
        private Mono unAuthorize(ServerWebExchange exchange) {
            // 设置错误状态码为401
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            // 设置返回的信息为JSON类型
            exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
            // 自定义错误信息
            String errorMsg = "{\"error\": \"" + "用户未登录或登录超时,请重新登录" + "\"}";
            // 将自定义错误响应写入响应体
            return exchange.getResponse()
                    .writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(errorMsg.getBytes())));
        }
    }

            注意需要使用@Order注解为该全局过滤器设置优先级。当过滤器的order值一致时,过滤器的执行顺序为:defaultFilter>路由过滤器>GlobalFilter,因此该过滤器的order值应当设置为较小值,以确保该全局过滤器的正确执行。(order值越小,优先级越高,执行顺序越靠前)

            微服务中拦截器的代码实现:

    public class LoginHandlerInterceptor implements HandlerInterceptor {
        private StringRedisTemplate stringRedisTemplate;
        
        // 由于该类未交给spring管理,因此不能使用自动装配的方式获取RedisTemplate对象
        public LoginHandlerInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
        
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String token = request.getHeader(GATEWAY_TOKEN);
            if (token == null || token.isEmpty()) {
                return false;
            }
            Map map = stringRedisTemplate.opsForHash().entries(LOGIN_TOKEN_KEY + token);
            if (map.isEmpty()) {
                return false;
            }
            UserDto userDto = BeanUtil.toBean(map, UserDto.class);
            UserContext.saveUser(userDto); // 将用户信息放入线程中
            return true;
        }
        
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserContext.removeUser();
        }
    }
    @Configuration
    public class MyWebConfig implements WebMvcConfigurer {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new LoginHandlerInterceptor(stringRedisTemplate))
                    .addPathPatterns("/**");
        }
    }

            到这里我们就完成了gateway到微服务的用户登录信息传递。接下来就需要解决微服务与微服务之间的登录信息传递问题。在这个项目中各微服务通过分Feign实现相互调用通信,那么我们只需要在调取Feign时携带token信息就好:

    @FeignClient(name = "test-gateway")
    public interface ExampleClient {
        @GetMapping("/api/example")
        String getExampleData(@RequestHeader("token") String token);
    }

            但每次调用该Feign接口时都需要我们手动传入token值,不太优雅,因此采用下面的方式来配置Feign,每当Feign接口被调用时就会携带token信息:

    public class FeignRequestInterceptor implements RequestInterceptor {
        @Override
        public void apply(RequestTemplate requestTemplate) {
            requestTemplate.header(GET_TOKEN, TokenContext.getToken());
        }
    }
    @FeignClient(name = "test-gateway", configuration = FeignRequestInterceptor.class)
    public interface ExampleClient {
        @GetMapping("/api/example")
        String getExampleData(@RequestHeader("token") String token);
    }

            若遇到启动时报错A bean with that name has already been defined and overriding is disabled可以看这篇文章:【SpringCloud】使用OpenFeign的spring项目启动时报错bean注册问题

            至此就完成了最基础的微服务登录信息传递。