在安全通信中,我们在我们的simplemath服务中加入了SSL/TLS,使得客户端可以验证服务器。在这篇文章中,我们在simplemath服务中加入验证客户端的功能。
在gRPC中,有一个有意思的功能,就是允许服务器对来自客户端的每一个请求进行拦截,客户端可以把一些信息加入到请求中,服务器就可以在拦截中获取这些信息来对客户端进行验证。
为了达到这个效果,我们需要更新我们的客户端代码,将验证信息(比如用户名和密码)加入每一次请求中的metadata中,然后服务器就可以在每一次请求中进行验证。
1. 修改客户端代码
在客户端,我们需要在我们的grpc.Dial()
函数中指定一个DialOption
。具体的代码如下(simplemath/client/rpc/simplemath.go
):
// AuthItem holds the username/password
type AuthItem struct {
Username string
Password string
}
// GetRequestMetadata gets the current request metadata
func (a *AuthItem) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return map[string]string{
"username": a.Username,
"password": a.Password,
}, nil
}
// RequireTransportSecurity indicates whether the credentials requires transport security
func (a *AuthItem) RequireTransportSecurity() bool {
return true
}
func getGRPCConn() (conn *grpc.ClientConn, err error) {
// Setup the username/password
auth := AuthItem{
Username: "valineliu",
Password: "root",
}
creds, err := credentials.NewClientTLSFromFile("../cert/server.crt", "")
return grpc.Dial(address, grpc.WithTransportCredentials(creds), grpc.WithPerRPCCredentials(&auth))
}
上面就是主要改变的地方,其它的地方没有变化。
2. Break it down
首先,我们定义了一个结构体AuthItem
用来存储服务器需要客户端携带的验证信息,即用户名(Username
)和密码(Password
)。在客户端的每一次调用中,都会携带这个结构体定义的信息。不过,这里仅仅是用户名和密码,在工程中,我们可以定义任何需要的信息;
在我们的getGRPCConn
函数中,我们使用auth
变量来存储我们的信息;
接下来,我们使用grpc.WithPerRPCCredentials
函数来创建一个DialOption
对象,并使用这个对象来作为grpc.Dial
的参数(grpc.Dial
函数是一个可变数量参数函数,可以放入多个参数,前面的grpc.WithTransportCredentials(creds)
就是一个DialOption
);
函数grpc.WithPerRPCCredentials
的参数是一个接口credentials.PerRPCCredentials
,因此我们的AuthItem
需要实现相应的函数,包括GetRequestMetadata
和RequireTransportSecurity
;
在我们的GetRequestMetadata
函数中,我们仅仅返回一个AuthItem
结构体的map;
最后,在我们的RequireTransportSecurity
函数中,我们总是返回true
,表明我们的grpc
客户端是否需要将metadata数据注入到传输层。我们也可以通过读取配置文件进行设置这个返回值。
这样,我们客户端的部分就修改完毕了。
3. 修改服务器端代码
在上面的代码中,我们的客户端可以将一些验证信息加入到每一次请求中,并发送给服务器。所以我们的服务器也要做一些相应的修改。代码如下(simplemath/server/main.go
):
package main
import (
"fmt"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection"
"log"
"net"
pb "simplemath/api"
"simplemath/server/rpcimpl"
"strings"
)
const (
port = ":50051"
)
// authenticateClient check the client credentials
func authenticateClient(ctx context.Context) (string, error) {
if md, ok := metadata.FromIncomingContext(ctx); ok {
clientUsername := strings.Join(md["username"], "")
clientPassword := strings.Join(md["password"], "")
if clientUsername != "valineliu" {
return "", fmt.Errorf("unknown user %s", clientUsername)
}
if clientPassword != "root" {
return "", fmt.Errorf("wrong password %s", clientPassword)
}
log.Printf("authenticated client: %s", clientUsername)
return "9527", nil
}
return "", fmt.Errorf("missing credentials")
}
// unaryInterceptor calls authenticateClient with current context
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
clientID, err := authenticateClient(ctx)
if err != nil {
return nil, err
}
ctx = context.WithValue(ctx, "clientID", clientID)
return handler(ctx, req)
}
func main() {
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
creds, err := credentials.NewServerTLSFromFile("../cert/server.crt", "../cert/server.key")
if err != nil {
log.Fatalf("could not load TLS keys: %s", err)
}
// Create an array of gRPC options with the credentials
opts := []grpc.ServerOption{grpc.Creds(creds), grpc.UnaryInterceptor(unaryInterceptor)}
s := grpc.NewServer(opts...)
pb.RegisterSimpleMathServer(s, &rpcimpl.SimpleMathServer{})
reflection.Register(s)
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
以上就是全部的修改,其它的地方没有变化。
4. Break it down again
下面我们看看我们究竟做了什么变化。
首先,和在客户端中加入DialOption
对应,我们在服务器端加入一个新的grpc.ServerOption
到之前的数组里(opts
):grpc.UnaryInterceptor
。为了构造这个参数,我们将之前定义的函数unaryInterceptor
传进去,这样每一次调用服务器都可以调用这个unaryInterceptor
函数(叙述不准确,只有调用我们的GreatCommonDivisor
函数才有效果,原因后面再说);
在我们定义的unaryInterceptor
函数中,我们通过authenticateClient
函数进行验证,并得到一个clientID
,随后将这个ID插入到context
中;
在我们定义的authenticateClient
函数中,我们从metadata中获取验证所需要的数据,然后做一个简单的验证。验证成功了,就返回一个ID。
这就是服务器中主要的修改,关于metadata和interceptor的具体含义,会在以后的文章中进行具体的介绍,这里仅仅了解大概的含义即可。
5. Let them talk
最后,我们编译客户端和服务器的代码,启动服务器后,执行客户端命令:
$ ./client gcd 12 15
结果如下:
2018/10/08 09:47:38 The Greatest Common Divisor of 12 and 15 is 3
说明验证通过了。而在我们的服务器端有如下输出:
2018/10/08 09:47:38 authenticated client: valineliu
之后,我们修改我们的客户端中验证的信息:
auth := AuthItem{
Username: "valineliu",
Password: "badroot",
}
编译后重新运行,结果如下:
2018/10/08 09:50:07 could not compute: rpc error: code = Unknown desc = wrong password badroot
在描述的信息中,指出了密码错误。而在服务器端,没有输出,说明验证不通过。
在上面的描述中,我们通过一个简单的例子做了演示,而在实际场景中,这样的验证逻辑可以更加复杂与智能。
To Be Continued~
网友评论