gRPCで独自の認証機構を利用する(パスワード認証編)
最近gRPCをちょこちょこ触っている。認証について調べた時に、自分で拡張して独自の認証機構を作ることができることを知ったので試してみた。
gRPCにビルトインされている認証機構
まず、Authenticationのページにも書かれているとおり、 SSL/TLS と Token-based authentication with Google の2種類がビルトインされている。それぞれの使い方について、様々な言語でサンプルが書かれていて親切。
独自の認証機構を作りたい場合
まず、Authenticationのページに「Extending gRPC to support other authentication mechanisms」とあるように、自分で拡張することができる。WithPerRPCCredentialsとWithTransportCredentialsの2種類が使えるようだ。今回はWithPerRPCCredentialsを試すよ。
WithPerRPCCredentials
クライアントがサーバーに接続する際、認証情報をメタデータとして送り、サーバーはその情報を認証に用いる。今回は非常に単純なパスワード認証を実装した。grpc-goのissueコメントやbuckhx/safari-zoneを参考にしている。
クライアントサイド
まず、認証情報用のインターフェースとしてPerRPCCredentialsが用意されているので、これに沿った構造体を用意する。
type loginCreds struct {
Username, Password string
}
func (c *loginCreds) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return map[string]string{
"username": c.Username,
"password": c.Password,
}, nil
}
func (c *loginCreds) RequireTransportSecurity() bool {
return false
}
後はgrpc.Dial時に認証情報を渡せばよい。
opts := []grpc.DialOption{
grpc.WithInsecure(),
grpc.WithPerRPCCredentials(&loginCreds{Username: "admin",Password: "admin123",}),
}
conn, err := grpc.Dial("127.0.0.1:11111", opts...)
サーバーサイド
クライアントから受け取ったメタデータを元に認証を行うためのAuthorizerを定義する。
type Authorizer struct {
Username, Password string
}
func NewAuthorizer(username string, password string) *Authorizer {
return &Authorizer{Username: username, Password: password}
}
func (a *Authorizer) HandleUnary(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
ctx, err := a.Context(ctx)
if err != nil {
return nil, err
}
return handler(ctx, req)
}
func (a *Authorizer) HandleStream(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
wrap := grpc_middleware.WrapServerStream(stream)
ctx := wrap.Context()
ctx, err := a.Context(ctx)
if err != nil {
return err
}
wrap.WrappedContext = ctx
return handler(srv, stream)
}
func (a *Authorizer) Verify(username string, password string) error {
if username =="admin" && password =="admin123" {
return nil
}
return fmt.Errorf("AccessDeniedError")
}
func (a *Authorizer) Context(ctx context.Context) (context.Context, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, grpc.Errorf(codes.Unauthenticated, "Authorization required: no context metadata")
}
if err := a.Verify(md["username"][0], md["password"][0]); err != nil {
return nil, grpc.Errorf(codes.Unauthenticated, err.Error())
}
return ctx, nil
}
gRPCサーバーを作成する時、InterceptorとしてAuthorizerのHandleStreamやHandleUnaryを渡す。これにより、RPC呼び出し時に認証処理を割り込ませることができる。
a := auth.NewAuthorizer("admin", "admin123")
server := grpc.NewServer(
grpc.StreamInterceptor(a.HandleStream),
grpc.UnaryInterceptor(a.HandleUnary),
)
サンプル
実際に動くCLIツールにしておいた。gRPCのProtocolBuffer周りはmattnさんの記事のものを利用した。ありがとうございます。サンプルコードはtakaishi/hello2018にある。
サーバー:
$ go run ./main.go --username admin -p admin123 server
クライアント:
$ go run ./main.go -u admin -p admin123 client add foo 1
$ go run ./main.go -u admin -p admin123 client list
name:"foo" age:1
間違った認証情報を指定した場合:
$ go run ./main.go -u admin -p hoge client list
2018/04/10 09:39:16 rpc error: code = Unauthenticated desc = AccessDeniedError
exit status 1