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関係
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をひとつ追加しました。
LOGIN_USER_ID_KEY=loginUserIdKey
LOGIN_USER_ID_KEYはCookie保存時に使う環境変数です。
環境変数をgolang上で読み込むには標準パッケージosのGetenv関数を使います。
Session(Redis)
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
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を取得し判定を行っています。
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を終了します。
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へ渡します。
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
<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)の時)にマイページとログアウトへのリンクを表示します。
ログアウト時に会員登録とログインページへのリンクを表示します。
<h1>MYPAGE</h1>
<p>ログイン中【{{ .user.ID }}】 {{ .user.UserId }}</p>
<a href="/">TOP</a>
<a href="/logout">ログアウト</a>
マイページではユーザー情報(IDとユーザーID)を表示します。
<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送信し会員登録を実行します。
<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送信しログイン処理を実行します。
参考
Discussion