手把手教大家在 gRPC 中使用 JWT 完成身份校驗
@[toc] 上篇文章鬆哥和小夥伴們聊了在 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 應用授權:
JWT 作為一種規範,並沒有和某一種語言繫結在一起,常用的 Java 實現是 GitHub 上的開源專案 jjwt,地址如下:https://github.com/jwtk/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 中配置的加密演算法生成。用於驗證整個資料完整和可靠性。
生成的資料格式如下圖:
注意,這裡的資料通過 .
隔開成了三部分,分別對應前面提到的三部分,另外,這裡資料是不換行的,圖片換行只是為了展示方便而已。
1.3.3 JWT 互動流程
流程圖:
步驟翻譯:
- 應用程式或客戶端向授權伺服器請求授權
- 獲取到授權後,授權伺服器會嚮應用程式返回訪問令牌
- 應用程式使用訪問令牌來訪問受保護資源(如 API)
因為 JWT 簽發的 token 中已經包含了使用者的身份資訊,並且每次請求都會攜帶,這樣服務的就無需儲存使用者資訊,甚至無需去資料庫查詢,這樣就完全符合了 RESTful 的無狀態規範。
1.3.4 JWT 存在的問題
說了這麼多,JWT 也不是天衣無縫,由客戶端維護登入狀態帶來的一些問題在這裡依然存在,舉例如下:
- 續簽問題,這是被很多人詬病的問題之一,傳統的 cookie+session 的方案天然的支援續簽,但是 jwt 由於服務端不儲存使用者狀態,因此很難完美解決續簽問題,如果引入 redis,雖然可以解決問題,但是 jwt 也變得不倫不類了。
- 登出問題,由於服務端不再儲存使用者資訊,所以一般可以通過修改 secret 來實現登出,服務端 secret 修改後,已經頒發的未過期的 token 就會認證失敗,進而實現登出,不過畢竟沒有傳統的登出方便。
- 密碼重置,密碼重置後,原本的 token 依然可以訪問系統,這時候也需要強制修改 secret。
- 基於第 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 用來放服務端的程式碼,我這裡服務端主要提供了兩個介面:
- 登入介面,登入成功之後返回 JWT 字串。
- 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";
}
這裡的每個常量我都給大家解釋下:
- JWT_KEY:這個是生成 JWT 字串以及進行 JWT 字串校驗的金鑰。
- AUTH_CLIENT_ID:這個是客戶端的 ID,即客戶端傳送來的請求攜帶了 JWT 字串,通過 JWT 字串確認了使用者身份,就存在這個變數中。
- AUTH_HEADER:這個是攜帶 JWT 字串的請求頭的 KEY。
- 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>() {
};
}
}
這段程式碼邏輯應該好理解:
- 首先從 Metadata 中提取出當前請求所攜帶的 JWT 字串(相當於從請求頭中提取出來)。
- 如果第一步提取到的值為 null 或者這個值不是以指定字元 Bearer 開始的,說明這個令牌是一個非法令牌,設定對應的響應 status 即可。
- 如果令牌都沒有問題的話,接下來就進行令牌的校驗,校驗失敗,則設定相應的 status 即可。
- 校驗成功的話,我們就會獲取到一個 Jws<claims> 物件,從這個物件中我們可以提取出來使用者名稱,並存入到 Context 中,將來我們在 HelloServiceImpl 中就可以獲取到這裡的使用者名稱了。
- 最後,登入成功的話,
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. 小結
上面的登入與校驗只是鬆哥給小夥伴們展示的一個具體案例而已,在此案例基礎之上,我們還可以擴展出來更多寫法,但是萬變不離其宗,其他玩法就需要小夥伴們自行探索啦~</stringvalue></loginresponse></claims></reqt></claims></reqt,></reqt,></reqt></reqt,></stringvalue></loginresponse></string>
- 手把手教大家在 gRPC 中使用 JWT 完成身份校驗
- 微服務的版本號要怎麼設計?
- 聊一聊 gRPC 的四種通訊模式
- 一個簡單的案例入門 gRPC
- Spring 事務失效的六種情況
- 到底什麼樣的 REST 才是最佳 REST?
- 一個不用寫程式碼的案例,來看看Flowable到底給我們提供了哪些功能?
- 來聊一聊 ElasticSearch 最新版的 Java 客戶端
- Spring AOP在專案中的典型應用場景
- Spring 事務失效的六種情況
- 微服務中的鑑權該怎麼做?
- 通過 Flowable-UI 來體驗一把 Flowable 流程引擎
- Flowable 任務如何認領,回退?
- 請假要組長和經理同時審批該怎麼辦?來看看工作流中的會籤功能!
- Flowable 設定任務處理人的四種方式
- SpringBoot Vue Flowable,模擬一個請假審批流程!
- Flowable 開篇,流程引擎掃盲
- Flowable 中 ReceiveTask 怎麼玩?
- 如何使用流程 中的 DataObject 併為流程設定租戶
- 一套程式碼,14個平臺執行,牛!