はじめに
Cloud Endpoints for gRPCの認証まわりでちょっと引っかかったので整理しました。 GCPのドキュメントは大元の英語版であっても間違ってる箇所が多いので注意が必要です。
言語はGolangです。
Cloud Endpoints
Cloud EndpointsはAPIを管理するためのサービスです。いまいちわかりにくいですが、こんなことをやってくれます。
- gRPC APIをREST APIに変換して提供
- APIのモニタリング
- Auth0やFirebaseでの認証
Cloud Endpointsは大きく3つの形式で利用できます。
- OpenAPI (JSON/REST API)
- 一般的なREST APIをCloud Endpointsで管理する感じ
- 旧Swagger
- Endpoints Framework
- アプリケーションに組み込んで使うタイプ
- App Engine Standard、Java or Python
- gRPC
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のコードとデスクリプタファイルを生成します。
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
サーバの実装です。
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
クライアントの実装です。
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 \
[email protected] \
--member serviceAccount:[email protected] \
--role roles/iam.serviceAccountUser
前述の表を満たすようにサービス設定を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: [email protected]
jwks_uri: https://www.googleapis.com/robot/v1/metadata/x509/[email protected]
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を定義します。
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の設定ファイルを作ります。
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
としておきます。
クライアントのコードを次のように修正します。
// 省略
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
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': '[email protected]'
}
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 [email protected])
audience
はCloud Endpointsのサービス名 (サービス設定のname
)、issuer
はサービス設定で設定したissuer
と一致する必要があります。
クライアントを次のように修正します。
// 適当に省略
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
認証の組合せ
確認するため雑な表にしてみました。
どちらも認証がかかっている場合は、どちらかが不正だとエラーになりました。また、両方不正な場合はサービスアカウント認証のエラーが返ってきました。
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.
おわりに
サンプルがなかったりドキュメントが間違ってたりしますが、認証を自分で持たなくていいのはとても楽ですね。