網關安全(五)-引入網關,在網關上實現流控,認證,審計,授權


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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM