gRPC 使用严格的认证机制。前文介绍了如何使用 TLS 实现客户端和服务器端的加密数据交换。下面将讨论如何验证调用者的身份,并使用不同的调用凭证技术(如基于令牌的认证等)实现访问控制功能。

为了方便对调用者进行验证,gRPC 为客户端提供了在每次调用中插入凭证(如用户名和密码)的功能。gRPC 服务器端能够拦截来自客户端的请求,并检查每一个传入调用的凭证。

下面将先介绍一个简单的认证场景,从而阐释对每个客户端调用进行认证的方式。

6.2.1 使用basic认证

basic 认证是最简单的认证机制。在这种机制中,客户端发送的请求带有 Authorization 头信息,该头信息的值以单词 Basic 开头,随后是一个空格和 base64 编码的字符串 <用户名>:<密码>。如果用户名和密码均为 admin,那么头信息将如下所示:

Authorization: Basic YWRtaW46YWRtaW4=

总体而言,gRPC 并不提倡使用用户名/密码来对服务进行认证。这是因为,相对于 JSON Web Token(JWT)和 OAuth2 Access Token 等其他令牌,用户名/密码没有时间方面的限制。这意味着当生成一个令牌时,我们可以指定它的有效时间,但对于用户名/密码,则不能指定它的有效期。在我们更改密码之前,它始终是有效的。如果需要在应用程序中启用 basic 认证,建议在客户端和服务器端之间的安全连接中共享基本凭证。我们选择 basic 认证,是为了能更方便地阐述 gRPC 中的认证原理。

我们先讨论如何将用户凭证以 basic 认证的方式注入调用之中。因为在gRPC 中没有内置的 basic 认证支持,所以需要将其以自定义凭证的形式添加到客户端上下文中。在 Go 语言中,可以很容易地实现这一点,只需要定义一个凭证结构体并实现 PerRPCCredentials 接口,如代码清单 6-5 所示。

代码清单 6-5 实现 PerRPCCredentials 接口以传递自定义凭证

type basicAuth struct { ➊
    username string
    password string
}

func (b basicAuth) GetRequestMetadata(ctx context.Context,in ...string) (map[string]string, error) { ➋
    auth := b.username + ":" + b.password
    enc := base64.StdEncoding.EncodeToString([]byte(auth))

    return map[string]string{
        "authorization": "Basic " + enc,
    }, nil
}
func (b basicAuth) RequireTransportSecurity() bool { ➌
    return true
}

❶ 定义结构体来存放要注入 RPC 的字段集合(在我们的场景中,也就是用户的凭证,如用户名和密码)。
❷ 实现 GetRequestMetadata 方法,并将用户凭证转换成请求元数据。在我们的场景中,键是 Authorization,值则由 Basic 和加上 <用户名>:<密码>base64 算法计算结果所组成。
❸ 声明在传递凭证时是否需要启用通道安全性。如前所述,建议启用。

实现完凭证对象后,需要使用合法的凭证对其进行初始化,并在建立连接时将其传递进去,如代码清单 6-6 所示。

代码清单 6-6 使用 basic 认证的安全 gRPC 客户端应用程序

package main

import (
    "log"
    pb "productinfo/server/ecommerce"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc"
)

var (
    address = "localhost:50051"
    hostname = "localhost"
    crtFile = "server.crt"
)

func main() {
    creds, err := credentials.NewClientTLSFromFile(crtFile, hostname)
    if err != nil {
        log.Fatalf("failed to load credentials: %v", err)
    }

    auth := basicAuth{ ➊
        username: "admin",
        password: "admin",
    }

    opts := []grpc.DialOption{
        grpc.WithPerRPCCredentials(auth), ➋
        grpc.WithTransportCredentials(creds),
    }

    conn, err := grpc.Dial(address, opts...)
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }

    defer conn.Close()
    c := pb.NewProductInfoClient(conn)
    ... // 省略了RPC方法调用
}
  • ❶ 使用有效的用户凭证(用户名和密码)初始化 auth 变量。auth 变量存放了我们要使用的值。
  • ❷ 传递 auth 变量给 grpc.WithPerRPCCredentials 函数。该函数接受一个接口作为参数。因为我们定义的认证结构符合该接口,所以可以传递变量。

现在,客户端在调用服务器端的时候加入了额外的元数据,但服务器端还没有注意到这一点。因此,我们需要告诉服务器端检查元数据。接下来更新服务器端,使其读取元数据,如代码清单 6-7 所示。

代码清单 6-7 支持 basic 认证校验的安全 gRPC 服务器端

package main

import (
    "context"
    "crypto/tls"
    "encoding/base64"
    "errors"
    pb "productinfo/server/ecommerce"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
    "log"
    "net"
    "path/filepath"
    "strings"
)

var (
    port = ":50051"
    crtFile = "server.crt"
    keyFile = "server.key"
    errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
    errInvalidToken = status.Errorf(codes.Unauthenticated, "invalid credentials")
)

type server struct {
    productMap map[string]*pb.Product
}

func main() {
    cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
    if err != nil {
        log.Fatalf("failed to load key pair: %s", err)
    }

    opts := []grpc.ServerOption{
        // 为所有传入的连接启用TLS
        grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
        grpc.UnaryInterceptor(ensureValidBasicCredentials), ➊
    }

    s := grpc.NewServer(opts...)
    pb.RegisterProductInfoServer(s, &server{})
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

func valid(authorization []string) bool {
    if len(authorization) < 1 {
        return false
    }

    token := strings.TrimPrefix(authorization[0], "Basic ")
        return token == base64.StdEncoding.EncodeToString([]byte("admin:admin"))
}

func ensureValidBasicCredentials(ctx context.Context, req interface{}, info*grpc.UnaryServerInfo,handler grpc.UnaryHandler) (interface{}, error) { ➋
    md, ok := metadata.FromIncomingContext(ctx) ➌
    if !ok {
        return nil, errMissingMetadata
    }

    if !valid(md["authorization"]) {
        return nil, errInvalidToken
    }
    // 在确保令牌合法之后,继续执行handler
    return handler(ctx, req)
}
  • ❶ 通过 TLS 服务器证书添加新的服务器选项(grpc.ServerOption)。grpc.UnaryInterceptor 是一个函数,我们在其中添加拦截器来拦截所有来自客户端的请求。我们向该函数传递一个引用(ensureValidBasicCredentials),拦截器会将所有的客户端请求传递给该函数。
    ❷ 定义名为 ensureValidBasicCredentials 的函数来校验调用者的身份。在这里,context.Context 对象包含所需的元数据,在请求的生命周期内,该元数据会一直存在。
    ❸ 从上下文中抽取元数据,获取 authentication 的值并校验凭证。

由于 metadata.MD 中的键会被标准化为小写字母,因此需要检查键的值。

现在,服务器端已经能够校验每个调用中的客户端身份了。这是一个非常简单的示例。在服务器端拦截器中,可以包含非常复杂的认证逻辑以校验客户端身份。

我们基本了解了如何为每个请求进行客户端认证,接下来讨论常用且推荐使用的 OAuth 2.0,它是基于令牌的认证机制。

6.2.2 使用OAuth 2.0

OAuth 2.0 是一个用于访问委托的框架。它允许用户以自己的名义授予服务有限的访问权限,而不会像用户名和密码方式那样给予服务全部访问权限。在这里,我们不会详细讨论什么是 OAuth 2.0。如果你掌握OAuth 2.0 的基础知识,那么更容易理解如何在应用程序中启用该功能。

在 OAuth 2.0 的流程中,有 4 个主要的角色:客户端、授权服务器、资源服务器和资源所有者。客户端要访问资源服务器上的资源。为了访问资源,客户端需要获取一个来自授权服务器的令牌(这是任意的一个字符串)。这个令牌必须具备恰当的长度,并且应该是不可预知的。客户端接收到该令牌之后,就可以使用它向资源服务器发送请求了。随后,资源服务器会与对应的授权服务器通信,并校验该令牌。如果该资源所有者校验了它,那么客户端就可以访问该资源。

gRPC 提供了在应用程序中启用 OAuth 2.0 的内置支持。我们先讨论如何将令牌注入调用中。因为在示例中,并没有授权服务器,所以我们硬编码任意的一个字符串来作为令牌的值。代码清单 6-8 展示了如何将OAuth 令牌添加到客户端请求中。

代码清单 6-8 用 Go 语言编写的使用 OAuth 令牌的安全 gRPC 客户端应用程序

package main

import (
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/credentials/oauth"
    "log"
    pb "productinfo/server/ecommerce"
    "golang.org/x/oauth2"
    "google.golang.org/grpc"
)

var (
    address = "localhost:50051"
    hostname = "localhost"
    crtFile = "server.crt"
)

func main() {
    auth := oauth.NewOauthAccess(fetchToken()) ➊
    creds, err := credentials.NewClientTLSFromFile(crtFile, hostname)
    if err != nil {
        log.Fatalf("failed to load credentials: %v", err)
    }

    opts := []grpc.DialOption{
        grpc.WithPerRPCCredentials(auth), ➋
        grpc.WithTransportCredentials(creds),
    }

    conn, err := grpc.Dial(address, opts...)
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }

    defer conn.Close()
    c := pb.NewProductInfoClient(conn)
    ... // 省略了RPC方法调用
}

func fetchToken() *oauth2.Token {
    return &oauth2.Token{
        AccessToken: "some-secret-token",
    }
}
  • ❶ 设置连接的凭证,需要提供 OAuth 令牌值来创建凭证。这里使用一个硬编码的字符串值作为令牌的值。
  • ❷ 配置 gRPC DialOption,为同一个连接的所有 RPC 使用同一个令牌。如果想为每个调用使用专门的 OAuth 令牌,那么需要使用CallOption 配置 gRPC 调用。

需要注意,我们还启用了通道安全性,这是因为 OAuth 需要底层传输安全。在 gRPC 内部,所提供的令牌会以令牌类型作为前缀,并以authorization 作为键附加到元数据上。

在服务器端,我们添加类似的拦截器,来检查和校验请求所带来的客户端令牌,如代码清单 6-9 所示。

代码清单 6-9 使用 OAuth 用户令牌校验的安全 gRPC 服务器端

package main

import (
    "context"
    "crypto/tls"
    "errors"
    "log"
    "net"
    "strings"
    pb "productinfo/server/ecommerce"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
)

// 用来实现ecommerce/product_info的服务器
type server struct {
    productMap map[string]*pb.Product
}

var (
    port = ":50051"
    crtFile = "server.crt"
    keyFile = "server.key"
    errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
    errInvalidToken = status.Errorf(codes.Unauthenticated, "invalid token")
)

func main() {
    cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
    if err != nil {
        log.Fatalf("failed to load key pair: %s", err)
    }

    opts := []grpc.ServerOption{
        grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
        grpc.UnaryInterceptor(ensureValidToken), ➊
    }

    s := grpc.NewServer(opts...)
    pb.RegisterProductInfoServer(s, &server{})
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

func valid(authorization []string) bool {
    if len(authorization) < 1 {
        return false
    }

    token := strings.TrimPrefix(authorization[0], "Bearer ")
    return token == "some-secret-token"
}

func ensureValidToken(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,handler grpc.UnaryHandler) (interface{}, error) { ➋
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, errMissingMetadata
    }
    if !valid(md["authorization"]) {
        return nil, errInvalidToken
    }
    return handler(ctx, req)
}
  • ❶ 添加新的服务器选项(grpc.ServerOption)以及 TLS 服务器证书。借助 grpc.UnaryInterceptor 函数,添加拦截器以拦截所有来自客户端的请求。
  • ❷ 定义名为 ensureValidToken 的函数来校验令牌。如果令牌丢失或不合法,则拦截器会阻止执行并提示错误;否则,拦截器调用传递上下文和接口的下一个 handler。

可以使用拦截器为所有 RPC 配置令牌校验。根据服务的类型,服务器端可能会配置 grpc.UnaryInterceptor 或grpc.StreamInterceptor。

与 OAuth 2.0 认证类似,gRPC 还支持基于 JWT 的认证。下面讨论启用JWT 认证所需要做的变更。

6.2.3 使用JWT

JWT 定义了一个在客户端和服务器端传输身份信息的容器。签名的JWT 可用作自包含的访问令牌,这意味着资源服务器无须与授权服务器通信来验证客户端的令牌,它可以通过验证签名来校验令牌。客户端请求访问授权服务器,授权服务器校验客户端的凭证,创建 JWT 并将其发送给客户端。带有 JWT 的客户端应用程序就允许访问资源了。

gRPC 内置了对 JWT 的支持。如果具有来自授权服务器的 JWT 文件,则需要传递该文件并创建 JWT 凭证。代码清单 6-10 说明了如何从 JWT令牌文件(token.json)创建 JWT 凭证,并在 Go 客户端应用程序中将它们作为 DialOption 进行传递。

代码清单 6-10 在 Go 客户端应用程序中使用 JWT 建立连接

jwtCreds, err := oauth.NewJWTAccessFromFile("token.json") ➊
if err != nil {
    log.Fatalf("Failed to create JWT credentials: %v", err)
}

creds, err := credentials.NewClientTLSFromFile("server.crt","localhost")
if err != nil {
    log.Fatalf("failed to load credentials: %v", err)
}

opts := []grpc.DialOption{
    grpc.WithPerRPCCredentials(jwtCreds),
    // 传输凭证
    grpc.WithTransportCredentials(creds), ➋
}

// 建立到服务器的连接
conn, err := grpc.Dial(address, opts...)
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
... // 省略了存根生成和RPC方法调用
  • ❶ 调用 oauth.NewJWTAccessFromFile 初始化credentials.PerRPCCredentials,需要提供一个有效的令牌文件来创建凭证。
  • ❷ 使用 DialOption WithPerRPCCredentials 配置 gRPC dial,为相同连接的所有 RPC 使用同一个 JWT 令牌。

除了这些认证技术之外,还可以在客户端扩展 RPC 凭证,并在服务器端添加新的拦截器,从而添加任意的认证机制。gRPC 还为部署在Google Cloud 上的 gRPC 服务提供了特殊的内置支持。下面讨论如何调用这些服务。

6.2.4 使用基于令牌的谷歌认证

识别用户,并决定是否允许他们访问部署在谷歌云平台上的服务,该平台是由可扩展服务代理(extensible service proxy,ESP)控制的。ESP支持多种认证方法,包括 FirebaseAuth0Google ID Token。不管使用哪种方法,客户端都需要在它们的请求中包含一个有效的 JWT。为了生成认证 JWT,我们必须为每个部署的服务创建一个服务账号。

获取到服务的 JWT 令牌之后,就可以通过和请求一起发送令牌来调用服务方法了。我们可以在创建通道时将凭证传递进来,如代码清单 6-11所示。

代码清单 6-11 在 Go 客户端应用程序中使用谷歌端点建立连接

perRPC, err := oauth.NewServiceAccountFromFile("service-account.json", scope) ➊
if err != nil {
    log.Fatalf("Failed to create JWT credentials: %v", err)
}

pool, _ := x509.SystemCertPool()

creds := credentials.NewClientTLSFromCert(pool, "")
opts := []grpc.DialOption{
    grpc.WithPerRPCCredentials(perRPC),
    grpc.WithTransportCredentials(creds), ➋
}

conn, err := grpc.Dial(address, opts...)
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
... // 省略了存根生成和RPC方法调用
  • ❶ 调用 oauth.NewServiceAccountFromFile 来初始化credentials.PerRPCCredentials。需要提供一个有效的令牌文件来创建凭证。
  • ❷ 与之前讨论的认证机制类似,我们使用 DialOptionWithPerRPCCredentials 配置 gRPC dial,从而将认证令牌作为元数据应用于相同连接的所有 RPC。
文档更新时间: 2023-09-02 06:46   作者:Minho