Tags: Go Golang JWT

Go で JWT を発行して検証する

Redis を使ってセッション管理していた部分を JWT 形式のアクセストークン認証に置き換えるための実装をした。

前書き

現在私が趣味で開発しているマイクロブログ風のブログサービスで Redis を使ってセッション管理していた部分があった。
「パスワードによる閲覧制限をかけたブログ」を閲覧したとき、パスワードを入力してから一定時間以内ならば再度パスワードを入力しなくてもいいようにするという要件である。

今回、ここを「有効期限 30 分間の JWT 形式アクセストークンを発行する」という仕組みに置き換えることにした。

有効期限が 30 分間かつ特定のブログの閲覧権限しかないトークンならばフロント側で WebStorage に格納するリスクは許容できると考えたので、フロント側では SessionStorage にトークンを保存する。
またリフレッシュトークンは発行せず、30 分間たったら再度パスワードを入力してもらうことにした。

以下はそのアクセストークン実装についての記述となる。

環境

  • go version go1.19.3 linux/amd64
  • github.com/lestrrat-go/jwx/v2 v2.0.7

署名用の鍵(JWK)の用意

JWT に署名するために鍵が必要である。

今回の私の要件としては JWT の発行者と使用者が同一アプリケーションであるため、共通鍵暗号(対象鍵暗号)を用いる。

また鍵のローテーションを行いたかったため、トークンの検証時には複数の有効な鍵の中から使用すべき鍵を特定する必要がある。
そのため鍵の形式は JWK (JSON Web Key) にする。

実装については乱数を元に JWK を生成し、nanoID で KeyID をセット、アルゴリズムに HS256 を指定すればよい。

import (
	"crypto/rand"
    "errors"

	"github.com/lestrrat-go/jwx/v2/jwa"
	"github.com/lestrrat-go/jwx/v2/jwk"
	"github.com/matoous/go-nanoid/v2"
)

func CreateKey() (jwk.Key, error) {
	raw := make([]byte, 256)
	_, err := rand.Read(raw)
	if err != nil {
		return nil, err
	}
	key, err := jwk.FromRaw(raw)
	if err != nil {
		return nil, err
	}
	if _, ok := key.(jwk.SymmetricKey); !ok {
		return nil, errors.New("CreateKey failed")
	}
	id, err := gonanoid.New(21)
	if err != nil {
		return nil, err
	}

	key.Set(jwk.KeyIDKey, id)
	key.Set(jwk.AlgorithmKey, jwa.HS256)

	return key, nil
}

あとはこの鍵を有効期限と共に DB に格納し、定期的にローテーションする。

ローテーションの間隔はアプリケーションによると思うが、私はとりあえず有効期限が一か月半の鍵を月に 1 回生成することにした。

JWT の発行

今回の要件としては iss, iat, exp の他に “blogKey” という Claim を入れる。
トークンを検証した後、閲覧リクエストで指定された BlogKey とトークンに含まれる blogKey が一致しなかったらエラーにするためである。

実装としてはトークン作成後、最新の有効な JWK を用いて署名し、string に変換してフロントに返す。

import (
	"time"

	"github.com/lestrrat-go/jwx/v2/jwa"
	"github.com/lestrrat-go/jwx/v2/jwt"
)

const (
	blogKeyClaimName = "blogKey"
	blogAuthMinute   = 30
	issuer           = "Sololog"
)

func New(now time.Time, blogKey string) (*string, error) {
	tok, err := jwt.NewBuilder().
		Issuer(issuer).
		IssuedAt(now).
		Expiration(now.Add(time.Minute*blogAuthMinute)).
		Claim(blogKeyClaimName, blogKey).
		Build()
	if err != nil {
		return nil, err
	}

	key, err := GetLatestKey(now) // 最新の JWK を取得する関数を別途作ったのを呼んでいる
	if err != nil {
		return nil, err
	}
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.HS256, key))
	if err != nil {
		return nil, err
	}

	str := string(signed)

	return &str, nil
}

JWT の検証

検証は現在有効な JWK を DB から取り出して jwk.Set にセットし、ParseOption の jwt.WithKeySet(keys) に渡すことによって実行できる。

検証時、iat, exp, nbf は指定しなくてもバリデーションされるので issuer のバリデーションを追加する。

また今回は “blogKey” Claim の存在もチェックするため、カスタムバリデータを作成した。

import (
	"context"
	"errors"
	"time"

	"github.com/lestrrat-go/jwx/v2/jwt"
)

const (
	blogKeyClaimName = "blogKey"
	issuer           = "Sololog"
)

func ParseAndValidate(now time.Time, token string) (jwt.Token, error) {
	keys, err := GetValidKeySet(now) // 現在有効な JWK を jwk.Set に格納したものを取得する関数を別途作ったのを呼んでいる
	if err != nil {
		return nil, err
	}

	// デフォルトで "iat", "exp", "nbf" はバリデーションチェックされる
	options := []jwt.ParseOption{
		jwt.WithKeySet(keys),
		jwt.WithIssuer(issuer),
		jwt.WithValidator(customClaimValidator),
	}
	tok, err := jwt.ParseString(token, options...)
	if err != nil {
		if errors.Is(err, jwt.ErrTokenExpired()) {
			return nil, nil
		}
		return nil, err
	}
	return tok, nil
}

var customClaimValidator = jwt.ValidatorFunc(func(_ context.Context, t jwt.Token) jwt.ValidationError {
	_, isExist := t.Get(blogKeyClaimName)
	if !isExist {
		return jwt.NewValidationError(errors.New("tokens should have blogKey"))
	}
	return nil
})

以上