My Note

自己理解のためのブログ

GoのWebフレームワーク Echo でJWT ( JSON Web Token )を使う

はじめに

前回はEchoで作成したwebアプリのセッション管理をRedisで行いました。

yhidetoshi.hatenablog.com

今回は JWT ( JSON Web Token ) を利用してAPIに認証をかけます。

■ JWTとは

https://e-words.jp/w/JWT.html

JWTとは、JSON形式で表現された認証情報などをURL文字列などとして安全に送受信できるよう、符号化やデジタル署名の仕組みを規定した標準規格

中身は <ヘッダー>.<ペイロード>.<署名> で構成されている

コード

❯ tree -L 2
.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── healthcheck_test.go
│   ├── investment_trust.go
│   └── metal.go
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   └── redis
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── db.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

他のコードは 前回の記事に記載。

ORMのgormを利用して Echo で サインアップとログイン機能を追加する - My Note

  • main.go(一部抜粋)
package main

import (
    "io"
    "net/http"
    "text/template"

    "github.com/gorilla/sessions"
    "github.com/labstack/echo-contrib/session"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"

    "github.com/yhidetoshi/apiEchoGAE-local/api"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/handler"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())

    // Template
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer

    // セッション
    e.Use(session.Middleware(sessions.NewCookieStore([]byte(conf.SIGNING_KEY))))

    // ルーティング //
    apiG := e.Group("/api")

    // HTMLページ
    e.GET("/", handler.ShowTopHTML)
    e.GET("/signup", handler.ShowSignUpHTML)
    e.POST("/signup", handler.SignUp)
    e.GET("/login", handler.ShowLoginHTML)
    e.POST("/login", handler.Login)
    e.GET("/restricted", handler.ShowRestrictedPage)

    apiG.Use(middleware.JWTWithConfig(handler.JWTConfig))
    apiG.GET("/private", handler.ShowData)

    e.Start(":8080")
}
  • conf/config.go
package conf

const (
    // MySQL
    DB_HOST = "localhost"
    DB_NAME = "echo"
    DB_PORT = "3306"
    DB_USER = "root"
    DB_PASS = "root"

    // Redis
    REDIS_HOST  = "localhost"
    REDIS_PORT  = "6379"
    SESSION_KEY = "my-investment"

    // JWT
    SIGNING_KEY = "secret"
)
  • handler/auth.go
package handler

import (
    "context"
    "html"
    "net/http"
    "time"

    "github.com/go-redis/redis/v8"
    "github.com/golang-jwt/jwt"
    "github.com/gorilla/sessions"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/labstack/gommon/log"
    "github.com/rbcervilla/redisstore/v8"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/model"
)

const (
    redisEndpoint = conf.REDIS_HOST + ":" + conf.REDIS_PORT
    sessionKey    = conf.SESSION_KEY
)

var signingKey = []byte(conf.SIGNING_KEY)

// JWTConfig https://echo.labstack.com/middleware/jwt/#configuration
var JWTConfig = middleware.JWTConfig{
    Claims:     &JWTCustomClaims{},
    SigningKey: signingKey,
}

// FormData Loginフォームのデータ処理用
type FormData struct {
    Name     string
    Password string
    Message  string
}

type JWTCustomClaims struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
    jwt.StandardClaims
}

type JWTResponseSample struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
}

// ResponseMessageJSON レスポンスメッセージ
type ResponseMessageJSON struct {
    Message string `json:"message"`
}
type ResponseJTWTokenJSON struct {
    Name  string `json:"name"`
    Token string `json:"token"`
}

// ShowSignUpHTML サインアップのページ表示
func ShowSignUpHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "signup", FormData{})
}

// ShowLoginHTML Loginページの表示
func ShowLoginHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowMyPageHTML Myページの表示
func ShowMyPageHTML(c echo.Context) error {
    // ログイン確認
    session := getSession(c)
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        mypageData := map[string]string{
            "UserName": session.Values["username"].(string),
        }
        return c.Render(http.StatusOK, "mypage", mypageData)
    }
}

func getSession(c echo.Context) *sessions.Session {
    client := redis.NewClient(&redis.Options{
        Addr: redisEndpoint,
    })
    store, err := redisstore.NewRedisStore(context.Background(), client)
    if err != nil {
        log.Fatal("Failed cannot connect redis", err)
        //return err
    }
    store.KeyPrefix("session_")
    store.Options(sessions.Options{
        MaxAge:   600,
        HttpOnly: true,
    })
    session, err := store.Get(c.Request(), sessionKey)
    if err != nil {
        log.Fatal("Failed cannot get session", err)
    }
    return session
}

// SignUp サインアップ処理
func SignUp(c echo.Context) error {
    signUpForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    name := html.EscapeString(signUpForm.Name)         // Form(Name)に入力されたデータを取得
    password := html.EscapeString(signUpForm.Password) // Form(Password)に入力されたデータを取得

    if name == "" || password == "" { // nullだけ弾く
        signUpForm.Message = "Invalid Name or Password"
        return c.Render(http.StatusOK, "signup", signUpForm)
    }
    user := new(model.User)
    user.Name = name
    user.Password = password

    if err := c.Bind(user); err != nil {
        return err
    }
    u := model.GetUser(&model.User{
        Name: user.Name,
    })
    if u.ID != 0 {
        return &echo.HTTPError{
            Code:    http.StatusConflict,
            Message: "Name already exists",
        }
    }
    model.CreateUser(user)
    user.Password = ""
    responseJSON := ResponseMessageJSON{
        Message: "SignUp Success",
    }
    return c.JSON(http.StatusOK, responseJSON)
}

// Login ログイン処理
func Login(c echo.Context) error {
    loginForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    // Formからデータ取得
    name := html.EscapeString(loginForm.Name)
    password := html.EscapeString(loginForm.Password)

    u := new(model.User)
    u.Name = name
    u.Password = password

    if err := c.Bind(u); err != nil {
        return err
    }

    user := model.GetUser(
        &model.User{Name: u.Name},
    )

    if u.Name != user.Name || u.Password != user.Password { // FormとDBのデータを比較
        return &echo.HTTPError{
            Code:    http.StatusUnauthorized,
            Message: "Invalid Name or Password",
        }
    }

    // セッション変数に値を付与 //
    session := getSession(c)
    session.Values["username"] = u.Name                              //ログインユーザ名を付与
    session.Values["auth"] = true                                    // ログイン有無の確認用
    if err := sessions.Save(c.Request(), c.Response()); err != nil { // Session情報の保存
        log.Fatal("Failed save session", err)
        return err
    }

    // JWT(Json Web Token)の処理 //
    claims := &JWTCustomClaims{ // https://pkg.go.dev/github.com/golang-jwt/jwt@v3.2.2+incompatible#NewWithClaims
        user.ID,
        user.Name,
        jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Hour * 1).Unix(),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    t, err := token.SignedString(signingKey)
    if err != nil {
        return err
    }
    session.Values["token"] = t

    ResponseJTWTokenJSON := ResponseJTWTokenJSON{
        Name:  user.Name,
        Token: t,
    }
    c.Redirect(http.StatusFound, "/mypage")
    return c.JSON(http.StatusOK, ResponseJTWTokenJSON)
}

// Logout ログアウト処理
func Logout(c echo.Context) error {
    session := getSession(c)
    // ログアクト
    session.Values["auth"] = false
    // セッション削除
    session.Options.MaxAge = -1
    if err := sessions.Save(c.Request(), c.Response()); err != nil {
        log.Fatal("Failed cannot delete session", err)
    }
    c.Redirect(http.StatusFound, "/login")
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowRestrictedPage ログイン済みユーザに表示するページ
func ShowRestrictedPage(c echo.Context) error {
    session := getSession(c)
    // ログイン確認
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        return c.String(http.StatusOK, session.Values["username"].(string))
    }
}

// ShowData JWT認証の確認用
func ShowData(c echo.Context) error {
    user := c.Get("user").(*jwt.Token)
    claims := user.Claims.(*JWTCustomClaims)
    responseJSON := JWTResponseSample{
        UID:  claims.UID,
        Name: claims.Name,
    }
    return c.JSON(http.StatusOK, responseJSON)
}

動作確認

JWT Recipe | Echo - High performance, minimalist Go web framework

■ JWT認証なし

❯ curl http://127.0.0.1:8080/api/private
{"message":"missing or malformed jwt"}

■ ログインしてトークンを発行

❯ curl -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login

<h1>
    <div>My Page</div>
</h1>

<body>
    <p>こんにちは yhidetoshi さん</p>
    <p>
    <h6>{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k"}</h6>
    </p>
</body>

トークンをデコードして中身を確認

  • ヘッダー: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • ペイロード: eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ
  • 署名: _9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k

これらをそれぞれデコードして中身を確認してみる。

<ヘッダー> をデコード
❯ echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d | jq
{
  "alg": "HS256",
  "typ": "JWT"
}<ペイロード> をデコード
❯ echo eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ | base64 -d
{"uid":1,"name":"yhidetoshi","exp":1648804081

■ <署名> をデコード
❯ echo _9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k | nkf -WmB
o=TqF;I|AHCp6X

■ JWT認証あり

❯ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k" localhost:8080/api/private
{"uid":1,"name":"yhidetoshi"}