我们将介绍一组安全性基础知识和模式,以解决我们在启用应用程序级安全性时面临的挑战。简单说,我们将探索如何保护微服务之间的通信通道,并验证和控制用户的访问。让我们从保护通信通道开始。
使用TLS认证gRPC通道
传输层安全(TLS)的目的是在两个通信应用程序之间提供隐私和数据完整性。在这里,它是关于在gRPC客户机和服务器应用程序之间提供一个安全的连接。根据传输级别安全协议规范,当客户端和服务器之间的连接是安全的,它应该具有以下一个或多个属性:
- 连接是私有的
对称密码学用于数据加密。它是一种只使用一个密钥(秘密密钥)进行加密和解密的加密类型。这些密钥是根据会话开始时协商的共享密钥。
对称加密,使用一个密钥(秘密密钥)进行加密和解密的加密,密钥是根据会话开始时协商的共享密钥为每个连接唯一生成的。 - 连接可靠
每条消息都包含消息完整性检查,以防止在传输期间未检测到的数据丢失或更改。
因此,通过安全连接发送数据非常重要。使用TLS保护gRPC连接并不是一项困难的任务,因为这种认证机制是内置在gRPC库中的。它还促进使用TLS进行身份验证和加密交换。
启用单向安全连接
在单向连接中,只有客户端验证服务器以确保它从预期的服务器接收数据。当客户端和服务器建立连接时,服务器与客户端共享它的公共证书,然后客户端根据收到的证书的有效期确定日期。这是通过证书颁发机构(CA)完成的。证书验证后,客户端使用秘密密钥发送加密的数据。
CA是一个受信任的实体,负责管理和颁发用于公共网络中安全通信的安全证书和公钥。由此受信任实体签发的证书的签名者称为ca签名证书。
要启用TLS,首先我们需要创建以下证书和密钥:
- server.key
用于签名和验证公钥的RSA私钥。 - server.pem / server.crt
用于分发的自签名X.509公钥。
缩写RSA代表了三位发明者的名字:Rivest, Shamir和Adleman。RSA是目前最流行的公钥密码系统之一,广泛应用于数据的安全传输。在RSA中,公钥(每个人都知道)用于加密数据。然后使用私钥对数据进行解密。其思想是,使用公钥加密的消息只能在合理的时间内使用私钥解密。
要生成密钥,我们可以使用OpenSSL工具,这是一个用于TLS和安全套接字层(SSL)协议的开源工具包。它支持生成具有不同大小和传递短语的私钥、公共证书等。还有其他工具,如mkcert和certstrap,也可以用来轻松地生成密钥和证书。我们在这里不会描述如何生成自签名证书的密钥。
启用gRPC服务器的单向安全连接
这是对客户机和服务器之间的通信进行加密的最简单方法。这里,服务器需要使用公钥/私钥对进行初始化。我们将解释如何使用我们的gRPC Go服务器。
func main() {
//读取和解析公钥/私钥对,并创建证书以启用TLS。
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("加载密钥对失败: %s", err)
}
opts := []grpc.ServerOption{
//通过添加证书作为TLS服务器凭据,为所有传入连接启用TLS。
grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
}
listen, err := net.Listen("tcp", ":9988")
if err != nil {
panic(err)
}
server := grpc.NewServer(opts...)
pd.RegisterOrderServiceServer(server, &OrderServiceImpl{})
log.Println("order service is starting....")
err = server.Serve(listen)
if err != nil {
panic(err)
}
}
现在我们已经修改了服务器以接受来自可以验证服务器证书的客户端的请求。让我们修改客户机代码以与该服务器进行对话。
在gRPC客户端中启用单向安全连接
为了使客户端连接,客户端需要拥有服务器的自认证公钥。
func main() {
//读取并解析公共证书,并创建证书以启用TLS。
creds, err := credentials.NewClientTLSFromFile("server.crt", "localhost")
if err != nil {
log.Fatalf("加载凭据失败: %v", err)
}
//添加传输凭据作为DialOption
opts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
}
conn, err := grpc.Dial(":9988", opts...)
if err != nil {
panic(err)
}
client := pd.NewOrderServiceClient(conn)
.... // Skip
}
这是一个相当简单的过程。我们只需要添加三行代码。首先,我们从服务器公钥文件创建凭据对象,然后将传输凭据传递到gRPC拨号器。这将在每次客户端建立服务器之间的连接时发起TLS握手。在单向TLS中,我们只验证服务器的身份。
启用mTLS安全连接
客户端和服务器之间的mTLS连接的主要目的是控制连接到服务器的客户端。与单向TLS连接不同,服务器被配置为接受来自有限的一组经过验证的客户机的连接。在这里,双方共享各自的公共证书并验证对方。连接的基本流程如下:
- 客户端从服务器发送一个访问受保护信息的请求。
- 服务器将其X.509证书发送给客户端。
- 客户端通过CA验证接收到的证书,以获得CA签名的证书。
- 如果验证成功,客户端将其证书发送给服务器。
- 服务器还通过CA验证客户端证书。
- 一旦成功,服务器将授予访问受保护数据的权限。
要在我们的示例中启用mTLS,我们需要了解如何处理客户端和服务器证书。我们需要创建一个带有自签名证书的CA,我们需要为客户端和服务器创建证书签名请求,并且需要使用我们的CA对它们进行签名。与前面的单向安全连接一样,我们可以使用OpenSSL工具来生成密钥和证书。
假设我们拥有所有必需的证书来启用客户端-服务器通信的mTLS。如果您正确地生成了它们,您将在工作区中创建以下密钥和证书:
- server.key
服务器的RSA私钥。 - server.crt
服务器的公共证书。 - client.key
客户端RSA私钥。 - client.crt
客户端公共证书。 - ca.crt
用于签署所有公共证书的CA的公共证书。
启用gRPC服务器的mTLS功能
func main() {
certificate, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("加载密钥对失败: %s", err)
}
//创建证书池。
certPool := x509.NewCertPool()
ca, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatalf("无法读取ca证书: %s", err)
}
//将CA证书追加到证书池
if ok := certPool.AppendCertsFromPEM(ca); !ok {
log.Fatalf("添加ca证书失败。")
}
opts := []grpc.ServerOption{
// 为所有传入连接启用TLS。
grpc.Creds(
credentials.NewTLS(&tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
Certificates: []tls.Certificate{certificate},
ClientCAs: certPool,
})),
}
listen, err := net.Listen("tcp", ":9988")
if err != nil {
panic(err)
}
server := grpc.NewServer(opts...)
pd.RegisterOrderServiceServer(server, &OrderServiceImpl{})
log.Println("order service is starting....")
err = server.Serve(listen)
if err != nil {
panic(err)
}
}
现在我们已经修改了服务器以接受来自经过验证的客户机的请求。让我们修改我们的客户端代码来与这个服务器对话。
在gRPC客户端中启用mTLS
func main() {
certificate, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("无法加载客户端密钥对: %s", err)
}
certPool := x509.NewCertPool()
ca, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatalf("无法读取ca证书: %s", err)
}
if ok := certPool.AppendCertsFromPEM(ca); !ok {
log.Fatalf("添加ca证书失败")
}
opts := []grpc.DialOption{
//添加传输凭据作为连接选项。这里的ServerName必须等于证书上的Common Name。
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
ServerName: "localhost", // 注意:这是必需的!
Certificates: []tls.Certificate{certificate},
RootCAs: certPool,
})),
}
conn, err := grpc.Dial(":9988", opts...)
if err != nil {
panic(err)
}
client := pd.NewOrderServiceClient(conn)
}
现在,我们已经使用基本的单向TLS和mTLS保护了gRPC应用程序的客户机和服务器之间的通信通道。下一步是在每个调用的基础上启用身份验证,这意味着将凭据附加到调用。每个客户端调用都有身份验证凭据,服务器端检查调用的凭据,并决定是允许客户端调用还是拒绝。
验证gRPC调用
gRPC被设计成使用严格的身份验证机制。在前一节中,我们介绍了如何使用TLS加密客户机和服务器之间交换的数据。现在,我们将讨论如何验证调用者的身份,并使用不同的调用凭据技术(如基于令牌的身份验证等)应用访问控制。
为了方便对呼叫方的验证,gRPC为命令行提供了在每次呼叫中注入他或她的凭证(如用户名和密码)的能力。gRPC服务器能够拦截来自客户机的请求,并为每个传入呼叫检查这些凭据。
首先,我们将回顾一个简单的身份验证场景,以解释每个客户机调用的身份验证是如何工作的。
使用基本身份验证
基本身份验证是最简单的身份验证机制。在这种机制中,客户端发送请求时,授权头的值以单词Basic开头,后面跟着一个空格和一个base64
编码的字符串 username:password
。例如,如果用户名是admin,密码是admin,header值看起来像这样:
Authorization: Basic YWRtaW46YWRtaW4=
一般来说,gRPC不鼓励我们使用用户名/密码对服务进行身份验证。这是因为与其他令牌(JSON Web tokens [jwt]
, OAuth2 access tokens
)相比,用户名/密码在时间上没有控制权。这意味着当我们生成一个令牌时,我们可以指定它的有效时间。但是对于用户名/密码,我们不能指定有效期。在我们更改密码之前,它是有效的。如果需要在应用程序中启用基本身份验证,建议在客户机和服务器之间的安全连接*享基本凭据。我们选择基本身份验证是因为它更容易解释gRPC中身份验证的工作方式。
让我们首先讨论如何将用户凭据(在基本身份验证中)注入调用。由于gRPC中没有对基本身份验证的内置支持,我们需要将它作为自定义凭据添加到客户机上下文中。在Go中,我们可以通过定义凭据结构和实现PerRPCCredentials
接口轻松做到这一点
//实现PerRPCCredentials接口来传递自定义凭据
//定义一个结构来保存要注入到RPC调用中的字段上的集合(在我们的例子中,它是用户凭据,如用户名和密码)。
type basicAuth struct {
username string
password string
}
//实现GetRequestMetadata方法并将用户凭据转换为请求元数据。在我们的例子中,“Authorization”是密钥,值是“Basic”,后面是base64 (<username>:<password>)。
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
}
实现凭据对象之后,需要使用有效凭据初始化它,并在创建连接时传递它
func main() {
creds, err := credentials.NewClientTLSFromFile("server.crt", "localhost")
if err != nil {
log.Fatalf("加载凭据失败: %v", err)
}
auth := basicAuth{
username: "admin",
password: "admin",
}
opts := []grpc.DialOption{
//将auth变量传递给grpc。grpc.WithPerRPCCredentials()函数接受接口作为参数。
//因为我们定义了身份验证结构以符合接口,所以我们可以传递变量。
grpc.WithPerRPCCredentials(auth),
grpc.WithTransportCredentials(creds),
}
conn, err := grpc.Dial(":9988", opts...)
if err != nil {
panic(err)
}
client := pd.NewOrderServiceClient(conn)
.... // Skip
}
现在,客户机在调用服务器期间向服务器推送额外的元数据,但服务器并不关心。所以我们需要告诉服务器检查元数据。让我们更新服务器来读取元数据
//具有基本用户凭证验证的gRPC安全服务器
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, status.Errorf(codes.InvalidArgument, "缺少元数据")
}
if !valid(md["authorization"]) {
return nil, status.Errorf(codes.Unauthenticated, "无效凭据")
}
// 确保有效令牌后继续执行处理程序。
return handler(ctx, req)
}
func main() {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("加载密钥对失败: %s", err)
}
opts := []grpc.ServerOption{
// 为所有传入连接启用TLS。
grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
//拦截器将所有客户端请求传递给该函数。
grpc.UnaryInterceptor(ensureValidBasicCredentials),
}
listen, err := net.Listen("tcp", ":9988")
if err != nil {
panic(err)
}
server := grpc.NewServer(opts...)
pd.RegisterOrderServiceServer(server, &OrderServiceImpl{})
log.Println("订单服务开始....")
err = server.Serve(listen)
if err != nil {
panic(err)
}
}
现在,服务器在每次调用中验证客户机身份。这是一个非常简单的例子。您可以在服务器拦截器中使用复杂的身份验证逻辑来验证客户端身份。既然您已经对客户机身份验证的工作原理有了基本的了解,那么让我们讨论一下常用的和推荐的基于令牌的身份验证(OAuth 2.0)。
使用OAuth 2.0
OAuth 2.0
是一个访问委托框架。它允许用户代表自己获得有限的服务访问权限,而不是像用户名和密码那样给予他们全部访问权限。这里我们不打算详细讨论OAuth 2.0
。如果您有一些关于OAuth 2.0
如何工作的基本知识,那么在您的应用程序中启用它是很有帮助的。
在OAuth 2.0流程中,有四个主要角色:客户端、授权服务器、资源服务器和资源所有者。客户端希望访问资源服务器中的资源。要访问资源,客户端需要从授权服务器获得一个令牌(任意字符串)。这个令牌必须有适当的长度,并且不应该是可预测的。一旦客户端接收到令牌,客户端就可以使用令牌向资源服务器发送请求。然后资源服务器与相应的授权服务器对话并验证令牌。如果该资源所有者的有效日期,客户可以访问该资源。
gRPC支持在gRPC应用程序中启用OAuth 2.0。让我们首先讨论如何将令牌注入到调用中。因为在我们的示例中没有授权服务器,所以我们将为令牌值硬编码一个任意字符串。
//在Go中使用OAuth令牌保护gRPC客户端应用程序
func fetchToken() *oauth2.Token {
return &oauth2.Token{
AccessToken: "some-secret-token",
}
}
func main() {
auth := oauth.NewOauthAccess(fetchToken())
creds, err := credentials.NewClientTLSFromFile("server.crt", "localhost")
if err != nil {
log.Fatalf("加载凭据失败: %v", err)
}
opts := []grpc.DialOption{
grpc.WithPerRPCCredentials(auth),
grpc.WithTransportCredentials(creds),
}
conn, err := grpc.Dial(":9988", opts...)
if err != nil {
panic(err)
}
client := pd.NewOrderServiceClient(conn)
.... // Skip
}
注意,我们还启用了通道安全性,因为OAuth要求底层传输是安全的。在gRPC内部,提供的令牌以令牌类型为前缀,并使用密钥授权将其附加到元数据中。
在服务器中,我们添加了一个类似的拦截器来检查和验证随请求而来的客户机令牌。
//使用OAuth用户令牌验证的gRPC安全服务器
func valid(authorization []string) bool {
if len(authorization) < 1 {
return false
}
token := strings.TrimPrefix(authorization[0], "Bearer ")
return token == "some-secret-token"
}
func ensureValidBasicCredentials(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "缺少元数据")
}
if !valid(md["authorization"]) {
return nil, status.Errorf(codes.Unauthenticated, "无效凭据")
}
// 确保有效令牌后继续执行处理程序。
return handler(ctx, req)
}
func main() {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("加载密钥对失败: %s", err)
}
opts := []grpc.ServerOption{
// 为所有传入连接启用TLS。
grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
grpc.UnaryInterceptor(ensureValidBasicCredentials),
}
listen, err := net.Listen("tcp", ":9988")
if err != nil {
panic(err)
}
server := grpc.NewServer(opts...)
pd.RegisterOrderServiceServer(server, &OrderServiceImpl{})
log.Println("订单服务开始....")
err = server.Serve(listen)
if err != nil {
panic(err)
}
}
可以使用拦截器为所有rpc配置令牌验证。服务器可以配置grpc.UnaryInterceptor
或grpc.StreamInterceptor
取决于服务类型。与OAuth 2.0认证类似,gRPC也支持基于JSON Web Token (JWT)
的认证。在下一节中,我们将讨论需要进行哪些更改才能启用基于jwt的身份验证。
Using JWT
JWT定义了一个容器来在客户机和服务器之间传输标识信息。签名的JWT可以用作自包含的访问令牌,这意味着资源服务器不需要与身份验证服务器对话来验证客户端令牌。它可以通过验证签名来验证令牌。客户机从身份验证服务器请求访问,身份验证服务器验证客户机的凭据,创建JWT,并将其发送给客户机。带有JWT的客户机应用程序允许访问资源。
gRPC内置支持JWT。如果您从身份验证服务器获得了JWT文件,则需要传递该令牌文件并创建JWT凭据。示例中的代码片段演示了如何从JWT令牌文件(token.json)创建JWT凭据,并在Go客户端应用程序中将它们作为DialOptions传递。
//在Go客户机应用程序中使用JWT设置连接
func main() {
jwtCreds, err := oauth.NewJWTAccessFromFile("token.json")
if err != nil {
log.Fatalf("创建JWT凭据失败: %v", err)
}
creds, err := credentials.NewClientTLSFromFile("server.crt", "localhost")
if err != nil {
log.Fatalf("加载凭据失败: %v", err)
}
opts := []grpc.DialOption{
grpc.WithPerRPCCredentials(jwtCreds),
grpc.WithTransportCredentials(creds),
}
conn, err := grpc.Dial(":9988", opts...)
if err != nil {
panic(err)
}
client := pd.NewOrderServiceClient(conn)
.... // Skip
}
服务端的验证也都类似在这里省略。除了这些身份验证技术,我们还可以通过在客户端扩展RPC凭据并在服务器端添加一个新的拦截器来添加任何身份验证机制。
总结
当构建一个生产就绪的gRPC应用程序时,对gRPC应用程序至少有最低的安全要求是至关重要的,以确保客户端和服务器之间的安全通信。gRPC库设计用于不同类型的认证机制,并能够通过添加自定义认证机制来扩展支持。这使得可以很容易安全地使用gRPC与其他系统进行通信。
在gRPC中有两种类型的凭证支持,通道和调用。通道凭据附加到通道(如TLS)等。调用凭据附加到调用,如OAuth 2.0令牌、基本身份验证等。我们甚至可以将这两种凭据类型应用到gRPC应用程序中。例如,我们可以让TLS启用客户机和服务器之间的连接,并将凭据附加到在连接上进行的每个RPC调用。