一、背景

一般在微服務架構中我們都會使用spring security oauth2來進行權限控制,我們將資源服務全部放在內網環境中,將API網關暴露在公網上,公網如果想要訪問我們的資源必須經過API網關進行鑑權,鑑權通過後再訪問我們的資源服務。我們根據如下圖片來分析一下問題。

現在我們有三個服務:分別是用戶服務、訂單服務和產品服務。用戶如果購買產品,則需要調用產品服務生成訂單,那麼我們在這個調用過程中有必要鑑權嗎?答案是否定的,因爲這些資源服務放在內網環境中,完全不用考慮安全問題。

二、思路

如果要想實現這個功能,我們則需要來區分這兩種請求,來自網關的請求進行鑑權,而服務間的請求則直接調用。

是否可以給接口增加一個參數來標記它是服務間調用的請求?

這樣雖然可以實現兩種請求的區分,但是實際中不會這麼做。在 Spring Cloud Alibaba系列(三)使用feign進行服務調用 中曾提到了實現feign的兩種方式,一般情況下服務間調用和網關請求的數據接口是同一個接口,如果寫成兩個接口來分別給兩種請求調用,這樣無疑增加了大量重複代碼。也就是說我們一般不會通過改變請求參數的個數來實現這兩種服務的區分。

雖然不能增加請求的參數個數來區分,但是我們可以給請求的header中添加一個參數用來區分。這樣完全可以避免上面提到的問題。

三、實現

3.1 自定義註解

我們自定義一個Inner的註解,然後利用aop對這個註解進行處理

1@Target(ElementType.METHOD)
2@Retention(RetentionPolicy.RUNTIME)
3@Documented
4public @interface Inner {
5    /**
6     * 是否AOP統一處理
7     */
8    boolean value() default true;
9}
 1@Aspect
 2@Component
 3public class InnerAspect implements Ordered {
 4
 5    private final Logger log = LoggerFactory.getLogger(InnerAspect.class);
 6
 7    @Around("@annotation(inner)")
 8    public Object around(ProceedingJoinPoint point, Inner inner) throws Throwable {
 9        String header = ServletUtils.getRequest().getHeader(SecurityConstants.FROM);
10        if (inner.value() && !StringUtils.equals(SecurityConstants.FROM_IN, header)){
11            log.warn("訪問接口 {} 沒有權限", point.getSignature().getName());
12            throw new AccessDeniedException("Access is denied");
13        }
14        return point.proceed();
15    }
16
17    @Override
18    public int getOrder() {
19        return Ordered.HIGHEST_PRECEDENCE + 1;
20    }
21}

上面這段代碼就是獲取所有加了@Inner註解的方法或類,判斷請求頭中是否有我們規定的參數,如果沒有,則不允許訪問接口。

3.2 暴露url

將所有註解了@Inner的方法和類暴露出來,允許不鑑權可以方法,這裏需要注意的點是如果方法使用pathVariable 傳參的,則需要將這個參數轉換爲*。如果不轉換,當成接口的訪問路徑,則找不到此接口。

 1@Configuration
 2public class PermitAllUrlProperties implements InitializingBean, ApplicationContextAware{
 3
 4    private static final Pattern PATTERN = Pattern.compile("\\{(.*?)\\}");
 5    private ApplicationContext applicationContext;
 6    private List<String> urls = new ArrayList<>();
 7    public static final String ASTERISK = "*";
 8
 9    @Override
10    public void afterPropertiesSet() {
11        RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
12        Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
13        map.keySet().forEach(info -> {
14            HandlerMethod handlerMethod = map.get(info);
15            // 獲取方法上邊的註解 替代path variable 爲 *
16            Inner method = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Inner.class);
17            Optional.ofNullable(method).ifPresent(inner -> info.getPatternsCondition().getPatterns()
18                    .forEach(url -> urls.add(ReUtil.replaceAll(url, PATTERN, ASTERISK))));
19            // 獲取類上邊的註解, 替代path variable 爲 *
20            Inner controller = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Inner.class);
21            Optional.ofNullable(controller).ifPresent(inner -> info.getPatternsCondition().getPatterns()
22                    .forEach(url -> urls.add(ReUtil.replaceAll(url, PATTERN, ASTERISK))));
23        });
24    }
25
26    @Override
27    public void setApplicationContext(ApplicationContext context) {
28        this.applicationContext = context;
29    }
30
31    public List<String> getUrls() {
32        return urls;
33    }
34
35    public void setUrls(List<String> urls) {
36        this.urls = urls;
37    }
38}

在資源服務器中,將請求暴露出來

 1public void configure(HttpSecurity httpSecurity) throws Exception {
 2    //允許使用iframe 嵌套,避免swagger-ui 不被加載的問題
 3    httpSecurity.headers().frameOptions().disable();
 4    ExpressionUrlAuthorizationConfigurer<HttpSecurity>
 5        .ExpressionInterceptUrlRegistry registry = httpSecurity
 6        .authorizeRequests();
 7    // 將上面獲取到的請求,暴露出來
 8    permitAllUrl.getUrls()
 9        .forEach(url -> registry.antMatchers(url).permitAll());
10    registry.anyRequest().authenticated()
11        .and().csrf().disable();
12}

3.3 如何去請求

定義一個接口:

1@PostMapping("test")
2@Inner
3public String test(@RequestParam String id){
4    return id;
5}

定義feign遠程調用接口

1@PostMapping("test")
2MediaFodderBean test(@RequestParam("id") String id,@RequestHeader(SecurityConstants.FROM) String from);

服務間進行調用,傳請求頭

1 String id = testService.test(id, SecurityConstants.FROM_IN);

四、思考

4.1 安全性

上面雖然實現了服務間調用,但是我們將@Inner的請求暴露出去了,也就是說不用鑑權既可以訪問到,那麼我們是不是可以模擬一個請求頭,然後在其他地方通過網關來調用呢?

答案是可以,那麼,這時候我們就需要對網關中分發的請求進行處理,在網關中寫一個全局攔截器,將請求頭的form參數清洗。

 1@Component
 2public class RequestGlobalFilter implements GlobalFilter, Ordered {
 3
 4    @Override
 5    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
 6        // 清洗請求頭中from 參數
 7        ServerHttpRequest request = exchange.getRequest().mutate()
 8            .headers(httpHeaders -> httpHeaders.remove(SecurityConstants.FROM))
 9            .build();
10        addOriginalRequestUrl(exchange, request.getURI());
11        String rawPath = request.getURI().getRawPath();
12        ServerHttpRequest newRequest = request.mutate()
13            .path(rawPath)
14            .build();
15        exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newRequest.getURI());
16        return chain.filter(exchange.mutate()
17            .request(newRequest.mutate()
18                .build()).build());
19    }
20
21    @Override
22    public int getOrder() {
23        return -1000;
24    }
25}

4.2 擴展性

我們自定義@Inner註解的時候,放了一個boolean類型的value(),默認爲true。如果我們想讓這個請求可以通過網關訪問的話,將value賦值爲false即可。

1@PostMapping("test")
2@Inner(value=false)
3public String test(@RequestParam String id){
4    return id;
5}

五、總結

這樣我們總共實現了以下幾個功能:

  1. 服務間訪問可以不鑑權,添加註解@Inner即可。

  2. 網關訪問不需要鑑權的資源,添加註解@Inner(value=false)即可。當然,這樣服務間不鑑權也可以訪問。

  3. 爲了安全性考慮,將網關中的請求頭form參數清洗,以防有人模擬請求,來訪問資源。

由於各個服務都是在內網環境中,只有網關會暴露公網,因此服務間調用是沒必要鑑權的。

< END >

相關文章