はじめに
みなさんはgRPCサーバの認証処理をどのように実装されているでしょうか?
例えば、Go言語で実装する場合、grpc-ecosystemのinterceptorを利用する方法が人気があるようです。
https://github.com/grpc-ecosystem/go-grpc-middleware
interceptorで認証処理を実装するのは便利なのですが、特定のAPI(例えばヘルスチェック用API)のみを認証スキップしたい場合、どのように実装すればよいかぱっとわからなかったので、ここでまとめておきます。
本記事では、Unary Interceptorのみを対象とします。
gRPCの基本部分で自信が無い方は、よろしければ、いまさらだけどgRPCに入門したので分かりやすくまとめてみたをご覧ください。
interceptorとは
前提となるinterceptorの説明をします。
ご存知の方は飛ばしてください。
interceptorとは、認証処理、ログ、メッセージ仕様、バリデーション、リトライ、監視などのシステム開発における共通的な処理(ミドルウェア)です。
grpc-ecosystemに組み込まれているので信頼性は高そうです。
UnaryとServer Streamingに対応しています。
go-grpc-middleware
配下にて各ミドルウェアのUnaryServerInterceptor関数が既に実装されています。
例えば、logrusはこのような実装になっています。
func UnaryServerInterceptor(entry *logrus.Entry, opts ...Option) grpc.UnaryServerInterceptor {
o := evaluateServerOpt(opts)
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
startTime := time.Now()
newCtx := newLoggerForCall(ctx, entry, info.FullMethod, startTime)
resp, err := handler(newCtx, req)
if !o.shouldLog(info.FullMethod, err) {
return resp, err
}
code := o.codeFunc(err)
level := o.levelFunc(code)
durField, durVal := o.durationFunc(time.Since(startTime))
fields := logrus.Fields{
"grpc.code": code.String(),
durField: durVal,
}
if err != nil {
fields[logrus.ErrorKey] = err
}
levelLogf(
ctx_logrus.Extract(newCtx).WithFields(fields), // re-extract logger from newCtx, as it may have extra fields that changed in the holder.
level,
"finished unary call with code "+code.String())
return resp, err
}
}
下記は使い方の例です。
NewServer関数にUnaryInterceptor関数を渡します。
UnaryInterceptor関数にはChainUnaryServer関数を渡します。
ChainUnaryServer関数には対象ミドルウェアのUnaryServerInterceptor関数を一つ以上指定します。ここでは、色々詰めています。
import "github.com/grpc-ecosystem/go-grpc-middleware"
myServer := grpc.NewServer(
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
grpc_ctxtags.UnaryServerInterceptor(),
grpc_opentracing.UnaryServerInterceptor(),
grpc_prometheus.UnaryServerInterceptor,
grpc_zap.UnaryServerInterceptor(zapLogger),
grpc_auth.UnaryServerInterceptor(myAuthFunction),
grpc_recovery.UnaryServerInterceptor(),
)),
)
また、ChainUnaryServer関数でなくWithUnaryChainServer関数を使えば、もう少しだけシンプルに実装できます。
interceptorは、必要なUnaryServerInterceptor関数を呼び出すだけで簡単にミドルウェアの実装ができるので楽です。
(ただし、各種ミドルウェアの事前設定は必要です。例えばログであればログフォーマットやログレベルなど。)
ヘルスチェックメソッドを認証スキップする
ここからが本題です。
ヘルスチェックメソッドの実装
まずは、対象となるヘルスチェックメソッドを用意します。
ヘルスチェックのIFは下記のhealth.proto
の通りです。
これは、gRPC公式で用意されているprotoファイルとなります。
https://github.com/grpc/grpc/blob/master/doc/health-checking.md
pb.goをprotocコマンドで自動生成しておきます。
syntax = "proto3";
package grpc.health.v1;
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
ヘルスチェックサーバの実装は下記のhealth.go
です。
ここは好きなように実装すれば大丈夫です。
- 構造体SkipAuthHealthServerにはhealth.HealthServerインターフェースを埋め込んでいます。
-
var _ grpc_auth.ServiceAuthFuncOverride = (*SkipAuthHealthServer)(nil)
により、構造体SkipAuthHealthServerはgrpc_auth.ServiceAuthFuncOverrideインターフェースを実装していることを明示的に示しています。 - 構造体SkipAuthHealthServerはgrpc_authパッケージのAuthFuncOverrideをオーバーライドすることで、ServiceAuthFuncOverrideインターフェースを満たしています。
- health.HealthServerインターフェースはCheckメソッドとWatchメソッドのシグネチャを持っており、構造体SkipAuthHealthServerはそれらを実装しているため、health.HealthServerインターフェースを満たしています。
- 今回はWatchメソッドは活用していません。
type SkipAuthHealthServer struct {
health.HealthServer
}
var _ grpc_auth.ServiceAuthFuncOverride = (*SkipAuthHealthServer)(nil)
// AuthFuncOverride SkipAuthHealthServer構造体がServiceAuthFuncOverrideインターフェースを実装する
func (*SkipAuthHealthServer) AuthFuncOverride(ctx context.Context, fullMethodName string) (context.Context, error) {
return ctx, nil
}
func (h *SkipAuthHealthServer) Check(context.Context, *health.HealthCheckRequest) (*health.HealthCheckResponse, error) {
// ここまで処理が来ればヘルスチェックとしては成功となる
return &health.HealthCheckResponse{
Status: health.HealthCheckResponse_SERVING,
}, nil
}
func (h *SkipAuthHealthServer) Watch(*health.HealthCheckRequest, health.Health_WatchServer) error {
return status.Error(codes.Unimplemented, "service watch is not implemented current version.")
}
interceptorを使った認証処理の実装
interceptorを使って認証処理を実装していきます。
認証方法はAPIキー認証とします。
metadata.go
は、HTTP2ヘッダ(metadata)に埋め込まれたauthorizationからトークンを抽出するコードです。
const AuthorizationKey = "authorization"
func ExtractAuthorization(ctx context.Context) (string, error) {
return fromMeta(ctx, AuthorizationKey)
}
func fromMeta(ctx context.Context, key string) (string, error) {
// metadataの取得
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", fmt.Errorf("not found metadata")
}
// トークン抽出
vs := md[key]
if len(vs) == 0 {
return "", fmt.Errorf("not found %s in metadata", key)
}
return vs[0], nil
}
auth.go
は、上記でmetadataから取得したトークンが正しいかを検証してコンテキストを返すコードです。
AuthFunc関数の第二引数にfullMethodNameを持たせています。
この値が認証パスしたいサービス(今回は/grpc.health.v1.Health/Check)の場合はトークンチェックせずにnilを返し、他のサービスの場合はAPIキーが正しいものかを確認します。
fullMethodNameは自動生成されたpb.goで定義されているため、そこで確認できます。
サンプルコードなので色々ベタ書きなのはご容赦ください。
type AuthFunc func(ctx context.Context, fullMethodName string) (context.Context, error)
func APIKeyAuth() AuthFunc {
return func(ctx context.Context, fullMethodName string) (context.Context, error) {
// APIキー認証をパスするサービスを指定する
if fullMethodName == "/grpc.health.v1.Health/Check" {
return nil, nil
}
// metadataからAPIキーを取得する
authorization, err := ExtractAuthorization(ctx)
if err != nil {
return nil, err
}
APIKey := "api_key"
// APIキーの比較
if authorization != APIKey {
return nil, errors.New("authentication failed")
}
return ctx, nil
}
}
interceptor.go
は、認証処理をgrpc.UnaryServerInterceptor型として返すことで、Chainできるようにします。
func UnaryServerInterceptor(authFunc AuthFunc) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
newCtx, err := authFunc(ctx, info.FullMethod)
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}
res, err := handler(newCtx, req)
if err != nil {
return nil, err
}
log.Info("%+v\n", res)
return res, nil
}
}
main.go
では、WithUnaryServerChainで認証処理を設定します。
本来は、ログ設定などもここで設定します。
s := grpc.NewServer(
grpc_middleware.WithUnaryServerChain(interceptor.UnaryServerInterceptor(interceptor.APIKeyAuth())))
health.RegisterHealthServer(s, &h.SkipAuthHealthServer{})
reflection.Register(s)
if err := s.Serve(lis); err != nil {
log.Fatal("failed to serve: ", err)
}
まとめ
- スキップしたい
fullMethodName
を調べて認証処理をスキップするだけ