golangでログイン機能を作る②(RedisでSessionとCookie)


【環境】
MacBook Air (M1, 2020)
OS: MacOS Big Sur version11.6
Docker Desktop for Mac version4.5.0

golangでログイン機能を作る①(bcryptでパスワード暗号化)の続きです。
今回はSessionとCookieを使いログイン状態を維持させます。
Session情報の保存には、Redisというメモリ上で実行されるデータベースを使います。

ディレクトリ構成

go_blog
├── .air.toml
├── build
│   ├── app
│   │   ├── .env
│   │   └── Dockerfile
│   └── db
│   │   ├── .env
│       └── Dockerfile
├── cmd
│   └── go_blog
│       └── main.go
├── controller
│   ├── home_controller.go
│   ├── login_controller.go
│   ├── mypage_controller.go
│   └── router.go
├── crypto
│   └── crypto.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── model
│   ├── db
│   │   ├── database.go
│   │   └── user_model.go
│   └── redis
│       └── redis.go
└── view
    ├── home.html
    ├── login.html
    ├── mypage.html
    └── signup.html

新規会員登録とログイン機能を実装し、ホーム画面は常時表示可能、マイページ画面はログイン中のみ表示可能というところまで進めます。
redis実装部分以外は前回とほとんど同じですので、前回紹介した部分は割愛しております。

Docker関係

docker-compose.yml
version: '3.8'

services:
  go_blog:
    container_name: go_blog
    build:
      context: ./build/app
      dockerfile: Dockerfile
    tty: true
    ports:
      - 8080:8080
    env_file:
      - ./build/app/.env
      - ./build/db/.env
    depends_on:
      - db
      - redis
    volumes:
      - type: bind
        source: .
        target: /go/app

  db:
    container_name: db
    build:
      context: ./build/db
      dockerfile: Dockerfile
    tty: true
    platform: linux/amd64
    ports:
      - 3306:3306
    env_file:
      - ./build/db/.env
    volumes:
      - type: volume
        source: mysql_test_volume
        target: /var/lib/mysql

  redis:
    container_name: redis
    image: redis:latest
    ports:
      - 6379:6379

volumes:
  mysql_test_volume:
    name: mysql_test_volume

networks:
  golang_test_network:
    external: true

docker-composeのサービスにredisを追加しました。redisのポート番号のデフォルトは6379です。
redisが立ち上がった後にgo_blogのサービスを立ち上げたいので、depends_onにredisを追加します。
またenv_fileをひとつ追加しました。

build/app/.env
LOGIN_USER_ID_KEY=loginUserIdKey

LOGIN_USER_ID_KEYはCookie保存時に使う環境変数です。
環境変数をgolang上で読み込むには標準パッケージosのGetenv関数を使います。

Session(Redis)

redis.go
package model_redis

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"

	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
)

var conn *redis.Client

func init() {
	conn = redis.NewClient(&redis.Options{
		Addr:     "redis:6379",
		Password: "",
		DB:       0,
	})
}

func NewSession(c *gin.Context, cookieKey, redisValue string) {
	b := make([]byte, 64)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		panic("ランダムな文字作成時にエラーが発生しました。")
	}
	newRedisKey := base64.URLEncoding.EncodeToString(b)

	if err := conn.Set(c, newRedisKey, redisValue, 0).Err(); err != nil {
		panic("Session登録時にエラーが発生:" + err.Error())
	}
	c.SetCookie(cookieKey, newRedisKey, 0, "/", "localhost", false, false)
}

func GetSession(c *gin.Context, cookieKey string) interface{} {
	redisKey, _ := c.Cookie(cookieKey)
	redisValue, err := conn.Get(c, redisKey).Result()
	switch {
	case err == redis.Nil:
		fmt.Println("SessionKeyが登録されていません。")
		return nil
	case err != nil:
		fmt.Println("Session取得時にエラー発生:" + err.Error())
		return nil
	}
	return redisValue
}

func DeleteSession(c *gin.Context, cookieKey string) {
	redisId, _ := c.Cookie(cookieKey)
	conn.Del(c, redisId)
	c.SetCookie(cookieKey, "", -1, "/", "localhost", false, false)
}

Redisにはgo-redisというパッケージを使います。

  • init関数はmain関数より先に自動実行されます。
    redis.NewClient関数で初期化しconn *redis.Clientを取得します。
    生成したconnを通してRedisの全操作を行います。
    Addrには接続先アドレスを指定します。[Dockerのservice名]:[ポート番号]という形で記述します。(Dockerを使わずローカル実行の場合は127.0.0.1:6379となります。)
  • NewSession関数は新規登録時とログイン時に呼び出されます。
    ランダムなSessionIDを作成し、go-redisのSet関数を使いSessionIdとValueを登録します。
    同時にgin.ContextのSetCookie関数でCookieの保存もします。
    CookieのKeyには環境変数LOGIN_USER_ID_KEYを、ValueにはUserモデルのUserIdを指定しています。
  • GetSession関数はログイン中のSession情報を取得します。
    CookieKeyからCookieに保存されているRedisKeyを読み出し、RedisKeyからRedisに保存されているValue(今回はUserId)を読み出しています。
    ログインしていない時はgo-redisのGet関数でSession情報が見つからず、err == redis.Nilの判定となります。
  • DeleteSession関数はログアウト時に呼ばれ、Sessionを削除します。
    同時にgin.ContextのSetCookie関数のMaxAgeをマイナスに設定することでCookieを削除することができます。

Controller

router.go
package controller

import (
	model_redis "go_blog/model/redis"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)

func GetRouter() *gin.Engine {
	router := gin.Default()
	router.LoadHTMLGlob("view/*/*.html")

	router.GET("/", getHome)

	loginCheckGroup := router.Group("/", checkLogin())
	{
		loginCheckGroup.GET("/mypage", getMypage)
		loginCheckGroup.GET("/logout", getLogout)
	}
	logoutCheckGroup := router.Group("/", checkLogout())
	{
		logoutCheckGroup.GET("/signup", getSignup)
		logoutCheckGroup.POST("/signup", postSignup)
		logoutCheckGroup.GET("/login", getLogin)
		logoutCheckGroup.POST("/login", postLogin)
	}

	return router
}

func checkLogin() gin.HandlerFunc {
	return func(c *gin.Context) {
		cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
		id := model_redis.GetSession(c, cookieKey)
		if id == nil {
			c.Redirect(http.StatusFound, "/login")
			c.Abort()
		} else {
			c.Next()
		}
	}
}

func checkLogout() gin.HandlerFunc {
	return func(c *gin.Context) {
		cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
		id := model_redis.GetSession(c, cookieKey)
		if id != nil {
			c.Redirect(http.StatusFound, "/")
			c.Abort()
		} else {
			c.Next()
		}
	}
}

ginのGroupを使い、マイページ表示とログアウトの前にcheckLogin(ログインの確認)、会員登録とログイン処理の前にcheckLogout(ログアウトの確認)というMiddlewareを実行します。
各Middleware内ではredis.goのGetSession関数からUserIdを取得し判定を行っています。

login_controller.go
package controller

import (
	model_db "go_blog/model/db"
	model_redis "go_blog/model/redis"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)

func getSignup(c *gin.Context) {
	c.HTML(http.StatusOK, "signup.html", nil)
}

func postSignup(c *gin.Context) {
	id := c.PostForm("user_id")
	pw := c.PostForm("password")
	user, err := model_db.Signup(c, id, pw)
	if err != nil {
		c.Redirect(http.StatusFound, "/signup")
		return
	}
	cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
	model_redis.NewSession(c, cookieKey, user.UserId)
	c.Redirect(http.StatusFound, "/")
}

func getLogin(c *gin.Context) {
	c.HTML(http.StatusOK, "login.html", nil)
}

func postLogin(c *gin.Context) {
	id := c.PostForm("user_id")
	pw := c.PostForm("password")
	user, err := model_db.Login(c, id, pw)
	if err != nil {
		c.Redirect(http.StatusFound, "/login")
		return
	}
	cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
	model_redis.NewSession(c, cookieKey, user.UserId)
	c.Redirect(http.StatusFound, "/")
}

func getLogout(c *gin.Context) {
	cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
	model_redis.DeleteSession(c, cookieKey)
	c.Redirect(http.StatusFound, "/")
}

  • postSignup関数とpostLogin関数でuserを取得できた時に、NewSession関数でSessionとCookieを作成しSessionがスタートします。
  • またgetLogout関数ではDeleteSession関数でSessionとCookieを削除しSessionを終了します。
home_controller.go
package controller

import (
	model_db "go_blog/model/db"
	model_redis "go_blog/model/redis"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)

func getHome(c *gin.Context) {
	user := model_db.User{}
	cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
	userId := model_redis.GetSession(c, cookieKey)
	if userId != nil {
		user = model_db.GetOneUser(userId.(string))
	}

	c.HTML(http.StatusOK, "home.html", gin.H{
		"user": user,
	})
}

ログインしている場合、GetSession関数でuserIdを取得しUserモデルの情報をviewへ渡します。

mypage_controller.go
package controller

import (
	model_db "go_blog/model/db"
	model_redis "go_blog/model/redis"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)

func getMypage(c *gin.Context) {
	user := model_db.User{}
	cookieKey := os.Getenv("LOGIN_USER_ID_KEY")
	userId := model_redis.GetSession(c, cookieKey)
	if userId != nil {
		user = model_db.GetOneUser(userId.(string))
	}

	c.HTML(http.StatusOK, "mypage.html", gin.H{"user": user})
}

mypage_controllerも同じく、ログインしている場合にGetSessionにてuserIdを取得しUserモデルをviewへ渡します。

View

home.html
<h1>TOP</h1>
{{ if ne .user.ID 0 }}
<a href="/mypage">マイページ</a>
<a href="/logout">ログアウト</a>
{{ else }}
<a href="/signup">会員登録</a>
<a href="/login">ログイン</a>
{{ end }}

ログイン時(userモデルのIDが0以外(ne:not equal)の時)にマイページとログアウトへのリンクを表示します。
ログアウト時に会員登録とログインページへのリンクを表示します。

mypage.html
<h1>MYPAGE</h1>
<p>ログイン中【{{ .user.ID }}】 {{ .user.UserId }}</p>
<a href="/">TOP</a>
<a href="/logout">ログアウト</a>

マイページではユーザー情報(IDとユーザーID)を表示します。

signup.html
<h1>SIGNUP</h1>
<form method="POST" action="/signup">
    <p>ユーザーID: <input type="text" name="user_id" /></p>
    <p>パスワード: <input type="password" name="password" /></p>
    <p><input type="submit" value="新規会員登録" /> 
        <a href="/"><input type="button" value="戻る" /></a></p>
</form>

ユーザーIDとパスワードを入力し、/signupへPOST送信し会員登録を実行します。

login.html
<h1>LOGIN</h1>
<form method="POST" action="/login">
    <p>ユーザーID: <input type="text" name="user_id" /></p>
    <p>パスワード: <input type="password" name="password" /></p>
    <p><input type="submit" value="ログイン" /> 
        <a href="/"><input type="button" value="戻る" /></a></p>
</form>

ユーザーIDとパスワードを入力し、/loginへPOST送信しログイン処理を実行します。

参考

https://korattablog.com/2020/07/20/ginを使ったgo-api開発の初歩(cookie編)/
https://qiita.com/koshi_an/items/12da955a1823b7f3e178
https://re-engines.com/2020/03/02/goフレームワークginでミドルウェアを使ってログイ/
https://note.crohaco.net/2019/golang-gin/
https://qiita.com/wsuzume/items/6451f9d90c94884f7753
https://leben.mobi/go/session-control/practice/web/