手把手教大家在 gRPC 中使用 JWT 完成身份校驗

張哥說技術發表於2023-02-23

上篇文章松哥和小夥伴們聊了在 gRPC 中如何使用攔截器,這些攔截器有服務端攔截器也有客戶端攔截器,這些攔截器的一個重要使用場景,就是可以進行身份的校驗。當客戶端發起請求的時候,服務端透過攔截器進行身份校驗,就知道這個請求是誰發起的了。今天松哥就來透過一個具體的案例,來和小夥伴們演示一下 gRPC 如何結合 JWT 進行身份校驗。

1. JWT 介紹

1.1 無狀態登入

1.1.1 什麼是有狀態

有狀態服務,即服務端需要記錄每次會話的客戶端資訊,從而識別客戶端身份,根據使用者身份進行請求的處理,典型的設計如 Tomcat 中的 Session。例如登入:使用者登入後,我們把使用者的資訊儲存在服務端 session 中,並且給使用者一個 cookie 值,記錄對應的 session,然後下次請求,使用者攜帶 cookie 值來(這一步有瀏覽器自動完成),我們就能識別到對應 session,從而找到使用者的資訊。這種方式目前來看最方便,但是也有一些缺陷,如下:

  • 服務端儲存大量資料,增加服務端壓力
  • 服務端儲存使用者狀態,不支援叢集化部署

1.1.2 什麼是無狀態

微服務叢集中的每個服務,對外提供的都使用 RESTful 風格的介面。而 RESTful 風格的一個最重要的規範就是:服務的無狀態性,即:

  • 服務端不儲存任何客戶端請求者資訊
  • 客戶端的每次請求必須具備自描述資訊,透過這些資訊識別客戶端身份

那麼這種無狀態性有哪些好處呢?

  • 客戶端請求不依賴服務端的資訊,多次請求不需要必須訪問到同一臺伺服器
  • 服務端的叢集和狀態對客戶端透明
  • 服務端可以任意的遷移和伸縮(可以方便的進行叢集化部署)
  • 減小服務端儲存壓力

1.2 如何實現無狀態

無狀態登入的流程:

  • 首先客戶端傳送賬戶名/密碼到服務端進行認證
  • 認證透過後,服務端將使用者資訊加密並且編碼成一個 token,返回給客戶端
  • 以後客戶端每次傳送請求,都需要攜帶認證的 token
  • 服務端對客戶端傳送來的 token 進行解密,判斷是否有效,並且獲取使用者登入資訊

1.3 JWT

1.3.1 簡介

JWT,全稱是 Json Web Token, 是一種 JSON 風格的輕量級的授權和身份認證規範,可實現無狀態、分散式的 Web 應用授權:

手把手教大家在 gRPC 中使用 JWT 完成身份校驗

JWT 作為一種規範,並沒有和某一種語言繫結在一起,常用的 Java 實現是 GitHub 上的開源專案 jjwt,地址如下:

1.3.2 JWT資料格式

JWT 包含三部分資料:

  • Header:頭部,通常頭部有兩部分資訊:

    • 宣告型別,這裡是JWT
    • 加密演算法,自定義

我們會對頭部進行 Base64Url 編碼(可解碼),得到第一部分資料。

  • Payload:載荷,就是有效資料,在官方文件中(RFC7519),這裡給了7個示例資訊:

    • iss (issuer):表示簽發人
    • exp (expiration time):表示token過期時間
    • sub (subject):主題
    • aud (audience):受眾
    • nbf (Not Before):生效時間
    • iat (Issued At):簽發時間
    • jti (JWT ID):編號

這部分也會採用 Base64Url 編碼,得到第二部分資料。

  • Signature:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務的的金鑰secret(金鑰儲存在服務端,不能洩露給客戶端),透過 Header 中配置的加密演算法生成。用於驗證整個資料完整和可靠性。

生成的資料格式如下圖:

手把手教大家在 gRPC 中使用 JWT 完成身份校驗

注意,這裡的資料透過 . 隔開成了三部分,分別對應前面提到的三部分,另外,這裡資料是不換行的,圖片換行只是為了展示方便而已。

1.3.3 JWT 互動流程

流程圖:

手把手教大家在 gRPC 中使用 JWT 完成身份校驗

步驟翻譯:

  1. 應用程式或客戶端向授權伺服器請求授權
  2. 獲取到授權後,授權伺服器會嚮應用程式返回訪問令牌
  3. 應用程式使用訪問令牌來訪問受保護資源(如 API)

因為 JWT 簽發的 token 中已經包含了使用者的身份資訊,並且每次請求都會攜帶,這樣服務的就無需儲存使用者資訊,甚至無需去資料庫查詢,這樣就完全符合了 RESTful 的無狀態規範。

1.3.4 JWT 存在的問題

說了這麼多,JWT 也不是天衣無縫,由客戶端維護登入狀態帶來的一些問題在這裡依然存在,舉例如下:

  1. 續簽問題,這是被很多人詬病的問題之一,傳統的 cookie+session 的方案天然的支援續簽,但是 jwt 由於服務端不儲存使用者狀態,因此很難完美解決續簽問題,如果引入 redis,雖然可以解決問題,但是 jwt 也變得不倫不類了。
  2. 登出問題,由於服務端不再儲存使用者資訊,所以一般可以透過修改 secret 來實現登出,服務端 secret 修改後,已經頒發的未過期的 token 就會認證失敗,進而實現登出,不過畢竟沒有傳統的登出方便。
  3. 密碼重置,密碼重置後,原本的 token 依然可以訪問系統,這時候也需要強制修改 secret。
  4. 基於第 2 點和第 3 點,一般建議不同使用者取不同 secret。

當然,為了解決 JWT 存在的問題,也可以將 JWT 結合 Redis 來用,服務端生成的 JWT 字串存入到 Redis 中並設定過期時間,每次校驗的時候,先看 Redis 中是否存在該 JWT 字串,如果存在就進行後續的校驗。但是這種方式有點不倫不類(又成了有狀態了)。

2. 實踐

我們來看下 gRPC 如何結合 JWT。

2.1 專案建立

首先我先給大家看下我的專案結構:

├── grpc_api
│   ├── pom.xml
│   └── src
├── grpc_client
│   ├── pom.xml
│   └── src
├── grpc_server
│   ├── pom.xml
│   └── src
└── pom.xml

還是跟之前文章中的一樣,三個模組,grpc_api 用來存放一些公共的程式碼。

grpc_server 用來放服務端的程式碼,我這裡服務端主要提供了兩個介面:

  1. 登入介面,登入成功之後返回 JWT 字串。
  2. hello 介面,客戶端拿著 JWT 字串來訪問 hello 介面。

grpc_client 則是我的客戶端程式碼。

2.2 grpc_api

我將 protocol buffers 和一些依賴都放在 grpc_api 模組中,因為將來我的 grpc_server 和 grpc_client 都將依賴 grpc_api。

我們來看下這裡需要的依賴和外掛:

<dependencies>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-netty-shaded</artifactId>
        <version>1.52.1</version>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-protobuf</artifactId>
        <version>1.52.1</version>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-stub</artifactId>
        <version>1.52.1</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>annotations-api</artifactId>
        <version>6.0.53</version>
        <scope>provided</scope>
    </dependency>
</dependencies>
<build>
    <extensions>
        <extension>
            <groupId>kr.motd.maven</groupId>
            <artifactId>os-maven-plugin</artifactId>
            <version>1.6.2</version>
        </extension>
    </extensions>
    <plugins>
        <plugin>
            <groupId>org.xolstice.maven.plugins</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
            <version>0.6.1</version>
            <configuration>
                <protocArtifact>com.google.protobuf:protoc:3.21.7:exe:${os.detected.classifier}</protocArtifact>
                <pluginId>grpc-java</pluginId>
                <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier}</pluginArtifact>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compile-custom</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

這裡的依賴和外掛松哥在本系列的第一篇文章中都已經介紹過了,唯一不同的是,這裡引入了 JWT 外掛,JWT 我使用了比較流行的 JJWT 這個工具。JJWT 松哥在之前的文章和影片中也都有介紹過,這裡就不再囉嗦了。

先來看看我的 Protocol Buffers 檔案:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.javaboy.grpc.api";
option java_outer_classname = "LoginProto";
import "google/protobuf/wrappers.proto";

package login;

service LoginService {
 rpc login (LoginBody) returns (LoginResponse);
}

service HelloService{
 rpc sayHello(google.protobuf.StringValue) returns (google.protobuf.StringValue);
}

message LoginBody {
 string username = 1;
 string password = 2;
}

message LoginResponse {
 string token = 1;
}

經過前面幾篇文章的介紹,這裡我就不多說啦,就是定義了兩個服務:

  • LoginService:這個登入服務,傳入使用者名稱密碼,返回登入成功之後的令牌。
  • HelloService:這個就是一個打招呼的服務,傳入字串,返回也是字串。

定義完成之後,生成對應的程式碼即可。

接下來再定義一個常量類供 grpc_server 和 grcp_client 使用,如下:

public interface AuthConstant {
    SecretKey JWT_KEY = Keys.hmacShaKeyFor("hello_javaboy_hello_javaboy_hello_javaboy_hello_javaboy_".getBytes());
    Context.Key<String> AUTH_CLIENT_ID = Context.key("clientId");
    String AUTH_HEADER = "Authorization";
    String AUTH_TOKEN_TYPE = "Bearer";
}

這裡的每個常量我都給大家解釋下:

  1. JWT_KEY:這個是生成 JWT 字串以及進行 JWT 字串校驗的金鑰。
  2. AUTH_CLIENT_ID:這個是客戶端的 ID,即客戶端傳送來的請求攜帶了 JWT 字串,透過 JWT 字串確認了使用者身份,就存在這個變數中。
  3. AUTH_HEADER:這個是攜帶 JWT 字串的請求頭的 KEY。
  4. AUTH_TOKEN_TYPE:這個是攜帶 JWT 字串的請求頭的引數字首,透過這個可以確認引數的型別,常見取值有 Bearer 和 Basic。

如此,我們的 gRPC_api 就定義好了。

2.3 grpc_server

接下來我們來定義 gRPC_server。

首先來定義登入服務:

public class LoginServiceImpl extends LoginServiceGrpc.LoginServiceImplBase {
    @Override
    public void login(LoginBody request, StreamObserver<LoginResponse> responseObserver) {
        String username = request.getUsername();
        String password = request.getPassword();
        if ("javaboy".equals(username) && "123".equals(password)) {
            System.out.println("login success");
            //登入成功
            String jwtToken = Jwts.builder().setSubject(username).signWith(AuthConstant.JWT_KEY).compact();
            responseObserver.onNext(LoginResponse.newBuilder().setToken(jwtToken).build());
            responseObserver.onCompleted();
        }else{
            System.out.println("login error");
            //登入失敗
            responseObserver.onNext(LoginResponse.newBuilder().setToken("login error").build());
            responseObserver.onCompleted();
        }
    }
}

省事起見,我這裡沒有連線資料庫,使用者名稱和密碼固定為 javaboy 和 123。

登入成功之後,就生成一個 JWT 字串返回。

登入失敗,就返回一個 login error 字串。

再來看我們的 HelloService 服務,如下:

public class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
    @Override
    public void sayHello(StringValue request, StreamObserver<StringValue> responseObserver) {
        String clientId = AuthConstant.AUTH_CLIENT_ID.get();
        responseObserver.onNext(StringValue.newBuilder().setValue(clientId + " say hello:" + request.getValue()).build());
        responseObserver.onCompleted();
    }
}

這個服務就更簡單了,不囉嗦。唯一值得說的是 AuthConstant.AUTH_CLIENT_ID.get(); 表示獲取當前訪問使用者的 ID,這個使用者 ID 是在攔截器中存入進來的。

最後,我們來看服務端比較重要的攔截器,我們要在攔截器中從請求頭中獲取到 JWT 令牌並解析,如下:

public class AuthInterceptor implements ServerInterceptor {
    private JwtParser parser = Jwts.parser().setSigningKey(AuthConstant.JWT_KEY);

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata, ServerCallHandler<ReqT, RespT> serverCallHandler) {
        String authorization = metadata.get(Metadata.Key.of(AuthConstant.AUTH_HEADER, Metadata.ASCII_STRING_MARSHALLER));
        Status status = Status.OK;
        if (authorization == null) {
            status = Status.UNAUTHENTICATED.withDescription("miss authentication token");
        } else if (!authorization.startsWith(AuthConstant.AUTH_TOKEN_TYPE)) {
            status = Status.UNAUTHENTICATED.withDescription("unknown token type");
        } else {
            Jws<Claims> claims = null;
            String token = authorization.substring(AuthConstant.AUTH_TOKEN_TYPE.length()).trim();
            try {
                claims = parser.parseClaimsJws(token);
            } catch (JwtException e) {
                status = Status.UNAUTHENTICATED.withDescription(e.getMessage()).withCause(e);
            }
            if (claims != null) {
                Context ctx = Context.current()
                        .withValue(AuthConstant.AUTH_CLIENT_ID, claims.getBody().getSubject());
                return Contexts.interceptCall(ctx, serverCall, metadata, serverCallHandler);
            }
        }
        serverCall.close(status, new Metadata());
        return new ServerCall.Listener<ReqT>() {
        };
    }
}

這段程式碼邏輯應該好理解:

  1. 首先從 Metadata 中提取出當前請求所攜帶的 JWT 字串(相當於從請求頭中提取出來)。
  2. 如果第一步提取到的值為 null 或者這個值不是以指定字元 Bearer 開始的,說明這個令牌是一個非法令牌,設定對應的響應 status 即可。
  3. 如果令牌都沒有問題的話,接下來就進行令牌的校驗,校驗失敗,則設定相應的 status 即可。
  4. 校驗成功的話,我們就會獲取到一個 Jws
  5. 最後,登入成功的話,Contexts.interceptCall 方法構建監聽器並返回;登入失敗,則構建一個空的監聽器返回。

最後,我們再來看看啟動服務端:

public class LoginServer {
    Server server;

    public static void main(String[] args) throws IOException, InterruptedException {
        LoginServer server = new LoginServer();
        server.start();
        server.blockUntilShutdown();
    }

    public void start() throws IOException {
        int port = 50051;
        server = ServerBuilder.forPort(port)
                .addService(new LoginServiceImpl())
                .addService(ServerInterceptors.intercept(new HelloServiceImpl(), new AuthInterceptor()))
                .build()
                .start();
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            LoginServer.this.stop();
        }));
    }

    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }
}

這個跟之前的相比就多加了一個 Service,新增 HelloServiceImpl 服務的時候,多加了一個攔截器,換言之,登入的時候,請求是不會被這個認證攔截器攔截的。

好啦,這樣我們的 grpc_server 就開發完成了。

2.4 grpc_client

接下來我們來看 grpc_client。

先來看登入:

public class LoginClient {
    public static void main(String[] args) throws InterruptedException {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost"50051)
                .usePlaintext()
                .build();
        LoginServiceGrpc.LoginServiceStub stub = LoginServiceGrpc.newStub(channel);
        login(stub);
    }

    private static void login(LoginServiceGrpc.LoginServiceStub stub) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        stub.login(LoginBody.newBuilder().setUsername("javaboy").setPassword("123").build(), new StreamObserver<LoginResponse>() {
            @Override
            public void onNext(LoginResponse loginResponse) {
                System.out.println("loginResponse.getToken() = " + loginResponse.getToken());
            }

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onCompleted() {
                countDownLatch.countDown();
            }
        });
        countDownLatch.await();
    }
}

這個方法直接呼叫就行了,看過前面幾篇 gRPC 文章的話,這裡都很好理解。

再來看 hello 介面的呼叫,這個介面呼叫需要攜帶 JWT 字串,而攜帶 JWT 字串,則需要我們構建一個 CallCredentials 物件,如下:

public class JwtCredential extends CallCredentials {
    private String subject;

    public JwtCredential(String subject) {
        this.subject = subject;
    }

    @Override
    public void applyRequestMetadata(RequestInfo requestInfo, Executor executor, MetadataApplier metadataApplier) {
        executor.execute(() -> {
            try {
                Metadata headers = new Metadata();
                headers.put(Metadata.Key.of(AuthConstant.AUTH_HEADER, Metadata.ASCII_STRING_MARSHALLER),
                        String.format("%s %s", AuthConstant.AUTH_TOKEN_TYPE, subject));
                metadataApplier.apply(headers);
            } catch (Throwable e) {
                metadataApplier.fail(Status.UNAUTHENTICATED.withCause(e));
            }
        });
    }

    @Override
    public void thisUsesUnstableApi() {

    }
}

這裡就是將請求的 JWT 令牌放入到請求頭中即可。

最後來看看呼叫:

public class LoginClient {
    public static void main(String[] args) throws InterruptedException {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost"50051)
                .usePlaintext()
                .build();
        LoginServiceGrpc.LoginServiceStub stub = LoginServiceGrpc.newStub(channel);
        sayHello(channel);
    }

    private static void sayHello(ManagedChannel channel) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        HelloServiceGrpc.HelloServiceStub helloServiceStub = HelloServiceGrpc.newStub(channel);
        helloServiceStub
                .withCallCredentials(new JwtCredential("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJqYXZhYm95In0.IMMp7oh1dl_trUn7sn8qiv9GtO-COQyCGDz_Yy8VI4fIqUcRfwQddP45IoxNovxL"))
                .sayHello(StringValue.newBuilder().setValue("wangwu").build(), new StreamObserver<StringValue>() {
            @Override
            public void onNext(StringValue stringValue) {
                System.out.println("stringValue.getValue() = " + stringValue.getValue());
            }

            @Override
            public void onError(Throwable throwable) {
                System.out.println("throwable.getMessage() = " + throwable.getMessage());
            }

            @Override
            public void onCompleted() {
                countDownLatch.countDown();
            }
        });
        countDownLatch.await();
    }
}

這裡的登入令牌就是前面呼叫 login 方法時獲取到的令牌。

好啦,大功告成。

3. 小結

上面的登入與校驗只是松哥給小夥伴們展示的一個具體案例而已,在此案例基礎之上,我們還可以擴充套件出來更多寫法,但是萬變不離其宗,其他玩法就需要小夥伴們自行探索啦~

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2936621/,如需轉載,請註明出處,否則將追究法律責任。

相關文章