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支持多种认证方法,包括 Firebase
、Auth0
和 Google 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。