Skip to content

セッション管理機構の実装

セッションストアを設定する

main.goに以下を追加しましょう。

go
func main() {
	(省略)
	// usersテーブルが存在しなかったら、usersテーブルを作成する
	_, err = db.Exec("CREATE TABLE IF NOT EXISTS users (Username VARCHAR(255) PRIMARY KEY, HashedPass VARCHAR(255))")
	if err != nil {
		log.Fatal(err)
	}

	// セッションの情報を記憶するための場所をデータベース上に設定
	store, err := mysqlstore.NewMySQLStoreFromConnection(db.DB, "sessions", "/", 60*60*24*14, []byte("secret-token")) 
	if err != nil { 
		log.Fatal(err) 
	} 

	h := handler.NewHandler(db)
	e := echo.New()
	e.Use(middleware.Logger())       // ログを取るミドルウェアを追加
	e.Use(session.Middleware(store)) // セッション管理のためのミドルウェアを追加

	e.POST("/signup", h.SignUpHandler)
	(省略)
}

これらはセッションストアの設定です。

最初に、セッションの情報を記憶するための場所をデータベース上に設定します。

この仕組みを使用するために、 e.Use(session.Middleware(store)) を含めてセッションストアを使ってね〜、って echo に命令しています。

e.Use(middleware.Logger()) は文字通りログを取るものです。ついでに入れましょう。

TIP

"secret-token"は、暗号化/復号化の際に使われる秘密鍵です。
実際に運用するときはこの"secret-token"を独自の値にしてください。環境変数などで管理するのが良いでしょう。

LoginHandler の実装

続いて、LoginHandlerhandler.go に実装していきましょう。

go
func (h *Handler) LoginHandler(c echo.Context) error { 
} 

LoginHandler の外に以下の構造体を追加します。

go
type User struct { 
	Username   string `json:"username,omitempty"  db:"Username"`
	HashedPass string `json:"-"  db:"HashedPass"`
} 

LoginHandler を実装していきます。

go
func (h *Handler) LoginHandler(c echo.Context) error {
	// リクエストを受け取り、reqに格納する
	var req LoginRequestBody
	err := c.Bind(&req) 
	if err != nil { 
		return c.String(http.StatusBadRequest, "bad request body") 
	} 

	// バリデーションする(PasswordかUsernameが空文字列の場合は400 BadRequestを返す)
	if req.Password == "" || req.Username == "" { 
		return c.String(http.StatusBadRequest, "Username or Password is empty") 
	} 

	// データベースからユーザーを取得する
	user := User{} 
	err = h.db.Get(&user, "SELECT * FROM users WHERE username=?", req.Username) 
	if err != nil { 
		if errors.Is(err, sql.ErrNoRows) { 
			return c.NoContent(http.StatusUnauthorized) 
		} else { 
			log.Println(err) 
			return c.NoContent(http.StatusInternalServerError) 
		} 
	} 
}

req への代入は signUpHandler と同じです。UserName と Password が入っているかも確認しましょう。

パスワードの一致チェックをするために、データベースからユーザーを取得してきましょう。

ユーザーが存在しなかった場合は sql.ErrNoRows というエラーが返ってきます。 もしそのエラーなら 401 (Unauthorized)、そうでなければ 500 (Internal Server Error) です。 もし 404 (Not Found) とすると、「このユーザーはパスワードが違うのではなく存在しないんだ」という事がわかってしまい(このユーザーは存在していてパスワードは違う事も分かります)、セキュリティ上のリスクに繋がります。

TIP

ここで、エラーチェックは基本的に errors.Is を使いましょう。
参考: https://pkg.go.dev/errors#Is

go
func (h *Handler) LoginHandler(c echo.Context) error {
	(省略)
	// データベースからユーザーを取得する
	user := User{}
	err = h.db.Get(&user, "SELECT * FROM users WHERE username=?", req.Username)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return c.NoContent(http.StatusUnauthorized)
		} else {
			log.Println(err)
			return c.NoContent(http.StatusInternalServerError)
		}
	}
	// パスワードが一致しているかを確かめる
	err = bcrypt.CompareHashAndPassword([]byte(user.HashedPass), []byte(req.Password)) 
	if err != nil { 
		if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { 
			return c.NoContent(http.StatusUnauthorized) 
		} else { 
			return c.NoContent(http.StatusInternalServerError) 
		} 
	} 
}

データベースに保存されているパスワードはハッシュ化されています。

ハッシュ化は不可逆な処理なので、ハッシュ化されたものから原文を調べることはできません。確認する際はもらったパスワードをハッシュ化することで行います。

これは bcrypt.CompareHashAndPassword が行ってくれるのでそれに乗っかりましょう。

  • この関数はハッシュが一致すれば返り値が nil となります
  • 一致しない場合、 bcrypt.ErrMismatchedHashAndPassword が返ってきます
  • 処理中にこれ以外の問題が発生した場合は、返り値はエラー型の何かです

従って、これらのエラーの内容に応じて、 500 (Internal Server Error), 401 (Unauthorized) を返却するか、処理を続行するかを選択していきます。

go
func (h *Handler) LoginHandler(c echo.Context) error {
	(省略)
	// パスワードが一致しているかを確かめる
	err = bcrypt.CompareHashAndPassword([]byte(user.HashedPass), []byte(req.Password))
	if err != nil {
		if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
			return c.NoContent(http.StatusUnauthorized)
		} else {
			return c.NoContent(http.StatusInternalServerError)
		}
	}

	// セッションストアに登録する
	sess, err := session.Get("sessions", c) 
	if err != nil { 
		log.Println(err) 
		return c.String(http.StatusInternalServerError, "something wrong in getting session") 
	} 
	sess.Values["userName"] = req.Username 
	sess.Save(c.Request(), c.Response()) 

	return c.NoContent(http.StatusOK) 
}

セッションストアに登録します。 セッションの userName という値にそのユーザーの名前を格納していることは覚えておきましょう。

ここまで書いたら、 LoginHandler を使えるようにしましょう。

go
func main() {
	(省略)
	e.Use(session.Middleware(store)) // セッション管理のためのミドルウェアを追加

	e.POST("/signup", h.SignUpHandler)
	e.POST("/login", h.LoginHandler) 

	e.GET("/cities/:cityName", h.GetCityInfoHandler)
	(省略)
}
ここまでの全体像
go
package main

import (
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/srinathgs/mysqlstore"
	"github.com/traPtitech/naro-template-backend/handler"
	"log"
	"os"
	"time"

	"github.com/go-sql-driver/mysql"

	"github.com/jmoiron/sqlx"
	"github.com/joho/godotenv"
)

func main() {
	// .envファイルから環境変数を読み込み
	err := godotenv.Load(".env")
	if err != nil {
		log.Fatal(err)
	}

	// データーベースの設定
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}
	conf := mysql.Config{
		User:      os.Getenv("DB_USERNAME"),
		Passwd:    os.Getenv("DB_PASSWORD"),
		Net:       "tcp",
		Addr:      os.Getenv("DB_HOSTNAME") + ":" + os.Getenv("DB_PORT"),
		DBName:    os.Getenv("DB_DATABASE"),
		ParseTime: true,
		Collation: "utf8mb4_unicode_ci",
		Loc:       jst,
	}

	// データベースに接続
	db, err := sqlx.Open("mysql", conf.FormatDSN())
	if err != nil {
		log.Fatal(err)
	}

	// usersテーブルが存在しなかったら、usersテーブルを作成する
	_, err = db.Exec("CREATE TABLE IF NOT EXISTS users (Username VARCHAR(255) PRIMARY KEY, HashedPass VARCHAR(255))")
	if err != nil {
		log.Fatal(err)
	}

	// セッションの情報を記憶するための場所をデータベース上に設定
	store, err := mysqlstore.NewMySQLStoreFromConnection(db.DB, "sessions", "/", 60*60*24*14, []byte("secret-token"))
	if err != nil {
		log.Fatal(err)
	}

	h := handler.NewHandler(db)
	e := echo.New()
	e.Use(middleware.Logger())       // ログを取るミドルウェアを追加
	e.Use(session.Middleware(store)) // セッション管理のためのミドルウェアを追加

	e.POST("/signup", h.SignUpHandler)
	e.POST("/login", h.LoginHandler)

	e.GET("/cities/:cityName", h.GetCityInfoHandler)
	e.POST("/cities", h.PostCityHandler)

	err = e.Start(":8080")
	if err != nil {
		log.Fatal(err)
	}
}
go
package handler

import (
	"database/sql"
	"errors"
	"github.com/jmoiron/sqlx"
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/bcrypt"
	"log"
	"net/http"
)

type Handler struct {
	db *sqlx.DB
}

func NewHandler(db *sqlx.DB) *Handler {
	return &Handler{db: db}
}

type City struct {
	ID          int            `json:"id,omitempty"  db:"ID"`
	Name        sql.NullString `json:"name,omitempty"  db:"Name"`
	CountryCode sql.NullString `json:"countryCode,omitempty"  db:"CountryCode"`
	District    sql.NullString `json:"district,omitempty"  db:"District"`
	Population  sql.NullInt64  `json:"population,omitempty"  db:"Population"`
}

type LoginRequestBody struct {
	Username string `json:"username,omitempty" form:"username"`
	Password string `json:"password,omitempty" form:"password"`
}

type User struct {
	Username   string `json:"username,omitempty"  db:"Username"`
	HashedPass string `json:"-"  db:"HashedPass"`
}

func (h *Handler) SignUpHandler(c echo.Context) error {
	// リクエストを受け取り、reqに格納する
	req := LoginRequestBody{}
	err := c.Bind(&req)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "bad request body")
	}

	// バリデーションする(PasswordかUsernameが空文字列の場合は400 BadRequestを返す)
	if req.Password == "" || req.Username == "" {
		return c.String(http.StatusBadRequest, "Username or Password is empty")
	}

	// 登録しようとしているユーザーが既にデータベース内に存在するかチェック
	var count int
	err = h.db.Get(&count, "SELECT COUNT(*) FROM users WHERE Username=?", req.Username)
	if err != nil {
		log.Println(err)
		return c.NoContent(http.StatusInternalServerError)
	}
	// 存在したら409 Conflictを返す
	if count > 0 {
		return c.String(http.StatusConflict, "Username is already used")
	}

	// パスワードをハッシュ化する
	hashedPass, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	// ハッシュ化に失敗したら500 InternalServerErrorを返す
	if err != nil {
		log.Println(err)
		return c.NoContent(http.StatusInternalServerError)
	}

	// ユーザーを登録する
	_, err = h.db.Exec("INSERT INTO users (Username, HashedPass) VALUES (?, ?)", req.Username, hashedPass)
	// 登録に失敗したら500 InternalServerErrorを返す
	if err != nil {
		log.Println(err)
		return c.NoContent(http.StatusInternalServerError)
	}
	// 登録に成功したら201 Createdを返す
	return c.NoContent(http.StatusCreated)
}

func (h *Handler) LoginHandler(c echo.Context) error {
	// リクエストを受け取り、reqに格納する
	var req LoginRequestBody
	err := c.Bind(&req)
	if err != nil {
		return c.String(http.StatusBadRequest, "bad request body")
	}

	// バリデーションする(PasswordかUsernameが空文字列の場合は400 BadRequestを返す)
	if req.Password == "" || req.Username == "" {
		return c.String(http.StatusBadRequest, "Username or Password is empty")
	}

	// データベースからユーザーを取得する
	user := User{}
	err = h.db.Get(&user, "SELECT * FROM users WHERE username=?", req.Username)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return c.NoContent(http.StatusUnauthorized)
		} else {
			log.Println(err)
			return c.NoContent(http.StatusInternalServerError)
		}
	}
	// パスワードが一致しているかを確かめる
	err = bcrypt.CompareHashAndPassword([]byte(user.HashedPass), []byte(req.Password))
	if err != nil {
		if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
			return c.NoContent(http.StatusUnauthorized)
		} else {
			return c.NoContent(http.StatusInternalServerError)
		}
	}

	// セッションストアに登録する
	sess, err := session.Get("sessions", c)
	if err != nil {
		log.Println(err)
		return c.String(http.StatusInternalServerError, "something wrong in getting session")
	}
	sess.Values["userName"] = req.Username
	sess.Save(c.Request(), c.Response())

	return c.NoContent(http.StatusOK)
}

func (h *Handler) GetCityInfoHandler(c echo.Context) error {
	cityName := c.Param("cityName")

	var city City
	err := h.db.Get(&city, "SELECT * FROM city WHERE Name=?", cityName)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return c.NoContent(http.StatusNotFound)
		}
		log.Printf("failed to get city data: %s\n", err)
		return c.NoContent(http.StatusInternalServerError)
	}

	return c.JSON(http.StatusOK, city)
}

func (h *Handler) PostCityHandler(c echo.Context) error {
	var city City
	err := c.Bind(&city)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "bad request body")
	}

	result, err := h.db.Exec("INSERT INTO city (Name, CountryCode, District, Population) VALUES (?, ?, ?, ?)", city.Name, city.CountryCode, city.District, city.Population)
	if err != nil {
		log.Printf("failed to insert city data: %s\n", err)
		return c.NoContent(http.StatusInternalServerError)
	}

	id, err := result.LastInsertId()
	if err != nil {
		log.Printf("failed to get last insert id: %s\n", err)
		return c.NoContent(http.StatusInternalServerError)
	}
	city.ID = int(id)

	return c.JSON(http.StatusCreated, city)
}

userAuthMiddleware の実装

続いて、userAuthMiddleware を実装します。 まず、これは Handler ではなく Middleware と呼ばれます。

送られてくるリクエストは、Middleware を経由して、 Handler に流れていきます。

Middleware から次の Middleware/Handler を呼び出す際は next(c) と記述します。 Middleware の実装は難しいので、なんとなく理解できれば十分です。

以下をhandler.goに追加しましょう。

go
func UserAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 
	return func(c echo.Context) error { 
		sess, err := session.Get("sessions", c) 
		if err != nil { 
			log.Println(err) 
			return c.String(http.StatusInternalServerError, "something wrong in getting session") 
		} 
		if sess.Values["userName"] == nil { 
			return c.String(http.StatusUnauthorized, "please login") 
		} 
		c.Set("userName", sess.Values["userName"].(string)) 
		return next(c) 
	} 
} 

関数が関数を呼び出していて混乱しそうですが、 2 行目から 13 行目が本質で、外側はおまじないと考えて良いです。

この Middleware はリクエストを送ったユーザーがログインしているのかをチェックし、 ログインしているなら Context (c) にそのユーザーの UserName を設定します。

セッションを取得し、ログイン時に設定した userName の値を確認しに行きます。

ここで名前が入っていればリクエストの送信者はログイン済みで、そうでなければログインをしていないことが分かります。

これを利用して、ログインしていない場合には処理をここで止めて 401 (Unauthorized) を返却し、していれば次の処理 (next(c)) に進みます。

最後に、Middleware を設定しましょう。 グループ機能を利用して、 withAuth に設定されてるエンドポイントは userAuthMiddleware を処理してから処理する、という設定をします。

go
func main() {
	(省略)
	e.POST("/login", h.LoginHandler)

	e.GET("/cities/:cityName", h.GetCityInfoHandler) 
	e.POST("/cities", h.PostCityHandler) 
	withAuth := e.Group("") 
	withAuth.Use(handler.UserAuthMiddleware) 
	withAuth.GET("/cities/:cityName", h.GetCityInfoHandler) 
	withAuth.POST("/cities", h.PostCityHandler) 

	err = e.Start(":8080")
	(省略)
}

これで、この章の目標である「ログインしないと利用できないようにする」が達成されました。

GetMeHandler の実装

最後に、 GetMeHandler を実装します。叩いたときに自分の情報が返ってくるエンドポイントです。

以下を handler.go に追加しましょう。

go
type Me struct { 
	Username string `json:"username,omitempty"  db:"username"`
} 
go
func GetMeHandler(c echo.Context) error { 
	return c.JSON(http.StatusOK, Me{ 
		Username: c.Get("userName").(string), 
	}) 
} 

アクセスしているユーザーのuserNameをセッションから取得して返しています。 userAuthMiddleware を実行したあとなので、c.Get("userName").(string) によって userName を取得できます。

main.gowithAuth.GET("/me", handler.GetMeHandler)を追加しましょう。

go
func main() {
	(省略)
	withAuth := e.Group("")
	withAuth.Use(handler.UserAuthMiddleware)
	withAuth.GET("/me", handler.GetMeHandler) 
	withAuth.GET("/cities/:cityName", h.GetCityInfoHandler)
	withAuth.POST("/cities", h.PostCityHandler)

	err = e.Start(":8080")
	(省略)
}