My Note

自己理解のためのブログ

go-echartsを使ってグラフを描画する

はじめに

今回は個人開発をしていてGoでグラフを描画したくて、何かいい方法がないか調べたときに go-echarts というOSSパッケージを見つけました。 スター数もかなり多く、サンプルコードも豊富だったので利用しました。

  • 利用したライブラリ
    • go-echarts/go-echarts

github.com

グラフを描画する

■ 試しに作成するグラフの要件

  • ページに複数グラフを表示させる
  • X軸に 10個のポイントを作る
  • Y軸に 300以下の数値をランダム生成
  • ラインを滑らかに引くもの & テーマを適用(ThemeChalk)
  • ラインを滑らかにせず、エリアを色塗りするもの(テーマは指定せずデフォルト)

■ コード

  • main.go
    • go run ./main.go
    • http://localhost:8082 にアクセスする
package main

import (
    "math/rand"
    "net/http"

    "github.com/go-echarts/go-echarts/v2/charts"
    "github.com/go-echarts/go-echarts/v2/components"
    "github.com/go-echarts/go-echarts/v2/opts"
    "github.com/go-echarts/go-echarts/v2/types"
)

const THEME = types.ThemeChalk

func generateLineItems() []opts.LineData {
    items := make([]opts.LineData, 0)
    for i := 0; i < 10; i++ {
        items = append(items, opts.LineData{Value: rand.Intn(300)})
    }
    return items
}

func handler(w http.ResponseWriter, _ *http.Request) {
    page := components.NewPage()
    page.AddCharts(
        lineExampleSmooth(),
        lineExampleArea(),
    )
    page.Render(w)
}

func lineExampleSmooth() *charts.Line {
    line := charts.NewLine()
    line.SetGlobalOptions(
        //charts.WithInitializationOpts(opts.Initialization{Theme: theme}),
        charts.WithInitializationOpts(opts.Initialization{Theme: THEME}),
        charts.WithTitleOpts(opts.Title{
            Title:    "line smooth",
            Subtitle: "Line chart subtile",
        }))

    line.SetXAxis([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}).
        AddSeries("Category A", generateLineItems()).
        SetSeriesOptions(charts.WithLineChartOpts(opts.LineChart{Smooth: true}))
    return line
}

func lineExampleArea() *charts.Line {
    line := charts.NewLine()
    line.SetGlobalOptions(
        charts.WithTitleOpts(opts.Title{
            Title: "area options",
        }),
    )
    line.SetXAxis([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}).
        AddSeries("Category A", generateLineItems()).
        SetSeriesOptions(
            charts.WithLabelOpts(
                opts.Label{
                    Show: true,
                }),
            charts.WithAreaStyleOpts(
                opts.AreaStyle{
                    Opacity: 0.2,
                }),
        )
    return line
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8082", nil)
}

■ main.goで生成したグラフ

さいごに

go-echarts を利用してグラフを作成しました。

個人開発では、評価額と損益額のグラフをこのライブラリを利用して作成しています。 X軸に 日付、Y軸に金額を表示するためにDBからデータを適宜取得してグラフを作成しました。 サーバーサイドもGoで実装しているので、作成したいグラフがこのライブラリで事足りるなら jsなど他の言語で書かなくてもGoでそのまま書けるので 便利かなと思います。

GoのWebフレームワーク Echoでの開発記録(bcryptを利用してログインパスワードを暗号化)

はじめに

今回は bcrypt を利用してログインパスワードを暗号化します。

今回は以前にまとめた内容のアップデート版。 yhidetoshi.hatenablog.com

bcryptについて

説明については以下の記事がわかりやすく参照しました。

medium-company.com

  • ソルト
    • パスワードをハッシュ値へと変換する際に、パスワードに付与するランダムな文字列のこと
  • ストレッチング
  • bcrypt はこの ソルトストレッチング を実施してくれる

handler/hash/hash.go

  • サインアップとログイン時に利用するために以下のコードを用意
    • PasswordEncrypt() 平文の文字列を受け取り暗号化する
    • CheckHashPassword()暗号化された文字列と平文の文字列を受け取り一致するか確認する
package hash

import "golang.org/x/crypto/bcrypt"

// 暗号化 (hash)
func PasswordEncrypt(password string) (string, error) {
    hashPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(hashPassword), err
}

// 暗号化パスワードと比較
func CheckHashPassword(hashPassword, password string) error {
    return bcrypt.CompareHashAndPassword([]byte(hashPassword), []byte(password))
}

bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) について確認

  • costはストレッチ回数を決めるパラーメタのようですね。(コストは 4〜31で指定可能)今回はデフォルト値を指定

bcryptのコード参照

const (
    MinCost     int = 4  // the minimum allowable cost as passed in to GenerateFromPassword
    MaxCost     int = 31 // the maximum allowable cost as passed in to GenerateFromPassword
    DefaultCost int = 10 // the cost that will actually be set if a cost below MinCost is passed into GenerateFromPassword
    maxSaltSize        = 16
)

func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
    p, err := newFromPassword(password, cost)
    if err != nil {
        return nil, err
    }
    return p.Hash(), nil
}

func newFromPassword(password []byte, cost int) (*hashed, error) {
    if cost < MinCost {
        cost = DefaultCost
    }
}

bcryptのソースコードに関しては、下記の記事で詳細に説明されています。(しっかり読んでいくには時間がかかりそうなので.. 。)

go bcryptのコードを読む - Qiita

サインアップ時

  • PasswordEncrypt()に 入力フォームに入力された文字列を渡して暗号化して DBに登録する

  • handler/auth/auth.go (一部抜粋)

package auth

    signUpForm := FormData{
        Username: c.FormValue("username"),
        Password: c.FormValue("password"),
    }
    username := html.EscapeString(signUpForm.Username)
    password := html.EscapeString(signUpForm.Password)

    users := new(user.User)
    users.Password, _ = hash.PasswordEncrypt(password)
 
    user.CreateUser(users) // DBに書き込む

ログイン時

  • ログイン時に以下の2つのデータを比較してログインチェックを実施する

    • user.Password : DBから取得したパスワード(暗号化済み)
    • u.Password: 入力フォームで入力されたパスワード(平文)
  • handler/auth/auth.go (一部抜粋)

package auth

func Login(c echo.Context) error {
    loginForm := FormData{
        Username: c.FormValue("username"),
        Password: c.FormValue("password"),
    }
    // Formからデータ取得
    username := html.EscapeString(loginForm.Username)
    password := html.EscapeString(loginForm.Password)

    u := new(user.User)
    u.Username = username
    u.Password = password

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

    user := user.GetUser(
        &user.User{Username: u.Username},
    )
    err := hash.CheckHashPassword(user.Password, u.Password)

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

model/user/user.go (一部抜粋)

package user

type User struct {
    ID        int `gorm:"primaryKey"`
    Username  string
    Password  string
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorn:"autoUpdateTime"`
}

func CreateUser(u *User) {
    db.Create(u)
}

func GetUser(u *User) User {
    var user User
    db.Where(u).
        First(&user)
    return user
}

確認

サインアップページでアカウント作成

  • ID/PW は test/hogehoge で作成。

  • DBに保存されたパスワードを確認↓
mysql> select id, username, password from users where username="test";
+----+----------+--------------------------------------------------------------+
| id | username | password                                                     |
+----+----------+--------------------------------------------------------------+
|  2 | test     | $2a$10$uuY/yhmk8DMaIEI4CMzXAeOjQGBaJ6lWkr.YUDAkza.uPxfU3AIXe |
+----+----------+--------------------------------------------------------------+

パスワードの "hogehoge" が暗号化されていますね!

  • ログイン確認
❯ curl -u test:hogehoge http://localhost:8080/login  -o /dev/null -w '%{http_code}\n' -s
200

goでbcryptライブラリを利用してログインパスワードを暗号化して管理できるようになりました。

GoのWebフレームワーク Echoでの開発記録 (GoのORM( GORM )でPaginationを利用する)

はじめに

個人開発中のWebアプリでPaginationのページを作成する機会があったので調べながらやってみました。 開発環境は Go1.6 + Echo + MySQLコンテナ + Redisコンテナで、ORマッパーに "gorm" を利用し、htmlのページは Goでレンダリングしています。

■ GORMのドキュメント

ページングの利用方法について説明されています。 gorm.io

コード

ディレクトリ構造

├── README.md
├── api
│   ├── crypto
│   │   ├── bat
│   │   │   └── bat.go
│   │   ├── coincheck
│   │   │   └── coincheck.go
│   │   └── gmo
│   │       └── gmo.go
│   ├── healthcheck
│   │   ├── healthcheck.go
│   │   └── healthcheck_test.go
│   ├── metal
│   │   └── metal.go
│   └── stock
│       └── investment_trust.go
├── app.yaml
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   │   ├── Dockerfile
│   │   ├── data
│   │   └── my.cnf
│   └── redis
│       └── data
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth
│   │   └── auth.go
│   ├── crypto
│   │   └── crypto.go
│   ├── graph
│   │   └── crypto.go
│   ├── hash
│   │   └── hash.go
│   └── top.go
├── main.go
├── model
│   ├── base.go
│   ├── crypto
│   │   ├── daily_rate
│   │   │   └── daily_rate.go
│   │   ├── exchange
│   │   │   └── exchange.go
│   │   ├── exchange_result
│   │   │   └── result.go
│   │   ├── token
│   │   │   └── token.go
│   │   ├── token_result
│   │   │   └── result.go
│   │   └── trade_history
│   │       └── trade_history.go
│   ├── date
│   │   └── date.go
│   ├── stock
│   │   └── investment_trust
│   │       └── investment_trust.go
│   └── user
│       └── user.go
├── public
│   └── assets
│       └── css
│           ├── crypto_register.css
│           ├── crypto_summary.css
│           ├── crypto_trade_history.css
│           ├── login.css
│           ├── mypage.css
│           └── signup.css
├── script
│   └── setup_local_env.sh
├── secret.yaml
├── staticcheck.conf
└── view
    ├── crypto_register.html
    ├── crypto_summary.html
    ├── crypto_trade_history.html
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

■ 利用するDBのテーブルは以下。

mysql> SELECT id,user_id, token_id, exchange_id,trade,date,volume,rate,price FROM crypto_trade_histories LIMIT 1;
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+
| id | user_id | token_id | exchange_id | trade | date                    | volume    | rate    | price |
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+
|  1 |       1 |        1 |           3 | buy   | 2021-07-03 00:00:00.000 | 0.0026152 | 3823799 | 10000 |
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+

mysql> SELECT id, token FROM tokens;
+----+-------+
| id | token |
+----+-------+
|  1 | BTC   |
|  2 | ETH   |
+----+-------+

mysql> SELECT id, exchange FROM exchanges;
+----+-----------+
| id | exchange  |
+----+-----------+
|  1 | gmo       |
|  2 | rakuten   |
|  3 | coincheck |
+----+-----------+

model/crypto/trade_history/trade_history.go(一部抜粋)

  • 構造体配列に結果を渡して、Paginate() にて LIMIT句とOFFSET句を利用して結果を分割してページネーションで表示させる。
  • GetTradeHistories() はページ番号とそのページの検索結果を返す
type TradeHistory struct {
    Exchange string
    Token    string
    Trade    string
    Volume   float64
    Rate     int
    Price    int
    Date     string
}

func GetTradeHistories(c echo.Context, userID int) ([]TradeHistory, int) {
    var tradeHistory []TradeHistory
    //db.Raw("SELECT exchange, token, trade, rate, price, DATE_FORMAT(date, '%Y-%m-%d') AS date FROM crypto_trade_histories INNER JOIN exchanges on crypto_trade_histories.exchange_id = exchanges.id INNER JOIN tokens on crypto_trade_histories.token_id = tokens.id  ORDER BY date desc").
    db.Scopes(Paginate(c.Request())).
        Table("crypto_trade_histories").
        Select("exchange, token, trade, volume, rate, price, DATE_FORMAT(date, '%Y-%m-%d') AS date").
        Joins("inner join exchanges on crypto_trade_histories.exchange_id = exchanges.id").
        Joins("inner join tokens on crypto_trade_histories.token_id = tokens.id").
        Where("user_id = ?", userID).
        Order("date desc").
        Scan(&tradeHistory)
    q := c.Request().URL.Query()
    page, _ := strconv.Atoi(q.Get("page"))

    return tradeHistory, page
}

func Paginate(r *http.Request) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        q := r.URL.Query()
        page, _ := strconv.Atoi(q.Get("page"))
        if page == 0 {
            page = 1
        }
        pageSize, _ := strconv.Atoi(q.Get("page_size"))
        switch {
        case pageSize > 100:
            pageSize = 100
        case pageSize <= 0:
            pageSize = 10
        }

        offset := (page - 1) * pageSize
        return db.Offset(offset).Limit(pageSize)
    }
}
  • htmlにレンダリングするために、マップ(key にstring、value に構造体)を用意して値を展開する
  • マップにするデータは、"取引履歴" と "ページURL"
    • TopPageは1ページ目に移動
    • BackPageは1ページ戻る
    • NextPageは次のページに進む

handler/crypto.go (一部抜粋)

type Page struct {
    TopPage  string
    BackPage string
    NextPage string
}

func ShowTradeHistoryPage(c echo.Context) error {
    session := auth.GetSession(c)
    if session.Values["auth"] != true {
        return c.Redirect(301, "/login")
    }
    username := session.Values["username"].(string)
    userID := user.GetUserID(username)

    tradeHistory, page := trade_history.GetTradeHistories(c, userID)
    p := make([]Page, 1, 1)
    p[0].TopPage = "/crypto/trade_history?page=" + strconv.Itoa(1)
    p[0].BackPage = "/crypto/trade_history?page=" + strconv.Itoa(page-1)
    p[0].NextPage = "/crypto/trade_history?page=" + strconv.Itoa(page+1)

    data := map[string]interface{}{
        "trade_history": &tradeHistory,
        "page":          &p,
    }
    return c.Render(http.StatusOK, "crypto_trade_history", data)
}
  • main.go(一部抜粋 Staticファイルの設定)
   // Staticファイル
    e.Static("/assets", "public/assets")
    e.Static("/crypto/assets", "public/assets")
    e.GET("/crypto/trade_history", crypto.ShowTradeHistoryPage)
  • func ShowTradeHistoryPage(c echo.Context) error {} で定義した dataの key名( "trade_history" と "page" ) を指定して、それをrangeで回して valueに 構造体のフィールド名を指定する

view/crypto_trade_history.html (cssに関してはコピペ利用できるサンプルを参照して作成。本記事ではコードは割愛)

{{define "crypto_trade_history"}}
<!DOCTYPE html>
<html lang="jp">
<head>
    <link rel="stylesheet" href="assets/css/crypto_trade_history.css">
</head>
<header>
    <p class="logo">Crypto</a><span>取引履歴</span></p>
</header>
<body>
    <table class="osare5-table col5t">
        <thead>
            <tr>
                <th>取引所</th>
                <th>トークン</th>
                <th>ボリューム</th>
                <th>売買</th>
                <th>レート</th>
                <th>金額</th>
                <th>日時</th>
            </tr>
        </thead>
        <tbody>
            {{range .trade_history}}
            <tr>
                <td>{{.Exchange}}</td>
                <td>{{.Token}}</td>
                <td>{{.Volume}}</td>
                <td>{{.Trade}}</td>
                <td>{{.Rate}}</td>
                <td>{{.Price}}</td>
                <td>{{.Date}}</td>
            </tr>
            {{end}}
        </tbody>
    </table>
    <p></p>
    {{range .page}}
    <a href="{{.TopPage}}" class="button">top</a>
    <a href="{{.BackPage}}" class="button">back</a>
    <a href="{{.NextPage}}" class="button">next</a>
    {{end}}
    <p></p>
    <a href="/mypage" class="button">MyPageへ</a>
</body>
</html>
{{end}}

結果

ページURLのクエリパラメータがページ番号になりページネーションで表示させ、遷移する事ができました。

GoのWebフレームワーク Echoでの開発記録(Deep Health Checkパターンを追加)

はじめに

今回は Deep Health Checkパターンの healthcheck apiを追加します。webアプリ(Echo)のヘルスチェックするapiは作成していましが、DBへのコネクションも正常かどうかを 含めて確認できるようにします。

APIを追加

  • リクエストパスを以下のように設定

    • /healthcheck アプリまでのヘルスチェック
    • /healthcheck/deep DBを含めたヘルスチェック (追加)
  • main.go から抜粋

   // ヘルスチェック
    apiG := e.Group("/api")
    apiG.GET("/healthcheck", api.Healthcheck)
    apiG.GET("/healthcheck/deep", api.HealthcheckDeep) // Check db connect
  • api/healthcheck.go
package api

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/yhidetoshi/apiEchoGAE-local/model"
)

type HealthcheckMessage struct {
    Status  int    `json:"status"`
    Message string `json:"message"`
}

func Healthcheck(c echo.Context) error {
    msg := &HealthcheckMessage{
        Status:  http.StatusOK,
        Message: "Success to connect echo",
    }
    res, err := json.Marshal(msg)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(res))
}

func HealthcheckDeep(c echo.Context) error {
    if model.CheckDBHealthy() {
        msg := &HealthcheckMessage{
            Status:  http.StatusOK,
            Message: "Success to connect database",
        }
        res, err := json.Marshal(msg)
        if err != nil {
            log.Println(err)
        }
        return c.String(http.StatusOK, string(res))
    }
    msg := &HealthcheckMessage{
        Status:  http.StatusServiceUnavailable,
        Message: "Fail to connect database",
    }
    res, err := json.Marshal(msg)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(res))

}
  • model/db.go から抜粋
func CheckDBHealthy() bool {
    var err error
    dsn := conf.DB_ENDPOINT
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Println(err)
        return false
    }
    return true
}

確認

  • DBの起動を確認
❯ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED       STATUS        PORTS                               NAMES
b71ff89a4d16   mysql          "docker-entrypoint.s…"   2 weeks ago   Up 20 hours   0.0.0.0:3306->3306/tcp, 33060/tcp   apiechogae-local_mysql_1
b2fb2ad53d34   redis:latest   "docker-entrypoint.s…"   2 weeks ago   Up 4 days     0.0.0.0:6379->6379/tcp              apiechogae-local_redis_1
  • DBを起動した状態で確認
❯ curl -sS localhost:8080/api/healthcheck/deep
{"status":200,"message":"Success to connect database"}
  • DBの停止を確認
❯ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED       STATUS      PORTS                    NAMES
b2fb2ad53d34   redis:latest   "docker-entrypoint.s…"   2 weeks ago   Up 4 days   0.0.0.0:6379->6379/tcp   apiechogae-local_redis_1
  • mysqlを停止した状態で確認
❯ curl -sS localhost:8080/api/healthcheck/deep
{"status":503,"message":"Fail to connect database"}

GoのWebフレームワーク Echoでの開発記録(DBのデータをmapにしてTemplate Rendering でページに表示する)

はじめに

以前の記事で初めて Echoで Template Renderingを利用してページを作成しました。 今回はEchoで個人開発をしていてそのTemplate Renderingを利用した備忘録です。

yhidetoshi.hatenablog.com

アプリのディレクトリ構成

MVCモデルを意識した構成にしています。

  • api/
  • model/
    • モデル部分を実装
  • view/
    • ビュー部分を実装
  • handler/
    • コントローラー部分を実装
  • conf/

ディレクトリ構成

❯ tree -L 3
.
├── README.md
├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_batch.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
│   │   ├── Dockerfile
│   │   ├── data
│   │   └── my.cnf
│   └── redis
│       └── data
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   ├── crypto.go
│   └── top.go
├── main.go
├── model
│   ├── crypto.go
│   ├── db.go
│   ├── stock.go
│   └── user.go
├── public
│   └── assets
│       └── css
├── staticcheck.conf
└── view
    ├── input_crypto.html
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

やること

tokens テーブルにあるトークン一覧をDBから取得してhtmlの画面にプルダウンで表示させます。 これは、トークンは増えていく場合もあるためtokenが追加されたら動的にプルダウンメニューに追加されるようにするためです。

  • 表示する画面

  • tokens テーブル
mysql> select id,token from tokens;
+----+-------+
| id | token |
+----+-------+
|  1 | BTC   |
|  2 | ETH   |
+----+-------+
  • handler/crypto_buy_save.go から抜粋
func ShowCryptoTradeDataHTML(c echo.Context) error {
    tokens := model.GetTokenIDs()
    return c.Render(http.StatusOK, "crypto_trade", tokens)
}
  • model/crypto.go から抜粋
    • ORMのgormを利用して、構造体の Tokenにデータを渡して map ( map[int]string{} )にする
type Token struct {
    ID        int       `json:"id" gorm:"primaryKey"`
    Token     string    `json:"token"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorn:"autoUpdateTime"`
}

func GetTokenIDs() map[int]string {
    var token []Token
    tokenResults := map[int]string{}

    db.Table("tokens").Select("id", "token").Order("id asc").
        Find(&token)
    for _, value := range token {
        tokenResults[value.ID] = value.Token
    }
    return tokenResults
}
  • view/input_crypto.html から抜粋
    • mapのデータを表示する
      • {{ range $key, $value := . }} このように定義した場合は、mapの keyを 変数名: $key に。 value を 変数名$value になる
<div class="mb-3">
  <select name="token" required>
    <option value="" selected disabled>ティッカーを選択する</option>
    {{ range $key, $value := . }}
    <option value={{ $key }}>{{ $value }}</option>
    {{ end }}
  </select>
</div>
  • main.go から抜粋
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)
}
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer
    e.GET("/crypto_trade", handler.ShowCryptoTradeDataHTML)

    // Staticファイル
    e.Static("/assets", "public/assets")

GoのWebフレームワーク Echo で CORS を設定する

はじめに

最近、Echoで個人開発をしていて(最近の記事に記載)CORSの設定をまだしていなかったので今回追加しました。 CORSについても改めて整理。

CORSについて

■ CORSとは

  • Cross-Origin Resource Sharing の略
  • あるオリジンで読み込まれたリソースのhtml 、スクリプトなどが異なるオリジンのリソースと通信する場合に制限される
  • 目的は他オリジンに悪影響を与えないようにするため
  • 自身のオリジンから別のオリジンへアクセスするのを許可する (クロスオリジン)
  • Origin オリジンとは??
    • 『 スキーム + ホスト + ポート番号 』
  • クロスオリジンリクエスト例
    • https://domain-a.com:8000 --> https://domain-a.com:8080 ( ポート番号が異なる )
    • https://domain-a.com:8000 --> https://domain-b.com:8000 ( ホストが異なる)

Ref) Cross-Origin Resource Sharing (CORS) - HTTP | MDN

コード

ディレクトリ構成

├── README.md
├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
├── api/ (省略)
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   └── redis
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── crypto.go
│   ├── db.go
│   ├── stock.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html
   // CORS //
    e.Use(middleware.CORS())
    e.Use(middleware.CORSWithConfig(
        middleware.CORSConfig{
            // Method
            AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },
            // Header
            AllowHeaders: []string{
                echo.HeaderOrigin,
            },
            // Origin
            /*
               AllowOrigins: []string{
                   "http://localhost:8080",
                   "http://localhost:9999",
               },*/
        }))
  • 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())

    // CORS //
    e.Use(middleware.CORS())
    e.Use(middleware.CORSWithConfig(
        middleware.CORSConfig{
            // Method
            AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },
            // Header
            AllowHeaders: []string{
                echo.HeaderOrigin,
            },
            // Origin
            /*
               AllowOrigins: []string{
                   "http://localhost:8080",
                   "http://localhost:9999",
               },*/
        }))

    cGmo := api.ClientCryptoGMO{}
    cGmo.NewClientCryptoGMO()
    cCc := api.ClientCryptoCoincheck{}
    cCc.NewClientCryptoCoincheck()

    // 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")
    apiG.GET("/healthcheck", api.Healthcheck) // /api/healthcheck (/apiを省略する形になる)

    // 貴金属の価格を取得
    apiG.GET("/metal", api.FetchMetal)

    // ISINコードを引数に基準価格を取得
    apiG.GET("/investment-trust/:isin", api.FetchInvestTrust)

    // 仮想通貨の価格を取得
    apiG.GET("/crypto/gmo", cGmo.FetchCryptoGMO)
    apiG.GET("/crypto/coincheck", cCc.FetchCryptoCoincheck)

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

    e.POST("/login", handler.Login)
    e.POST("/logout", handler.Logout)
    e.GET("/restricted", handler.ShowRestrictedPage)

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

    e.Start(":8080")
}

動作確認

CORSを検証するための準備

├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="./script.js"></script>
  </head>
  <body>
    CORS Test Page
  </body>
</html>
  • script.js
var xhr = new XMLHttpRequest()
var url = 'http://localhost:8080/'

const handler = () => {
  // コンソールに出力
  console.log(xhr.responseText)
}

const getRequest = () => {
  xhr.open('GET', url)
  xhr.onloadend = handler
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  getRequest()
})
  • クライアントを起動 ( Portは任意 )
    • $ php -S localhost:9999

検証

  • クライアント( php + js ) localhost:9999。 Echoを localhost:8080 で起動する。
  • localhost:9999 --> localhost:8080 でクロスオリジンリクエストを発生させる。

CORS設定をしない場合

f:id:yhidetoshi:20220407111614p:plain

Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://localhost:9999' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

f:id:yhidetoshi:20220407111506p:plain

AllowHeaders を追加

AllowHeaders: []string{
                echo.HeaderOrigin,
            },

f:id:yhidetoshi:20220407113015p:plain

AllowMethods を追加

AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },

f:id:yhidetoshi:20220407112835p:plain

AllowOrigins を追加

AllowOrigins: []string{
    "http://localhost:8080",
    "http://localhost:9999",
},

f:id:yhidetoshi:20220407112921p:plain

→ それぞれ blocked by CORS policy: No 'Access-Control-Allow-Origin' header のエラーを解消することができた。

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