jwt-goを試す

はじめに

JWTをGoで扱えるライブラリを少し探していて、検索して1番最初に出てきたのがjwt-goが出てきて、スターも多いしIsuueみる限り開発も盛んそうだったので使ってみようと思います。

そもそもJWTってなんぞ?

よく聞くし、なんとなく理解した気ではいたのですが、説明しろと言われるとすっとはできなかったので改めて説明を記述して、まとめることで理解を深めてみようと思います。
JWTはJSON Web Tokenの略でRFC7519で定義されます。セキュアにJSON形式のオブジェクトをピア間で届けるための方法を提供します。JWTはシークレット(HMACアルゴリズム)か、RSAやECDSAと言ったようなpublic/privateキーで電子署名が行われるため、JWTで扱われる情報は信頼のおけるものになります。
JWTは、ピア間で共有されたシークレットキーで暗号化を行なうことも可能です。
JWTは主に以下の2つの目的で利用されます。

  • 認可
    • 最も一般的な用いられ方です。認証されたユーザは毎回のリクエストにJWTを含むようにします。そうすることで、ユーザはJWTによって許可されるリソースへのアクセスなどを行えるようになります。
  • 情報交換
    • JWTには電子署名が用い垂れるため、誰が情報を送ってきたのかが明確になります。更に、情報の改ざんが行われていないことも保証できます。

JWTは主に以下の3つパートから成り立ち、それぞれのが.によって区切られます。

イメージとしては以下のような構成になります。

xxxxx.yyyyy.zzzzz
ヘッダー

ヘッダーは主にトークンのタイプ署名アルゴリズムの構成で行われます。 ここで、署名アルゴリズムは以下のようなものがあります。

  • HMAC
  • SHA256
  • RSA

また、ヘッダーのサンプルは以下のようになります。

{
  "alg": "HS256",
  "typ": "JWT"
}

このようなヘッダーをBase64エンコードして、第1パートに含めます。

ペイロード

JWTの第2パートはペイロードになります。このペイロードclaimを含みます。claimはユーザの情報や追加情報などです。 claimには通常以下の3つの種類があります。

  • Registered claims

    • iss(issure)、exp(JWTの有効期限)、sub(JWTの件名)、aud(情報の受信者)などの推奨されるが、必須ではないClaimsです。(詳細はこちらを参照)
  • Public claims

    • 共有されるカスタムな情報。JWTを利用するものの間で取り決めで決定されます。IANA JSON Web Token RegistryRegistered claimsなどで定義されるものを避ける必要があります。
  • Private claims
    • JWTの提供者と利用者で同意された、プライベートなclaim名。Registered claimsPrivate claimsで定義されるものを避ける必要があります。
      ペイロードのサンプルは以下のようになります。
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

このようなペイロードBase64エンコードして、第2パートに含めます。

署名

エンコードされたヘッダーとエンコードされたペイロード等を含めて、署名を作成します。例えば、HMACSHA256をを利用したい場合は以下のようにして署名を作成します。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

署名はメッセージの改ざんを検知や、秘密鍵で署名が行われる場合はJWTの送信者が誰であるかの証明も行なうことができます。

使ってみる

少し主題からそれましたが、jwt-goを使ってみたいと思います。
jwt-goはJWTのGo実装で、JWTのパースとバリデーション、そして署名をサポートしています。以下のような署名アルゴリズムをサポートしています。

また自作のものをフックすることも可能です。

環境

今回の動作環境は以下のとおりです。

$ go version
go version go1.16 linux/amd64

$ uname -srvmpio
Linux 5.4.0-65-generic #73-Ubuntu SMP Mon Jan 18 17:25:17 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal

プロジェクトの作成

$ mkdir go-jwt-sample

$ cd go-jwt-sample

$ go mod init github.com/samuraiball/go-sandbox
go: creating new go.mod: module github.com/samuraiball/go-sandbox

作成された、go.sumをみると以下のような感じになってました。

github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=

現時点でのステイブルの最新バージョンは3.2.x系みたいですね。GitHubから飛べるバージョンのドキュメントが1.x.x系のものだったので少し混乱しましたが、3.x.x系のドキュメントを参考に進めていこうと思います。

private/public keyを作成する

今回利用する鍵を作成しておきます。
今回はRSA形式の公開鍵と暗号鍵を作成し、暗号鍵で署名を行なうことを想定します。
opensslコマンドを用いてそれぞれの鍵を生成します。

$ openssl genrsa 2048 > private-key.pem
$ openssl rsa -in private-key.pem -pubout -out public-key.pem

鍵ができました。

JWTを作成する

ここまでで準備が完了したので、JWTを作っていきます。
今回は独自のClaimsであるuserIdを作ってb5548e70-732d-11eb-971c-57ee55c52577と言う値を受け渡してみます。
具体的には以下のようにします。

type MyClaims struct {
    UserId string `json:"userId"`
    jwt.StandardClaims
}


func main() {

    claims := MyClaims{
        "b5548e70-732d-11eb-971c-57ee55c52577",
        jwt.StandardClaims{
            Issuer:    "issuer",
            ExpiresAt: time.Now().Add(time.Hour * 3).Unix(),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

    var privateKeyRow = []byte(`
-----BEGIN RSA PRIVATE KEY-----
(省略)
-----END RSA PRIVATE KEY-----
`)

    privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyRow)
    if err != nil {
        panic(err)
    }

    ss, err := token.SignedString(privateKey)
    if err != nil {
        panic(err)
    }

    fmt.Printf("%v\n", ss)
}

独自のClaimsをJWTに含めたい場合まずは構造体を作成します。

type MyClaims struct {
    UserId string `json:"userId"`
    jwt.StandardClaims
}

ここでは、UerIdというクレイムとjwt.StandardClaimsを使って構造体を作成しています。jwt.StandardClaimsは前述したRegistered Claim Namesの構造体のセットを用意してくれています。

main関数の中では作成した構造体をインスタンス化しjwt.NewWithClaims(jwt.SigningMethodHS256, claims)にでトークンを作成しています。 そして、最後に署名ですが、token.SignedString()を呼び出すことで署名つきのトークンを生成できます。この際に事前に用意しておいた鍵を利用します。RSA形式の秘密鍵を利用する場合はjwt.ParseRSAPrivateKeyFromPEM()に生の鍵を渡してやってrsa.PrivateKeyを生成してtoken.SignedString()に渡してやると署名が付与されたトークンが作成されます。

コードを実行すると以下のような結果が出力されます。

$ go build main.go 
$ ./main
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJiNTU0OGU3MC03MzJkLTExZWItOTcxYy01N2VlNTVjNTI1NzciLCJleHAiOjE2MTM4MDM4MjcsImlzcyI6Imlzc3VlciJ9.rarbCzWG57hVBzuMsxAPRHooG3QrAO1hsr8LCadXN0Z9jWY2y8cFP7xSwvwIqAxWtTBbaV3MPAfmxjb6zsrE482RmBnbhyQl0XI0COQpKp1xshqyQNksFvbD0NBaNdmSEQmiVXp6mXJtf1i38eGf3O9H6UfIepN6WwAGLbgMZ-LEtToNOe_fPZksrIPbGONMybKuMaS4KvqpcZb27epPylm7lWunatZgjZ_KxxHsddjJWNCyIGt4t_8zNs8Oew6oqCqbV38KVz7YanBAe_mLBNvyPELbfgbZrPRZdslRVQwBo49O1_5UytYCiiNwTzSb9b0LNVGES_hm3hcQTnelNQ

作られたトークンのペイロード部分をデコードしてみると以下のような結果が得られました。

$ echo eyJ1c2VySWQiOiJiNTU0OGU3MC03MzJkLTExZWItOTcxYy01N2VlNTVjNTI1NzciLCJleHAiOjE2MTM4MDM4MjcsImlzcyI6Imlzc3VlciJ9 | base64 -d
{"userId":"b5548e70-732d-11eb-971c-57ee55c52577","exp":1613803827,"iss":"issuer"}

JWTのパースを行なう

JWTの生成までできたので今度は公開鍵をつかいJWTの署名の検証を行います。 具体的には以下のようなコードで可能になります。

type MyClaims struct {
    UserId string `json:"userId"`
    jwt.StandardClaims
}

func main() {

    var keyPublicRaw = []byte(`
-----BEGIN PUBLIC KEY-----
(省略)
rQIDAQAB
-----END PUBLIC KEY-----
`)

    var tokenString = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJiNTU0OGU3MC03MzJkLTExZWItOTcxYy01N2VlNTVjNTI1NzciLCJleHAiOjE2MTM4MDM4MjcsImlzcyI6Imlzc3VlciJ9.rarbCzWG57hVBzuMsxAPRHooG3QrAO1hsr8LCadXN0Z9jWY2y8cFP7xSwvwIqAxWtTBbaV3MPAfmxjb6zsrE482RmBnbhyQl0XI0COQpKp1xshqyQNksFvbD0NBaNdmSEQmiVXp6mXJtf1i38eGf3O9H6UfIepN6WwAGLbgMZ-LEtToNOe_fPZksrIPbGONMybKuMaS4KvqpcZb27epPylm7lWunatZgjZ_KxxHsddjJWNCyIGt4t_8zNs8Oew6oqCqbV38KVz7YanBAe_mLBNvyPELbfgbZrPRZdslRVQwBo49O1_5UytYCiiNwTzSb9b0LNVGES_hm3hcQTnelNQ"

    parsedToken, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
        publicKey, err := jwt.ParseRSAPublicKeyFromPEM(keyPublicRaw)
        if err != nil {
            panic(err)
        }
        return publicKey, nil
    },
    )

    parsedClaims := parseToken.Claims.(*MyClaims)
    fmt.Println("userId:", decodedClaims.UserId)
}

go-jwtでClaimsのパースを行いかつ署名の検証を行なう場合場jwt.ParseWithClaims()を使います。 この関数は引数にトークンとClaimsの型、そして、鍵を返すKeyfuncを受け取ります。今回の場合は公開鍵を使って検証してみたいと思います。この検証を行なうことで、鍵を公開しているサーバから送られていることかつ改ざんが行われていないことを検証できます。
パースされたトークンのClaimsはparseToken.Claims.(*MyClaims)のようにしてキャストすることで、構造体のインスタンス化が行えます。

このプログラムの実行結果は以下のとおりです。

$ go build main.go 
$ ./main
userId: b5548e70-732d-11eb-971c-57ee55c52577

参考資料