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