はじめに
Go言語でJWT認証を実装してみます。
本記事ではjwt-goライブラリを利用して、下記の内容を試します。
- jwt-goによる認証情報の生成
- jwt-goによる認証情報の検証
- echoライブラリと連携した認証情報の検証
JWTとは
JWTとは
JWTとはJSON Web Tokenの略称で、JSON形式のデータに署名や暗号化を施す方法を定めた標準規格です。IETFによってRFC7519として標準化されています。
読み方はジェットと読むらしいです。
JWTは、JWS(JSON Web Signature)またはJWE(JSON Web Encryption)構造、あるいはその両方でエンコードされたJSONオブジェクトとして一連の情報(Claim)を表します。Claimとして、RFCで定義されている標準的なキーと値のペアを取ることにより、標準的な取り扱いが可能になります。Claimの標準的なキーと値については後述する構成要素の節に記載します。
このJWTは認証用のトークンとして使用されるケースがよくあります。次の図はJWTトークンを利用する一例です。
Tokenの構成要素
JWT規格に沿って生成したTokenは次の三つの要素から構成されます。
- ヘッダー
- ペイロード
- 署名
三つの構成要素を順番にピリオド.
で繋ぎ、一つに値にしたものがTokenになります。
ヘッダー.ペイロード.署名
ヘッダー
ヘッダーには認証情報に関するメタ情報(主に署名生成に使用したアルゴリズム)がJSON形式で格納されます。Tokenで使用する際にはこのJSONをBase64urlエンコードします。
{
"typ": "JWT"
"alg": "HS256"
}
ヘッダーで使用される標準的なClaimは次の通りです。
コード | 名称 | 説明 |
---|---|---|
typ | Token type | トークンの形式。JWT とすることが推奨される。 |
cty | Content type | JWTを入れ子にして署名や暗号化を行う場合、このフィールドにJWT を指定する。それ以外では通常指定しない。 |
alg | Message authentication code algorithm | 発行者が使用した署名アルゴリズム。任意のアルゴリズムが指定可能だが、いくつかのアルゴリズムは安全ではない。 |
ペイロード
ペイロードには認証情報が格納され、任意のClaimを格納することができます。JWTの仕様では、一般的に用いられる標準的なClaimが定義されています。
コード | 名称 | 説明 |
---|---|---|
iss | Issuer | トークン発行者の識別子。 |
sub | Subject | トークンの主題の識別子。 |
aud | Audience | トークンが意図している受信者の識別子。トークンを受け付ける受信者は、この値に自身が含まれるかを識別しなければならない。もしaud クレームが存在し、かつ自身が含まれない場合、トークンを拒否しなければならない。 |
exp | Expiration Time | トークンの有効期限。この期限以降の場合、トークンを受け付けてはならない。有効期限は1970-01-01 00:00:00Zからの秒数を数値で指定する(UNIX時間)。 |
nbf | Not Before | トークンの開始日時。この期限以降の場合、トークンを受け付けてよい。秒数を数値で指定する。 |
iat | Issued at | トークンの発行日時。秒数を数値で指定する。 |
jti | JWT ID | 発行者ごとトークンごとに一意な識別子。 |
Tokenで使用する際にはJSONをBase64urlエンコードします。
{
"loggedInAs" : "admin",
"iat" : 1422779638
}
署名
署名はToken検証用の署名です。Base64urlエンコードしたヘッダーとペイロードをピリオドで繋ぎ、ヘッダーで指定したアルゴリズムで暗号化することで生成します。Tokenに使用する際には暗号化された値をさらにBase64urlエンコードします。
JWT認証の実装
jwt-goによる認証情報の生成
jwt-goライブラリには、一般的に使用される暗号化方式が次のように定義されています。
var (
SigningMethodES256 *SigningMethodECDSA
SigningMethodES384 *SigningMethodECDSA
SigningMethodES512 *SigningMethodECDSA
)
var (
SigningMethodHS256 *SigningMethodHMAC
SigningMethodHS384 *SigningMethodHMAC
SigningMethodHS512 *SigningMethodHMAC
ErrSignatureInvalid = errors.New("signature is invalid")
)
var (
SigningMethodRS256 *SigningMethodRSA
SigningMethodRS384 *SigningMethodRSA
SigningMethodRS512 *SigningMethodRSA
)
var (
SigningMethodPS256 *SigningMethodRSAPSS
SigningMethodPS384 *SigningMethodRSAPSS
SigningMethodPS512 *SigningMethodRSAPSS
)
これら暗号化方式の定義はSigningMethod interface
をimplementしており、暗号化に必要な情報及びメソッドを持っています。jwt-goでTokenオブジェクトを生成する際に、これら暗号化方式の定義を使用します。
// Implement SigningMethod to add new methods for signing or verifying tokens.
type SigningMethod interface {
Verify(signingString, signature string, key interface{}) error // Returns nil if signature is valid
Sign(signingString string, key interface{}) (string, error) // Returns encoded signature or error
Alg() string // returns the alg identifier for this method (example: 'HS256')
}
また、ペイロード情報はtype Claim interface
に準拠するようtype MapClaims map[string]interface{}
もしくはtype StandardClaims struct
を用いて作成します。
以上の暗号化方式の定義とペイロード情報を用いて、func NewWithClaims(method SigningMethod, claims Claims) *Token
を実行することで、ヘッダーとClaimを含んだTokenオブジェクトを生成することができます。
Tokenオブジェクトを生成した後は、ユーザに返却する認証情報Tokenを生成して完了です。認証情報Tokenはfunc (t *Token) SignedString(key interface{}) (string, error)
を実行することで生成できます。
package main
import (
"fmt"
"github.com/dgrijalva/jwt-go"
)
func main() {
// Claimsオブジェクトの作成
claims := jwt.MapClaims{
"user_id": 12345678,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
// ヘッダーとペイロードの生成
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
fmt.Printf("Header: %#v\n", token.Header) // Header: map[string]interface {}{"alg":"HS256", "typ":"JWT"}
fmt.Printf("Claims: %#v\n", token.Claims) // CClaims: jwt.MapClaims{"exp":1634051243, "user_id":12345678}
// トークンに署名を付与
tokenString, _ := token.SignedString([]byte("SECRET_KEY"))
fmt.Println("tokenString:", tokenString) // tokenString: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzQwNTEzNjMsInVzZXJfaWQiOjEyMzQ1Njc4fQ.OooYrharapD5X2LV5UUWBOkEqH57wDfMd5ibkIpJHYM
}
jwt-goによる認証情報の検証
HTTPリクエストなどでトークンを受け取った後、正しいトークンかどうか検証を行います。次のように検証することができます。
- 受け取ったトークンからヘッダーとペイロードを取り出す
- 取り出したヘッダーとペイロードから改めて署名を作成
- 作成した署名と、トークンに含まれる署名を比較
- 一致していれば正しいトークン、不一致であれば正しくないトークン
jwt-goでこの検証を行うにはfunc (p Parser) Parse(tokenString string, keyFunc Keyfunc) (Token, error)
メソッドを用います。
このメソッドは、第一引数に検証するトークン、第二引数に暗号化に用いたキーを検索するための関数を渡します。実行することでトークン文字列からTokenオブジェクトへとパースするとともに、内部的に検証を行い、Token.Validに検証結果を格納しまう。つまり、Token.Valid == true
であれば正しいトークン、Token.Valid == false
であれば正しくないトークンであることがわかります。
package main
import (
"fmt"
"github.com/dgrijalva/jwt-go"
)
func main() {
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzQwNTEzNjMsInVzZXJfaWQiOjEyMzQ1Njc4fQ.OooYrharapD5X2LV5UUWBOkEqH57wDfMd5ibkIpJHYM"
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte("SECRET_KEY"), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
fmt.Printf("user_id: %v\n", int64(claims["user_id"].(float64)))
fmt.Printf("exp: %v\n", int64(claims["exp"].(float64)))
} else {
fmt.Println(err)
}
}
echoライブラリと連携した認証情報の検証
上述してきたjwt-goによる認証情報の生成と検証を、echoライブラリと連携して実現します。
echoにはJWT Middlewareが用意されています。echoのルーティング設定時にe.Use(middleware.JWTWithConfig(config))
のようにJWT Middlewareを設定することで、そのルートを通るリクエストが届いた際にJWTトークンの検証を行ってくれるようになります。
検証する際に必要な情報はtype JWTConfig
に設定します。今回は、トークン生成に使用した暗号化のキー(SigningKey)と、トークンのパースおよび解析をするParseTokenFuncをJWTConfigに渡します。
以上を踏まえて実装すると次のようになります。
package main
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// ログイン処理&トークン生成
func login(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
// username, passwordの確認
if username != "testname" || password != "testpw" {
return echo.ErrUnauthorized
}
// ペイロードの作成
claims := jwt.MapClaims{
"user_id": 12345678,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
// トークン生成
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// トークンに署名を付与
tokenString, err := token.SignedString([]byte("SECRET_KEY"))
if err != nil {
return err
}
return c.JSON(http.StatusOK, echo.Map{
"token": tokenString,
})
}
// ユーザ情報取得
func user(c echo.Context) error {
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
userID := int64(claims["user_id"].(float64))
return c.String(http.StatusOK, fmt.Sprintf("userID: %v", userID))
}
func main() {
e := echo.New()
// ログイン処理
e.POST("/login", login)
// user group
r := e.Group("/user")
// echo.middleware JWTConfigの設定
config := middleware.JWTConfig{
SigningKey: []byte("SECRET_KEY"),
ParseTokenFunc: func(tokenString string, c echo.Context) (interface{}, error) {
keyFunc := func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte("SECRET_KEY"), nil
}
token, err := jwt.Parse(tokenString, keyFunc)
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return token, nil
},
}
r.Use(middleware.JWTWithConfig(config))
r.GET("", user)
e.Start(":1323")
}
正しく動作することを確認するため、リクエストを送ってみます。
▼トークン取得
% curl -X POST -F 'username=testname' -F 'password=testpw' http://localhost:1323/login
// {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzQxNDI4NjIsInVzZXJfaWQiOjEyMzQ1Njc4fQ.kWMEchPsYOPiEECovoand4MYzWatfX8Bc4-nA25W4KE"}
▼取得したトークンを利用したユーザ情報取得
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzQxNDI4NjIsInVzZXJfaWQiOjEyMzQ1Njc4fQ.kWMEchPsYOPiEECovoand4MYzWatfX8Bc4-nA25W4KE' http://localhost:1323/user
// userID: 12345678
まとめ
本記事ではjwt-goを利用したJWT認証情報の生成、JWT認証情報の検証、およびechoライブラリと連携したJWT認証情報生成/検証の方法についてまとめました。認証周りは一見複雑なので難しい印象を持っていましたが、実際にコードを書いてみることで理解が深めることができました。
他のパッケージやGo言語における基本的な利用方法についても記事を書いていますので、もし興味がありましたらご参照ください。
・【Go言語】echoフレームワークの使い方入門
・【Go言語】ファイル/ディレクトリ操作方法 – 基本
・【Go言語】database/sqlパッケージによるデータベース操作入門 – sqlite3
・【Go言語】encoding/jsonパッケージでJSONをパースする
・【Go言語】net/httpパッケージでAPIリクエストを送信する
コメント