微服务网关与安全


API Gateway 网关服务作为微服务架构中系统外部与内部的网络屏障,安全策略的应用是必不可少的。我们知道在微服务架构设计中有一个重要原则,即服务的单一责任性,尤其是对于某一业务服务而言,理想状态下我们希望其仅承担某一项业务职责。而系统的安全问题是一个全局的抽象问题,我们并不希望安全策略的应用侵入到业务代码中。在大部分系统中安全检验主要是针对系统外部对系统内部的请求而言,而对于系统内部服务间的 RPC 调用一般不需要进行严格的安全校验,当然一些复杂系统内部可能会分为高权限区域与低权限区域,由低到高的数据访问依然需要通过网关。因此网关服务无论作为系统内外的屏障,还是子系统间的屏障,由 API Gateway 承担系统安全拦截的职责不失为是一个合理的选择,在此背景下业务服务可以更加专注到业务开发中,同时针对系统认为有风险的访问,可以将拦截行为前置到网关中,很大程度上减轻了一部分对后端业务服务的负载,同时把安全策略相关的代码从业务服务剥离后,业务服务无需加载安全相关的资源,提供了系统整体的资源利用率。

无网关状态下的服务安全


当一个系统没有建立 API Gateway 网关服务时,我们往往会在每个需要对外解析的服务中加入安全策略的实现,例如在 MVC 的编程模型下,我们可以在各个服务中集成一系列过滤器以达到安全校验的目的,这些过滤器往往作用于 Controller 控制器之前,可以在用户请求到达控制层之前达到拦截作用。

在这种设计下如果我们针对每个服务单独维护一套安全校验的代码,那么一方面系统安全策略难以统一,另一方面维护成本较高。此时我们可以将一系列安全相关的过滤器统一维护为一个公用组件,各服务统一依赖于该组件,达到代码的高度复用,事实上我在工作中也曾使用这种方式处理服务安全问题。当然这种方案依然有其不足之处,例如在对安全组件进行迭代升级时,需要在每个服务更新依赖版本号,存在一定维护成本,同时安全组件本身也会占用一定的服务资源,带来性能开销。我们应该意识到在缺少 API 网关的情况下,意味着系统需要对外暴露更多的服务,虽然我们可以针对每个服务严格控制其安全规则,但暴露面越大的同时打击面也就越大,在系统未来的扩展中难免有疏忽之处,存在安全风险。

网关服务集成安全策略


上文我们说到在业务服务中集成系统安全措施虽然可行,但存在其缺陷。如果我们的系统中有 API 网关这样的微服务基础设施,由于网关本身是作为系统内外网的网络屏障,天然适合承担系统安全校验的职责。

其实不光是安全校验的功能,诸如限流,熔断降级,请求缓存,负载均衡等具有通用性的功能组件,将这些功能集成到 API 网关中可以成为我们系统设计的一种重要思路,高度提升代码复用性的同时也易于维护管理。大部分框架会使用责任链模式将这些功能组件组合在一起,根据单一责任原则,设计一系列过滤器,每种过滤器承担某一项功能职责,它们共同连接成一个链条完成对用户请求的拦截工作。而将这个链条放置到 API 网关中可以最大程度地保证这个链条的覆盖性,确保每一个外部请求都必须经过这个链条访问系统内部数据,这对系统的安全性是至关重要的。

Spring Cloud Gateway 中的设计


Spring Cloud Gateway 作为 Spirng 官方团队推出的微服务网关解决方案,其中的设计值得我们去思考,同时可以印证自己对于 API 网关的理解。我曾在 微服务网关 Spring Cloud Gateway 之限流 这篇文章中讨论过 Spring Cloud Gateway 对于限流机制的支持。同时出于自己在工作中对于 API Gateway 的实践需要,找机会对 Spring Cloud Gateway 的源码实现以及设计思想做了进一步探究。

这是 Spring Cloud Gateway 官方文档中对于该框架工作流程的概括图,我们可以看到其大概设计与我们上文中提到的网关过滤器设想是非常相似的。Spring Cloud Gateway 框架中内置了大量的过滤器供开发者选择,分为全局过滤器以及针对某一路由的过滤器,路由是一个由开发者定义的概念,开发者可以通过自定义路由规则将某一系列请求分配到某一路由上,这个路由可以是一个服务,也可以是一个接口地址,这完全由开发者决定。借助 Spring Cloud Gateway 内置的过滤器已经可以实现大部分的功能,例如 RequestRateLimiterGatewayFilterFactory 默认集成 Redis 实现基于令牌桶算法的限流功能,SpringCloudCircuitBreakerFilterFactory 通过集成 Spring Cloud Circuit Breaker 组件实现服务熔断功能。但是通过浏览框架内置的过滤器,我们似乎没有找到与安全功能相关的实现。

Spring Cloud Gateway 集成 Spring Security 安全校验


我们知道 Spirng 家族生态中有一个安全框架 Spring Security,那么我们是否可以将 Spring Security 提供的安全校验机制集成到 Spring Cloud Gateway 中,避免重复造轮子呢?首先在新版本的 Spring Cloud 体系中 Spring 官方团队为了推动 Reactive 异步体系,因此使用了 Spring WebfluxProject Reactor 等响应式编程技术实现 Spring Cloud GatewaySpring Cloud Circuit Breaker 等微服务组件。在这个大前提下,如果我们想要集成 Spring Security 框架,也必须是建立在响应式技术栈上,事实上官方针对 Spring Security 也提供了对 Reactive 的支持。

Spring Security 中的过滤器链本质是一个 WebFilter,在 Spring Webflux 的定义的规范中,WebFilter 是作用于 WebHandler 之前的。Spring Cloud Gateway 中的过滤器链本质是 WebHandler 的一部分,通过翻阅源码可知 Spring Cloud Gateway 通过装配一个 FilteringWebHandler 替代默认的 DispatcherHandler 完成路由分配。因此我们可以知道如果将两者结合使用,那么 Spring Security 中的过滤器顺序是在整个 Spring Cloud Gateway 过滤器链条之前的。在开发过程中,我们不妨先实现 Spring Security 的安全机制,在考虑如何与网关适配。

Spring Security 代码实现

/**
 * 安全配置
 * @author Ethan Zhang
 */
@EnableWebFluxSecurity
public class GatewaySecurityConfiguration {

    public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();

    private static final String LOGIN_URL = "/security/login";
    private static final String LOGOUT_URL = "/security/logout";

    @Bean
    public ReactiveUserDetailsService userDetailsService() {
        return new CustomReactiveUserDetailsService();
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http
                // 跨域处理策略
                .cors().configurationSource(corsConfigurationSource())
                .and()
                // 禁用 CSRF
                .csrf().disable()
                // 身份上下文存储
                .securityContextRepository(redisServerSecurityContextRepository())
                // 身份认证
                .authorizeExchange().anyExchange().permitAll()
                .and()
                // 表单登录
                .formLogin().loginPage(LOGIN_URL)
                .authenticationSuccessHandler(customAuthenticationSuccessHandler())
                .authenticationFailureHandler(customAuthenticationFailureHandler())
                .and()
                // 匿名用户
                .anonymous()
                .and()
                // 退出
                .logout().logoutUrl(LOGOUT_URL).logoutSuccessHandler(customLogoutSuccessHandler())
                .and()
                // 异常处理
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint())
                .accessDeniedHandler(customAccessDeniedHandler())
                .and()
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PASSWORD_ENCODER;
    }

    @Bean
    public CustomAuthenticationEntryPoint customAuthenticationEntryPoint() {
        return new CustomAuthenticationEntryPoint();
    }

    @Bean
    public CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler() {
        return new CustomAuthenticationSuccessHandler();
    }

    @Bean
    public CustomAuthenticationFailureHandler customAuthenticationFailureHandler() {
        return new CustomAuthenticationFailureHandler();
    }

    @Bean
    public CustomAccessDeniedHandler customAccessDeniedHandler() {
        return new CustomAccessDeniedHandler();
    }

    @Bean
    public RedisServerSecurityContextRepository redisServerSecurityContextRepository() {
        return new RedisServerSecurityContextRepository();
    }

    @Bean
    public CustomLogoutSuccessHandler customLogoutSuccessHandler() {
        return new CustomLogoutSuccessHandler();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        return exchange -> {
            CorsConfiguration corsConfiguration = new CorsConfiguration();
            corsConfiguration.setAllowedOrigins(Collections.singletonList("localhost"));
            corsConfiguration.setAllowedMethods(Arrays.stream(HttpMethod.values())
                    .map(HttpMethod::toString).collect(Collectors.toList()));
            return corsConfiguration;
        };
    }

}

上文我们提到过 Spring Security 框架提供了针对 Spring Webflux 非阻塞式 Web 应用的支持,我们只需根据官方指引进行相关安全配置即可。我在 Spring Security(一)身份认证Spring Security(二)权限管理 系列文章中讨论过 Spring Security 的设计思想及实现原理,当中的讨论是基于 Spring MVC 的,事实上 Spring Security 在适配 Reactive 的过程中并没有改变原有的设计架构,主要是在 API 层面进行异步编程的适配。

需要注意的是,我选择将权限拦截机制设定为 permitAll, 在 Spring Security 层面上只进行用户身份认证,而不做权限拦截。因为在微服务架构中,API 网关需要将用户请求路由到不同的服务,而不同的服务的安全配置往往是不同,例如有些服务需要系统后台管理员才能访问,而有些服务直接对游客开放。上文我们提到 Spring Security 的过滤器作用于网关之前,如果我们直接在其中进行权限拦截,就无法实现面向路由的权限个性化。因此在 Spring Security 中我们只进行用户身份认证,用户完成身份认证后即拿到了用户所拥有的权限,而权限校验则后置到 Spring Cloud Gateway 的过滤器中进行。

/**
 * 基于 redis 的用户认证信息存储
 * @author Ethan Zhang
 */
public class RedisServerSecurityContextRepository implements ServerSecurityContextRepository {

    private static final String COOKIE_TOKEN_KEY = "ethan-token";

    private static final Duration timeout = Duration.ofDays(15);

    @Resource
    private ReactiveRedisTemplate<Object, Object> reactiveRedisTemplate;

    @Override
    public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
        Object principal = context.getAuthentication().getPrincipal();
        if (principal instanceof CustomUserDetails) {
            CustomUserDetails customUserDetails = (CustomUserDetails) principal;
            String token = UUID.randomUUID().toString();
            exchange.getResponse().addCookie(ResponseCookie.from(COOKIE_TOKEN_KEY, token)
                    .domain("localhost").httpOnly(true).maxAge(timeout).path("/").build());
            return reactiveRedisTemplate.opsForValue().set(token, customUserDetails, timeout).then();
        }
        return Mono.empty();
    }

    @Override
    public Mono<SecurityContext> load(ServerWebExchange exchange) {
        return Mono.justOrEmpty(exchange.getRequest().getCookies().getFirst(COOKIE_TOKEN_KEY))
                .flatMap(cookie -> reactiveRedisTemplate.opsForValue().get(cookie.getValue()).cast(CustomUserDetails.class)
                        .map(customUserDetails -> new SecurityContextImpl(new UsernamePasswordAuthenticationToken(customUserDetails,
                                customUserDetails.getPassword(), customUserDetails.getAuthorities()))));
    }

}

对于用户的身份认证,核心逻辑是如何将用户身份信息持久化到后端服务,并给用户发放 TOKEN,后续用户凭借 TOKEN 即可访问权限范围内的路由。在 Spring Security 的规范中我们可以实现 ServerSecurityContextRepository 接口自定义用户身份的保存与加载逻辑。在这里我选择使用 redis 实现用户身份信息的持久化,一方面 redis 作为分布式缓存中间件,可以有效达到用户身份信息在分布式系统间的共享,另一方面,redis 提供了 Reactive API 方面的支持,可以与响应式编程技术栈完美适配。在用户完成登录认证后,我们将用户身份信息(包含权限信息)持久化到 redis 中,并向用户浏览器发放 TOKEN 身份令牌。后续该用户再次请求网关服务时,后端即可获取到 TOKENredis 中取得当前用户的身份信息,并将此信息放入 ServerWebExchange 请求信息中,这些身份权限信息将成为后续完成权限校验的依据。

Spring Cloud Gateway 代码实现

/**
 * 自定义权限校验过滤器,用以适配 Spring Cloud Gateway
 * @author Ethan Zhang
 */
@Slf4j
@Component
public class AuthorizationGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthorizationGatewayFilterFactory.Config> {

    @Resource
    private ServerAccessDeniedHandler accessDeniedHandler;

    public AuthorizationGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> exchange.getPrincipal()
                .cast(UsernamePasswordAuthenticationToken.class)
                .map(UsernamePasswordAuthenticationToken::getPrincipal)
                .cast(CustomUserDetails.class)
                .flatMap(customUserDetails -> {
                    Set<String> userAuthorities = customUserDetails.getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
                    boolean matched = userAuthorities.containsAll(config.authorities);
                    return matched ? Mono.just(customUserDetails) : Mono.error(new AccessDeniedException("Authorization failed"));
                })
                .switchIfEmpty(Mono.error(new AccessDeniedException("Authorization failed")))
                .doOnSuccess(customUserDetails -> log.info("Authorization successful"))
                .doOnError(e -> accessDeniedHandler.handle(exchange, new AccessDeniedException(e.getMessage(), e)))
                .then(chain.filter(exchange));
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return Collections.singletonList("authorities");
    }

    @Override
    public ShortcutType shortcutType() {
        return ShortcutType.GATHER_LIST;
    }

    @Getter
    @Setter
    public static class Config {

        private List<String> authorities = Collections.emptyList();

    }

}

Spring Cloud Gateway 中我们通过扩展 AbstractGatewayFilterFactory 达到自定义网关过滤器的目的。AuthorizationGatewayFilterFactory.Config 类用于承载路由权限配置信息,也就说访问该路由需要哪些权限。上文我们提到 Spring Security 中的过滤器已经帮助我们进行了用户请求的身份认证,并将用户身份信息存入了 ServerWebExchange 请求对象中,而 Spring Cloud Gateway 中的网关过滤器位于其后,所以在这里我们可以可轻松地获取到用户身份信息,并取出用户所拥有的权限信息,将其与路由配置中的权限进行比对,即可知道该用户是否具备访问该路由的权限。

spring:
  application:
    name: api-gateway
  redis:
    client-name: ${spring.application.name}
    host: test-redis.ethanzhang.cn
    port: 6379
    password: admin
    database: 1
  cloud:
    gateway:
      routes:
        - id: app-service
          uri: http://app-service.ethanzhang.cn
          predicates:
            - Method=GET, POST
            - Path=/app/**
          filters:
            - Authorization=createOrder, deleteOrder
        - id: web-service
          uri: http://web-service.ethanzhang.cn
          predicates:
            - Method=GET, POST
            - Path=/web/**
server:
  port: 8888
logging:
  level:
    org:
      springframework:
        security: DEBUG

以上文的网关配置为例,我们自定义了一个 Route 路由,IDapp-service,如果用户想要访问匹配 /app/** 的任何地址,都需要经过过滤器 AuthorizationGatewayFilter 的权限校验,只有当用户拥有名为 createOrderdeleteOrder 的权限时,用户才有权访问该路由。虽然这里只是一个简要的 demo,但是通过这种方式我们可以方面地为系统中的每一个路由定制安全配置,达到用户请求安全拦截的目的。当 Spring Cloud Gateway 集成 Spring Security 后,我相信会成为一个更成熟健壮的微服务网关解决方案。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 本篇
微服务网关与安全 微服务网关与安全
API Gateway 网关服务作为微服务架构中系统外部与内部的网络屏障,安全策略的应用是必不可少的。我们知道在微服务架构设计中有一个重要原则,即服务的单一责任性,尤其是对于某一业务服务而言,理想状态下我们希望其仅承担某一项业务职责。而系
2021-03-31 Ethan Zhang
下一篇 
浅谈微服务架构设计 浅谈微服务架构设计
“架构”一个抽象而看似高端的词汇,我们难以给它一个明确的定义。人们发明了很多描述架构的概念,单体架构、SOA架构、六边形架构、洋葱圈架构、微服务架构等。这些不同概念的定义和思想相互渗透影响,没有清晰的边界。在实践中一个系统往往是参考多种思
2021-03-02 Ethan Zhang