@[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 目标,从这个目标中咱们能够提取出来用户名,并存入到 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. 小结
上面的登录与校验仅仅松哥给小伙伴们展示的一个具体案例罢了,在此案例根底之上,咱们还能够扩展出来更多写法,可是万变不离其宗,其他玩法就需求小伙伴们自行探索啦~