Carpe Diem

備忘録

GoでJWTの具体的な実装

概要

以前JWTを認証用トークンに使う時に調べたこと - Carpe Diemで紹介した内容の具体的な実装の紹介です。

環境

署名アルゴリズムと鍵長は以下とします。

署名アルゴリズム 鍵長
RSA-SHA256 4096bit

成果物

今回の完成形はこちら

github.com

公開鍵認証のためのキーペア作成

秘密鍵の生成

$ openssl genrsa 4096 > secret.key

秘密鍵から公開鍵の生成

$ openssl rsa -pubout < secret.key > public.key

今回は簡単のためソースコードに貼り付けます。

var (
    rawPublicKey = []byte(`-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAw8eiDb307pWnvR2XL0CW
...
Horlxxaj4cGE2OYxaJmRpL0CAwEAAQ==
-----END PUBLIC KEY-----`)

    rawSecretKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIJKgIBAAKCAgEAw8eiDb307pWnvR2XL0CWOD/Nscc5ZTN7peZzuyjOQSzqEYjE
a4XxUjI9KgdJD6zYW5h5BSA0Xa2quagJtZCSj5BjZ5YCF9rMaLAeUAmifKJyGj0Z
...
XtrgK2rkGLqvf0is3zma6Fc0m/C2Jg/mv8vHNeYzqLw2uf8AZX8GJMNjFxkCtQ==
-----END RSA PRIVATE KEY-----`)
)

文字列からGoのPublicKey, SecretKeyへ変換

rsa.PublicKeyrsa.SecretKeyに変換して使う必要があるので、その処理を入れます。

func parseKeys(rawSK []byte, rawPK []byte) error {
    var err error
    privateKeyBlock, _ := pem.Decode(rawSK)
    if privateKeyBlock == nil {
        return errors.New("Private key cannot decode")
    }
    if privateKeyBlock.Type != "RSA PRIVATE KEY" {
        return errors.New("Private key type is not rsa")
    }
    secretKey, err = x509.ParsePKCS1PrivateKey(privateKeyBlock.Bytes)
    if err != nil {
        return errors.New("Failed to parse private key")
    }

    publicKeyBlock, _ := pem.Decode(rawPK)
    if publicKeyBlock == nil {
        return errors.New("Public key cannot decode")
    }
    if publicKeyBlock.Type != "PUBLIC KEY" {
        return errors.New("Public key type is invalid")
    }

    publicKey, err = x509.ParsePKIXPublicKey(publicKeyBlock.Bytes)
    if err != nil {
        return errors.New("Failed to parse public key")
    }

    return nil
}

JWTの生成

JWTのメリットはペイロードの改竄が検知できる点です。
トークンとして使う場合は、userIDなどを入れるのが一般的だと思います。
またトークンに期限を持たせるため、IssuedAtExpiresAtを入れた方が良いです。

func GenerateToken(userID string, now int64) (tokenString string, err error) {
    claims := jwt.StandardClaims{
        Subject:   userID,
        IssuedAt:  now,
        ExpiresAt: time.Unix(now, 0).AddDate(0, 0, expiryDays).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

    // sign by secret key
    tokenString, err = token.SignedString(secretKey)
    if err != nil {
        err = errors.Wrap(err, "Failed to sign token")
        return
    }

    return
}

userID, unixtimeをいれて実行すると以下のようなJWTが生成されます。

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0OTIyODI1MTksImlhdCI6MTQ5MjE5NjExOSwic3ViIjoidXNlcjAwMSJ9.H1Vh6B3Y-wCwiFDRENz7YDxahXrlAu3DAFIP3HwcLId32DsjshMtOoSNYMWsXtuW36rf5SOOBFCeGQFI1b9cXDB9XOTykmN0MBJg6x2_0dWgaWrQrgaA4qRcc2_FvRSAxg_bDzqdKOoAI3mx5mZVb7voBPBFSxcymgz_x5kOKFXwQZ7s5PwASsnELpYpUg0cottehw0h4TYJHzD0Itxbp97-lWOvj3ncmR1UJnCCMH1H9Hj7yrNGs7f4RCd8weANoVBi-jvScoOjNvZowbQrg09phwuYf1tM81uvAARE_S6hVSCujFaSMii-8c122ckfcsOvM5PZveUhvGOpe2RX856tBtmV4wU-cAwkq7qtNKNc6ebp1v2vdBZFSPoijK-9b94BKkattLnIY1PARCUmNYTWFUSBh0a-CJcRr-RXEWatt3w-DyuHyp1ESoj0tidxtlX_W-bvlyaaTVCHPiPg1vFcWDl3PDEJaGjlanBChcrdVfhFAbvNBY5ujjJvJom2GqMvuh9JDUXAporMqMylxxhFt1jsO2haK28hbVpfL6xcE_lHfwowamtdgsvSzNDxO7r3FHMeKl0MxKV5h1bbAYaqZqBRqTL1N0Oi3--RRhQ1kxMSuvjGq3YAvqWVQJh73UT9sj4efNdYpTwGBhr-Pc5bZs2TDbJJIR8bLVtCCw4

生成された署名の検証

func VerifyToken(tokenString string) (*jwt.Token, error) {
    parsedToken, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // check signing method
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            err := errors.New("Unexpected signing method")
            return nil, err
        }
        return publicKey, nil
    })
    if err != nil {
        err = errors.Wrap(err, "Token is invalid")
        return nil, err
    }
    if !parsedToken.Valid {
        return nil, errors.New("Token is invalid")
    }

    return parsedToken, nil
}

jwt.Parse()関数のcallbackの中で公開鍵を戻り値に設定します。
HMACなどの共通鍵でのアルゴリズムを採用していれば共通鍵を返すことになります。

また内部で使用するアルゴリズムのチェックをしています。

       // check signing method
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            err := errors.New("Unexpected signing method")
            return nil, err
        }

これはJWSで見落としがちな以下の脆弱性のためです。

JWS 実装時に作りがちな脆弱性パターン - OAuth.jp

ただ、今回使用しているjwt-goというライブラリは、デフォルトでnoneアルゴリズムを禁止しています。
またHS*アルゴリズムで置換する方法もGoでは型が違うため(RSArsa.PublicKey、HMACは[]byte)、処理に失敗してくれます。

なので正直なところ無くても問題ないですが、念のため書いておきます。

動作結果

署名をいじらなければきちんと値を得ることができます。

{
  "sub":"user001",
  "exp":1.492282997e+09,
  "iat":1.492196597e+09
}

githubのソースにはテストコードも付けてあり、そちらでは

  • ペイロードを改竄した
  • algを改竄した(none, HS*)
  • ExpiresAtが切れている

といった場合にエラーになることを証明しています。

ソース