SpringCloudGateway+Nacos+OAuth2

Posted by youthred on August 25, 2022

POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<dependencyManagement>
    <dependencies>
        <!--支持Spring Boot 2.1.X-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.3.2.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Hoxton.SR8</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.2.5.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons.lang3.version}</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>${commons.io.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
            <version>${commons.collections.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <version>2.3.0.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis.plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
            <version>${dynamic.datasource.version}</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>${junit.platform.launcher.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.10</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>${druid.starter.version}</version>
        </dependency>
        <dependency>
            <groupId>com.oracle</groupId>
            <artifactId>ojdbc8</artifactId>
            <version>${oracle.version}</version>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>io.gitghub.youthred.common</groupId>
            <artifactId>common</artifactId>
            <version>${io.gitghub.youthred.common.version}</version>
        </dependency>
        <dependency>
            <groupId>p6spy</groupId>
            <artifactId>p6spy</artifactId>
            <version>${p6spy.version}</version>
            <optional>true</optional>
        </dependency>
    </dependencies>
</dependencyManagement>

<properties>
    <spring-boot-maven-plugin.version>2.3.0.RELEASE</spring-boot-maven-plugin.version>
    <maven-surefire-plugin.version>2.22.2</maven-surefire-plugin.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <commons.lang3.version>3.7</commons.lang3.version>
    <commons.io.version>2.6</commons.io.version>
    <commons.collections.version>4.1</commons.collections.version>
    <junit.platform.launcher.version>1.6.2</junit.platform.launcher.version>
    <hutool.version>5.8.3</hutool.version>
    <mybatis.plus.version>3.3.2</mybatis.plus.version>
    <druid.starter.version>1.1.22</druid.starter.version>
    <oracle.version>1.0</oracle.version>
    <io.gitghub.youthred.common.version>0.0.1</io.gitghub.youthred.common.version>
    <p6spy.version>3.8.0</p6spy.version>
    <dynamic.datasource.version>3.1.1</dynamic.datasource.version>
</properties>

项目结构

1
2
3
4
5
6
7
goc
+- goc-common 公共模块
+- goc-auth
|  +- goc-authorizer 授权服务(本篇不作说明,只写网关鉴权转发)
|  +- goc-authenticator 鉴权服务
|  +- goc-authenticator-client 鉴权Feign客户端
+- goc-gateway 网关服务

1 SpringCloudGateway

SpringCloudGateway + Nacos

1.1 ReactiveFilter

这里把拦截过滤写在 WebFilter 而不是 GlobalFilter 是为了拦截所有从网关发起的请求,不仅包含网关转发,还包含Feign远程鉴权等请求。

而如果仅仅实现 GlobalFilter 则只能拦截到网关转发的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
 * 拦截所有请求(包括非网关请求)
 *
 * @author youthred.github.io
 */
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
// 必须加上组件扫描(Feign客户端)才不会报错"feign.codec.DecodeException: No qualifying bean of type 'org.springframework.boot.autoconfigure.http.HttpMessageConverters' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}"
@ComponentScan(basePackages = "io.gitghub.youthred.goc.authenticator.client")
@RequiredArgsConstructor
public class ReactiveFilter implements WebFilter, Ordered {

    private final AuthProvider authProvider;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String roles = request.getHeaders().getFirst(CommonConstant.Header.ROLES);
        ServerHttpRequest.Builder mutate = request.mutate();
        mutate.header(CommonConstant.Header.ROLES, roles);
        // roles 不能未空或空字符串
        // (这里也可以使用feign.RequestInterceptor实现全局header自动填写,但WebFlux的请求上下文暂时没搞明白,用它推荐的Context也无法在非控制层获取到Request,在后面的段落会详细说明)
        // R: 响应封装实体,这里省略说明
        // 为了简单说明,这里直接判断请求路径是否有相关角色
        R<Boolean> permit = authProvider.permit(roles, request.getURI().getPath(), request.getMethodValue());
        if (permit.getCode() == HttpStatus.OK.getCode()) {
            return permit.getData()
                    ? chain
                        // 写入header
                        .filter(exchange.mutate().request(mutate.build()).build())
                        // 写入Context上下文
                        .subscriberContext(ctx -> ctx.put(ReactiveRequestContextHolder.CONTEXT_KEY, request))
                    : ServerWebExchangeUtil.forbidden(exchange);
        }
        return ServerWebExchangeUtil.custom(exchange, permit);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

1.1.1 Reactive请求上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * Reactive请求上下文
 *
 * @author youthred.github.io
 */
public class ReactiveRequestContextHolder {

    static final Class<ServerHttpRequest> CONTEXT_KEY = ServerHttpRequest.class;

    public static Mono<Context> getContext() {
        return Mono.subscriberContext();
    }

    public static Mono<ServerHttpRequest> getRequest() {
        return Mono.subscriberContext()
                .map(ctx -> ctx.get(CONTEXT_KEY));
    }

    public static Mono<String> getHeader(String headerName) {
        return getRequest().map(request -> request.getHeaders().getFirst(headerName));
    }

    public static Function<Context, Context> clear() {
        return (context) -> context.delete(CONTEXT_KEY);
    }
}

1.1.2 Reactive自定义响应工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * @author youthred.github.io
 */
public class ServerWebExchangeUtil {

    /**
     * forbidden
     *
     * @param exchange ServerWebExchange
     * @return Mono<Void>
     */
    public static Mono<Void> forbidden(ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        DataBuffer buffer = response.bufferFactory().wrap(JSONUtil.toJsonStr(R.forbidden()).getBytes(StandardCharsets.UTF_8));
        response.setStatusCode(HttpStatus.FORBIDDEN);
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }

    /**
     * forbidden
     *
     * @param exchange ServerWebExchange
     * @param r        R
     * @return Mono<Void>
     */
    public static Mono<Void> custom(ServerWebExchange exchange, R r) {
        ServerHttpResponse response = exchange.getResponse();
        DataBuffer buffer = response.bufferFactory().wrap(JSONUtil.toJsonStr(r).getBytes(StandardCharsets.UTF_8));
        response.setStatusCode(HttpStatus.valueOf(r.getCode()));
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }
}

1.2 Nacos

Nacos gateway.yml

1
2
3
4
5
6
7
8
9
10
11
spring:
  cloud:
    gateway:
      routes:
        - id: goc-authenticator
          order: 1
          predicates:
            - Path=/goc-authenticator/**
          filters:
            - StripPrefix=1
          uri: lb://goc-authenticator

网关服务 bootstrap.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server:
  port: ${GATEWAY_SERVER_PORT}
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        server-addr: ${REGISTER_HOST}:${REGISTER_PORT}
      config:
        server-addr: ${REGISTER_HOST}:${REGISTER_PORT}
        enabled: true
        file-extension: yml
        shared-configs:
          - data-id: gateway.yml
            refresh: true
          - data-id: common.yml
            refresh: true
          - data-id: server.yml
            refresh: true
    sentinel:
      transport:
        dashboard: ${SENTINEL_DASHBOARD_HOST}:${SENTINEL_DASHBOARD_PORT}

2 authenticator 鉴权服务

2.1 提供鉴权接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthenticatorRest {

    private final Authenticator authenticator;

    @GetMapping("/permit")
    public R<Boolean> permit(
            @RequestParam("path") String path,
            @RequestParam("method") String method
    ) {
        return R.ok(authenticator.permit(new RequestDto().setPath(path).setMethod(method)));
    }
}

2.2 鉴权器 Authenticator

为了简单说明,这里直接判断请求路径是否有相关角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
 * 鉴权器
 *
 * @author youthred.github.io
 */
@Service
@RequiredArgsConstructor
public class Authenticator {

    private static final PathMatcher ANT_PATH_MATCHER = new AntPathMatcher();

    private final RedisService redisService;

    /**
     * 鉴权是否通过
     *
     * @param request ServerHttpRequest
     * @return boolean
     */
    public boolean permit(ServerHttpRequest request) {
        String rolesHeaderValue = request.getHeaders().getFirst(CommonConstant.Header.ROLES);
        return doPermit(rolesHeaderValue, request.getMethodValue(), request.getURI().getPath());
    }

    /**
     * 鉴权是否通过
     *
     * @param roles header roles's value
     * @param dto   RequestDto
     * @return boolean
     */
    public boolean permit(String roles, RequestDto dto) {
        return doPermit(roles, dto.getMethod(), dto.getPath());
    }

    /**
     * 鉴权是否通过
     *
     * @param dto RequestDto
     * @return boolean
     */
    public boolean permit(RequestDto dto) {
        // Servlet环境可以直接从默认Context获取请求
        return doPermit(HttpServletUtil.getHeader(CommonConstant.Header.ROLES), dto.getMethod(), dto.getPath());
    }

    private boolean doPermit(String rolesStr, String methodValue, String path) {
        if (StringUtils.isBlank(rolesStr)) {
            return false;
        }
        // cn.hutool.core.convert.Convert
        List<String> hasRoles = Convert.toList(String.class, rolesStr);
        Map<String, List<String>> permissionMapByMethod = redisService.getPermissionRoleTypesMapByMethod(methodValue);
        if (MapUtils.isNotEmpty(permissionMapByMethod)) {
            for (Map.Entry<String, List<String>> e : permissionMapByMethod.entrySet()) {
                if (ANT_PATH_MATCHER.match(e.getKey(), path)) {
                    List<String> needRoles = e.getValue();
                    return CollUtil.containsAny(hasRoles, needRoles);
                }
            }
        }
        // 404
        return false;
    }
}

Servlet工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * @author youthred.github.io
 */
public class HttpServletUtil {

    public static HttpServletRequest getRequestFromContextHolder() {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (Objects.nonNull(requestAttributes)) {
            return ((ServletRequestAttributes) requestAttributes).getRequest();
        }
        return null;
    }

    public static String getHeader(String headerName) {
        HttpServletRequest request = getRequestFromContextHolder();
        return Objects.nonNull(request)
                // cn.hutool.extra.servlet.ServletUtil
                ? ServletUtil.getHeader(getRequestFromContextHolder(), headerName, StandardCharsets.UTF_8)
                : null;
    }
}

3 authenticator-client 鉴权Feign服务器

3.1 POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
</properties>

<dependencies>
    <dependency>
        <groupId>io.gitghub.youthred.goc</groupId>
        <artifactId>goc-common</artifactId>
        <version>1.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
</dependencies>

<build>
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>${spring-boot-maven-plugin.version}</version>
        </plugin>
    </plugins>
</build>

3.2

避免报错 feign.codec.DecodeException: No qualifying bean of type 'org.springframework.boot.autoconfigure.http.HttpMessageConverters' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

1
2
3
4
5
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
    return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}

3.3 AuthProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * @author youthred.github.io
 */
@FeignClient(name = "goc-authenticator", path = "/auth", fallback = AuthProvider.AuthProviderFallback.class)
public interface AuthProvider {

    @GetMapping("/permit")
    R<Boolean> permit(
            // 这里放入请求头(鉴权服务上不需要这个参数,直接从请求头获取)
            @RequestHeader(CommonConstant.Header.ROLES) String roles,
            @RequestParam("path") String path,
            @RequestParam("method") String method
    );

    @Component
    class AuthProviderFallback implements AuthProvider {

        @Override
        public R<Boolean> permit(String roles, String path, String method) {
            return R.error(false, "Feign Request Timeout");
        }
    }
}

3.4 开启熔断

1
2
3
feign:
  hystrix:
    enabled: true

4 完善

4.1 Feign Reactive

1.1 里说过暂时没有找到合适的方法从上下文中获取Request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * 与WebFlux的请求上下文配合自动传递header
 *
 * @author youthred.github.io
 */
@Component
public class FeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {
        ReactiveRequestContextHolder.getHeader(CommonConstant.Header.ROLES)
                .subscribe(rolesStr ->
                    // 这一句不会执行
                    requestTemplate.header(CommonConstant.Header.ROLES, rolesStr)
                );
    }
}

不知道为什么不会执行,这个问题暂留。

其实有一个开源项目 feign-reactive 可以实现,但没有深究如何使用,这里就将就手动使用 @RequestHeader 转递,幸好网关的鉴权功能只需要一个接口。

4.2 Feign Timeout

开启熔断降级后,一般得自定义远程服务超时时间,默认1S,建议5S

在网关服务端配置

1
2
3
4
5
6
feign:
  client:
    config:
      default: # org.springframework.cloud.openfeign.FeignClientProperties.FeignClientConfiguration
        connect-timeout: 5000
        read-timeout: 5000

完整代码地址 youthred/goc