JWTとは
JWT(ジョットと言うらしい)はJSON Web Tokenの略で、JSONをベースとしたアクセストークンのためのオープン標準 (RFC 7519) です。色々記事を見ましたが、最終的にWikipediaが分かりやすく一番参考にしました。
https://ja.wikipedia.org/wiki/JSON_Web_Token
JWTの構造
JWTは以下の3つの要素をピリオドで区切った文字列で構成されます。
ヘッダー
署名生成に使用するアルゴリズムを格納します。下記のHS256は、このトークンがHMAC-SHA256で署名されていることを示しています。署名アルゴリズムとしては、SHA-256を使用したHMAC (HS256) や、SHA-256を使用したRSA署名 (RS256) がよく用いられます。
{ "alg" : "HS256", "typ" : "JWT" }
ペイロード
認証情報などのクレームを格納します。クレームとはペイロードに含める以下のような標準フィールド(クレーム)を指します。JWTの仕様では、トークンに一般的に含まれる7つの標準フィールドが定義されています。また用途に応じた独自のカスタムフィールドを含むこともできます。下記の例では、トークン発行日時を示す標準のクレーム (iat) と、カスタムクレーム (loggedInAs) を格納しています。
{ "loggedInAs" : "admin", "iat" : 1422779638 }
7つのペイロードの標準クレーム
署名
トークン検証用の署名です。署名はヘッダーとペイロードをBase64urlエンコーディングしてピリオドで結合したものから生成します。署名はヘッダーで指定された暗号化アルゴリズムにより生成されます。下記はHMAC-SHA256形式でのコード例です。
HMAC-SHA256(base64urlEncoding(header) + '.' + base64urlEncoding(payload), 'secret key')
JWTを使用するにあたって
JWTはトークンが返され、それをローカルに保存して利用します(主にlocal storageやsession storageが用いられますが、セッションIDのようにCookieを用いる場合もあります。) 認証時にはAuthorizationヘッダーでBearerスキーマを利用します。またサーバー上に認証状態を保持しないステートレスな認証方式です。その為JWT単体ではトークンを無効にすることが出来ません。サーバーに状態を保持すれば可能ですが、その場合ステートレスの利点は失われることになります。
さて、ここまではほぼ Wikipedia に書いてある内容そのままです。ここから実際にGo/Gin
のJWT Middleware
を使って実際の動作を確認してみます。
Go/Gin
のJWT Middleware
を使った動作確認
利用するJWT Middleware
について
ここでは、「https://github.com/gin-gonic/gin」 を使う前提で、次のMiddleware
を利用します。「https://github.com/appleboy/gin-jwt」。このMiddleware
は、auth_jwt.go
の1ファイルでで構成されていて、「https://github.com/dgrijalva/jwt-go」 をGin
用に薄くラップしたものです。jwt-go
はトークンを作成したりパースしたり様々な機能が用意されています。
サンプルソース
サンプルソースは、https://github.com/appleboy/gin-jwt/blob/master/README.md に載っているのでこれを元に確認します。処理は大きく「ログイン時にToken発行する」と「トークン認証&処理実行する」の2種類あります。
ログイン時にToken発行する
ログイン時にTokenを発行する処理は、LoginHandler
です。Router
では次のように定義しています。LoginHandler
ではAuthenticator
とPayloadFunc
が呼ばれる為、Middleware
にてこれらを実装する必要があります。
r.POST("/login", authMiddleware.LoginHandler)
Authenticator
はログイン認証の為の関数です。例では固定値が設定されていますが、実際は主にDBから値を取得することになると思います。PayloadFunc
はペイロードに含めるクレームを設定します。ペイロードには任意のクレームを追加可能なので、ログインIDとなるuserIDをセットしています。
// ログインに基づいたユーザの認証振る舞いをするコールバック Authenticator: func(c *gin.Context) (interface{}, error) { var loginVals login if err := c.ShouldBind(&loginVals); err != nil { return "", jwt.ErrMissingLoginValues } userID := loginVals.Username password := loginVals.Password // ユーザIDとパスワード認証(実際は主にDBから値を取得する) if (userID == "admin" && password == "admin") || (userID == "test" && password == "test") { return &User{ UserName: userID, }, nil } return nil, jwt.ErrFailedAuthentication }, // ペイロードのクレーム設定 PayloadFunc: func(data interface{}) jwt.MapClaims { if v, ok := data.(*User); ok { return jwt.MapClaims{ IdentityKey: v.UserName, } } return jwt.MapClaims{} }
ログイン時にアクセストークンを取得するにはサーバ起動後に以下のコマンドを実行します。
% http -v --json POST localhost:8000/login username=admin password=admin
実行すると次のような結果が得られます。
POST login HTTP/1.1 Accept: application/json, */* Accept-Encoding: gzip, deflate Connection: keep-alive Content-Length: 42 Content-Type: application/json Host: localhost:8000 User-Agent: HTTPie/1.0.2 { "password": "admin", "username": "admin" } HTTP/1.1 200 OK Content-Length: 213 Content-Type: application/json; charset=utf-8 Date: Tue, 27 Aug 2019 03:26:18 GMT { "code": 200, "expire": "2019-08-27T13:26:18+09:00", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY4Nzk5NzgsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg3NjM3OH0.D0z_y8vLEeQW_mtgOCw6gfrmz6eSGfW6uOG7KoEaMAo" }
LoginHandler
の処理を見てみる
ここであらためてauth_jwt.go
のLoginHandler
の処理を見てみると内部で何をやっているかが具体的に分かります。エラーチェックなどを省いて主要なところだけ確認してみます。
上記middleware
で実装しているAuthenticator
を呼んでログイン認証します。
data, err := mw.Authenticator(c)
ログイン認証が通ったらクレームを取得します。
// Create the token token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) claims := token.Claims.(jwt.MapClaims)
上記で実装しているPayloadFunc
を呼んで独自クレームを設定します。
if mw.PayloadFunc != nil { for key, value := range mw.PayloadFunc(data) { claims[key] = value } }
更にペイロードに必要なクレーム情報を設定します。exp
はトークン切れタイムで、orig_iat
はトークン生成タイムです。
expire := mw.TimeFunc().Add(mw.Timeout) claims["exp"] = expire.Unix() claims["orig_iat"] = mw.TimeFunc().Unix()
最後に署名付きトークンを生成してレスポンスとして返します。
tokenString, err := mw.signedString(token) mw.LoginResponse(c, http.StatusOK, tokenString, expire)
これらを組み合わせたLoginHandler
の実際の処理です。
func (mw *GinJWTMiddleware) LoginHandler(c *gin.Context) { if mw.Authenticator == nil { mw.unauthorized(c, http.StatusInternalServerError, mw.HTTPStatusMessageFunc(ErrMissingAuthenticatorFunc, c)) return } data, err := mw.Authenticator(c) if err != nil { mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) return } // Create the token token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) claims := token.Claims.(jwt.MapClaims) if mw.PayloadFunc != nil { for key, value := range mw.PayloadFunc(data) { claims[key] = value } } expire := mw.TimeFunc().Add(mw.Timeout) claims["exp"] = expire.Unix() claims["orig_iat"] = mw.TimeFunc().Unix() tokenString, err := mw.signedString(token) if err != nil { mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrFailedTokenCreation, c)) return } // set cookie if mw.SendCookie { maxage := int(expire.Unix() - time.Now().Unix()) c.SetCookie( mw.CookieName, tokenString, maxage, "/", mw.CookieDomain, mw.SecureCookie, mw.CookieHTTPOnly, ) } mw.LoginResponse(c, http.StatusOK, tokenString, expire) } func (mw *GinJWTMiddleware) signedString(token *jwt.Token) (string, error) { var tokenString string var err error if mw.usingPublicKeyAlgo() { tokenString, err = token.SignedString(mw.privKey) } else { tokenString, err = token.SignedString(mw.Key) } return tokenString, err }
払い出されたTokenを検証してみる
払い出されたTokenを検証してみます。トークンはピリオドで分割し3つに分けることができます。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY4Nzk5NzgsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg3NjM3OH0.D0z_y8vLEeQW_mtgOCw6gfrmz6eSGfW6uOG7KoEaMAo
- ヘッダー:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- ペイロード:eyJleHAiOjE1NjY4Nzk5NzgsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg3NjM3OH0
- 署名:D0z_y8vLEeQW_mtgOCw6gfrmz6eSGfW6uOG7KoEaMAo
ヘッダーとペイロードはbase64でエンコードされているだけなので以下のコマンドで確認することができます。
・ヘッダー
% echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -D {"alg":"HS256","typ":"JWT"}%
・ペイロード
% echo eyJleHAiOjE1NjY4Nzk5NzgsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg3NjM3OH0 | base64 -D {"exp":1566879978,"id":"admin","orig_iat":156687637%
・署名は暗号化されているので当然復元できません。
% echo D0z_y8vLEeQW_mtgOCw6gfrmz6eSGfW6uOG7KoEaMAo | base64 -D L������k`8,:���ϧ���*�%
またトークンを確認するのに便利なサイトがあります。このサイトを利用するとJWT認証の確認が簡単にできます。認証ができていないと、Invalid Signature
となりますが、シークレットキーに今回使った文字列secret key
と入力し今回のトークンを貼り付けるとSignature Verified
と認証成功が確認できます。
https://jwt.io/
・認証NG
・認証OK
トークン認証&処理実行する
次にトークン認証と認証後の処理の実行をみてみます。auth_jwt.go
ではMiddlewareFunc
を呼び出します。Router
にはauthMiddleware.MiddlewareFunc()
をかまし、その中に実行したいRoute
を設定します。
auth := r.Group("/auth") auth.Use(authMiddleware.MiddlewareFunc()) { auth.GET("/hello", helloHandler) } func helloHandler(c *gin.Context) { claims := jwt.ExtractClaims(c) user, _ := c.Get(middleware.IdentityKey) c.JSON(200, gin.H{ "userID": claims["id"], "userName": user.(*middleware.User).UserName, "text": "Hello World.", }) }
middleware
側では、IdentityHandler
とAuthorizator
をあらかじめ実装しておく必要があります。
// クレームからログインIDを取得する IdentityHandler: func(c *gin.Context) interface{} { claims := jwt.ExtractClaims(c) return &User{ UserName: claims["id"].(string), } }, // トークンのユーザ情報からの認証 Authorizator: func(data interface{}, c *gin.Context) bool { // UserNameは主にDBから取得 if v, ok := data.(*User); ok && v.UserName == "admin" { return true } return false },
トークンを使ってリクエストを投げるとリクエストが実行されます。
http -f GET localhost:8000/auth/hello "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY4Nzk5NzgsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg3NjM3OH0.D0z_y8vLEeQW_mtgOCw6gfrmz6eSGfW6uOG7KoEaMAo" "Content-Type: application/json"
正常にレスポンスが取得できました。
http -f GET localhost:8000/auth/hello "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY4ODQ5ODQsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg4MTM4NH0.b22SkPtS5q5-YRHf9GCsUstvcKsNh2ds1fRdZN-Yxac" "Content-Type: application/json" HTTP/1.1 200 OK Content-Length: 60 Content-Type: application/json; charset=utf-8 Date: Tue, 27 Aug 2019 04:50:04 GMT { "text": "Hello World.", "userID": "admin", "userName": "admin" }
試しに署名の最後の4文字をaaaa
に変更してみると署名の認証エラーであるsignature is invalid
が返ってきます。
http -f GET localhost:8000/auth/hello "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY4ODQ5ODQsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTU2Njg4MTM4NH0.b22SkPtS5q5-YRHf9GCsUstvcKsNh2ds1fRdZN-aaaa" "Content-Type: application/json" HTTP/1.1 401 Unauthorized Content-Length: 46 Content-Type: application/json; charset=utf-8 Date: Tue, 27 Aug 2019 04:50:59 GMT Www-Authenticate: JWT realm=test zone { "code": 401, "message": "signature is invalid" }
MiddlewareFunc
の処理を見てみる
では実際中ではどのような処理が行われているかauth_jwt.go
のMiddlewareFunc
の主要処理をみてみます。
トークンからクレームを取得します。
claims, err := mw.GetClaimsFromJWT(c)
クレーム内のexp
をチェックしトークンが有効かをチェックします。
if claims["exp"] == nil { mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c)) return } if _, ok := claims["exp"].(float64); !ok { mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c)) return } if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() { mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c)) return }
ミドルウェアで実装しているIdentityHandler
を呼び出し、クレームからユーザ情報を取得します。
IdentityHandler: func(c *gin.Context) interface{} { claims := jwt.ExtractClaims(c) return &User{ UserName: claims["id"].(string), } },
ミドルウェアで実装しているAuthorizator
を呼び出しIDが一致していたら認証OKとなります。
// トークンのユーザ情報からの認証 Authorizator: func(data interface{}, c *gin.Context) bool { // UserNameは主にDBから取得 if v, ok := data.(*User); ok && v.UserName == "admin" { return true } return false },
MiddlewareFunc
の全処理です。
func (mw *GinJWTMiddleware) MiddlewareFunc() gin.HandlerFunc { return func(c *gin.Context) { mw.middlewareImpl(c) } } func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) { claims, err := mw.GetClaimsFromJWT(c) if err != nil { mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) return } if claims["exp"] == nil { mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c)) return } if _, ok := claims["exp"].(float64); !ok { mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c)) return } if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() { mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c)) return } c.Set("JWT_PAYLOAD", claims) identity := mw.IdentityHandler(c) if identity != nil { c.Set(mw.IdentityKey, identity) } if !mw.Authorizator(identity, c) { mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c)) return } c.Next() }
これで一通りの動作確認ができました。
まとめ
はじめ知らないワードが出てきて理解しきれなかったのですが、実際に触ってみることでJWTについてある程度理解できました。触ってみてステートレスであり、ステートレスであるがゆえにJWT単体ではトークンを無効にすることが出来ないという理由も実感できました。