Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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"}}}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away