Auth0で保護されたGo gRPCサーバーをAWS上に構築してみる

2021.01.27

調査する機会があったので、ブログにまとめてみました。

gRPCでは、データ通信にProtocol Buffersとよばれるシリアライゼーション形式を利用します。

Protocl Buffersは、データのシリアライゼーション形式として知られる一方で、シリアライズするためのスキーマ定義用として.protoファイルというインタフェース定義言語(IDL)を持っています。

実際の開発においては、この.protoファイルを先に作成することでスキーマファーストな進め方ができるのが大きな特徴です。

今回は、.protoファイルの作成~認可処理の実装~AWS環境へのデプロイまで一気通貫で紹介したいと思います。

せっかちな人へ

GitHubリポジトリにすべて上げています。

構成図

ディレクトリ構成

$ tree -L 2
.
├── Dockerfile
├── go.mod
├── go.sum
├── proto
│   ├── gen
│   └── helloworld.proto
├── README.md
├── server
│   ├── auth
│   ├── server.go
│   └── server.go.back1
└── terraform
    ├── acm.tf
...
  • proto
    • .protoファイルの保存先
  • server
    • サーバー側のソースコード保存先
  • terraform
    • AWSデプロイ用のTerraformのコード保存先

.protoファイルの作成

まず.protoファイルを作成していきます。

今回は、公式のサンプルを流用します。

  • helloworld.protoファイルの作成

proto/helloworld.proto

proto:helloworld.proto
syntax = "proto3";

option go_package = "gen;gen";
option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}
  • .protoファイルからインターフェースとなるGoのコードを自動作成
protoc -Iproto --go_out=plugins=grpc:proto proto/*.proto

gRPCサーバーを作成

gRPCサーバーのソースコードを書いていきます。

公式のサンプルを参考に進めます。

  • Go version
$ go version
go version go1.15.5 linux/amd64
  • 初期化
go mod init helloworld
  • モジュールの取得
go get -u google.golang.org/grpc
  • コード

server/server.go

package main

import (
 "context"
 "log"
 "net"

 pb "helloworld/proto/gen"

 "google.golang.org/grpc"
)

const (
 port = ":50051"
)

type server struct {
 pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
 log.Printf("Received: %v", in.GetName())
 return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
 lis, err := net.Listen("tcp", port)
 if err != nil {
  log.Fatalf("failed to listen: %v", err)
 }
 s := grpc.NewServer()
 pb.RegisterGreeterServer(s, &server{})
 if err := s.Serve(lis); err != nil {
  log.Fatalf("failed to serve: %v", err)
 }
}

ローカルサーバー上での動作確認

サーバーをローカルで起動し、grpcurlというツールを使って動作確認をしていきます。

  • サーバー起動
go run server/server.go
  • リクエストを送信
$ grpcurl \
  -plaintext \
  -d '{"name": "arai"}' \
  -import-path proto \
  -proto helloworld.proto \
  localhost:50051 helloworld.Greeter/SayHello
{
  "message": "Hello arai"
}

Auth0にAPIを登録する

Auth0による認可機能を追加するため、Auth0ダッシュボードからAPIの登録を行っていきます。

  • Auth0のダッシュボードよりAPIの作成

IdentifierにはAPIのエンドポイントURLを入力することが推奨されていますが、今回は適当に入力します。

  • ClientIDを控えておきます

  • アクセストークンも控えておきます

インターセプターに認可処理を追加する

Goのインターセプターの機能を使って、認可処理を実装していきます。

Auth0では、アクセストークンがjwt形式で提供されるためサーバー側で下記の処理が必要になります。(細かな点は公式ドキュメントを確認してください)

  1. 一般的なJWTの検証
  2. audクレームがダッシュボードで指定したAPIのIdentifierと一致しているか確認
  3. scopeクレームが設定されている場合は、適切なアクセスコントロールの処理

今回のケースでは、1と2をおこなう必要があります。

いい感じのモジュールがないかと調べましたが、結論から言うと本番では自前実装するの必要がありそうです。

今回は簡易的に、Auth0 Communityの方で作成されているauth0-goモジュールを利用しますが、Issueにもある通り今後メンテナンスする予定はなさそうです。

※ 本番利用を検討する際は、auth0-gogo-jwt-middlewareのコードを参考に開発するのが良さそうです。

  • モジュールの取得
go get -u github.com/grpc-ecosystem/go-grpc-middleware
go get github.com/auth0-community/go-auth0@b9b0f95be5688e006bed4a55a7aae9145cfc0370
# ログ用にzapも入れておく
go get -u go.uber.org/zap
  • コード修正

server/server.go

package main

import (
 "context"
 "log"
 "net"

 pb "helloworld/proto/gen"

 "go.uber.org/zap"
 "google.golang.org/grpc"
 "google.golang.org/grpc/codes"
 "gopkg.in/square/go-jose.v2"
 "gopkg.in/square/go-jose.v2/jwt"

 auth0 "github.com/auth0-community/go-auth0"
 grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
 grpc_auth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
 grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap"
)

const (
 port = ":50051"
 // TODO: fill
 auth0_url = "<your_auth0_url>"
 audience  = "<your_audience>"
)

type server struct {
 pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
 log.Printf("Received: %v", in.GetName())
 return &pb.HelloReply{Message: "Hello " + in.GetName() + " your id is " + ctx.Value("userId").(string)}, nil
}

func auth(ctx context.Context) (context.Context, error) {
 token, err := grpc_auth.AuthFromMD(ctx, "Bearer")
 if err != nil {
  return nil, err
 }

 parsedToken, err := jwt.ParseSigned(token)
 if err != nil {
  return nil, grpc.Errorf(codes.Unauthenticated, "Cannot parse token because of", err)
 }

 client := auth0.NewJWKClient(auth0.JWKClientOptions{URI: "https://" + auth0_url + "/.well-known/jwks.json"}, nil)
 configuration := auth0.NewConfiguration(client, []string{audience}, "https://"+auth0_url+"/", jose.RS256)
 validator := auth0.NewValidator(configuration, nil)

 if err := validator.ValidateToken(parsedToken); err != nil {
  return nil, grpc.Errorf(codes.Unauthenticated, "Cannot validate token because of", err)
 }

 claims := make(map[string]interface{})
 validator.Claims(parsedToken, &claims)

 return context.WithValue(ctx, "userId", claims["sub"]), nil
}

func main() {
 lis, err := net.Listen("tcp", port)
 if err != nil {
  log.Fatalf("failed to listen: %v", err)
 }

 zapLogger, err := zap.NewProduction()
 if err != nil {
  panic(err)
 }

 grpc_zap.ReplaceGrpcLogger(zapLogger)

 s := grpc.NewServer(
  grpc.UnaryInterceptor(
   grpc_middleware.ChainUnaryServer(
    grpc_zap.UnaryServerInterceptor(zapLogger),
    grpc_auth.UnaryServerInterceptor(auth),
   ),
  ),
 )
 pb.RegisterGreeterServer(s, &server{})
 if err := s.Serve(lis); err != nil {
  log.Fatalf("failed to serve: %v", err)
 }
}

auth0_urlにはxxx.auth0.comのようなテナントURL、audienceには先ほど控えたAPIのIdentifierを入力してください。

Dockerで起動

今度はDockerコンテナ上で起動し、動作確認していきます。

  • Dockerfileの作成

Dockerfile

FROM golang:1.15.5 as builder

ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64

WORKDIR /build
COPY . .

WORKDIR /build/server
RUN go build

FROM alpine:3.12.1

COPY --from=builder /build/server/server /opt/app/

ENTRYPOINT [ "/opt/app/server" ]
docker build -t go-grpc-server -f Dockerfile .
docker run --rm --name go-grpc -p 50051:50051 go-grpc-server
  • 動作確認
$ grpcurl \
  -plaintext \
  -H "Authorization: Bearer <your-access-token>" \
  -d '{"name": "arai"}' \
  -import-path proto \
  -proto helloworld.proto \
  localhost:50051 helloworld.Greeter/SayHello
{
  "message": "Hello arai your id is xxxxxxxxxx@clients"
}

<your-access-token>は先ほど控えておいたアクセストークンに変えてください

AWSリソースの作成

Terraformを使ってAWSリソースを作成していきます。

本筋とは関係ないため、細かい部分は省きます。気になる方はGitHubリポジトリのREADMEを見てください。

イメージのプッシュ

  • docker-push.shを作成し実行

docker-push.sh

#!/usr/bin/env bash

# Version=latest
ACCOUNT_ID=$(aws sts get-caller-identity --output text --query 'Account')
ECR_PREFIX="${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com"

# echo $Version
echo $ACCOUNT_ID

aws ecr get-login-password | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com

# go grpc server
docker tag go-grpc-server:latest ${ECR_PREFIX}/go-grpc-server-test-ecr-repo/go-grpc-server:latest
docker push ${ECR_PREFIX}/go-grpc-server-test-ecr-repo/go-grpc-server:latest

AWS上での動作確認

  • 動作確認
$ grpcurl \
  -H "Authorization: Bearer ${access_token}" \
  -d '{"name": "arai"}' \
  -import-path proto \
  -proto helloworld.proto \
  go-grpc-server.cm-arai.com:50051 helloworld.Greeter/SayHello
{
  "message": "Hello arai your id is xxxxxxxxxx@clients"
}
  • ログ

まとめ

いかがだったでしょうか。

どなたかの役にたてば幸いです。