一、为什么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(用来做授权服务器和业务服务的基础框架)。

先做些基础准备:

  1. 定义一个简单的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);
}
  1. 用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调用场景,比如:

  1. 微服务架构中,服务之间的调用需要权限控制,比如订单服务只能被支付服务调用,不能被其他服务随便调用;
  2. 前后端分离的项目中,前端通过gRPC调用后端服务,需要验证用户的登录状态;
  3. 第三方服务调用自己的gRPC接口,需要给第三方分配Token,控制第三方的调用权限。

7.2 技术优缺点

优点:

  1. 灵活:自定义Credentials和拦截器可以根据自己的需求定制Token的携带、刷新和验证逻辑,不受gRPC默认机制的限制;
  2. 安全:结合OAuth2的授权机制,能有效控制调用方的权限,避免非法调用;
  3. 无感刷新:Token自动刷新对业务代码无侵入,业务代码不用关心Token的过期和刷新,只需要写业务逻辑就行;
  4. 可扩展性强:可以在拦截器里加入更多的逻辑,比如日志记录、限流、熔断等。

缺点:

  1. 增加了复杂度:需要自己实现Credentials、拦截器、Token管理等逻辑,比用gRPC默认的认证机制要复杂;
  2. 依赖授权服务器:Token的验证和刷新都需要授权服务器的支持,如果授权服务器挂了,整个认证体系就会失效;
  3. 性能损耗:拦截器会增加请求的处理时间,尤其是在高并发场景下,需要优化拦截器的逻辑,避免性能瓶颈。

7.3 注意事项

  1. 生产环境要加密:上面的例子里用了usePlaintext(),生产环境一定要用TLS加密,避免Token被窃取;
  2. Token的存储安全:Token管理类里的Token要安全存储,不能存在明文里,避免被泄露;
  3. 刷新Token的安全:刷新Token的有效期要比访问Token长,但也不能太长,避免被窃取后被滥用;
  4. 错误处理:要处理Token刷新失败、Token验证失败等异常情况,避免业务逻辑出错;
  5. 日志记录:要记录Token的刷新、验证等日志,方便排查问题;
  6. 测试要充分:要测试Token过期、刷新、验证等各种场景,确保逻辑正确。

八、文章总结

本文从实际需求出发,一步步实现了gRPC结合OAuth2的认证方案,包括自定义Credentials携带Token、客户端拦截器实现Token自动刷新、服务端拦截器实现Token验证。整个方案的核心是利用gRPC的拦截器机制,把认证逻辑和业务逻辑解耦,业务代码不用关心认证的细节,只需要专注于业务逻辑的实现。

这个方案能满足大多数gRPC调用的权限控制需求,而且具有很高的灵活性和可扩展性。在实际使用的时候,要根据自己的业务场景调整细节,比如Token的有效期、刷新逻辑、验证逻辑等,同时要注意安全和性能的问题,确保整个体系的稳定和安全。