My Note

自己理解のためのブログ

Go EchoのwebアプリのセッションをRedisで管理する

はじめに

yhidetoshi.hatenablog.com

前回の記事で Echoでサインアップ、ログインの機能を実装しました。 今回はその続きとしてセッションを管理を行いたいと思います。 セッションはwebサーバのストレージに保存したりしますが、インスタンスやコンテナのスケールイン・アウトに 対応できるように外部ストレージに保存する必要があります。そこで、インメモリのKVSで高速に処理できるRedisを利用したいと思います。

Redisコンテナの準備

Redisコンテナを以下のとおりで用意しました。

  • docker-compose.yaml
version: '3'
services:
  mysql:
    build: ./docker/mysql/
    volumes:
      - ./docker/mysql/data:/docker-entrypoint-initdb.d
      - ./docker/mysql/data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    image: mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=root
  redis:
    image: "redis:latest"
    ports:
      - "6379:6379"
    volumes:
      - "./docker/redis/data:/data"
  • redisの操作はredis-cliを利用します。
❯ redis-cli
127.0.0.1:6379>

コード

❯ tree -L 2
.
├── README.md
├── api/ (省略)
├── conf
│   └── config.go
├── docker
│   ├── mysqli/ (省略)
│   └── redisi/ (省略)
├── 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))))
 
    // 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.POST("/logout", handler.Logout)
    e.GET("/restricted", handler.ShowRestrictedPage)

    e.Start(":8080")
}

今回セッション管理には以下のライブラリを利用しました。

github.com

そして、Redisでセッション管理するには以下のライブラリを利用しました。

github.com

  • 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)
}
  • view/mypage.html
{{define "mypage"}}
<!DOCTYPE html>
<html lang="jp">
<h1>
    <div>My Page</div>
</h1>

<body>
    <p>こんにちは {{.UserName}} さん</p>
    <form action="/logout" method="post">
        <p><input type="submit" value="Logout"></p>
    </form>
</body>

</html>
{{end}}

動作確認

■ ログインする - http://127.0.0.1:8080/login

  • curlでログインしてCookieを取得する場合は以下のコマンドで可能
❯ curl -c ./cookie.txt -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login
{"name":"yhidetoshi","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODkxMDI5fQ.oPj8rXovd8WZ3R5YzSTy9jrDR8-pIgoLoUbJdSIIJlo"}

■ ログイン成功したら以下のアドレスにアクセス

■ cookieを指定せずにアクセスすると
❯ curl http://127.0.0.1:8080/restricted
401

■ Cookieを取得する
❯ curl -c ./cookie.txt -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login
{"name":"yhidetoshi","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODkxMDI5fQ.oPj8rXovd8WZ3R5YzSTy9jrDR8-pIgoLoUbJdSIIJlo"}

■ Cookieを指定してアクセス
❯ curl -b ./cookie.txt http://127.0.0.1:8080/restricted
yhidetoshi

Chromeで確認

  • 検証 → Application → Storage → Cookies

Redisに保存されたセッションを確認する

127.0.0.1:6379> keys *
1) "session_BMNFXJAHHZBM4WPWUEMBLWD3H4MYYZXA3BR7QEUYU7KFNVHU4UA72GCKJTIUWTH63EPKAXBKTKLRTJQ2KICQMW63KDA4EAQXYNB5PRI"
127.0.0.1:6379>

Chromeで確認したセッションIDとRedisに保存されたセッションIDが一致。

→ ログアウトすると、セッションが削除された。

さいごに

今回は セッションの管理をRedisに保存して利用するようにしました。今回はローカルのdockerコンテナを利用しましたが エンドポイントをAWSのElastiCache(Redis)などクラウドマネージドサービスを利用できればと思います。