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),而帶下劃線的頭,默認會過濾掉, 詳見:Module ngx_http_core_module ,我一開始傳 頭部傳了 access_token,所以排查了好一會兒。

總結

上面我們看了兩種 gRPC 做鑑權的方案,首先我們還是要承認,access_token 這種東西,放在頭部還是比較合適的, 並不需要爲了不動 gRPC 的 metadata 而去遷就。其次,簡單粗暴的方案也可以是相對優雅的,所以最後我們選擇了 實現上更簡單但是代碼可讀性,維護行好很多的方案 2。

轉自:

jiajunhuang.com/articles/2020_12_19-grpc_authentication.md.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/avcRRLLMQNMUBiyfp-SznQ