Google Cloud Endpoints for gRPCの認証まわり

Contents
  1. はじめに
  2. Cloud Endpoints
  3. Extensible Service Proxy
  4. 認証
  5. APIキー
  6. 認証スキーム
  7. やってみる
  8. サービスの定義・実装
  9. Cloud Endpointsのデプロイ
  10. APIのデプロイ
  11. APIキーで認証
  12. サービスアカウントで認証
  13. 認証の組合せ
  14. おわりに

はじめに

Cloud Endpoints for gRPC の認証まわりでちょっと引っかかったので整理しました。 GCP のドキュメントは大元の英語版であっても間違ってる箇所が多いので注意が必要です。

言語は Golang です。

Cloud Endpoints

Cloud Endpoints は API を管理するためのサービスです。いまいちわかりにくいですが、こんなことをやってくれます。

  • gRPC API を REST API に変換して提供
  • API のモニタリング
  • Auth0 や Firebase での認証

Cloud Endpoints は大きく 3 つの形式で利用できます。

Extensible Service Proxy

for gRPC ではリクエストは Extensible Service Proxy(以下 ESP)を経由することになります。ESP は Nginx ベースのリバースプロキシで Cloud Endpoints の機能を提供します。

この ESP をサイドカーコンテナとしてアプリケーションと一緒にデプロイすることで Cloud Endpoints の機能を利用できます。

認証

Cloud Endpoints の認証には大きくわけて 2 種類あります。

  • API キー
    • 呼び出し元を識別する
  • 認証スキーム (Firebase、Auth0 など)
    • エンドユーザを識別する

APIキー

API キーで API アクセスを制限する(gRPC)  |  Cloud Endpoints  |  Google Cloud Platform

API キーの認証を有効化するには サービス設定 の usage.rules で allow_unregistered_calls を false にします。

usage:
  rules:
    - selector: "*"
      allow_unregistered_calls: false

クライアント側は metadata の x-api-key に API キーをセットしてリクエストします。また、例えば API キーの制限としてリファラを設定している場合は、metadata の referer にリファラをセットする必要があります。

認証スキーム

ユーザーの認証  |  gRPC を使用する Cloud Endpoints  |  Google Cloud Platform

認証スキームではより細い認証を行うことができます。Firebase やサービスアカウント、Auth0 などで認証できます。有効化するにはサービス設定の authentication を設定します。

クライアント側では metadata の authorization に Baarer + token をセットします。

やってみる

実際に実装して認証結果をみていきます。細かい部分はかなり適当なのでそのままプロダクションでは使わないでください。

gRPC のメソッドは Echo1〜4 までの 4 つを用意して、次のように認証を設定します。

メソッド APIキー認証 認証スキーム認証
Echo1 なし なし
Echo2 なし あり
Echo3 あり なし
Echo4 あり あり

認証スキームにはサービスアカウントを利用します。

ここでは GCP のプロジェクトとして cloud-endpoints-grpc-auth を使っています。適宜書き換えてください。

完成コードは こちら にあります。

サービスの定義・実装

まず、gRPC のサービスを定義して Go のコードとデスクリプタファイルを生成します。

echo.proto
syntax = "proto3";

package echo;
option go_package = "main";

service EchoService {
  rpc Echo1 (Request) returns (Response) {}
  rpc Echo2 (Request) returns (Response) {}
  rpc Echo3 (Request) returns (Response) {}
  rpc Echo4 (Request) returns (Response) {}
}

message Request {
  string message = 1;
}

message Response {
  string message = 1;
}
protoc \
  -I=. \
  --include_source_info \
  --descriptor_set_out=echo.pb \
  --go_out=plugins=grpc:. \
  echo.proto

次に、サーバとクライアントを実装します。まだ認証部分は実装しません。それぞれのディレクトリを作成します。ここではめんどくさいので pb ファイルはコピーします。

mkdir server client
cp echo.pb.go server
cp echo.pb.go client

サーバの実装です。

server/main.go
package main

import (
	"fmt"
	"net"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/reflection"
)

const (
	port = ":50051"
)

func echo(ctx context.Context, in *Request) (*Response, error) {
	md, _ := metadata.FromIncomingContext(ctx)
	fmt.Println(", Metadata:", md)
	return &Response{Message: in.Message}, nil
}

type server struct{}

func (server) Echo1(ctx context.Context, in *Request) (*Response, error) {
	fmt.Print("Echo1 Received: ", in.Message)
	return echo(ctx, in)
}

func (server) Echo2(ctx context.Context, in *Request) (*Response, error) {
	fmt.Print("Echo2 Received: ", in.Message)
	return echo(ctx, in)
}

func (server) Echo3(ctx context.Context, in *Request) (*Response, error) {
	fmt.Print("Echo3 Received: ", in.Message)
	return echo(ctx, in)
}

func (server) Echo4(ctx context.Context, in *Request) (*Response, error) {
	fmt.Print("Echo4 Received: ", in.Message)
	return echo(ctx, in)
}

func main() {
	s := grpc.NewServer()
	RegisterEchoServiceServer(s, server{})
	reflection.Register(s)

	lis, err := net.Listen("tcp", port)
	if err != nil {
		panic(err)
	}

	if err := s.Serve(lis); err != nil {
		panic(err)
	}
}

metadata の x-endpoint-api-userinfo には認証したユーザの情報が入ります。

起動してみましょう。

cd server
go get .
go run *.go

クライアントの実装です。

client/main.go
package main

import (
	"flag"
	"fmt"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
)

func main() {
	var addr, msg string
	flag.StringVar(&addr, "addr", "127.0.0.1:50051", "server address")
	flag.StringVar(&msg, "msg", "Hello", "message")
	flag.Parse()

	conn, err := grpc.Dial(addr, grpc.WithInsecure())
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	c := NewEchoServiceClient(conn)

	ctx := context.Background()
	req := &Request{Message: msg}

	res, err := c.Echo1(ctx, req)
	if err == nil {
		fmt.Println("Echo1: succeeded:", res)
	} else {
		fmt.Println("Echo1: failed:", err)
	}

	res, err = c.Echo2(ctx, req)
	if err == nil {
		fmt.Println("Echo2: succeeded:", res)
	} else {
		fmt.Println("Echo2: failed:", err)
	}

	res, err = c.Echo3(ctx, req)
	if err == nil {
		fmt.Println("Echo3: succeeded:", res)
	} else {
		fmt.Println("Echo3: failed:", err)
	}

	res, err = c.Echo4(ctx, req)
	if err == nil {
		fmt.Println("Echo4: succeeded:", res)
	} else {
		fmt.Println("Echo4: failed:", err)
	}
}

サーバが起動していることを確認して、クライアントを実行してみます。

cd client
go run *.go

次のような出力が得られます。

Echo1: succeeded: message:"Hello"
Echo2: succeeded: message:"Hello"
Echo3: succeeded: message:"Hello"
Echo4: succeeded: message:"Hello"

これで gRPC サービスの定義と実装がおわりました。

Cloud Endpointsのデプロイ

Cloud Endpoints をデプロイする前に、認証に必要なサービスアカウントを作成しておきます。

gcloud iam service-accounts create client1 --display-name=client1
gcloud iam service-accounts add-iam-policy-binding \
  client1@cloud-endpoints-grpc-auth.iam.gserviceaccount.com \
  --member serviceAccount:client1@cloud-endpoints-grpc-auth.iam.gserviceaccount.com \
  --role roles/iam.serviceAccountUser

前述の表を満たすようにサービス設定を YAML で書きます。

api_config.yaml
type: google.api.Service
config_version: 3

name: echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog

title: Echo API
apis:
  - name: echo.EchoService

usage:
  rules:
    - selector: "echo.EchoService.Echo1"
      allow_unregistered_calls: true
    - selector: "echo.EchoService.Echo2"
      allow_unregistered_calls: true
    - selector: "echo.EchoService.Echo3"
      allow_unregistered_calls: false
    - selector: "echo.EchoService.Echo4"
      allow_unregistered_calls: false

authentication:
  providers:
    - id: client1
      issuer: client1@cloud-endpoints-grpc-auth.iam.serviceaccount.com
      jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/client1@cloud-endpoints-grpc-auth.iam.gserviceaccount.com
  rules:
    - selector: "echo.EchoService.Echo2"
      requirements:
        - provider_id: client1
    - selector: "echo.EchoService.Echo4"
      requirements:
        - provider_id: client1

Cloud Endpoints をデプロイします。

gcloud endpoints services deploy api_config.yaml echo.pb

成功すると次のようなメッセージが表示されます。

Service Configuration [2018-02-05r0] uploaded for service [echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog]

To manage your API, go to: https://console.cloud.google.com/endpoints/api/echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog/overview?project=cloud-endpoints-grpc-auth

この 2018-02-05r0 と echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog は後で必要になります。また、最後の URL にアクセスすると API のダッシュボードが表示され、モニタリング結果を確認できます。

APIのデプロイ

実装した API(サービス)をデプロイします。今回は GKE にデプロイします。また、Cloud Endpoints を有効にするため、サイドカーコンテナとして ESP を使います。

まずはサーバの Dockerfile を定義します。

server/Dockerfile
FROM golang:1.9

WORKDIR /go/src/app
ADD . .
RUN go get .

EXPOSE 50051

CMD ["go", "run", "main.go", "echo.pb.go"]

Docker イメージをレジストリにプッシュします。コンソールで Container Registry API を有効にしておく必要があります。https://console.cloud.google.com/apis/api/containerregistry.googleapis.com/overview?project=cloud-endpoints-grpc-auth

cd server
docker build -t gcr.io/cloud-endpoints-grpc-auth/echo .
gcloud docker -- push gcr.io/cloud-endpoints-grpc-auth/echo

Kubernetes の Deployment と Service の設定ファイルを作ります。

server/k8s.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: echo
spec:
  replicas: 1
  template:
    metadata:
      labels:
        service: echo
    spec:
      containers:
        - name: esp
          image: gcr.io/endpoints-release/endpoints-runtime:1
          imagePullPolicy: Always
          args: [
            "-P", "9000",
            "-a", "grpc://127.0.0.1:50051",
            "-s", "echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog",
            "-v", "2018-02-05r0",
          ]
          ports:
            - containerPort: 9000
        - name: echo
          image: gcr.io/cloud-endpoints-grpc-auth/echo
          ports:
            - containerPort: 50051
---
apiVersion: v1
kind: Service
metadata:
  name: echo
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 9000
      protocol: TCP
  selector:
    service: echo

ESP の args の -s はサービス名、-v は設定 ID です。Endpoint をデプロイしたときに表示されたものを使います。

GKE クラスタを作成して API をデプロイします。Kubernetes Engine API を有効にしておく必要があります。 https://console.cloud.google.com/apis/api/container.googleapis.com/overview?project=cloud-endpoints-grpc-auth

# GKEクラスタ作成
gcloud container clusters create cluster-1 \
  --cluster-version 1.8.7-gke.0 \
  --num-nodes 1 \
  --zone asia-northeast1-a

# APIデプロイ
gcloud container clusters get-credentials cluster-1 --zone asia-northeast1-a
kubectl create -f k8s.yaml

しばらくすると GCE のロードバランサが作成されて IP が取得できるので、クライアントでリクエストしてみます。

ADDRESS=$(kubectl get service echo --output jsonpath="{.status.loadBalancer.ingress[0].ip}")
cd client
go run *.go -addr=${ADDRESS}:80

次のように出力されます。

Echo1: succeeded: message:"Hello"
Echo2: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials
Echo3: failed: rpc error: code = Unauthenticated desc = Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API.
Echo4: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials

なんの認証もかかっていない Echo1 だけレスポンスを返します。他は ESP で拒否されています。

ログを見ると ESP で 401 を返していることがわかります。

$ kubectl logs -lservice=echo -c esp
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo1 HTTP/2.0" 200 12 "-" "grpc-go/1.10.0-dev"
2018/02/05 05:50:59 [warn] 9#9: *1 a client request body is buffered to a temporary file /var/cache/nginx/client_temp/0000000001, client: 10.52.0.1, server: , request: "POST /echo.EchoService/Echo1 HTTP/2.0", host: "35.189.159.64:80"
2018/02/05 05:50:59[warn]9#9: Received non-matching report response service config ID: '', requested: '2018-02-05r0'
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo2 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo3 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"
10.52.0.1 - - [05/Feb/2018:05:50:59 +0000] "POST /echo.EchoService/Echo4 HTTP/2.0" 401 0 "-" "grpc-go/1.10.0-dev"

APIキーで認証

クライアントが認証できるようにしていきます。

まず、API キーを取得します。コンソールの API とサービスの認証情報から、API キーを作成します。 https://console.cloud.google.com/apis/credentials?project=cloud-endpoints-grpc-auth

なんらかの制限をかけるように警告されるので、ここでは HTTP リファラーを設定してみます。とりあえず dummy.example.com としておきます。

クライアントのコードを次のように修正します。

client/main.go
// 省略

type credential struct {
	key     string
	referer string
}

func (c credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
	return map[string]string{
		"x-api-key": c.key,
		"referer":   c.referer,
	}, nil
}

func (credential) RequireTransportSecurity() bool {
	return false
}

func main() {
	var addr, msg, key, referer string
	flag.StringVar(&addr, "addr", "127.0.0.1:50051", "server address")
	flag.StringVar(&msg, "msg", "Hello", "message")
	flag.StringVar(&key, "key", "invalid", "API Key")
	flag.StringVar(&referer, "referer", "invalid", "referer")
	flag.Parse()

	cred := credential{
		key:     key,
		referer: referer,
	}

	conn, err := grpc.Dial(addr, grpc.WithInsecure(), grpc.WithPerRPCCredentials(cred))
	if err != nil {
		panic(err)
	}
	defer conn.Close()

        // 省略
}

認証情報をリクエストに付加する方法は何通りかありますが、grpc パッケージの API を見る限りこれがよさそうな気がします。

これを実行すると、次のように API キーでの認証が成功していることがわかります。

$ go run *.go -addr=${ADDRESS}:80 -key=${API_KEY} -referer=dummy.example.com
Echo1: succeeded: message:"Hello"
Echo2: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials
Echo3: succeeded: message:"Hello"
Echo4: failed: rpc error: code = Unauthenticated desc = JWT validation failed: Missing or invalid credentials

認証は ESP が行うのでサーバのコードは変更不要です。

サービスアカウントで認証

次にサービスアカウントで認証します。認証にはトークンが必要になるので、まずサービスアカウントの秘密鍵で JSON Web Token を生成します。

サービスアカウントの秘密鍵は JSON で生成して client/service_account.json に保存しておいてください。

JWT 生成には Python スクリプトを使います。このスクリプトは Google公式のもの を少し修正しています。公式のものだと Issuer と Subject が異なるため認証できません。1

client/gen_jwt.py
import argparse
import json
import time

import google.auth.crypt
import google.auth.jwt

MAX_TOKEN_LIFETIME_SECS = 3600

def generate_jwt(service_account_file, issuer, audiences):
    with open(service_account_file, 'r') as fh:
        service_account_info = json.load(fh)

    signer = google.auth.crypt.RSASigner.from_string(
        service_account_info['private_key'],
        service_account_info['private_key_id'])

    now = int(time.time())

    payload = {
        'iat': now,
        'exp': now + MAX_TOKEN_LIFETIME_SECS,
        'aud': audiences,
        'iss': issuer,
        'sub': issuer,
        'email': 'user@example.com'
    }

    signed_jwt = google.auth.jwt.encode(signer, payload)
    return signed_jwt


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--file',
                        help='The path to your service account json file.')
    parser.add_argument('--issuer', default='', help='issuer')
    parser.add_argument('--audiences', default='', help='audiences')

    args = parser.parse_args()

    signed_jwt = generate_jwt(args.file, args.issuer, args.audiences)
    print(signed_jwt.decode())

実行します。

JWT=$(python gen_jwt.py --file service_account.json --audience echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog --issuer client1@cloud-endpoints-grpc-auth.iam.serviceaccount.com)

audience は Cloud Endpoints のサービス名 (サービス設定の name)、issuer はサービス設定で設定した issuer と一致する必要があります。

クライアントを次のように修正します。

client/main.go
// 適当に省略

type credential struct {
	key     string
	referer string
	jwt     string
}

func (c credential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
	return map[string]string{
		"x-api-key":     c.key,
		"referer":       c.referer,
		"authorization": "Bearer " + c.jwt,
	}, nil
}

func main() {
	var addr, msg, key, referer, jwt string
	flag.StringVar(&jwt, "jwt", "invalid", "JSON Web Token")

	cred := credential{
		key:     key,
		referer: referer,
		jwt:     jwt,
	}
}

これを実行するとすべての認証が成功します。

go run *.go -addr=${ADDRESS}:80 -key=${API_KEY} -referer=dummy.example.com -jwt=${JWT}
Echo1: succeeded: message:"Hello"
Echo2: succeeded: message:"Hello"
Echo3: succeeded: message:"Hello"
Echo4: succeeded: message:"Hello"

サーバのログを見ると、それぞれの認証で Metadata がどうなっているか確認できます。JWT や API キーがそのまま格納されるようです。

kubectl logs -lservice=echo -c echo

認証の組合せ

確認するため雑な表にしてみました。

スクリーンショット 2018-02-05 16.15.16.png (270.9 kB)

どちらも認証がかかっている場合は、どちらかが不正だとエラーになりました。また、両方不正な場合はサービスアカウント認証のエラーが返ってきました。

API キー、JWT ともにフォーマットが不正だとその旨のエラーが返ってきます。

API キーの認証エラーは次のような感じで結構細かくメッセージが返ってきました。

# フォーマット不正
code = InvalidArgument desc = API key not valid. Please pass a valid API key.

# 違うプロジェクトのAPIキー
code = PermissionDenied desc = API echo.endpoints.cloud-endpoints-grpc-auth.cloud.goog is not enabled for the project.

# 削除した
code = InvalidArgument desc = API key expired. Please renew the API key.

おわりに

サンプルがなかったりドキュメントが間違ってたりしますが、認証を自分で持たなくていいのはとても楽ですね。

Footnotes

  1. https://github.com/grpc/grpc/blob/master/src/core/lib/security/credentials/jwt/jwt_verifier.cc#L298-L306 ↩