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 Registryや
Registered claims
などで定義されるものを避ける必要があります。
- 共有されるカスタムな情報。JWTを利用するものの間で取り決めで決定されます。IANA JSON Web Token Registryや
- Private claims
- JWTの提供者と利用者で同意された、プライベートなclaim名。
Registered claims
やPrivate claims
で定義されるものを避ける必要があります。
ペイロードのサンプルは以下のようになります。
- JWTの提供者と利用者で同意された、プライベートなclaim名。
{ "sub": "1234567890", "name": "John Doe", "admin": true }
このようなペイロードをBase64エンコードして、第2パートに含めます。
署名
エンコードされたヘッダーとエンコードされたペイロード等を含めて、署名を作成します。例えば、HMAC
、SHA256
をを利用したい場合は以下のようにして署名を作成します。
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