11

gRPC鉴权方案

 3 years ago
source link: https://jiajunhuang.com/articles/2020_12_19-grpc_authentication.md.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

gRPC鉴权方案

昨日与群友讨论,gRPC比较优雅的鉴权方案应该怎么做,本文记录一下最终相对优雅的实践方案。

不过我们仍然要从故事的开头讲起。

我在使用gRPC的时候,一开始是不想使用header的,尽量避免往gRPC里修改一些东西,因此最开始,我是想着,在每个请求的message里, 都带上access_token,例如:

message PingPong {
    string access_token = 1;
}

因此,如果想要做认证的话,就得在每个gRCP实现的方法里,对 access_token 进行校验,例如:

userID, err := getUserIDByAccessToken(req.AccessToken)
if err != nil {
    logrus.Errorf("failed to get userID by accessToken %s: %s", req.AccessToken, err)
    return nil, status.Errorf(codes.Unauthenticated, "AccessToken expired")
}

但是由于Go既没有像Python那样灵活的decorator,可以在函数执行前执行一些代码,又没有像Java那样的annotation可以注入一些 metadata以便根据这些metadata来执行一些操作。所以导致这种方案必须在每一个gRPC方法里都加上上面这样的代码,这就很麻烦。

解决方案1,注入metadata

要想在gRPC里,针对每一个方法都做一点事情,那么最简单的,自然是使用中间件,在gRPC里,叫做 interceptor 。gRPC是可以注入 metadata的,链接见 这里,gRPC提供的options,其中有 一种就叫做 google.protobuf.MethodOptions:

extend google.protobuf.MethodOptions {
  optional MyMessage my_method_option = 50007;
}

然后就可以使用它:

service MyService {
  option (my_service_option) = FOO;

  rpc MyMethod(RequestType) returns(ResponseType) {
    // Note:  my_method_option has type MyMessage.  We can set each field
    //   within it using a separate "option" line.
    option (my_method_option).foo = 567;
    option (my_method_option).bar = "Some string";
  }
}

不过我最终没有选择这种方案,原因是文档极少,而且需要在中间件里把方法的options拿出来,但是中间件函数的签名是:

// UnaryHandler defines the handler invoked by UnaryServerInterceptor to complete the normal
// execution of a unary RPC. If a UnaryHandler returns an error, it should be produced by the
// status package, or else gRPC will use codes.Unknown as the status code and err.Error() as
// the status message of the RPC.
type UnaryHandler func(ctx context.Context, req interface{}) (interface{}, error)

// UnaryServerInfo consists of various information about a unary RPC on
// server side. All per-rpc information may be mutated by the interceptor.
type UnaryServerInfo struct {
	// Server is the service implementation the user provides. This is read-only.
	Server interface{}
	// FullMethod is the full RPC method string, i.e., /package.service/method.
	FullMethod string
}

// UnaryServerInterceptor provides a hook to intercept the execution of a unary RPC on the server. info
// contains all the information of this RPC the interceptor can operate on. And handler is the wrapper
// of the service method implementation. It is the responsibility of the interceptor to invoke handler
// to complete the RPC.
type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)

想要拿到option不容易。不过,这倒是引出了方案2。

解决方案2: 手动维护白名单/黑名单

经群友建议,我们可以把该要鉴权,或者不需要鉴权的方法,根据方法名放在一个列表(我使用的是map)里。看上面的 UnaryServerInfo, 此方法可行。还有,与此配合的就是,把 access_token 放在metadata里(其实就是HTTP/2 里的header)传输。

我这里是大部分接口都需要鉴权,因此我把不需要鉴权的接口放在map里:

// 所有不需要用户登录的接口,就放在这里,否则则必须登录
var publicAPIMapper = map[string]bool{
	"/cashapp.CashApp/PingPong": true,
	"/cashapp.CashApp/Register": true,
	"/cashapp.CashApp/Login":    true,
}

func IsPublicAPI(fullMethodName string) bool {
	return publicAPIMapper[fullMethodName]
}

然后就可以在interceptor里做统一处理:

func SentryUnaryServerInterceptor() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
		fullMethodName := info.FullMethod

		if !utils.IsPublicAPI(fullMethodName) {
			accessToken := utils.GetAccessToken(ctx)
			userID, err := utils.GetUserIDByAccessToken(accessToken)
			if err != nil || userID == 0 {
				logrus.Infof("failed to find user by %s: %s", accessToken, err)
				return nil, status.Errorf(codes.Unauthenticated, err.Error())
			}

			ctx = context.WithValue(ctx, "access_token", accessToken)
			ctx = context.WithValue(ctx, "user_id", userID)
		}

		logrus.Infof("got request with %v", req)
		result, err := handler(ctx, req)
		if err != nil {
			sentry.CaptureException(fmt.Errorf("%v", err))
			sentry.Flush(time.Second * 5)
		}
		return result, err
	}
}

其中 utils.GetAccessToken 是从ctx里拿metadata,然后从中拿 access-token 的,代码如下:

func GetAccessToken(ctx context.Context) string {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return ""
	}

	accessTokenList := md.Get("access-token")
	if len(accessTokenList) == 1 {
		return accessTokenList[0]
	}

	return ""
}

这个时候,客户端只需要统一添加一个 Access-Token 的头部,值为对应用户的 access_token 即可。

注意踩坑,Nginx会把头部做一次转换,比如 Access-Token 会转换成 access-token(HTTP/2 规范要求, 详见RFC:https://tools.ietf.org/html/rfc7540#section-8.1.2 ),而带下划线的头,默认会过滤掉, 详见:http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers ,我一开始传 头部传了 access_token,所以排查了好一会儿。

上面我们看了两种gRPC做鉴权的方案,首先我们还是要承认,access_token这种东西,放在头部还是比较合适的, 并不需要为了不动gRPC的metadata而去迁就。其次,简单粗暴的方案也可以是相对优雅的,所以最后我们选择了 实现上更简单但是代码可读性,维护行好很多的方案2。


微信公众号
关注公众号,获得及时更新

Redis通信协议阅读

2016年就要结束了,2017年就要开始啦!

unittest 源代码阅读

APUEv3 - 重读笔记

Mock源码阅读与分析

Thinking in Python

我的代码进CPython标准库啦

Python零碎小知识

Python和单元测试

工作一年的总结

Python 的继承

MongoDB 的一些坑

Python的yield关键字有什么作用?

借助coroutine用同步的语法写异步

Python3函数参数中的星号




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK