Go EchoのwebアプリのセッションをRedisで管理する
はじめに
前回の記事で 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") }
今回セッション管理には以下のライブラリを利用しました。
そして、Redisでセッション管理するには以下のライブラリを利用しました。
- 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 -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を保存して制限したページにアクセスする
■ 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)などクラウドマネージドサービスを利用できればと思います。