如何構造基於JWT認證的API Gateway

先說需求,公司的後端服務越來越多,用到的技術棧有Java,PHP,Go等,每個服務API都需要認證Authentication和授權Authorization,一開始不同的項目之間,如果是用相同的語言寫的,直接複製粘貼,然而,如果認證流程發現一個bug,每個API項目的代碼都得修。

API Gateway在這時比較適合解決這個難題,通過提供唯一的entry point來統一認證流程邏輯。API Gateway在最近幾年隨著微服務逐漸升溫,其本質的作用無非就是:負載均衡+服務分發+用戶認證。

技術選型

基於Nginx的技術棧

  1. 純Nginx,要把Nginx改造成API Gateway,在這裡的主要需求就是用戶認證,其實Nginx官方早就有Nginx Plus,提供現成的插件,然而它不是免費的。。。像我等摳門的公司怎麼會出錢呢。有沒有開源的插件?有,但是是基於Lua腳本開發的,組裡沒一個人懂啊,出了bug不好改。
  2. OpenResty,提供豐富的插件和庫,但是其仍是基於Nginx的核心的開發框架,仍然需要nginx.conf配置文件來靜態配置。現有的眾多的API Gateway的賣點就是動態配置,無需重啟,所以作罷。
  3. Kong,基於OpenResty的框架,非常好用,插件開發非常方便,然而,還是需要Lua開發。

所以在觀察了一圈之後,所有基於Nginx的服務網關都不太適合小組開發,因為Lua是個過不去的坎。

Voyager

基於HAProxy的服務網關,天然支持Kubernetes的Ingress Controller,剛好適用於我們的K8s集群,然而其官方的開源文檔嚴重匱乏,後來才知道,我們需要去聯繫客服,獲得技術支持,這些都不是免費的。

Vulcand

基於Go開發的服務網關,組裡有使用經驗,然而其官方的維護狀態令人擔憂。

當考慮維護的成本時候,這些就不得不認證考慮了:Vulcand由於是基於Go來開發,插件開發機制就是先Fork一個分支,開發者看懂之後,在通過官方的Vbundle來添加一個自己的插件,重新編譯,上線。

其實所有基於Go的服務網關的項目,最大的一個痛點就是無法提供方便的插件開發。我個人覺得Go 1.8之後的Plugin機制真的非常適合這個場景開發,在另一個開源項目traefik的github上,已經有類似的建議: enable custom plugins/middlewares for Traefik · Issue #1336 · containous/traefik。 如果這個feature可以實現,那可以說是game changer。

Spring Cloud Gateway

考慮到後端的所有產品API的流量都不大,對性能的要求不是很高,那麼Spring Cloud Gateway的最大優勢就是上手快,非常容易開發。考慮到後續的維護,組裡的技術人員儲備,最後決定使用Spring Cloud Gateway。

代碼實現

服務網關接收一個請求,取出header中的jwt token,驗證jwt token,如果驗證成功,則提取jwt token中的sub域,這個sub域就是一個用戶的唯一標識,後端的API則使用這個唯一用戶標識來進行授權。具體的流程可以參考這篇

Yi Wang:如何保障Rest API的安全性?

zhuanlan.zhihu.com圖標

API Gateway只用來統一提供認證,提供一個經過認證後的用戶id,而每個後端的API自己負責授權

構造JWTVerifier

package com.acm.proxy;import com.auth0.jwk.Jwk;import com.auth0.jwk.JwkException;import com.auth0.jwk.UrlJwkProvider;import com.auth0.jwt.JWT;import com.auth0.jwt.JWTVerifier;import com.auth0.jwt.algorithms.Algorithm;import com.fasterxml.jackson.databind.JsonNode;import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.stereotype.Component;import org.springframework.web.client.RestTemplate;import java.io.IOException;import java.security.interfaces.RSAPublicKey;@Componentpublic class JWTVerifierFactory { @Bean public JWTVerifier cerate(@Value("${jwt.issuer}") String issuer, @Value("${jwt.audience}") String audience) throws JwkException, IOException { UrlJwkProvider urlJwkProvider = new UrlJwkProvider(issuer); RestTemplate restTemplate = new RestTemplate(); ObjectMapper objectMapper = new ObjectMapper(); JsonNode jsonNode = objectMapper.readTree(restTemplate.getForObject(issuer + "/.well-known/jwks.json", String.class)); String kid = jsonNode.get("keys").get(0).get("kid").asText(); Jwk jwk = urlJwkProvider.get(kid); return JWT.require(Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null)) .withIssuer(issuer) .withAudience(audience) .build(); }}// 自定義的異常package com.acm.proxy;import com.auth0.jwt.exceptions.JWTVerificationException;public class JWTTokenExtractException extends JWTVerificationException { public JWTTokenExtractException(String message) { super(message); }}

構造JWTFilter

package com.acm.proxy;import com.auth0.jwt.JWTVerifier;import com.auth0.jwt.exceptions.JWTVerificationException;import com.auth0.jwt.interfaces.DecodedJWT;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.cloud.gateway.filter.GatewayFilter;import org.springframework.cloud.gateway.filter.factory.AbstractNameValueGatewayFilterFactory;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpRequest;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.stereotype.Component;import org.springframework.web.server.ServerWebExchange;import reactor.core.publisher.Mono;import java.util.List;@Componentpublic class JWTFilter extends AbstractNameValueGatewayFilterFactory { private static final String WWW_AUTH_HEADER = "WWW-Authenticate"; private static final String X_JWT_SUB_HEADER = "X-jwt-sub"; private static final Logger logger = LoggerFactory.getLogger(JWTFilter.class); @Autowired private final JWTVerifier jwtVerifier; public JWTFilter(JWTVerifier jwtVerifier) { this.jwtVerifier = jwtVerifier; } @Override public GatewayFilter apply(NameValueConfig config) { return (exchange, chain) -> { try { String token = this.extractJWTToken(exchange.getRequest()); DecodedJWT decodedJWT = this.jwtVerifier.verify(token); ServerHttpRequest request = exchange.getRequest().mutate(). header(X_JWT_SUB_HEADER, decodedJWT.getSubject()). build(); return chain.filter(exchange.mutate().request(request).build()); } catch (JWTVerificationException ex) { logger.error(ex.toString()); return this.onError(exchange, ex.getMessage()); } }; } private Mono<Void> onError(ServerWebExchange exchange, String err) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add(WWW_AUTH_HEADER, this.formatErrorMsg(err)); return response.setComplete(); } private String extractJWTToken(ServerHttpRequest request) { if (!request.getHeaders().containsKey("Authorization")) { throw new JWTTokenExtractException("Authorization header is missing"); } List<String> headers = request.getHeaders().get("Authorization"); if (headers.isEmpty()) { throw new JWTTokenExtractException("Authorization header is empty"); } String credential = headers.get(0).trim(); String[] components = credential.split("\s"); if (components.length != 2) { throw new JWTTokenExtractException("Malformat Authorization content"); } if (!components[0].equals("Bearer")) { throw new JWTTokenExtractException("Bearer is needed"); } return components[1].trim(); } private String formatErrorMsg(String msg) { return String.format("Bearer realm="yieldr.com", " + "error="https://tools.ietf.org/html/rfc7519", " + "error_description="%s" ", msg); }}

主函數

package com.acm.proxy;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); }}

配置文件

spring: application: name: dsp-api-gatewayspring: cloud: gateway: routes: - id: reporting-service uri: http://localhost:9092 predicates: - Path=/api/reporting/** - id: platform-service uri: http://localhost:9091 predicates: - Path=/api/** filters: - JWTFilter=RSA256,HS256server: port: 5000

Maven

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.acm</groupId> <artifactId>proxy</artifactId> <version>0.1</version> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> </plugins> </build> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-gateway</artifactId> <version>2.0.0.M9</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>jwks-rsa</artifactId> <version>0.3.0</version> </dependency> </dependencies> <repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/libs-milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> </repositories></project>

打完,收工。


推薦閱讀:

《微服務設計》閱讀筆記(六)部署
《微服務設計》閱讀筆記(四)集成
【詳解】以銀行零售業務為例,一個案例說清楚可視化微服務架構
《微服務設計》閱讀筆記(九)安全

TAG:KongAPI網關 | 微服務架構 | SpringCloud |