Carpe Diem

備忘録。https://github.com/jun06t

Golangで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-----`)
)

文字列からgolangの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
}

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

       // 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*アルゴリズムで置換する方法もgolangでは型が違うため(RSArsa.PublicKey、HMACは[]byte)、処理に失敗してくれます。

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

動作結果

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

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

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

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

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

ソース