GolangでJWT(JSON Web Token)を取り扱いたいと思い、jwt-goの使い方を調べました。
JWTについての説明は他の有益な解説サイトがあるので、そちらを参考にして下さい。
早速実装の説明に入ります。
Routing定義
main.goに、ルーティングの定義をします。この記事では、julienschmidt/httprouterを使用しています。
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"sync"
"github.com/julienschmidt/httprouter"
)
type templateHandler struct {
once sync.Once
filename string
templ *template.Template
}
type API struct {
router *httprouter.Router
}
func main() {
fmt.Println("running...")
api := API{
router: httprouter.New(),
}
api.setAppRouter()
log.Fatal(http.ListenAndServe(":8080", api.router))
}
func (api *API) setAppRouter() {
api.router.POST("/tokenAuth", LoginHandler) //Login
api.router.GET("/tokenAuthenticate", RequireTokenAuthenticationHandler) //Tokenが有効か確認
}
tokenAuth でJWTの発行、tokenAuthenticateで発行されたJWTが有効か確認します。
Handlerの定義
説明をしやすくするため、handler.goの中身を三分割して説明を行います。
import、変数宣言
package main
import (
"crypto/rsa"
"fmt"
"io/ioutil"
"net/http"
"time"
jwt "github.com/dgrijalva/jwt-go"
request "github.com/dgrijalva/jwt-go/request"
"github.com/julienschmidt/httprouter"
)
var (
verifyKey *rsa.PublicKey
signKey *rsa.PrivateKey
)
jwt-goは、version2からversion3に変わった際、JWTを読み込むParseFromRequestメソッドが、requestフォルダに移動されました。
そのため、
jwt "github.com/dgrijalva/jwt-go"
request "github.com/dgrijalva/jwt-go/request"
の様に別々importしています。
今回、RSA256を利用し、JWT発行時は秘密鍵でサインを行い、JWT認証時は公開鍵で認証を行います。
そのため、verifyKey, signKeyを宣言しました。
プロジェクトパス以下に秘密鍵、公開鍵を作成します。
$ ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key:
${projectのパス}/demo.rsa
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in ${projectのパス}/demo.rsa.
Your public key has been saved in ${projectのパス}/demo.rsa.pub.
The key fingerprint is:
SHA256:8nz3BBJQKWrivQ8Msa/UT92R/JgQTKqKeLWrf7kVXHg
@Mac.local
The key's randomart image is:
+---[RSA 2048]----+
| ..o. |
| .*. |
| . .o.E |
| .ooo o + . |
| .++o S o = |
| . o.B.+ o + * |
|. o + =o= o = o |
| . . o+= . . o |
| .o+..oo . |
+----[SHA256]-----+
公開鍵のフォーマットをpksc8に変更します。
こちらの記載にある通り、公開鍵を読み込むにはpksc1もしくはpksc8でないといけない為です。
$ssh-keygen -f demo.rsa.pub -e -m pkcs8 > demo.rsa.pub.pkcs8
##LoginHandler(JWTの発行)
// LoginHandler : JWTの発行
func LoginHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
//今回、username,password共にtestを使っていますが、皆様が実際に行う場合はbodyから読み込んだり、DBから読み込んだりして下さい。
username := "test"
password := "test"
signBytes, err := ioutil.ReadFile("./demo.rsa")
if err != nil {
panic(err)
}
signKey, err := jwt.ParseRSAPrivateKeyFromPEM(signBytes)
if err != nil {
panic(err)
}
if username == "test" && password == "test" {
// create token
token := jwt.New(jwt.SigningMethodRS256)
// set claims
claims := token.Claims.(jwt.MapClaims)
claims["name"] = "test"
claims["admin"] = true
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
tokenString, err := token.SignedString(signKey)
if err != nil {
fmt.Println(err)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(tokenString))
}
}
秘密鍵は、読み込んで[]byte型になったsignBytesをParseRSAPrivateKeyFromPEMで*PrivateKey型のsignKeyに格納しています。
claimsの箇所もversion2からvarsion3に変更になった際に仕様が変更になりました。こちらを参照
version2のときは、
token := jwt.New(jwt.SigningMethodRS256)
token.Claims["name"] = "test"
token.Claims["admin"] = true
token.Claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
と書いていました。
##RequireTokenAuthenticationHandler(JWTの認証)
// RequireTokenAuthenticationHandler : Tokenの検証
func RequireTokenAuthenticationHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
verifyBytes, err := ioutil.ReadFile("./demo.rsa.pub.pkcs8")
if err != nil {
panic(err)
}
verifyKey, err := jwt.ParseRSAPublicKeyFromPEM(verifyBytes)
if err != nil {
panic(err)
}
token, err := request.ParseFromRequest(r, request.AuthorizationHeaderExtractor, func(token *jwt.Token) (interface{}, error) {
_, err := token.Method.(*jwt.SigningMethodRSA)
if !err {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
} else {
return verifyKey, nil
}
})
if err == nil && token.Valid {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusUnauthorized)
}
}
公開鍵については、秘密鍵と似た方法で、読み込んで[]byte型になったverifyBytesをParseRSAPublicKeyFromPEMで*PublicKey型のverifyKeyに格納しています。
認証時にややこしい箇所は、下記のParseFromRequestだと思います。
token, err := request.ParseFromRequest(r, request.AuthorizationHeaderExtractor, func(token *jwt.Token) (interface{}, error) { ...
version2からversion3になった際、ParseFromRequestメソッドに渡す引数が増えました。それが、今回でいうと、request.AuthorizationHeaderExtractor です。
こちらの通り、http.request内のどこにJWTがあるのかを指定することになりました。
Added new interface type Extractor, which is used for extracting JWT strings from http requests. Used with ParseFromRequest and ParseFromRequestWithClaims.
version2の際は、下記の様な書き方でOKでした。
token, err := ParseFromRequest(r, func(token *jwt.Token) (interface{}, error) { ...
httpリクエストでJWTを送る場合、リクエストヘッダのAuthorizationを用いることが多いと思うので、AuthorizationHeaderExtractorを今回使用しています。
それでは早速動作確認を行ってみます。
下記の様なテストを書きました。
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/julienschmidt/httprouter"
)
func TestLoginHandler(t *testing.T) {
router := httprouter.New()
router.POST("/tokenAuth", LoginHandler)
req, err := http.NewRequest("POST", "/tokenAuth", nil)
if err != nil {
t.Error("NewRequest URI error")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
fmt.Printf("api error. code is %d\n", w.Code)
fmt.Printf("api error. header is %#v\n", w.Header())
fmt.Printf("api error. body is %#v\n", w.Body.String())
} else {
fmt.Printf("code is %d\n", w.Code)
fmt.Printf("header is %#v\n", w.Header())
fmt.Printf("body is %#v\n", w.Body.String())
}
}
実行すると、
code is 200
header is http.Header{}
body is "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNTI3OTY1ODU3LCJuYW1lIjoidGVzdCJ9.a_wC2avD1HhDWypx-xGZo0Vo3Jwe7gpezNGhiSFOm_zR_h1ACcqzFLbYhl1YDgJ2ijTcZD0GUGknZpBOGpYR_fQKQf8i-TnzmYicwzS8MW8p8_X4MOdIyg2_AHsTGsMr5-vW5d3Qd64HHsn45Dz_-TQRhqr1Ws0R2T1GlnEo9bR3ylPBuI9HhYtosSD8mOTiYZYLqbZnl5rRVu0mGtNNHNuEsvxebCLf0o1xdBN-zUzZdaofiLQYtDL9NTvG0taosM5e74HcDfNK2xvnbEXO-MoXttzMVfZK55H2rLdcl9FchUdAfji7-zfXALwp7CCZVMYqq0tlt9Ltw8H8cfzGNw"
PASS
ok {プロジェクトパス} 0.018s
Success: Tests passed.
と、responsebodyの中にJWTが返ってきました。
このJWTを使って、認証が通るか試してみます。
func TestRequireTokenAuthentication(t *testing.T) {
router := httprouter.New()
router.GET("/tokenAuthenticate", RequireTokenAuthenticationHandler)
req, err := http.NewRequest("GET", "/tokenAuthenticate", nil)
if err != nil {
t.Error("NewRequest URI error")
}
w := httptest.NewRecorder()
req.Header.Set("Authorization", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNTI3OTY1ODU3LCJuYW1lIjoidGVzdCJ9.a_wC2avD1HhDWypx-xGZo0Vo3Jwe7gpezNGhiSFOm_zR_h1ACcqzFLbYhl1YDgJ2ijTcZD0GUGknZpBOGpYR_fQKQf8i-TnzmYicwzS8MW8p8_X4MOdIyg2_AHsTGsMr5-vW5d3Qd64HHsn45Dz_-TQRhqr1Ws0R2T1GlnEo9bR3ylPBuI9HhYtosSD8mOTiYZYLqbZnl5rRVu0mGtNNHNuEsvxebCLf0o1xdBN-zUzZdaofiLQYtDL9NTvG0taosM5e74HcDfNK2xvnbEXO-MoXttzMVfZK55H2rLdcl9FchUdAfji7-zfXALwp7CCZVMYqq0tlt9Ltw8H8cfzGNw")
router.ServeHTTP(w, req)
if w.Code != 200 {
fmt.Printf("api error. code is %d\n", w.Code)
fmt.Printf("api error. header is %#v\n", w.Header())
fmt.Printf("api error. body is %#v\n", w.Body.String())
} else {
fmt.Printf("code is %d\n", w.Code)
fmt.Printf("header is %#v\n", w.Header())
fmt.Printf("body is %#v\n", w.Body.String())
}
}
結果は、
code is 200
header is http.Header{}
body is ""
PASS
ok {プロジェクトパス} 0.017s
Success: Tests passed.
と、無事認証が通ったことが確認できました。
(わかりにくい説明箇所もあったと思うので、随時更新予定です。)