手把手教大家在 gRPC 中使用 JWT 完成身份校驗
上篇文章松哥和小夥伴們聊了在 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,地址如下:
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 最後,登入成功的話, 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/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 使用java完成ldap身份驗證JavaLDA
- 在Delphi中使用正規表示式校驗身份證號
- 使用 JWT 時,新增自定義資料並在登陸時校驗JWT
- node學習---jwt實現驗證使用者身份JWT
- javascript身份證號碼校驗JavaScript
- 使用 JWT 認證使用者身份JWT
- SpringBoot整合JWT做身份驗證Spring BootJWT
- 使用JWT做RESTful API的身份驗證-Go語言實現JWTRESTAPIGo
- 使用 JWT 身份驗證保護你的 Spring Boot 應用JWTSpring Boot
- gRPC(七)進階:自定義身份驗證RPC
- PHP 使用 jwt 使用者身份認證PHPJWT
- jwt-在asp.net core中的使用jwtJWTASP.NET
- asp.core 同時相容JWT身份驗證和Cookies 身份驗證兩種模式JWTCookie模式
- 身份證校驗碼的計算方法
- 18位身份證校驗演算法演算法
- NET Core 多身份校驗與策略模式模式
- JS校驗身份證號的合法性JS
- [譯]簡單的React身份校驗機制React
- JWT 在專案中的實際使用JWT
- Kerberos 身份驗證在 ChunJun 中的落地實踐ROS
- JQuery+Ajax 使用JQuery完成非同步使用者名稱的校驗jQuery非同步
- PHP校驗15位和18位身份證號PHP
- 身份證號碼校驗位的計算方式
- 2.13.3 使用 Oracle Wallet 實現在DBCA中使用身份驗證Oracle
- Jwt建立身份令牌TokenJWT
- jwt加meta元資訊實現登入後校驗JWT
- 在Linux Shell程式中進行身份驗證(轉)Linux
- 構建使用者管理微服務(六):新增持久 JWT 令牌的 remember me 身份驗證微服務JWTREM
- day102:MoFang:後端完成對簡訊驗證碼的校驗&基於celery完成非同步簡訊傳送&flask_jwt_extended&使用者登入的API介面後端非同步FlaskJWTAPI
- 教大家如何在html中使用特殊字型HTML
- thinkphp中ajax使用者名稱校驗PHP
- 請教在eclispe中如何配置使用jiveLisp
- 在 SpringBoot 專案中簡單實現 JWT 驗證Spring BootJWT
- 用WPS格式轉換工具校驗身份證號碼
- 關於執行緒使用中的幾個疑問,請教大家!執行緒
- 在學校教學Java是有爭議的Java
- JWT驗證使用者資訊功能JWT
- 譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證微服務JWTSpring