0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Golangはじめて物語(第8話:API Gateway+LambdaでJWTを実装する)

Last updated at Posted at 2021-05-16

はじめに

Golang での JWT の実装はライブラリが充実しているので、比較的簡単に実装できる。
しかし、ググって見つかるのは Gin や Echo と組み合わせたものが多く、AWS で API Gateway を使った実例というのが少なかったので、組み込んでみる。

本記事は、以下の知識があることを前提とする。

  • JWT に関する概要が分かっている(と言っても、あまり難しいことは知らなくてよくてこの記事くらいの概要が分かっていれば充分)
  • Golang で API Gateway + Lambda の WebAPI を実装したことがある(手前味噌ではあるが、この記事を読んで理解ができていれば充分)

なお、今回は IdP には頼らずに自分で秘密鍵を使ってトークンを払い出して検証するという方式を検証する。

トークン払い出しの実装

まずは、JWT のトークンを払い出す部分を実装する。
ライブラリは form3tech-oss/jwt-go を使用する。

import (
	"context"
	"encoding/json"
	"time"

	jwt "github.com/form3tech-oss/jwt-go"
	"github.com/google/uuid"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(handler)
}

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	uuidObj, _ := uuid.NewUUID()

	tokenString, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{
		Id:        uuidObj.String(),
		ExpiresAt: time.Now().Add(time.Minute * 1).Unix(),
	}).SignedString("[秘密鍵の文字列]")

	jsonBytes, _ := json.Marshal(struct {
		Token string `json:"token"`
	}{
		Token: tokenString,
	})

	return events.APIGatewayProxyResponse{
		StatusCode:      200,
		IsBase64Encoded: false,
		Body:            string(jsonBytes),
	}, nil
}

これだけ。とてもシンプル。
キモになるのは以下の部分で、それ以外は API Gateway のレスポンスを作っているだけである。

	tokenString, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{
		Id:        uuidObj.String(),
		ExpiresAt: time.Now().Add(time.Minute * 1).Unix(),
	}).SignedString("[秘密鍵の文字列]")

見ての通り、jwt.NewWithClaims() は、暗号化方式、Claim、秘密鍵 を渡しているだけである。

秘密鍵の文字列は、当然のことながら平文で書いて Git に登録すべきではない。本記事では AWS で API Gateway を使う前提としているため、KMS で管理しているデータキーを使うのが良いだろう。

ExpiresAt は本記事では検証のために短く設定しているが、ここは要件に合わせて設定をしよう。

Id については何でもよい。これはトークンの JTI にあたる部分になる。
リクエスト単位でランダムな文字列になり、改ざん検知ができれば良いので、UUID を生成して渡すことにした。

これを、API Gateway の Lambda 統合で呼び出すように設定をしよう(設定方法は冒頭の、第一話を参照)。

これでAPIを呼び出すと、以下のように JSON が返却される。

$ curl -i  https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/auth
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjEyNjEzNDIsImp0aSI6IjI3ZTk3NzJiLWI3MWItMTFlYi1hYWQyLTYyM2E0YzVlMGY4MCJ9.Rf2f0_Iwm-uIXXOPYvbEjjkBk9xcc8IYNDWGoobqdE8"}

これを、JWT.IO の Debugger に食わせてみると、以下のように、自分の作ったキーが復号されることが分かる。
※右下の your-256-bit-secret と書かれているところに、上記で設定した秘密鍵の文字列を入力すると、正しい文字列になる。

キャプチャ2.png

トークン検証の実装

さて、続いてはトークンの検証だ。

本記事では、検証を行う必要がある API で、Authorization ヘッダに設定されたトークンを検証する。
※実装を簡易にするために、Bearer の文字列は設定しない。

以下のような関数を作って検証をすることにしよう。

import (
	"errors"
	"log"

	jwt "github.com/form3tech-oss/jwt-go"
)

func checkToken(headers map[string]string) error {
	tokenString, headerIsNotNull := headers["Authorization"]
	if !headerIsNotNull {
		return errors.New("[Header]Authorization is not specified")
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := []byte("[秘密鍵の文字列]")
		return b, nil
	})
	if err != nil {
		log.Println(err)
		return errors.New("jwt.Parse() returned error")
	}

	return nil
}

キモになるのは、

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := []byte("[秘密鍵の文字列]")
		return b, nil
	})

で、これでトークンを構造体に割り付けることができる。
ここも、払い出し同様に、秘密鍵の文字列はシークレット用の処理を施すこと。

トークンから Claim を参照にするには、token.Claims.(jwt.MapClaims) を見ればよい。
あとは、好きに中身を検証しよう。

なお、ExpiresAt(exp) は、jwt.Parse() 内で勝手に検証してくれるため、自分で実装する必要はない。便利!

ドキュメントには VerifyExpiresAt という関数もあるが、おそらくこれは、検証なしの Parse 関数を使った場合等に自分で実装をするためのものと思われる。

ともあれ、これを実装した API に対して、以下のようにデータを投げてみよう。

$ curl -i -X PUT -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjEyNjEzNDIsImp0aSI6IjI3ZTk3NzJiLWI3MWItMTFlYi1hYWQyLTYyM2E0YzVlMGY4MCJ9.Rf2f0_Iwm-uIXXOPYvbEjjkBk9xcc8IYNDWGoobqdE8' https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/resource

払い出した正しいトークンを投げた場合は正常に処理が行われ、そうでない場合は jwt.Parse() がエラーになった旨のログが出るだろう。ためしに、トークンの文字列を変更してみたり、払い出しから1分経過後にトークンを使ってみると分かりやすい。

でも、色々な API でトークンの検証するの面倒臭くない?

そんな物臭なあなたに Lambda Authorizer
というか、AWS で API Gateway を使うのであればこちらを採用する方が、マネージドサービスの恩恵を享受できるだろう。
Authorize 設定をした API について、共通的に検証用の Lambda 関数を呼び出してくれるという便利機能だ。

Terraformでは、Authorize 設定をしたいリソース/メソッドについて、以下の設定を行う。

resource "aws_api_gateway_method" "data_put" {
  rest_api_id   = aws_api_gateway_rest_api.jwt_example.id
  resource_id   = aws_api_gateway_resource.resource.id
  http_method   = "PUT"
  authorization = "CUSTOM"
  authorizer_id = aws_api_gateway_authorizer.jwt.id
}

resource "aws_api_gateway_authorizer" "jwt" {
  name           = "jwt_authorizer"
  type           = "REQUEST"
  rest_api_id    = aws_api_gateway_rest_api.jwt_example.id
  authorizer_uri = aws_lambda_function.authorizer.invoke_arn
}

aws_api_gateway_methodauthorization は、 IAMCOGNITO_USER_POOLS による認証も可能だが、今回の Lambda の方式の場合は CUSTOM を設定する。

また、認証用の Lambda を別途 Terraform で定義して、aws_api_gateway_authorizerauthorizer_uri に ARN を設定する。

上記の設定をした Lambda Authorizer では、API Gateway が Lambda に渡してくるイベントが APIGatewayProxyRequest ではなくて APIGatewayCustomAuthorizerRequestTypeRequest になるので注意が必要だ。また、ハンドラの戻り値についても、APIGatewayCustomAuthorizerResponse を返す必要がある。

APIGatewayCustomAuthorizerResponse の PolicyDocument では、IAM のポリシードキュメント形式で返す必要があるが、結局は、APIGatewayCustomAuthorizerRequestTypeRequest の MethodArn のプロパティで、アクセスさせたいリソースとメソッドは簡単に抽出できるため、たいして難しくはない。捻ったことをしようとしない限りは、お決まりの形で Allow/Deny を返してあげれば良いだろう。

上記を踏まえて、以下のような Lambda のハンドラを用意する。

import (
	"context"
	"errors"
	"log"

	jwt "github.com/form3tech-oss/jwt-go"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(handler)
}

func checkToken(headers map[string]string) error {
	tokenString, headerIsNotNull := headers["Authorization"]
	if !headerIsNotNull {
		return errors.New("[Header]Authorization is not specified")
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		b := secretKey
		return b, nil
	})
	if err != nil {
		log.Println(err)
		return errors.New("jwt.Parse() returned error")
	}

	return nil
}

func handler(ctx context.Context, request events.APIGatewayCustomAuthorizerRequestTypeRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
	if err := checkToken(request.Headers); err != nil {
		log.Println(err)
		log.Println("Invalid Token.")
		return events.APIGatewayCustomAuthorizerResponse{
			PrincipalID: "user",
			PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
				Version: "2012-10-17",
				Statement: []events.IAMPolicyStatement{
					{
						Action:   []string{"execute-api:Invoke"},
						Effect:   "Deny",
						Resource: []string{request.MethodArn},
					},
				},
			},
		}, nil
	}

	return events.APIGatewayCustomAuthorizerResponse{
		PrincipalID: "user",
		PolicyDocument: events.APIGatewayCustomAuthorizerPolicy{
			Version: "2012-10-17",
			Statement: []events.IAMPolicyStatement{
				{
					Action:   []string{"execute-api:Invoke"},
					Effect:   "Allow",
					Resource: []string{request.MethodArn},
				},
			},
		},
	}, nil
}

これで、先ほどと同様に

$ curl -i -X PUT -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MjExMzUyODYsInN1YiI6IjAwMDAxIn0.QcJWTzH1wBe6QdbWJIECO3QMn9ZiE_bK9-Paft1YBy4' https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/resource

を投げ込むと、resource の PUT の API でチェックを実装しなくても、API Gateway がオーソライザーの Lambda を実行してチェックしてくれるぞ!なお、このケースでは、デフォルトの HTTP レスポンスコードは 403 で、

{"Message":"User is not authorized to access this resource with an explicit deny"}

というワーディングが返される。
変更したい場合は、ゲートウェイのレスポンスから変更を行おう。
また、デフォルトではAPI Gatewayのキャッシュ機能がONであるため、トークン期限切れでも認証が通ってしまうことがある。嫌なのであれば、Terraform の aws_api_gateway_authorizer のリソースで authorizer_result_ttl_in_seconds の設定を変更しよう。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?