57
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-11-13

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

##JWTの環境を構築する
dgrijalva/jwt-goを利用するのが一番簡単な実装に思えたのでまずはこちらを利用した。

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

$ go get github.com/labstack/echo
$ go get github.com/dgrijalva/jwt-go

なお、ここではサンプル作成にあたり、こちらのGitHubを参考にした。

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

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を定義する。

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+"!")
	}
}

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

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

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

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

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

$ go get github.com/jinzhu/gorm
$ go get github.com/jinzhu/gorm/dialects/mysql

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

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?)というランタイムエラーが発生する。

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モデルを定義する

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)を実行する必要がある。

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の環境を構築
graphqlgo getする。

$ go get github.com/graphql-go/graphql

サンプルは、こちらのGitHubを参考にした。

###モデルの変更
user.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"`
}

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

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を以下のように変更する。

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)
	}
}

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

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

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

{"data":{"query":{"hobby":"games","id":"1","name":"test"}}}

##refresh tokenを扱った実装
こちらdgrijalva/jwt-goを利用し、リフレッシュトークンを生成するサンプルを作成されていたので、それを参考に実装する。

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

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 tokentokenの作成機能を用意

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 tokentokenを認証時に受取り、再認証ができるようにする。

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も変更する。

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 tokentokenを取得する。

$ 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している。

$ 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でクエリを実行する。

$ 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"}}}
57
49
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
49

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?