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、將在網關上實現認證,審計,授權
/** * 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