@[toc]
上篇文章松哥和小伙伴們聊了在 gRPC 中如何使用攔截器,這些攔截器有服務(wù)端攔截器也有客戶端攔截器,這些攔截器的一個(gè)重要使用場(chǎng)景,就是可以進(jìn)行身份的校驗(yàn)。當(dāng)客戶端發(fā)起請(qǐng)求的時(shí)候,服務(wù)端通過攔截器進(jìn)行身份校驗(yàn),就知道這個(gè)請(qǐng)求是誰(shuí)發(fā)起的了。今天松哥就來(lái)通過一個(gè)具體的案例,來(lái)和小伙伴們演示一下 gRPC 如何結(jié)合 JWT 進(jìn)行身份校驗(yàn)。
1. JWT 介紹
1.1 無(wú)狀態(tài)登錄
1.1.1 什么是有狀態(tài)
有狀態(tài)服務(wù),即服務(wù)端需要記錄每次會(huì)話的客戶端信息,從而識(shí)別客戶端身份,根據(jù)用戶身份進(jìn)行請(qǐng)求的處理,典型的設(shè)計(jì)如 Tomcat 中的 Session。例如登錄:用戶登錄后,我們把用戶的信息保存在服務(wù)端 session 中,并且給用戶一個(gè) cookie 值,記錄對(duì)應(yīng)的 session,然后下次請(qǐng)求,用戶攜帶 cookie 值來(lái)(這一步有瀏覽器自動(dòng)完成),我們就能識(shí)別到對(duì)應(yīng) session,從而找到用戶的信息。這種方式目前來(lái)看最方便,但是也有一些缺陷,如下:
- 服務(wù)端保存大量數(shù)據(jù),增加服務(wù)端壓力
- 服務(wù)端保存用戶狀態(tài),不支持集群化部署
1.1.2 什么是無(wú)狀態(tài)
微服務(wù)集群中的每個(gè)服務(wù),對(duì)外提供的都使用 RESTful 風(fēng)格的接口。而 RESTful 風(fēng)格的一個(gè)最重要的規(guī)范就是:服務(wù)的無(wú)狀態(tài)性,即:
- 服務(wù)端不保存任何客戶端請(qǐng)求者信息
- 客戶端的每次請(qǐng)求必須具備自描述信息,通過這些信息識(shí)別客戶端身份
那么這種無(wú)狀態(tài)性有哪些好處呢?
- 客戶端請(qǐng)求不依賴服務(wù)端的信息,多次請(qǐng)求不需要必須訪問到同一臺(tái)服務(wù)器
- 服務(wù)端的集群和狀態(tài)對(duì)客戶端透明
- 服務(wù)端可以任意的遷移和伸縮(可以方便的進(jìn)行集群化部署)
- 減小服務(wù)端存儲(chǔ)壓力
1.2 如何實(shí)現(xiàn)無(wú)狀態(tài)
無(wú)狀態(tài)登錄的流程:
- 首先客戶端發(fā)送賬戶名/密碼到服務(wù)端進(jìn)行認(rèn)證
- 認(rèn)證通過后,服務(wù)端將用戶信息加密并且編碼成一個(gè) token,返回給客戶端
- 以后客戶端每次發(fā)送請(qǐng)求,都需要攜帶認(rèn)證的 token
- 服務(wù)端對(duì)客戶端發(fā)送來(lái)的 token 進(jìn)行解密,判斷是否有效,并且獲取用戶登錄信息
1.3 JWT
1.3.1 簡(jiǎn)介
JWT,全稱是 Json Web Token, 是一種 JSON 風(fēng)格的輕量級(jí)的授權(quán)和身份認(rèn)證規(guī)范,可實(shí)現(xiàn)無(wú)狀態(tài)、分布式的 Web 應(yīng)用授權(quán):

JWT 作為一種規(guī)范,并沒有和某一種語(yǔ)言綁定在一起,常用的 Java 實(shí)現(xiàn)是 GitHub 上的開源項(xiàng)目 jjwt,地址如下:https://github.com/jwtk/jjwt
1.3.2 JWT數(shù)據(jù)格式
JWT 包含三部分?jǐn)?shù)據(jù):
-
Header:頭部,通常頭部有兩部分信息:
- 聲明類型,這里是JWT
- 加密算法,自定義
我們會(huì)對(duì)頭部進(jìn)行 Base64Url 編碼(可解碼),得到第一部分?jǐn)?shù)據(jù)。
-
Payload:載荷,就是有效數(shù)據(jù),在官方文檔中(RFC7519),這里給了7個(gè)示例信息:
- iss (issuer):表示簽發(fā)人
- exp (expiration time):表示token過期時(shí)間
- sub (subject):主題
- aud (audience):受眾
- nbf (Not Before):生效時(shí)間
- iat (Issued At):簽發(fā)時(shí)間
- jti (JWT ID):編號(hào)
這部分也會(huì)采用 Base64Url 編碼,得到第二部分?jǐn)?shù)據(jù)。
- Signature:簽名,是整個(gè)數(shù)據(jù)的認(rèn)證信息。一般根據(jù)前兩步的數(shù)據(jù),再加上服務(wù)的的密鑰secret(密鑰保存在服務(wù)端,不能泄露給客戶端),通過 Header 中配置的加密算法生成。用于驗(yàn)證整個(gè)數(shù)據(jù)完整和可靠性。
生成的數(shù)據(jù)格式如下圖:

注意,這里的數(shù)據(jù)通過 . 隔開成了三部分,分別對(duì)應(yīng)前面提到的三部分,另外,這里數(shù)據(jù)是不換行的,圖片換行只是為了展示方便而已。
1.3.3 JWT 交互流程
流程圖:

步驟翻譯:
- 應(yīng)用程序或客戶端向授權(quán)服務(wù)器請(qǐng)求授權(quán)
- 獲取到授權(quán)后,授權(quán)服務(wù)器會(huì)向應(yīng)用程序返回訪問令牌
- 應(yīng)用程序使用訪問令牌來(lái)訪問受保護(hù)資源(如 API)
因?yàn)?JWT 簽發(fā)的 token 中已經(jīng)包含了用戶的身份信息,并且每次請(qǐng)求都會(huì)攜帶,這樣服務(wù)的就無(wú)需保存用戶信息,甚至無(wú)需去數(shù)據(jù)庫(kù)查詢,這樣就完全符合了 RESTful 的無(wú)狀態(tài)規(guī)范。
1.3.4 JWT 存在的問題
說了這么多,JWT 也不是天衣無(wú)縫,由客戶端維護(hù)登錄狀態(tài)帶來(lái)的一些問題在這里依然存在,舉例如下:
- 續(xù)簽問題,這是被很多人詬病的問題之一,傳統(tǒng)的 cookie+session 的方案天然的支持續(xù)簽,但是 jwt 由于服務(wù)端不保存用戶狀態(tài),因此很難完美解決續(xù)簽問題,如果引入 redis,雖然可以解決問題,但是 jwt 也變得不倫不類了。
- 注銷問題,由于服務(wù)端不再保存用戶信息,所以一般可以通過修改 secret 來(lái)實(shí)現(xiàn)注銷,服務(wù)端 secret 修改后,已經(jīng)頒發(fā)的未過期的 token 就會(huì)認(rèn)證失敗,進(jìn)而實(shí)現(xiàn)注銷,不過畢竟沒有傳統(tǒng)的注銷方便。
- 密碼重置,密碼重置后,原本的 token 依然可以訪問系統(tǒng),這時(shí)候也需要強(qiáng)制修改 secret。
- 基于第 2 點(diǎn)和第 3 點(diǎn),一般建議不同用戶取不同 secret。
當(dāng)然,為了解決 JWT 存在的問題,也可以將 JWT 結(jié)合 Redis 來(lái)用,服務(wù)端生成的 JWT 字符串存入到 Redis 中并設(shè)置過期時(shí)間,每次校驗(yàn)的時(shí)候,先看 Redis 中是否存在該 JWT 字符串,如果存在就進(jìn)行后續(xù)的校驗(yàn)。但是這種方式有點(diǎn)不倫不類(又成了有狀態(tài)了)。
2. 實(shí)踐
我們來(lái)看下 gRPC 如何結(jié)合 JWT。
2.1 項(xiàng)目創(chuàng)建
首先我先給大家看下我的項(xiàng)目結(jié)構(gòu):
├── grpc_api
│ ├── pom.xml
│ └── src
├── grpc_client
│ ├── pom.xml
│ └── src
├── grpc_server
│ ├── pom.xml
│ └── src
└── pom.xml
還是跟之前文章中的一樣,三個(gè)模塊,grpc_api 用來(lái)存放一些公共的代碼。
grpc_server 用來(lái)放服務(wù)端的代碼,我這里服務(wù)端主要提供了兩個(gè)接口:
- 登錄接口,登錄成功之后返回 JWT 字符串。
- hello 接口,客戶端拿著 JWT 字符串來(lái)訪問 hello 接口。
grpc_client 則是我的客戶端代碼。
2.2 grpc_api
我將 protocol buffers 和一些依賴都放在 grpc_api 模塊中,因?yàn)閷?lái)我的 grpc_server 和 grpc_client 都將依賴 grpc_api。
我們來(lái)看下這里需要的依賴和插件:
<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>
這里的依賴和插件松哥在本系列的第一篇文章中都已經(jīng)介紹過了,唯一不同的是,這里引入了 JWT 插件,JWT 我使用了比較流行的 JJWT 這個(gè)工具。JJWT 松哥在之前的文章和視頻中也都有介紹過,這里就不再啰嗦了。
先來(lái)看看我的 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;
}
經(jīng)過前面幾篇文章的介紹,這里我就不多說啦,就是定義了兩個(gè)服務(wù):
- LoginService:這個(gè)登錄服務(wù),傳入用戶名密碼,返回登錄成功之后的令牌。
- HelloService:這個(gè)就是一個(gè)打招呼的服務(wù),傳入字符串,返回也是字符串。
定義完成之后,生成對(duì)應(yīng)的代碼即可。
接下來(lái)再定義一個(gè)常量類供 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";
}
這里的每個(gè)常量我都給大家解釋下:
- JWT_KEY:這個(gè)是生成 JWT 字符串以及進(jìn)行 JWT 字符串校驗(yàn)的密鑰。
- AUTH_CLIENT_ID:這個(gè)是客戶端的 ID,即客戶端發(fā)送來(lái)的請(qǐng)求攜帶了 JWT 字符串,通過 JWT 字符串確認(rèn)了用戶身份,就存在這個(gè)變量中。
- AUTH_HEADER:這個(gè)是攜帶 JWT 字符串的請(qǐng)求頭的 KEY。
- AUTH_TOKEN_TYPE:這個(gè)是攜帶 JWT 字符串的請(qǐng)求頭的參數(shù)前綴,通過這個(gè)可以確認(rèn)參數(shù)的類型,常見取值有 Bearer 和 Basic。
如此,我們的 gRPC_api 就定義好了。
2.3 grpc_server
接下來(lái)我們來(lái)定義 gRPC_server。
首先來(lái)定義登錄服務(wù):
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();
}
}
}
省事起見,我這里沒有連接數(shù)據(jù)庫(kù),用戶名和密碼固定為 javaboy 和 123。
登錄成功之后,就生成一個(gè) JWT 字符串返回。
登錄失敗,就返回一個(gè) login error 字符串。
再來(lái)看我們的 HelloService 服務(wù),如下:
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();
}
}
這個(gè)服務(wù)就更簡(jiǎn)單了,不啰嗦。唯一值得說的是 AuthConstant.AUTH_CLIENT_ID.get(); 表示獲取當(dāng)前訪問用戶的 ID,這個(gè)用戶 ID 是在攔截器中存入進(jìn)來(lái)的。
最后,我們來(lái)看服務(wù)端比較重要的攔截器,我們要在攔截器中從請(qǐng)求頭中獲取到 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>() {
};
}
}
這段代碼邏輯應(yīng)該好理解:
- 首先從 Metadata 中提取出當(dāng)前請(qǐng)求所攜帶的 JWT 字符串(相當(dāng)于從請(qǐng)求頭中提取出來(lái))。
- 如果第一步提取到的值為 null 或者這個(gè)值不是以指定字符 Bearer 開始的,說明這個(gè)令牌是一個(gè)非法令牌,設(shè)置對(duì)應(yīng)的響應(yīng) status 即可。
- 如果令牌都沒有問題的話,接下來(lái)就進(jìn)行令牌的校驗(yàn),校驗(yàn)失敗,則設(shè)置相應(yīng)的 status 即可。
- 校驗(yàn)成功的話,我們就會(huì)獲取到一個(gè) Jws<Claims> 對(duì)象,從這個(gè)對(duì)象中我們可以提取出來(lái)用戶名,并存入到 Context 中,將來(lái)我們?cè)?HelloServiceImpl 中就可以獲取到這里的用戶名了。
- 最后,登錄成功的話,
Contexts.interceptCall方法構(gòu)建監(jiān)聽器并返回;登錄失敗,則構(gòu)建一個(gè)空的監(jiān)聽器返回。
最后,我們?cè)賮?lái)看看啟動(dòng)服務(wù)端:
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();
}
}
}
這個(gè)跟之前的相比就多加了一個(gè) Service,添加 HelloServiceImpl 服務(wù)的時(shí)候,多加了一個(gè)攔截器,換言之,登錄的時(shí)候,請(qǐng)求是不會(huì)被這個(gè)認(rèn)證攔截器攔截的。
好啦,這樣我們的 grpc_server 就開發(fā)完成了。
2.4 grpc_client
接下來(lái)我們來(lái)看 grpc_client。
先來(lái)看登錄:
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();
}
}
這個(gè)方法直接調(diào)用就行了,看過前面幾篇 gRPC 文章的話,這里都很好理解。
再來(lái)看 hello 接口的調(diào)用,這個(gè)接口調(diào)用需要攜帶 JWT 字符串,而攜帶 JWT 字符串,則需要我們構(gòu)建一個(gè) CallCredentials 對(duì)象,如下:
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() {
}
}
這里就是將請(qǐng)求的 JWT 令牌放入到請(qǐng)求頭中即可。
最后來(lái)看看調(diào)用:
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();
}
}
這里的登錄令牌就是前面調(diào)用 login 方法時(shí)獲取到的令牌。
好啦,大功告成。
3. 小結(jié)
上面的登錄與校驗(yàn)只是松哥給小伙伴們展示的一個(gè)具體案例而已,在此案例基礎(chǔ)之上,我們還可以擴(kuò)展出來(lái)更多寫法,但是萬(wàn)變不離其宗,其他玩法就需要小伙伴們自行探索啦~