Golang(ECHO+GORM)でJWTとGraphQLの環境を構築

#`ECHO`+`GORM`で`JWT`と`GraphQL`の環境を構築する
`Go`で簡単な`JWT`と`GraphQL`環境の環境を作成したところ、思った以上に簡単に作成することができなかったので、備忘録として記載した（更新随時更新予定）。
なお、`golang`は、[ここ](https://golang.org/dl/)から`go1.9.2`をダウンロードし、利用した。

##`JWT`の環境を構築する
[dgrijalva/jwt-go](https://github.com/dgrijalva/jwt-go)を利用するのが一番簡単な実装に思えたのでまずはこちらを利用した。

###`GORM`を使わないパターン
まずはDBなしで、`GORM`を使わないパターンで環境を作成した。
`echo`と`jwt-go`を下記のように`go get`する。

```shell-session
$ go get github.com/labstack/echo
$ go get github.com/dgrijalva/jwt-go
```
なお、ここではサンプル作成にあたり、こちらの[`GitHub`](https://github.com/labstack/echox/tree/master/cookbook/jwt)を参考にした。

###サンプル
####ルーティングの定義
`go run`を実行する対象となるファイル。

```go:main.go
package main

import (
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
	"./handler"
)

func main() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/hello", handler.Hello())
	e.POST("/login", handler.Login())
	r := e.Group("/restricted")
	r.Use(middleware.JWT([]byte("secret")))
	r.POST("", handler.Restricted())

	e.Start(":3000")
}
```

####コントローラの定義
コントローラの位置づけとなる`handler.go`を定義する。

```go:handler/handler.go
package handler

import (
	"net/http"
	"time"
	"github.com/labstack/echo"
	"github.com/dgrijalva/jwt-go"
)

func Hello() echo.HandlerFunc {
	return func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello World")
	}
}

func Login() echo.HandlerFunc {
	return func(c echo.Context) error {
		username := c.FormValue("username")
		password := c.FormValue("password")

		if username == "test" && password == "test" {
			// Create token
			token := jwt.New(jwt.SigningMethodHS256)

			// Set claims
			claims := token.Claims.(jwt.MapClaims)
			claims["name"] = "test"
			claims["admin"] = true
			claims["exp"] = time.Now().Add(time.Hour * 72).Unix()

			// Generate encoded token and send it as response.
			t, err := token.SignedString([]byte("secret"))
			if err != nil {
				return err
			}
			return c.JSON(http.StatusOK, map[string]string{
				"token": t,
			})
		}

		return echo.ErrUnauthorized
	}
}

func Restricted() echo.HandlerFunc  {
	return func(c echo.Context) error {
		user := c.Get("user").(*jwt.Token)
		claims := user.Claims.(jwt.MapClaims)
		name := claims["name"].(string)
		return c.String(http.StatusOK, "Welcome "+name+"!")
	}
}
```

####実行結果
まずは、ログインをし、トークンを取得する。

```shell-session
$ curl -X POST -d 'username=test' -d 'password=test' localhost:3000/login
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNTEwNzUzNTU1LCJuYW1lIjoiSm9uIFNub3cifQ.LPRv0prfLL1Xpy0Us06E97qPb0Nca6UoDcHYVlSVWwc"}
```

トークンを利用して、認証の検証をする。

```shell-session
$ curl -X POST localhost:3000/restricted -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNTEwNzUzNTU1LCJuYW1lIjoiSm9uIFNub3cifQ.LPRv0prfLL1Xpy0Us06E97qPb0Nca6UoDcHYVlSVWwc"
Welcome test!
```

###`GORM`を使うパターン
ローカルインストールしている`MySQL`を利用した。
`gorm`と`MySQL`のドライバを`go get`する。

```shell-session
$ go get github.com/jinzhu/gorm
$ go get github.com/jinzhu/gorm/dialects/mysql
```

####`DDL/DML`の実行
下記のように準備する。
ユーザ名は`UNIQUE`にし、パスワードは平文のままのため、`MD5`等で暗号化する予定。

```sql
CREATE DATABASE `go_test`;

CREATE TABLE IF NOT EXISTS `go_test`.`user` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(60) NOT NULL,
  `password` VARCHAR(60) NOT NULL,
  `hobby` VARCHAR(60) NOT NULL,
  PRIMARY KEY (`id`))
ENGINE = InnoDB;

INSERT INTO `go_test`.`user` (`name`, `password`, `hobby`) VALUES ("test", "test", "games");
```


####`MySQL`との接続
`_ "github.com/go-sql-driver/mysql"`を`import`で指定しないと、`sql: unknown driver \"mysql\" (forgotten import?)`というランタイムエラーが発生する。

```go:db/connect.go
package db

import (
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

func ConnectGORM() *gorm.DB {
	DBMS     := "mysql"
	USER     := "root"
	PASS     := ""
	PROTOCOL := "tcp(0.0.0.0:3306)"
	DBNAME   := "go_test"

	CONNECT := USER+":"+PASS+"@"+PROTOCOL+"/"+DBNAME
	db,err := gorm.Open(DBMS, CONNECT)

	if err != nil {
		panic(err.Error())
	}
	return db
}
```

####モデルの定義
`User`モデルを定義する

```go:models/user.go
package models

type User struct {
	Id int64
	Name string `sql:"size:60"`
	Password string `sql:"size:60"`
	Hobby string `sql:"size:60"`
}
```

####コントローラの変更
`handler.go`を以下のように変更する。テーブル名を`User`と複数形にしていないので、`db.SingularTable(true)`を実行する必要がある。

```go:handler/handler.go
package handler

import (
	"net/http"
	"github.com/labstack/echo"
	"github.com/dgrijalva/jwt-go"
	"../db"
	"../models"
	"time"
)

func Hello() echo.HandlerFunc {
	return func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello World")
	}
}

func Login() echo.HandlerFunc {
	return func(c echo.Context) error {
		username := c.FormValue("username")
		password := c.FormValue("password")

		db := db.ConnectGORM()
		db.SingularTable(true)
		user := [] models.User{}
		db.Find(&user, "name=? and password=?", username, password)

		if len(user) > 0 && username == user[0].Name {
			// Create token
			token := jwt.New(jwt.SigningMethodHS256)

			// Set claims
			claims := token.Claims.(jwt.MapClaims)
			claims["name"] = username
			claims["admin"] = true
			claims["exp"] = time.Now().Add(time.Hour * 72).Unix()

			// Generate encoded token and send it as response.
			t, err := token.SignedString([]byte("secret"))
			if err != nil {
				return err
			}
			return c.JSON(http.StatusOK, map[string]string{
				"token": t,
			})
		}

		return echo.ErrUnauthorized
	}
}

func Restricted() echo.HandlerFunc  {
	return func(c echo.Context) error {
		user := c.Get("user").(*jwt.Token)
		claims := user.Claims.(jwt.MapClaims)
		name := claims["name"].(string)
		return c.String(http.StatusOK, "Welcome "+name+"!")
	}
}
```

####実行結果
「`GORM`を使わないパターン」と同一のため、省略。

##`GraphQL`の環境を構築
`graphql`を`go get`する。

```shell-session
$ go get github.com/graphql-go/graphql
```

サンプルは、こちらの[GitHub](https://github.com/syossan27/sample-graphql)を参考にした。

###モデルの変更
`user.go`を下記のように変更する

```go:models/user.go
package models

type User struct {
	Id int64 `db:"id" json:"id"`
	Name string `sql:"size:60" db:"name" json:"name"`
	Password string `sql:"size:60" db:"password" json:"password"`
	Hobby string `sql:"size:60" db:"hobby" json:"hobby"`
}
```

###スキーマの追加
スキーマを定義する。

```go:graphql/schema.go
package graphql

import (
	"github.com/graphql-go/graphql"
	"fmt"
	"../db"
	"../models"
	"log"
	"strconv"
)

var userType = graphql.NewObject(
	graphql.ObjectConfig {
		Name: "User",
		Fields: graphql.Fields {
			"id": &graphql.Field {
				Type: graphql.String,
			},
			"name": &graphql.Field{
				Type: graphql.String,
			},
			"hobby": &graphql.Field{
				Type: graphql.String,
			},
		},
	},
)

var queryType = graphql.NewObject(
	graphql.ObjectConfig {
		Name: "Query",
		Fields: graphql.Fields {
			"User": &graphql.Field {
				Type: userType,
				Args: graphql.FieldConfigArgument {
					"id": &graphql.ArgumentConfig {
						Type: graphql.String,
					},
				},
				Resolve: func(p graphql.ResolveParams) (interface{}, error) {
					idQuery, err :=strconv.ParseInt(p.Args["id"].(string), 10, 64)
					if err == nil {
						db := db.ConnectGORM()
						db.SingularTable(true)
						user := models.User{}
						user.Id = idQuery
						db.First(&user)
						log.Print(idQuery)
						return &user, nil
					}

					return nil, nil
				},
			},
		},
	},
)

func ExecuteQuery(query string) *graphql.Result {
	var schema, _ = graphql.NewSchema(
		graphql.SchemaConfig {
			Query: queryType,
		},
	)

	result := graphql.Do(graphql.Params {
		Schema: schema,
		RequestString: query,
	})

	if len(result.Errors) > 0 {
		fmt.Printf("wrong result, unexpected errors: %v", result.Errors)
	}

	return result
}
```

###コントローラの変更
`Restricted`を以下のように変更する。

```go:handler/handler.go
package handler

import (
	"net/http"
	"github.com/labstack/echo"
	"github.com/dgrijalva/jwt-go"
	"../db"
	"../models"
	"../graphql"
	"time"
	"bytes"
	"log"
)

func Hello() echo.HandlerFunc {
	return func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello World")
	}
}

func Login() echo.HandlerFunc {
	return func(c echo.Context) error {
		username := c.FormValue("username")
		password := c.FormValue("password")

		db := db.ConnectGORM()
		db.SingularTable(true)
		user := [] models.User{}
		db.Find(&user, "name=? and password=?", username, password)

		if len(user) > 0 && username == user[0].Name {
			// Create token
			token := jwt.New(jwt.SigningMethodHS256)

			// Set claims
			claims := token.Claims.(jwt.MapClaims)
			claims["name"] = username
			claims["admin"] = true
			claims["exp"] = time.Now().Add(time.Hour * 72).Unix()

			// Generate encoded token and send it as response.
			t, err := token.SignedString([]byte("secret"))
			if err != nil {
				return err
			}
			return c.JSON(http.StatusOK, map[string]string{
				"token": t,
			})
		}

		return echo.ErrUnauthorized
	}
}

func Restricted() echo.HandlerFunc  {
	return func(c echo.Context) error {
		user := c.Get("user").(*jwt.Token)
		_ = user.Claims.(jwt.MapClaims)
		bufBody := new(bytes.Buffer)
		bufBody.ReadFrom(c.Request().Body)
		query := bufBody.String()
		log.Printf(query)
		result := graphql.ExecuteQuery(query)
		return c.JSON(http.StatusOK, result)
	}
}
```

###実行結果
ヘッダにトークンを指定して、クエリを実行する。

```shell-session
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNTEwNzk5MDU0LCJuYW1lIjoidGVzdCJ9.0snV1Goej4BtEv1Q8-M3N22aBtwB2BsdxNRvr3uhUFQ" -X POST -d '
{
  query: User(id: "1") { id, name, hobby }
}
' http://localhost:3000/restricted
```

クエリの実行結果はこちら。

```shell-session
{"data":{"query":{"hobby":"games","id":"1","name":"test"}}}
```

##`refresh token`を扱った実装
[こちら](https://github.com/adam-hanna/goLang-jwt-auth-example)で[dgrijalva/jwt-go](https://github.com/dgrijalva/jwt-go)を利用し、リフレッシュトークンを生成するサンプルを作成されていたので、それを参考に実装する。

###`refresh token`の管理を追加
下記を追加する。

```go:libs/randomString.go
package libs

import (
	"../db"
	"../models"
	"crypto/rand"
	"encoding/base64"
)

var refreshTokens map[string]string

func GenerateRandomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	_, err := rand.Read(b)
	if err != nil {
		return nil, err
	}

	return b, nil
}

func GenerateRandomString(s int) (string, error) {
	b, err := GenerateRandomBytes(s)
	return base64.URLEncoding.EncodeToString(b), err
}

func InitDB() {
	refreshTokens = make(map[string]string)
}

func FetchUser(username string, password string) models.User {
	con := db.ConnectGORM()
	con.SingularTable(true)
	user := models.User{}
	con.First(&user, "name=? and password=?", username, password)
	return user
}

func StoreRefreshToken() (jti string, err error) {
	jti, err = GenerateRandomString(32)
	if err != nil {
		return jti, err
	}

	for refreshTokens[jti] != "" {
		jti, err = GenerateRandomString(32)
		if err != nil {
			return jti, err
		}
	}

	refreshTokens[jti] = "valid"

	return jti, err
}

func DeleteRefreshToken(jti string) {
	delete(refreshTokens, jti)
}

func CheckRefreshToken(jti string) bool {
	return refreshTokens[jti] != ""
}
```

###`refresh token`と`token`の作成機能を用意
```go:middleware/jwtExtension.go
package middleware

import (
	"time"
	"../libs"
	jwt "github.com/dgrijalva/jwt-go"
	"github.com/labstack/echo"
	"fmt"
)

type MyClaim struct {
	UserId int64
	IsAdmin bool
	RefreshJti string
	jwt.StandardClaims
}

func createRefreshTokenString(userid int64) (refreshTokenString string, err error) {
	refreshJti, err := libs.StoreRefreshToken()
	if err != nil {
		return "", err
	}

	if userid != 0 {
		// Create token
		token := jwt.NewWithClaims(jwt.SigningMethodHS256, &MyClaim{
			UserId: userid,
			IsAdmin: false,
			RefreshJti: refreshJti,
			StandardClaims: jwt.StandardClaims{
				ExpiresAt: time.Now().Add(time.Hour * 24 * 7).Unix(),
			}})

		// Generate encoded token and send it as response.
		t, err := token.SignedString([]byte("secret2"))
		if err != nil {
			return "", err
		}
		return t, err
	}
	return "", echo.ErrUnauthorized
}

func createAuthTokenString(userid int64) (authTokenString string, err error) {
	// Create token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, &MyClaim{
		UserId: userid,
		IsAdmin: true,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: time.Now().Add(time.Hour * 24).Unix(),
		}})

	// Generate encoded token and send it as response.
	t, err := token.SignedString([]byte("secret1"))
	if err != nil {
		return "", err
	}
	return t, err
}

func CreateNewTokens(username string, password string) (authTokenString string, refreshTokenString string, err error) {
	user := libs.FetchUser(username, password)

	refreshTokenString, err = createRefreshTokenString(user.Id)

	if err != nil {
		return "", "", err
	}

	authTokenString, err = createAuthTokenString(user.Id)

	if err != nil {
		return "", "", err
	}

	return
}

func UpdateRefreshTokenExp(myClaim *MyClaim, oldTokenString string) (newTokenString, newRefreshTokenString string, err error) {
	myClaim2 := MyClaim{}
	_, err = jwt.ParseWithClaims(oldTokenString, &myClaim2, func(token *jwt.Token) (interface{}, error) {
		return []byte("secret1"), nil
	})

	if err != nil {
		return "", "", err
	}

	if !libs.CheckRefreshToken(myClaim.RefreshJti) || myClaim.UserId != myClaim2.UserId {
		return "", "", fmt.Errorf("error: %s", "old token is invalid")
	}

	libs.DeleteRefreshToken(myClaim2.RefreshJti)

	newRefreshTokenString, err = createRefreshTokenString(myClaim2.UserId)

	if err != nil {
		return "", "", err
	}

	newTokenString, err = createAuthTokenString(myClaim2.UserId)

	if err != nil {
		return "", "", err
	}

	return
}
```

###コントローラを変更
下記のように変更し、`refresh token`と`token`を認証時に受取り、再認証ができるようにする。

```go:hadler/handler.go
package handler

import (
	"net/http"
	"github.com/labstack/echo"
	"github.com/dgrijalva/jwt-go"
	"../graphql"
	"../middleware"
	"bytes"
	"log"
)

func Hello() echo.HandlerFunc {
	return func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello World")
	}
}

func Login() echo.HandlerFunc {
	return func(c echo.Context) error {
		username := c.FormValue("username")
		password := c.FormValue("password")

		tokenString, refreshTokenString, err := middleware.CreateNewTokens(username, password)

		if err == nil {
			return c.JSON(http.StatusOK, map[string]string{
				"token": tokenString,
				"refreshToken": refreshTokenString,
			})
		}

		return echo.ErrUnauthorized
	}
}

func Restricted() echo.HandlerFunc  {
	return func(c echo.Context) error {
		user := c.Get("user").(*jwt.Token)
		_ = user.Claims.(jwt.MapClaims)
		bufBody := new(bytes.Buffer)
		bufBody.ReadFrom(c.Request().Body)
		query := bufBody.String()
		log.Printf(query)
		result := graphql.ExecuteQuery(query)
		return c.JSON(http.StatusOK, result)
	}
}

func ReAuth() echo.HandlerFunc {
	return func(c echo.Context) error {
		user := c.Get("user").(*jwt.Token)
		claims := user.Claims.(*middleware.MyClaim)
		oldToken := c.FormValue("old_token")
		tokenString, refreshTokenString, err := middleware.UpdateRefreshTokenExp(claims, oldToken)
		if err == nil {
			return c.JSON(http.StatusOK, map[string]string{
				"token": tokenString,
				"refreshToken": refreshTokenString,
			})
		}
		return echo.ErrUnauthorized
	}
}
```

`main`も変更する。

```go:main.go
package main

import (
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
	"./handler"
	"./libs"
	ext "./middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	libs.InitDB()

	e.GET("/hello", handler.Hello())
	e.POST("/login", handler.Login())
	r1 := e.Group("/restricted")
	r1.Use(middleware.JWT([]byte("secret1")))
	r1.POST("", handler.Restricted())

	r2 := e.Group("/reauth")
	config := middleware.JWTConfig{
		Claims:     &ext.MyClaim{},
		SigningKey: []byte("secret2"),
	}
	r2.Use(middleware.JWTWithConfig(config))
	r2.POST("", handler.ReAuth())

	e.Start(":3000")
}
```

###実行結果
まずは`refresh token`と`token`を取得する。

```sh
$ curl -X POST -d 'username=test' -d 'password=test' localhost:3000/login
{"refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOmZhbHNlLCJSZWZyZXNoSnRpIjoiX0RYZC12SFZtNTZ4WE9VMHFfbXUxN053eEVFMDAyZmdqRGRFcmFfeFB2az0iLCJleHAiOjE1MjQ0MDc1MzR9.QVc8kiHn1VIv9A-zR_bXFwcDjcECc_7j1s683kizs8M","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOnRydWUsIlJlZnJlc2hKdGkiOiIiLCJleHAiOjE1MjM4ODkxMzR9.E_BiLRjCLVv-MUoYGGtqS9oMEzR612bn4ucAA6PFscU"}
```

再認証する。
ヘッダには`refresh token`を付与し、古い`token`を念のため`POST`している。

```sh
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOmZhbHNlLCJSZWZyZXNoSnRpIjoiX0RYZC12SFZtNTZ4WE9VMHFfbXUxN053eEVFMDAyZmdqRGRFcmFfeFB2az0iLCJleHAiOjE1MjQ0MDc1MzR9.QVc8kiHn1VIv9A-zR_bXFwcDjcECc_7j1s683kizs8M" -X POST -d 'old_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOnRydWUsIlJlZnJlc2hKdGkiOiIiLCJleHAiOjE1MjM4ODkxMzR9.E_BiLRjCLVv-MUoYGGtqS9oMEzR612bn4ucAA6PFscU' localhost:3000/reauth
{"refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOmZhbHNlLCJSZWZyZXNoSnRpIjoicWg0UG5pUEpwUG9Ld19mQ3JVZUttX1Q3c1dUd1VSbTlNOWJQNllHblppVT0iLCJleHAiOjE1MjQ0MDc1ODd9.AZyIEGVMeZ8Rr5gEw21FFM39qeRFg6qF6kvfR9VjU9I","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOnRydWUsIlJlZnJlc2hKdGkiOiIiLCJleHAiOjE1MjM4ODkxODd9.AT7vIchiphnVuMGb9-TW8xtGxvwpDKN3hm8N2n1eX4I"}
```

再取得した`token`でクエリを実行する。

```sh
$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjEsIklzQWRtaW4iOnRydWUsIlJlZnJlc2hKdGkiOiIiLCJleHAiOjE1MjM4ODkxODd9.AT7vIchiphnVuMGb9-TW8xtGxvwpDKN3hm8N2n1eX4I" -X POST -d '
{
 query: User(id: "1") { id, name, hobby }
}
' http://localhost:3000/restricted
{"data":{"query":{"hobby":"games","id":"1","name":"test"}}}
```