はじめに
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
と書かれているところに、上記で設定した秘密鍵の文字列を入力すると、正しい文字列になる。
トークン検証の実装
さて、続いてはトークンの検証だ。
本記事では、検証を行う必要がある 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_method
の authorization
は、 IAM
や COGNITO_USER_POOLS
による認証も可能だが、今回の Lambda の方式の場合は CUSTOM
を設定する。
また、認証用の Lambda を別途 Terraform で定義して、aws_api_gateway_authorizer
の authorizer_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
の設定を変更しよう。