网关安全(五)-引入网关,在网关上实现流控,认证,审计,授权

1、当前项目存在的问题

  在前面我们已经完成了一个基于Oauth2认证和授权的流程(如上图)。但是到现在还没有进入到微服务的环境下,如果资源服务器(订单服务),不仅仅是一个单一服务。而是几十个微服务,并且每个微服务都是一个集群,在这样一个流程中存在如下问题:

  1.1、安全处理和业务逻辑耦合,增加了复杂性和变更成本。

  1.2、随着业务节点增加,认证服务器压力增大。

  1.3、多个微服务同时暴漏,增加了外部访问的复杂性。

2、引入网关解决问题

  针对上面的问题,我们引入网关来解决,将安全处理放到网关中,微服务只处理自己的业务;有网关来验证令牌,微服务不与认证服务器直接交互了。对于外部访问来说,只需要网关的地址即可,内部微服务由网关进行转发。

3、搭建zuul网关,转发路由

  3.1、pom.xml

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.2.0.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Greenwich.SR2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <properties>
        <java.version>1.8</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>
    </dependencies>

  3.2、启动类GatewayServerApplication

/**
 * 网关
 *
 * @author caofanqi
 * @date 2020/2/2 15:36
 */
@EnableZuulProxy
@SpringBootApplication
public class GatewayServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayServerApplication.class,args);
    }

}

  3.3、application.yml

server:
  port: 9010

spring:
  application:
    name: gateway-server

zuul:
  routes:
    token:
      url: http://127.0.0.1:9020
      path: /token/**
    order:
      url: http://127.0.0.1:9080
      path: /order/**
  #敏感头设置为空,因为默认的包含 Authorization,我们需要通过Authorization传递信息
  sensitive-headers:

  3.4、启动项目,通过网关获取令牌及创建订单

  

4、将在网关上实现认证,审计,授权

  4.1、删除order微服务上的安全配置,只留下业务代码

  4.2、在网关上添加OAuth2认证过滤器

/**
 * OAuth2认证过滤器
 *
 * @author caofanqi
 * @date 2020/2/2 22:54
 */
@Slf4j
@Component
public class OAuth2Filter extends ZuulFilter {


    private RestTemplate restTemplate = new RestTemplate();


    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 2;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        log.info("++++++认证++++++");

        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();

        if (StringUtils.startsWith(request.getRequestURI(), "/token")) {
            //发往认证服务器的请求直接放行
            return null;
        }

        String authorization = request.getHeader("Authorization");

        if (StringUtils.isBlank(authorization)) {
            //没有Authorization头的直接放行
            return null;
        }

        if (!StringUtils.startsWithIgnoreCase(authorization, "bearer ")) {
            //不是OAuth认证的直接放行
            return null;
        }

        try {
            request.setAttribute("tokenInfo", getTokenInfo(authorization));
        } catch (Exception e) {
            log.info("check token fail :", e);
        }

        return null;
    }

    /**
     *  向认证服务器校验token的有效性
     */
    private TokenInfoDTO getTokenInfo(String authorization) {

        String token = StringUtils.substringAfter(authorization, "bearer ");
        String checkTokenEndpointUrl = "http://127.0.0.1:9020/oauth/check_token";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setBasicAuth("gateway", "123456");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.set("token", token);

        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

        ResponseEntity<TokenInfoDTO> response = restTemplate.exchange(checkTokenEndpointUrl, HttpMethod.POST, httpEntity, TokenInfoDTO.class);
        TokenInfoDTO tokenInfo = response.getBody();

        log.info("tokenInfo : {}", tokenInfo);

        return tokenInfo;

    }


}

  4.3、添加审计过滤器

/**
 * 审计日志过滤器
 *
 * @author caofanqi
 * @date 2020/2/2 23:59
 */
@Slf4j
@Component
public class AuditLogPreFilter extends ZuulFilter {

    @Resource
    private AuditLogRepository auditLogRepository;

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 3;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        log.info("++++++pre审计++++++");

        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();
        TokenInfoDTO tokenInfo = (TokenInfoDTO) request.getAttribute("tokenInfo");
        String username = "anonymous";
        if (tokenInfo != null) {
            username = tokenInfo.getUser_name();
        }

        AuditLogDO auditLogDO = new AuditLogDO();
        auditLogDO.setPath(request.getRequestURI());
        auditLogDO.setHttpMethod(request.getMethod());
        auditLogDO.setUsername(username);
        auditLogRepository.saveAndFlush(auditLogDO);

        request.setAttribute("auditLogId",auditLogDO.getId());

        return null;
    }

}
/**
 * 审计日志过滤器
 *
 * @author caofanqi
 * @date 2020/2/2 23:59
 */
@Slf4j
@Component
public class AuditLogPostFilter extends ZuulFilter {

    @Resource
    private AuditLogRepository auditLogRepository;

    @Override
    public String filterType() {
        return "post";
    }

    @Override
    public int filterOrder() {
        return 5;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        log.info("++++++post审计++++++");

        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();

        Long auditLogId = (Long) request.getAttribute("auditLogId");
        Optional<AuditLogDO> auditLogOp = auditLogRepository.findById(auditLogId);
        AuditLogDO auditLogDO = auditLogOp.orElse(new AuditLogDO());
        auditLogDO.setHttpStatus(requestContext.getResponseStatusCode());
        if (requestContext.getThrowable()!= null){
            auditLogDO.setErrorMessage(requestContext.getThrowable().getMessage());
        }
        auditLogRepository.saveAndFlush(auditLogDO);

        return null;
    }

}

  4.4、添加授权过滤器,将/token/** 设置为忽略校验

/**
 * 授权过滤器
 *
 * @author caofanqi
 * @date 2020/2/3 0:15
 */
@Slf4j
@Component
public class AuthorizationFilter extends ZuulFilter implements InitializingBean {

    @Value("${permit.urls}")
    private String permitUrls;

    private Set<String> permitUrlSet = new HashSet<>();

    private AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 4;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        log.info("++++++授权++++++");

        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();

        if (isPermitUrl(request)) {
            return null;
        }

        /*
         * 需要认证
         */
        TokenInfoDTO tokenInfo = (TokenInfoDTO) request.getAttribute("tokenInfo");

        if (tokenInfo != null && tokenInfo.getActive()) {
            if (!hasPermission(tokenInfo, request)) {
                //没权限
                handlerError(HttpStatus.FORBIDDEN.value(), requestContext);
            }
            //认证通过,向请求头中放入用户名,供微服务获取
            requestContext.addZuulRequestHeader("username", tokenInfo.getUser_name());
        } else {
            //没认证,或认证信息有误
            handlerError(HttpStatus.UNAUTHORIZED.value(), requestContext);
        }

        return null;
    }

    private void handlerError(int httpStatus, RequestContext requestContext) {
        requestContext.setResponseStatusCode(httpStatus);
        requestContext.getResponse().setContentType(MediaType.APPLICATION_JSON_VALUE);
        requestContext.setResponseBody("{"message":"auth fail"}");
        //不继续往下走了,返回
        requestContext.setSendZuulResponse(false);

    }


    private boolean isPermitUrl(HttpServletRequest request) {
        String uri = request.getRequestURI();
        for (String url : permitUrlSet) {
            if (pathMatcher.match(url, uri)) {
                // 不需要认证和权限,直接访问
                return true;
            }
        }

        return false;
    }


    private boolean hasPermission(TokenInfoDTO tokenInfo, HttpServletRequest request) {

        String[] scope = tokenInfo.getScope();
        if (StringUtils.equalsIgnoreCase(request.getMethod(), HttpMethod.GET.name())) {
            return ArrayUtils.contains(scope, "read");
        }

        if (StringUtils.equalsIgnoreCase(request.getMethod(), HttpMethod.POST.name())) {
            return ArrayUtils.contains(scope, "write");
        }

        return true;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Collections.addAll(permitUrlSet, StringUtils.splitByWholeSeparatorPreserveAllTokens(permitUrls, ","));
    }

}

  4.5、order中获取用户名

    @PostMapping
    public OrderDTO create(@RequestBody OrderDTO orderDTO, @RequestHeader String username) {
        log.info("username is :{}", username);
        PriceDTO price = restTemplate.getForObject("http://127.0.0.1:9070/prices/" + orderDTO.getProductId(), PriceDTO.class);
        log.info("price is : {}", price.getPrice());
        return orderDTO;
    }

  4.6、启动各个项目

    4.6.1、将网关配置到oauth_client_details中

    4.6.2、通过网关获取scope为read和write的令牌,并通过网关携带正确的令牌访问获取订单请求,创建订单请求都成功,并且创建订单成功拿到了用户名

    4.6.3、通过网关获取scope为read的令牌,并通过网关携带正确的令牌访问获取订单请求,可以正常访问,但访问创建订单响应403。带错误的令牌或不带令牌访问服务响应401,说明我们的配置都生效了。

5、使用spring-cloud-zuul-ratelimit进行限流

  5.1、pom中添加spring-cloud-zuul-ratelimit依赖

        <dependency>
            <groupId>com.marcosbarbero.cloud</groupId>
            <artifactId>spring-cloud-zuul-ratelimit</artifactId>
            <version>2.3.0.RELEASE</version>
        </dependency>

  5.2、在限流时需要存放一些信息,需要有相应的存储,支持的如下,推荐使用REDIS,我们引入spring-boot-starter-data-redis依赖

public enum RateLimitRepository {

    REDIS,

    CONSUL,

    JPA,

    BUCKET4J_JCACHE,

    BUCKET4J_HAZELCAST,

    BUCKET4J_IGNITE,

    BUCKET4J_INFINISPAN,
}
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

  5.3、application.yml添加限流配置,我们只配置默认策略,更多配置请看 https://github.com/marcosbarbero/spring-cloud-zuul-ratelimit 

zuul:
  routes:
    token:
      url: http://127.0.0.1:9020
      path: /token/**
    order:
      url: http://127.0.0.1:9080
      path: /order/**
  #敏感头设置为空,因为默认的包含 Authorization,我们需要通过Authorization传递信息
  sensitive-headers:
  #限流相关配置
  ratelimit:
    key-prefix: zuul-ratelimit  #key的前缀,默认为应用名
    enabled: true #是否启用限流
    repository: REDIS #使用的存储
    behind-proxy: false #是否是代理之后,默认false
    default-policy-list: #默认策略列表:可选,针对所有的路由配置的策略,除非有具体的policy,否者使用默认该默认策略
      - limit: 2 # 可选,每个 refresh-interval 窗口的请求数限制
        quota: 1 # 可选,每个refresh-interval窗口的请求时间限制,单位秒
        refresh-interval: 10 # 默认值,单位秒
        type: #可选,限流方式,组合使用
          - url #根据请求路径
          - http_method #根据请求方法

  我们这段配置的意思是,1、相同的url和http method在10秒内,只可以有两个请求(limit)。2、相同的url和http method在10秒内的请求响应时间不能超过1秒(quota)。这两个条件满足任何一个都会被限流。

  5.4、启动各项目,启动redis,我们在10秒内,请求三次获取订单服务,被限流,如下

  5.5、我们可以将refresh-interval设置的大一点,可以发现redis中会根据我们的配置生成key,key的名称就是 配置的前缀:路由:url:httpmethod,并且类型是string,有过期时间。针对limit和quota会生成两个key。

   5.6、可以自定义key的生成规则,自定义错误处理,和自定义超速事件监听

/**
 * 限流自定义配置 https://github.com/marcosbarbero/spring-cloud-zuul-ratelimit
 *
 * @author caofanqi
 * @date 2020/2/3 15:34
 */
@Slf4j
@Configuration
public class RateLimitConfig {


    /**
     * 自定义限流key生成规则
     */
    @Bean
    public RateLimitKeyGenerator ratelimitKeyGenerator(RateLimitProperties properties, RateLimitUtils rateLimitUtils) {
        return new DefaultRateLimitKeyGenerator(properties, rateLimitUtils) {
            @Override
            public String key(HttpServletRequest request, Route route, RateLimitProperties.Policy policy) {
                /*
                 * 可以根据自己的需求自定义
                 */
                return super.key(request, route, policy) + ":custom";
            }
        };
    }

    /**
     * 自定义错误处理
     */
    @Bean
    public RateLimiterErrorHandler rateLimitErrorHandler() {
        return new DefaultRateLimiterErrorHandler() {
            @Override
            public void handleSaveError(String key, Exception e) {
                // 自定义代码
                super.handleSaveError(key, e);
            }

            @Override
            public void handleFetchError(String key, Exception e) {
                // 自定义代码
                super.handleFetchError(key, e);
            }

            @Override
            public void handleError(String msg, Exception e) {
                // 自定义代码
                super.handleError(msg, e);
            }
        };
    }


    /**
     * 超速事件监听
     */
    @EventListener
    public void observe(RateLimitExceededEvent event) {
        log.info("监听到超速了...");
    }

}

  注意:网关上不要做细粒度的限流,主要为服务器硬件设备的并发处理能力做限流。

 项目源码:https://github.com/caofanqi/study-security/tree/dev-gateway

原文地址:https://www.cnblogs.com/caofanqi/p/12254503.html