@[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. 小结
下面的登录与校验只是松哥给小伙伴们展现的一个具体案例而已,在此案例根底之上,咱们还能够扩大进去更多写法,然而万变不离其宗,其余玩法就须要小伙伴们自行摸索啦~