一、为什么gRPC需要OAuth2认证
咱们做开发的都知道,接口调用最怕没权限——比如你做的后台管理系统,不能让普通用户随便改别人的数据;你做的APP后台,不能让别人随便拿你的用户接口乱调。gRPC是现在跨服务调用很火的工具,它本身有基础的身份验证机制,但默认的不够灵活,没法满足现在常用的权限控制需求,比如要区分用户角色、要给不同接口设不同权限、要实现Token过期自动续期这些。
OAuth2是现在互联网上最常用的授权框架,它的核心逻辑是:第三方应用要访问用户的资源,得先拿用户的授权,然后用授权换一个Token,之后每次调用接口都带着这个Token,服务端就能通过Token判断调用方有没有权限。把OAuth2和gRPC结合,就能解决gRPC的权限控制问题,而且能让不同服务之间的调用更安全。
二、核心概念先理清楚
2.1 什么是gRPC的Credentials和拦截器
gRPC里的Credentials(凭证),简单说就是调用方发给服务端的“身份证”,用来证明自己是谁。这个身份证可以是用户名密码、Token、证书这些。gRPC本身支持自定义Credentials,就是你可以自己定义这个身份证的格式和内容。
拦截器是gRPC里的“中间件”,它就像小区门口的保安,调用方发请求过来,先过保安这关;调用方发请求的时候,保安也能先给请求做些处理再发出去。gRPC的拦截器分客户端拦截器和服务端拦截器:客户端拦截器在调用方发请求前做处理,比如给请求加Token;服务端拦截器在收到请求后、交给业务逻辑前做处理,比如验证Token的合法性。
2.2 OAuth2的Token自动刷新逻辑
OAuth2里的Token一般分两种:访问Token(Access Token)和刷新Token(Refresh Token)。访问Token是用来调用接口的,有效期短,比如1小时;刷新Token是用来续访问Token的,有效期长,比如7天。自动刷新的逻辑是:当访问Token过期后,调用方拿着刷新Token去授权服务器换一个新的访问Token,然后用新的访问Token继续调用接口,不用用户重新登录授权。
三、实战准备:明确技术栈和环境
本次实战统一使用Java技术栈,具体版本:JDK 11、gRPC 1.54.0、Spring Boot 2.7.14(用来做授权服务器和业务服务的基础框架)。
先做些基础准备:
- 定义一个简单的gRPC服务,用来模拟业务调用:
// 业务服务的Proto定义,存放在src/main/proto/user.proto
syntax = "proto3";
package com.example.user;
option java_multiple_files = true;
option java_package = "com.example.user.service";
option java_outer_classname = "UserServiceProto";
// 定义请求和响应
message GetUserInfoRequest {
string user_id = 1;
}
message GetUserInfoResponse {
string user_name = 1;
string email = 2;
}
// 定义业务服务接口
service UserService {
rpc GetUserInfo(GetUserInfoRequest) returns (GetUserInfoResponse);
}
- 用protoc工具编译Proto文件,生成Java代码,编译命令如下:
# 编译Proto文件,生成Java代码到指定目录
protoc --proto_path=src/main/proto --java_out=src/main/java --grpc-java_out=src/main/java src/main/proto/user.proto
四、自定义Credentials实现Token携带
gRPC的Credentials分两类:CallCredentials和ChannelCredentials。CallCredentials是针对单个请求的凭证,适合每次请求携带不同的Token;ChannelCredentials是针对整个连接的凭证,适合连接建立时的凭证。这里我们用CallCredentials,因为每个请求的Token可能会刷新,适合自动刷新的场景。
自定义Credentials的核心是实现CallCredentials接口,重写apply方法,在这个方法里把Token加到请求的元数据(Metadata)里,Metadata就是gRPC用来传递请求头的地方。
自定义Credentials的代码如下:
// 自定义的OAuth2 CallCredentials,用来携带Token
package com.example.credentials;
import io.grpc.CallCredentials;
import io.grpc.Metadata;
import io.grpc.Status;
import java.util.concurrent.Executor;
public class OAuth2CallCredentials extends CallCredentials {
// 定义Metadata里的Key,用来存放Token,和HTTP的Authorization头类似
public static final Metadata.Key<String> AUTHORIZATION_KEY = Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
private String accessToken; // 当前的访问Token
// 构造方法,初始化Token
public OAuth2CallCredentials(String accessToken) {
this.accessToken = accessToken;
}
// 重写apply方法,把Token加到Metadata里
@Override
public void applyRequestMetadata(RequestInfo requestInfo, Executor executor, MetadataApplier metadataApplier) {
executor.execute(() -> {
try {
// 创建Metadata对象,把Token加进去,格式是Bearer Token
Metadata metadata = new Metadata();
metadata.put(AUTHORIZATION_KEY, "Bearer " + accessToken);
// 把Metadata传给gRPC,用于后续的请求
metadataApplier.apply(metadata);
} catch (Exception e) {
// 出错的话,返回权限拒绝的状态
metadataApplier.fail(Status.UNAUTHENTICATED.withCause(e));
}
});
}
// 这个方法用来更新Token,方便后续自动刷新的时候调用
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
}
五、客户端拦截器实现Token自动刷新
客户端拦截器的作用是:在每次发请求前,先检查当前的Token有没有过期,如果过期了就用刷新Token去换一个新的,然后更新到自定义的Credentials里,再发请求。
5.1 实现Token检查和刷新逻辑
首先,我们需要一个Token管理类,用来存储当前的访问Token、刷新Token,以及Token的过期时间。代码如下:
// Token管理类,用来存储和管理Token信息
package com.example.token;
import java.time.Instant;
public class TokenManager {
private String accessToken; // 访问Token
private String refreshToken; // 刷新Token
private Instant accessTokenExpireTime; // 访问Token的过期时间
// 构造方法,初始化Token信息
public TokenManager(String accessToken, String refreshToken, long accessTokenExpireSeconds) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
// 计算过期时间,当前时间加上过期秒数
this.accessTokenExpireTime = Instant.now().plusSeconds(accessTokenExpireSeconds);
}
// 检查访问Token是否过期,提前1分钟刷新,避免调用时刚好过期
public boolean isAccessTokenExpired() {
return Instant.now().isAfter(accessTokenExpireTime.minusSeconds(60));
}
// 刷新访问Token的方法,这里模拟调用授权服务器的刷新接口
public boolean refreshAccessToken() {
try {
// 模拟调用授权服务器的刷新接口,实际开发中需要用HTTP请求调用
String newAccessToken = "new_access_token_" + System.currentTimeMillis();
// 更新Token和过期时间
this.accessToken = newAccessToken;
this.accessTokenExpireTime = Instant.now().plusSeconds(3600); // 新的Token有效期1小时
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
// 下面是getter方法,用来获取Token信息
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
}
5.2 实现客户端拦截器
客户端拦截器需要实现ClientInterceptor接口,重写interceptCall方法,在这个方法里先检查Token是否过期,如果过期就刷新,然后再调用业务逻辑。代码如下:
// 客户端拦截器,实现Token自动刷新
package com.example.interceptor;
import com.example.token.TokenManager;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.MethodDescriptor;
public class OAuth2ClientInterceptor implements ClientInterceptor {
private TokenManager tokenManager; // Token管理类实例
private OAuth2CallCredentials callCredentials; // 自定义的Credentials实例
// 构造方法,注入TokenManager和CallCredentials
public OAuth2ClientInterceptor(TokenManager tokenManager, OAuth2CallCredentials callCredentials) {
this.tokenManager = tokenManager;
this.callCredentials = callCredentials;
}
// 重写interceptCall方法,拦截所有客户端调用
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions,
Channel next) {
// 检查Token是否过期
if (tokenManager.isAccessTokenExpired()) {
// 刷新Token
boolean refreshSuccess = tokenManager.refreshAccessToken();
if (!refreshSuccess) {
// 刷新失败,抛出异常
throw new RuntimeException("Failed to refresh access token");
}
// 更新Credentials里的Token
callCredentials.setAccessToken(tokenManager.getAccessToken());
}
// 把自定义的Credentials加到CallOptions里,传给gRPC
CallOptions newCallOptions = callOptions.withCallCredentials(callCredentials);
// 继续调用业务逻辑
return next.newCall(method, newCallOptions);
}
}
5.3 客户端启动时配置拦截器
客户端启动的时候,需要把自定义的拦截器加到gRPC的Channel里,这样所有的请求都会经过拦截器。代码如下:
// 客户端启动类,配置gRPC Channel和拦截器
package com.example.client;
import com.example.credentials.OAuth2CallCredentials;
import com.example.interceptor.OAuth2ClientInterceptor;
import com.example.token.TokenManager;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import com.example.user.service.UserServiceGrpc;
public class UserClient {
public static void main(String[] args) {
// 初始化TokenManager,模拟从授权服务器获取的初始Token
TokenManager tokenManager = new TokenManager(
"initial_access_token_123",
"initial_refresh_token_456",
60 // 初始Token有效期60秒,方便测试过期刷新
);
// 初始化自定义的Credentials
OAuth2CallCredentials callCredentials = new OAuth2CallCredentials(tokenManager.getAccessToken());
// 初始化客户端拦截器
OAuth2ClientInterceptor interceptor = new OAuth2ClientInterceptor(tokenManager, callCredentials);
// 构建gRPC Channel,加入拦截器
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090)
.usePlaintext() // 测试用,生产环境要加密
.intercept(interceptor) // 加入拦截器
.build();
// 创建业务服务的Stub
UserServiceGrpc.UserServiceBlockingStub userStub = UserServiceGrpc.newBlockingStub(channel);
// 模拟多次调用业务接口,测试Token刷新
for (int i = 0; i < 10; i++) {
try {
// 构建请求
com.example.user.service.GetUserInfoRequest request = com.example.user.service.GetUserInfoRequest.newBuilder()
.setUserId("user_123")
.build();
// 调用接口
com.example.user.service.GetUserInfoResponse response = userStub.getUserInfo(request);
// 打印响应
System.out.println("第" + (i+1) + "次调用成功,用户名:" + response.getUserName());
// 间隔10秒调用一次,模拟不同时间的请求
Thread.sleep(10000);
} catch (Exception e) {
System.out.println("第" + (i+1) + "次调用失败:" + e.getMessage());
}
}
// 关闭Channel
channel.shutdown();
}
}
六、服务端拦截器实现Token验证
服务端拦截器的作用是:收到请求后,先从Metadata里取出Token,验证Token的合法性,如果合法就交给业务逻辑,不合法就返回权限拒绝的错误。
服务端拦截器的代码如下:
// 服务端拦截器,验证Token
package com.example.server.interceptor;
import com.example.credentials.OAuth2CallCredentials;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
public class OAuth2ServerInterceptor implements ServerInterceptor {
// 重写interceptCall方法,拦截所有服务端请求
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
// 从Metadata里取出Token
String authorization = headers.get(OAuth2CallCredentials.AUTHORIZATION_KEY);
// 检查Token是否存在,格式是否正确
if (authorization == null || !authorization.startsWith("Bearer ")) {
// 格式错误,关闭调用,返回权限拒绝
call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
// 取出Token内容
String token = authorization.substring("Bearer ".length());
// 验证Token的合法性,这里模拟验证逻辑,实际开发中需要调用授权服务器验证
if (!isTokenValid(token)) {
call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
// 验证通过,交给业务逻辑
return next.startCall(call, headers);
}
// 模拟Token验证方法,实际开发中要调用授权服务器的验证接口
private boolean isTokenValid(String token) {
// 模拟验证:只要不是空的就认为有效
return token != null && !token.isEmpty();
}
}
服务端启动的时候,需要把服务端拦截器加到gRPC的服务里,代码如下:
// 服务端启动类,配置gRPC服务和拦截器
package com.example.server;
import com.example.server.interceptor.OAuth2ServerInterceptor;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import com.example.user.service.UserServiceGrpc;
import com.example.user.service.GetUserInfoRequest;
import com.example.user.service.GetUserInfoResponse;
import io.grpc.stub.StreamObserver;
public class UserServer {
public static void main(String[] args) throws Exception {
// 构建gRPC服务,加入服务端拦截器
Server server = ServerBuilder.forPort(9090)
.intercept(new OAuth2ServerInterceptor()) // 加入服务端拦截器
.addService(new UserServiceImpl()) // 加入业务服务实现
.build();
// 启动服务
server.start();
System.out.println("服务端启动成功,端口:9090");
// 等待服务关闭
server.awaitTermination();
}
// 业务服务的实现类
static class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUserInfo(GetUserInfoRequest request, StreamObserver<GetUserInfoResponse> responseObserver) {
// 模拟业务逻辑,返回用户信息
GetUserInfoResponse response = GetUserInfoResponse.newBuilder()
.setUserName("张三")
.setEmail("zhangsan@example.com")
.build();
// 返回响应
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
}
七、应用场景、优缺点和注意事项
7.1 应用场景
这个方案适合大多数需要权限控制的gRPC调用场景,比如:
- 微服务架构中,服务之间的调用需要权限控制,比如订单服务只能被支付服务调用,不能被其他服务随便调用;
- 前后端分离的项目中,前端通过gRPC调用后端服务,需要验证用户的登录状态;
- 第三方服务调用自己的gRPC接口,需要给第三方分配Token,控制第三方的调用权限。
7.2 技术优缺点
优点:
- 灵活:自定义Credentials和拦截器可以根据自己的需求定制Token的携带、刷新和验证逻辑,不受gRPC默认机制的限制;
- 安全:结合OAuth2的授权机制,能有效控制调用方的权限,避免非法调用;
- 无感刷新:Token自动刷新对业务代码无侵入,业务代码不用关心Token的过期和刷新,只需要写业务逻辑就行;
- 可扩展性强:可以在拦截器里加入更多的逻辑,比如日志记录、限流、熔断等。
缺点:
- 增加了复杂度:需要自己实现Credentials、拦截器、Token管理等逻辑,比用gRPC默认的认证机制要复杂;
- 依赖授权服务器:Token的验证和刷新都需要授权服务器的支持,如果授权服务器挂了,整个认证体系就会失效;
- 性能损耗:拦截器会增加请求的处理时间,尤其是在高并发场景下,需要优化拦截器的逻辑,避免性能瓶颈。
7.3 注意事项
- 生产环境要加密:上面的例子里用了usePlaintext(),生产环境一定要用TLS加密,避免Token被窃取;
- Token的存储安全:Token管理类里的Token要安全存储,不能存在明文里,避免被泄露;
- 刷新Token的安全:刷新Token的有效期要比访问Token长,但也不能太长,避免被窃取后被滥用;
- 错误处理:要处理Token刷新失败、Token验证失败等异常情况,避免业务逻辑出错;
- 日志记录:要记录Token的刷新、验证等日志,方便排查问题;
- 测试要充分:要测试Token过期、刷新、验证等各种场景,确保逻辑正确。
八、文章总结
本文从实际需求出发,一步步实现了gRPC结合OAuth2的认证方案,包括自定义Credentials携带Token、客户端拦截器实现Token自动刷新、服务端拦截器实现Token验证。整个方案的核心是利用gRPC的拦截器机制,把认证逻辑和业务逻辑解耦,业务代码不用关心认证的细节,只需要专注于业务逻辑的实现。
这个方案能满足大多数gRPC调用的权限控制需求,而且具有很高的灵活性和可扩展性。在实际使用的时候,要根据自己的业务场景调整细节,比如Token的有效期、刷新逻辑、验证逻辑等,同时要注意安全和性能的问题,确保整个体系的稳定和安全。
Comments