taisablog

taisa's engineer blog

Go

Golangを使ってJWTを15分で理解する

投稿日:August 28, 2019 更新日:


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/GinJWT Middlewareを使って実際の動作を確認してみます。

Go/GinJWT 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ではAuthenticatorPayloadFuncが呼ばれる為、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.goLoginHandlerの処理を見てみると内部で何をやっているかが具体的に分かります。エラーチェックなどを省いて主要なところだけ確認してみます。
上記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側では、IdentityHandlerAuthorizatorをあらかじめ実装しておく必要があります。

// クレームからログイン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.goMiddlewareFuncの主要処理をみてみます。
トークンからクレームを取得します。

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単体ではトークンを無効にすることが出来ないという理由も実感できました。

その他の参考記事

https://qiita.com/k_k_hogetaro/items/0c97f42ecb8207767db2

-Go

執筆者:

関連記事

[Golang]Goのio/ioutilパッケージは分かりやすくて使いやすい

Goのioパッケージは主にインターフェースになっていて他のパッケージで多く実装されています。またioパッケージにもパブリックな関数がありファイルの入出力はできますが少し細かい処理になります。io/ioutilパッケージを使うとファイルの入出力処理が簡単にできます。以下にio/ioutilパッケージを使った処理とそれに対するテストコードを記載します。 io/ioutil/ioutil.go ReadAll() func ReadAll() string { file, _ := os.Open(“testdata/src.txt”) b, _ := ioutil.ReadAll(file) return string(b) } ReadAllテスト func TestReadAll(t *testing.T) { str := ReadAll() if str != “0123456789” { t.Errorf(“TestReadAll Error. %s”, str) } } ReadFile() func ReadFile() string { b, _ := ioutil.ReadFile(“testdata/src.txt”) return string(b) } ReadFileテスト func TestReadFile(t *testing.T) { str := ReadFile() if str != “0123456789” { t.Errorf(“TestReadAll Error. %s”, str) } } WriteFile() func WriteFile() string { b := []byte(“0123456789”) _ = ioutil.WriteFile(“testdata/dst.txt”, b, os.ModePerm) b, _ = ioutil.ReadFile(“testdata/dst.txt”) return string(b) } WriteFileテスト func TestWriteFile(t *testing.T) { str := WriteFile() if str != “0123456789” …

no image

【3分で作れる】Goのコマンドラインツール by Cobra

同僚のおすすめでコマンドラインツール作成にCobraを使いました。ものすごく簡単につくれるのですが、それでも少しハマったところがあったので、コマンドラインツールを作るまでの流れを書いておきます。 前提 go version go1.14.2 darwin/amd64利用ライブラリ:https://github.com/spf13/cobra本記事のサンプルコード:https://github.com/taisa831/sandbox-cobra Index 雛形を作成するコマンドを追加するコンフィグを追加するサブコマンドを追加するまとめ 雛形を作成する 雛形作成にはgeneratorを使います。go getしてcobraコマンドを利用可能にします。 go get -u github.com/spf13/cobra/cobra cobraコマンドを実行すると以下のようなusageが表示されます。 $ cobra Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application. Usage: cobra [command] Available Commands: add Add a command to a Cobra Application help Help about any command init Initialize a Cobra Application Flags: -a, –author string author name for copyright attribution (default “YOUR NAME”) –config string config file (default is $HOME/.cobra.yaml) -h, –help help for cobra -l, –license string name of license for the project …

no image

DockerでGoのWebサーバーを起動する

ミニマムにやっておかないと忘れがちなのでメモ サンプル用Goサーバを作成する package main import ( “encoding/json” “net/http” ) type User struct { FirstName string `json:”firstName”` LastName string `json:”lastName”` } func users(w http.ResponseWriter, req *http.Request) { w.Header().Set(“Content-Type”, “application/json”) user := User{ FirstName: “John”, LastName: “Doe”, } var users []User users = append(users, user) json.NewEncoder(w).Encode(users) } func main() { http.HandleFunc(“/users”, users) http.ListenAndServe(“:8002”, nil) } 普通に起動して動作確認をします。 $ go run main.go http://localhost:8002/usersにアクセスするとJSON結果が出力されます。 [ { “firstName”: “John”, “lastName”: “Doe” } ] Dockerfile Dockerfileを作成します。alpineをつけるとよりミニマムなイメージができます。参考(https://hub.docker.com/_/golang?tab=description) FROM golang:1.14 #FROM golang:1.14-alpine # コンテナログイン時のディレクトリ指定 WORKDIR /opt/sandbox-docker-go # ホストのファイルをコンテナの作業ディレクトリにコピー COPY . . # ADD . . # ビルド RUN go build -o app main.go # 起動 CMD [“/opt/sandbox-docker-go/app”] 構成 $ …

Golang 1.13 released! The difference from 1.12 to 1.13

Golang 1.13 was released in 3 September 2019. This post has difference from 1.12 to 1.13. I check the changes at the release notes and difference of src. Release Notes Go 1.13 Release Notes The latest Go release, version 1.13, arrives six months after Go 1.12. Most of its changes are in the implementation of the toolchain, runtime, and libraries. As always, the release maintains the Go 1 promise of compatibility. We expect almost all Go programs to continue to compile and run as before. Diff on GitHub Release branch.go1.13 by taisa831 · Pull Request #1 · taisa831/go This PR …

no image

docker-composeでGoのWebサーバーを起動する

ミニマムにやっておかないと忘れがちなのでメモ サンプル用Goサーバを作成する package main import ( “encoding/json” “net/http” ) type User struct { FirstName string `json:”firstName”` LastName string `json:”lastName”` } func users(w http.ResponseWriter, req *http.Request) { w.Header().Set(“Content-Type”, “application/json”) user := User{ FirstName: “John”, LastName: “Doe”, } var users []User users = append(users, user) json.NewEncoder(w).Encode(users) } func main() { http.HandleFunc(“/users”, users) http.ListenAndServe(“:8002”, nil) } 普通に起動して動作確認をします。 $ go run main.go http://localhost:8002/usersにアクセスするとJSON結果が出力されます。 [ { “firstName”: “John”, “lastName”: “Doe” } ] Dockerfile FROM golang:1.14 #FROM golang:1.14-alpine # コンテナログイン時のディレクトリ指定 WORKDIR /opt/sandbox-docker-compose-go # ホストのファイルをコンテナの作業ディレクトリにコピー COPY . . # ADD . . # ビルド RUN go build -o app main.go # 起動 CMD [“/opt/sandbox-docker-compose-go/app”] docker-compose.ymlファイル version: ‘3’ …