jwt

2021年2月3日

【Go】EchoによるJWT認証の実装

Echoを使ったJWT認証のサンプル

WEBアプリケーションに必要不可欠なユーザー認証について、JWT認証を用いて実装します。

JWTとはJSON Web Tokenの略です。
セッションによる認証ではセッションIDをCookieに保存しますが、JWTにおいてはトークンをLocal storageなどに保存して利用します。
トークン内に任意の情報(クレーム)を保持することが可能で秘密鍵により署名されています。

とても説明しきれないので、ここでは詳しい説明については割愛します。

JWT特設サイト
https://jwt.io/

Echo cookbook > jwt
https://echo.labstack.com/cookbook/jwt

今回のサンプルについて

本当は前の記事で実装した全ての機能にJWT認証をつけて解説すべきですが、冗長になるのでポイントを絞って説明します。
WEBアプリケーションで認証が必要な領域と、そうでない領域を分ける場合、最小限の構成としては下記になるかと思います。

  • 認証が不要な領域
    • ユーザー登録
    • ログイン画面
  • 認証が必要な領域
    • マイページ

ディレクトリ構造(変更)

上記の最小限の構成のみに絞ってソースを掲載します。
画面はユーザー登録(signup)、ログイン画面(login)+マイページ(mypage)となります。

└── src
    └── sample
        ├── controller
        │   └─ user_controller.go
        ├── go.mod
        ├── go.sum
        ├── main.go
        ├── model
        │   ├── db.go
        │   └── user.go
        ├── public
        │   └── views
        │       ├── mypage.html
        │       └── signup.html
        ├── router
        │   └── router.go

追加変更したソース

前々回の記事および前回の記事から追加変更したソースのみ記載します。
main.go、そしてmodelフォルダ内のdb.goは変更ありません。

controller/user_controller.go

GoでJWT認証を利用するためのライブラリjwt-goを使用しています。

https://github.com/dgrijalva/jwt-go

また、パスワードのハッシュ化にはgolang.org/x/crypto/bcryptを使用します。

https://pkg.go.dev/golang.org/x/crypto/bcrypt

トークンを返すのがfunc Restrictedになります。

前の記事同様、本当はユーザー情報の登録やログイン情報の参照に際して、入力内容のバリデーションやサニタイズなどのセキュリティ対策をすべきです。ですのでこのサンプルソースをそのままプロダクトに利用しないでください。この記事ではあくまでもJWT認証の説明のみに焦点を当てています。

package controller

import (
    "net/http"
    "time"

    "sample/model"

    "github.com/dgrijalva/jwt-go"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "golang.org/x/crypto/bcrypt"
)

// jwtCustomClaims are custom claims extending default ones.
type jwtCustomClaims struct {
    UID  uint   `json:"uid"`
    Name string `json:"name"`
    jwt.StandardClaims
}

var signingKey = []byte("secret")

var Config = middleware.JWTConfig{
    Claims:     &jwtCustomClaims{},
    SigningKey: signingKey,
}

type User struct {
    ID       uint   `json:"id" form:"id" query:"id"`
    Name     string `json:"name" form:"name" query:"name"`
    Password string `json:"password" form:"password" query:"password"`
}

func MethodOverride(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if c.Request().Method == "POST" {
            method := c.Request().PostFormValue("_method")
            if method == "PUT" || method == "PATCH" || method == "DELETE" {
                c.Request().Method = method
            }
        }
        return next(c)
    }
}

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

    user := model.User{}
    user.FirstByName(username)

    // Throws unauthorized error
    err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
    if err != nil {
        return echo.ErrUnauthorized
    }

    // Set custom claims
    claims := &jwtCustomClaims{
        user.ID,
        user.Name,
        jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
        },
    }

    // Create token with claims
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

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

    return c.JSON(http.StatusOK, echo.Map{
        "token": t,
    })
}

func Mypage(c echo.Context) error {
    return c.Render(http.StatusOK, "mypage.html", map[string]interface{}{
        "title": "Mypage",
    })
}

func SignUp(c echo.Context) error {
    return c.Render(http.StatusOK, "signup.html", map[string]interface{}{
        "title": "Login / Signup Form",
    })
}

func CreateUser(c echo.Context) error {
    name := c.FormValue("name")
    p := c.FormValue("password")
    hashed, _ := bcrypt.GenerateFromPassword([]byte(p), 12)
    password := string(hashed)

    user := model.User{
        Name:     name,
        Password: password,
    }
    user.Create()

    return c.Render(http.StatusOK, "signup.html", map[string]interface{}{
        "title": "Your account was created successfully",
    })
}

func Restricted(c echo.Context) error {
    user := c.Get("user").(*jwt.Token)
    claims := user.Claims.(*jwtCustomClaims)
    name := claims.Name
    return c.JSON(http.StatusOK, echo.Map{
        "username": name,
    })
}

model/user.go

シンプルにポイントを絞るために、以前の記事から年齢(Age)を削除して、新たにパスワード(Password)を追加しています。
また、ユーザー名(Name)はユニーク(一意)としています。

ユーザー情報の更新、削除は割愛しました。

package model

import (
	"time"

	"gorm.io/gorm"
)

type User struct {
	ID        uint      `json:"id"`
	Name      string    `json:"name" gorm:"unique;type:varchar(255);not null"`
	Password  string    `json:"password" gorm:"type:varchar(255);not null"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

func (p *User) FirstByName(name string) (tx *gorm.DB) {
	return DB.Where("name = ?", name).First(&p)
}

func (p *User) Create() (tx *gorm.DB) {
	return DB.Create(&p)
}

router/router.go

今回のJWT認証で特にポイントとなる箇所について説明します。
この記述より下のルーティングが、JWT認証が必要なルーティングとなります。

r := e.Group("/restricted")
r.Use(middleware.JWTWithConfig(controller.Config))

今回のサンプルでは、JWT認証結果を返す処理のみを追加しています。
割愛したユーザー情報の更新や削除も、今回の最小構成の応用で実装できるかと思います。

package router

import (
    "io"
    "sample/controller"
    "text/template"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

// TemplateRenderer is a custom html/template renderer for Echo framework
type TemplateRenderer struct {
    templates *template.Template
}

// Render renders a template document
func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    // Add global methods if data is a map
    if viewContext, isMap := data.(map[string]interface{}); isMap {
        viewContext["reverse"] = c.Echo().Reverse
    }
    return t.templates.ExecuteTemplate(w, name, data)
}

func Init() {
    t := &TemplateRenderer{
        templates: template.Must(template.ParseGlob("public/views/*.html")),
    }
    e := echo.New()
    e.Pre(controller.MethodOverride)
    e.Renderer = t
    e.GET("/", controller.SignUp)
    e.POST("/user", controller.CreateUser)
    e.POST("/login", controller.Login)
    e.GET("/mypage", controller.Mypage)

    r := e.Group("/restricted")
    r.Use(middleware.JWTWithConfig(controller.Config))
    r.GET("", controller.Restricted)

    e.Logger.Fatal(e.Start(":8080"))
}

public/views/mypage.html

マイページを表示するには、Local storageからトークン情報を取得して、Authorization: Bearer ヘッダをつけて認証リクエストをしています。

認証後のマイページでは、id=”greet”のところに、ユーザー名の入った挨拶が入るようにしています。
ログアウトボタンは、Local storageからトークン情報を削除して、ログイン前のページに遷移させています。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{index . "title"}}</title>
</head>
<body>
    <h1>{{index . "title"}}</h1>
    <div id="greet"></div>
    <div>
        <p><input type="button" name="button" class="btn" value="Logout"></p>
    </div>
</body>
<script>
    const btn = document.querySelector('.btn');
    const token = getToken();
    const url = 'restricted';
    const headers = {'Authorization': `Bearer ${getToken()}`}
    if (localStorage.getItem('token') === null) {
        location.href = '/';
    }
    fetch(url, {headers}).then(response => {
        if(response.ok) {
            return response.json();
        }
        return [];
    }).then(json => {
        var greet = document.getElementById('greet');
        greet.innerHTML = '<p>Hello ' + json.username +' !</p>';
    })
    
    btn.addEventListener('click', logout, false);
    
    function getToken() {
        return localStorage.getItem('token');
    }
    function logout() {
        localStorage.removeItem('token');
        location.href = '/';
    }
</script>
</html>

public/views/signup.html

ログイン(Login)およびユーザー登録(Sign Up)フォームです。簡潔にするために一つのページにしています。
ユーザー登録などは本来であればバリデーションやセキュリティ対策など実装すべきですが、今回はJWT認証の説明のため簡略化しています。

トークンを利用した認証をするために、Authorization: Bearer ヘッダをつけてリクエストをしています。
認証が通ればマイページへ遷移します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{index . "title"}}</title>
</head>
<body>
    <h1>{{index . "title"}}</h1>
    <h2>Login</h2>
    <form class="fetchForm">
        <div>
            <label for="name">Name:</label>
            <input type="text" id="name" name="name">
        </div>
        <div>
            <label for="password">Password:</label>
            <input type="password" id="password" name="password">
        </div>
        <p><input type="button" name="button" class="btn" value="Login"></p>
    </form>
    <h2>Sign Up</h2>
    <form method="POST" action="/user">
        <div>
            <label for="name">Name:</label>
            <input type="text" id="name1" name="name">
        </div>
        <div>
            <label for="password">Password:</label>
            <input type="password" id="password1" name="password">
        </div>
        <p><input type="submit" name="submit" value="Sign Up"></p>
    </form>
    
    <script>
        const fetchForm = document.querySelector('.fetchForm');
        const btn = document.querySelector('.btn');
        const url = '/login';
        if (localStorage.getItem('token') !== null) {
            location.href = '/mypage';
        }
        const postFetch = () => {
            let formData = new FormData(fetchForm);
            fetch(url, {
                method: 'POST',
                body: formData
            }).then((response) => {
                if (!response.ok) {
                    console.log('response error!');
                    return false;
                } else {
                    console.log('response good!');
                    return response.json();
                }
            }).then((data)  => {
                if (token) {
                    console.log('tokenreq = ' + token);
                    this.rReq(token);
                } else { 
                    console.log("token error!");
                    return false;
                }
            }).catch((error) => {
                console.log(error);
            });
        };

        btn.addEventListener('click', postFetch, false);

        function rReq(token) {
            fetch('/restricted', {
                method: "GET",
                headers: {
                Authorization:
                    "Bearer " + `${token}`,
                },
            }).then(function(response) {
                return response.json();
            }).then(function(json) {
                localStorage.setItem('token', token);
                location.href = '/mypage';
            });
        }
    </script>
</body>
</html>

今回はJWT認証のトークンをLocal Storageに保存していますが、そのことに対して賛否両論があることを記載しておきます。こと認証機能については、ご自身でよく調べてメリット・デメリットを確認したうえで実装してください。

ここまでになります。
次回の記事ではTemplates機能を使用せず、フロント側にReactを使用して、フロントとバックエンドを完全に分ける形で実装予定です。

 

LINEで送る
Pocket

label

isaka

Written by
isaka

バックエンドエンジニア

CONTACT

お問い合わせ、ご依頼などは下記電話番号かメールアドレスまでご連絡ください。
※内容により回答までお時間をいただく場合がございます、予めご了承ください。

tel. 06-6534-9333

10:00-19:00(※土日祝を除く)