Go + React

2021年2月12日

【Go】JWT認証をReactとGoの連携で実装(その1)

GoをAPIサーバーとして、フロントのReactから連携

前回前々回は、EchoのTemplates機能を使用しましたが、本当にやりたいことはGoはあくまでもAPIサーバーに徹して、フロント側のアプリケーションとの連携です。

そこで今回は、Go(Echo)はTemplatesを使用せず、フロント側とAPIのやり取りに徹します。
フロント側にReactでログインフォームを実装、JWT認証に挑戦します。

今回はフロント側WebアプリケーションとしてReactを使用しますが、Go側とAPIを介しての疎結合にすることで、Webアプリケーションだけでなくスマホアプリなど他の方法とも連携がしやすくなります。

この連載はあくまでGoを主体としているので、Reactについての説明は割愛します。

フロントとバックエンドの構成について

開発環境はAWS(EC2)を使用、一つのインスタンスで構築しているため、今回は下記のような構成になります。

フロント側

Reactで構築、ポート番号は3000
https://ec2-xx-xxx-xxx-xx.ap-northeast-1.compute.amazonaws.com:3000/

バックエンド側

Go(Echo)で構築、ポート番号は8080
https://ec2-xx-xxx-xxx-xx.ap-northeast-1.compute.amazonaws.com:8080/

CORS (Cross-Origin Resource Sharing) オリジン間リソース共有

フロント側とバックエンド側を別々に構築するため、CORSを使用します。

CORSとは、あるオリジンで動いている Web アプリケーションに対して、別のオリジンのサーバーへのアクセスをオリジン間 HTTP リクエストによって許可できる仕組みのことを言います。

クロスオリジンとクロスドメインの違いとしては、プロトコルとポート番号を含んでいるという点です。
今回のAWS(EC2)の一つのインスタンスで2つのポート番号で連携する場合も含みます。

つまり、上の例ではドメインが、
ec2-xx-xxx-xxx-xx.ap-northeast-1.compute.amazonaws.com
オリジンが(プロトコル=httpsと、ポート番号=3000を含む)
https://ec2-xx-xxx-xxx-xx.ap-northeast-1.compute.amazonaws.com:3000/
となります。

EchoでのCORSの設定をする

EchoでCORSを設定する場合は、ルーティングの箇所で下記の公式サイトのサンプルのように記述します。
環境によっては簡易な設定でも動作可能です。完全にドメイン違いなどの場合は、厳密な設定が必要です。
Configurationの書き方も公式サイトをご確認ください。

CORS Middleware
https://echo.labstack.com/middleware/cors

CORS Recipe
https://echo.labstack.com/cookbook/cors

// 簡易な設定(ローカル環境や、同じAWS EC2インスタンス内でポート番号違いなど)
e.Use(middleware.CORS())

// 厳密な設定
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
  AllowOrigins: []string{"https://labstack.com", "https://labstack.net"},
  AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))

ディレクトリ構造(Go側の追加変更)

前回前々回で作成したものをTemplates使用を取りやめてJSON連携に変更、CORS設定を追記したものに改修します。

下記はGo側になります。React側は別でディレクトリを作成して実装となります。
今回のように同じEC2インスタンス内に同居する場合、下記のようにGoとReactを配置しましたが、Reactはこのような配置でなくても問題ありません。Goとバッティングしないようにすることだけ注意したらOKです。

Amazon Linux2 に普通にGoをインストールした場合、
/home/ec2-user/go/
というディレクトリ構造での想定になります。

Templatesの使用をやめた関係でGoフォルダ内にHTMLテンプレートのディレクトリ(public/views/)がごっそりなくなっています。
controllerのフォルダ名とファイル名をapiに変更しました(これは気分的な理由です)。
あとDB設定をconf/config.goに記述するようにしました。

├── Go
│   └── src
│       └── sample
│           ├── api
│           │   └─ user.go
│           ├── conf
│           │   └─ config.go
│           ├── go.mod
│           ├── go.sum
│           ├── main.go
│           ├── model
│           │   ├── db.go
│           │   └── user.go
│           └── router
│               └── router.go
│ 
└── React

追加変更したソース

main.goは変更ありませんが、他のソースは変更が入っています。

api/user.go

前回のcontroller/user_controller.goに当たるソースです。
ディレクトリ名とファイル名を変えた以外に、HTTPを扱う標準ライブラリとしnet/httpから、より高速に動作するというvalyala/fasthttpに変更しています。

valyala/fasthttp
https://github.com/valyala/fasthttp

package api

import (
    "time"

    "sample/model"

    "github.com/dgrijalva/jwt-go"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/valyala/fasthttp"
    "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{
        "name": name,
    })
}

conf/config.go

DB設定はこちらにまとめて記述するように変更しました。

package conf

const (
    USER string = "root"
    PASSWORD string = "password"
    DB string = "db_name"
    HOST string = "127.0.0.1"
    PORT string = "3306"
)

model/db.go

DB設定をconf/config.goから読み込むように修正しました。
なお、model/user.goは、前回の記事から変更ありません。

package model

import (
	"log"
	"sample/conf"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var DB *gorm.DB
var err error

func init() {
	dsn := conf.USER + ":" + conf.PASSWORD + "@tcp(" + conf.HOST + ":" + conf.PORT + ")/" + conf.DB + "?charset=utf8mb4&parseTime=True&loc=Local"
	DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatalln(dsn + "database can't connect")
	}
	DB.AutoMigrate(&User{})
}

router/router.go

Templates機能の使用をやめたことにより、大きく変更が入っています。
また今回のテーマであるCORSの記述を追記しています。
下記のサンプルでは、CORSの簡易な設定で記述していますが、GoとReactを別ドメインのサーバーに設置する等の場合は厳密な設定で記述してください。

package router

import (
    "sample/api"

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

func Init() {
    e := echo.New()
    e.Use(middleware.CORS())
    e.Pre(api.MethodOverride)
    e.GET("/", api.SignUp)
    e.POST("/user", api.CreateUser)
    e.POST("/login", api.Login)
    e.GET("/mypage", api.Mypage)

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

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

※なお、毎回記載していますが、本当はユーザー情報の登録やログイン情報の参照に際して、入力内容のバリデーションやサニタイズなどのセキュリティ対策をすべきです。
また、JWT認証のトークンをLocal Storageに保存することに対して賛否両論です。JWT認証がどうとかではなく、どんなものに対してもLocal Storageを使用すべきではないという意見です。
この記事はあくまで実験用なので、このサンプルソースをそのままプロダクトに利用しないでください。

次回は、フロント側のReactについて記載する予定です。

LINEで送る
Pocket

label

isaka

Written by
isaka

バックエンドエンジニア

CONTACT

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

tel. 06-6534-9333

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