Goのgorilla/sessionsでセッションIDが自動で振られない問題を解決した

# はじめに
## 問題の背景
わたしはGo言語の初心者で、ソフトウェアエンジニアではあるが、非Web系のエンジニアであり、転職のためにGo言語でのバックエンド実装を勉強している。
サービスの開発の勉強で、ログイン状態管理のためのセッション管理の実装をおこなうところタイトルのような問題に遭遇した。

## 検証結果
結論を最初にいうと、gorilla/sessionsではセッション生成時にSession.IDを自動で振られるはずだが、どうしてもIDが空になるため、プログラムで手動でIDを割り当てるようにした。試したコードは[gorilla/sessions GitHubページ](https://github.com/gorilla/sessions)の公式サンプルを再現した。

こちらはセッションIDの生成と代入部分のみ
```go
if session.ID == "" {
    // Generate a random session ID key suitable for storage in the DB
    session.ID = string(securecookie.GenerateRandomKey(32))
    session.ID = strings.TrimRight(
        base32.StdEncoding.EncodeToString(
            securecookie.GenerateRandomKey(32)), "=")
}

fmt.Println("Session ID:", session.ID)
```
私が改良して実装したコードのGithubレポジトリは[こちら](https://github.com/inai17ibar/session-manager)

## 前提条件
Go言語のバックエンド開発にはこれらを開発環境を利用。Ginなどのフレームワークを使わなかったのは、検証用の別プロジェクトに分ける際、入れるのを単純に忘れたので意味はない。
* macbook 2017 (macOS Ventura)
* go version go1.21.0 darwin/amd64
* github.com/gorilla/sessions v1.2.2
* github.com/go-redis/redis/v8 v8.11.5 

## Go言語とgorilla/sessionsの簡単な紹介
Go言語は、Googleによって開発されたパフォーマンスと効率を重視したプログラミング言語である。そのシンプルな構文と高速な実行速度は、特にサーバーサイドのアプリケーション開発に適している。

gorilla/sessionsはGo言語でのWeb開発において広く使用されるミドルウェアで、セッション管理を簡単かつ効果的に行うためのツールを提供する。このパッケージは、セッションの作成、データの格納、セッションの復元など、Webアプリケーションに必要な基本的なセッション機能をサポートする。

# 問題の詳細
## セッションIDが自動で振られない問題
[gorilla/sessions GitHubページ](https://github.com/gorilla/sessions)にあるサンプルを利用して、Sessionを作成し、削除するコードを実装したところ、Session.IDが空になることがわかった。

なぜわかったかというと、Redisでデータをキャッシュする際、キーとなるセッションIDが空であるとキャッシュを失敗することがわかったからである。

# 問題解決のためのアプローチ
## 調査と分析のプロセス
ログインのタイミング、ログイン状態、ログアウトのタイミング、そのときのChromeデバロッパーツールでのセッション値の確認を行って、どこで問題が起きているか調査した。

## 考慮された代替案とその評価
セッションIDを手動で生成した暗号をふり、それをバックエンドでクッキーにつめてフロントエンドとやり取りするようにした。また、これによってセッションIDをキーとしてRedisからバリューを保存したり、ロードしたりできるようにした。

# 実装手順
## セッションIDを自動生成するための具体的なコードの変更
上述した暗号生成部分のコードを含めた、GoのLoginハンドラーにあたる関数がこちらである。

```go

func login(w http.ResponseWriter, r *http.Request) {
	session, err := store.Get(r, "session-name")
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		log.Printf("Error retrieving session: %v", err)
		return
	}

	if session.ID == "" {
		session.ID = strings.TrimRight(
			base32.StdEncoding.EncodeToString(
				securecookie.GenerateRandomKey(32)), "=")
	}

	fmt.Println("Session ID:", session.ID)

	session.Options = &sessions.Options{
		Path:     "/",
		MaxAge:   86400 * 7, // 1週間
		HttpOnly: true,
		// Secure: true, // HTTPSを使用する場合に有効化
	}

	// 認証成功と見なす
	session.Values["authenticated"] = true

	// Redisにセッションデータを保存
	err = saveSessionToRedis(session)
	if err != nil {
		// エラー処理
		log.Printf("Error saving session: %v", err)
		return
	}

	err = session.Save(r, w)
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		log.Printf("Error saving session: %v", err)
		return
	}

	// セッションIDをクッキーに設定
	cookie := http.Cookie{
		Name:     "session-id",
		Value:    session.ID,
		Path:     "/",
		MaxAge:   86400 * 7,
		HttpOnly: true, // JavaScriptからアクセスを防ぐ
		// Secure: true, // HTTPSを使用する場合にコメントアウトを外す
	}

	// クッキーをレスポンスに追加
	http.SetCookie(w, &cookie)

	// セッションの値を確認
	fmt.Println("Session ID:", session.ID)
	fmt.Println("Session Values:", session.Values)

	fmt.Println("Login successful")

	// レスポンス
	w.Write([]byte("Logged in"))
}


```

## 実装上の注意点

セッションIDの保持の仕方がわからず、とりあえずクッキーに保存することにした。

Login後、クッキーを使わなければ、セッションのキーとバリューは維持されているが、セッションIDは損失するようだった。`err = session.Save(r, w)`でセッションIDも保存されるのかと思っていたが、うまく行かなかった。
そのためクッキーを使っている。クッキーにセッションIDを入れるだけでなく、セッションIDを必要とするときはクッキーから取り出す必要がある。

また、クッキーをHTTPリクエストに入れて受け渡すにはCORSやHTTPリクエストの設定が必要である。

以下、クッキーからのセッションIDの取得のコードである。
```go
// クライアントからのリクエストで送信されたクッキーを取得
cookie, err := r.Cookie("session-id")
if err != nil {
    if err == http.ErrNoCookie {
        // クッキーが存在しない場合の処理
        http.Error(w, "No cookie found", http.StatusBadRequest)
        return
    }
    // その他のエラー
    http.Error(w, "Internal server error", http.StatusInternalServerError)
    return
}

// クッキーの値を取得
session.ID = cookie.Value
```

バックエンドでクッキーを使う設定 `AllowCredentials: true, `
```go
mux := http.NewServeMux()
mux.HandleFunc("/secret", secret)
mux.HandleFunc("/login", login)
mux.HandleFunc("/logout", logout)

c := cors.New(cors.Options{
    AllowedOrigins:   []string{"http://localhost:3000"}, // ReactアプリのURLを設定
    AllowCredentials: true,                              // Cookieが使えるように設定
})
```

フロントエンド側のクッキーを使う設定 `{ withCredentials: true })`
```React
const handleLogin = () => {
axios.post('http://localhost:8080/login', {}, { withCredentials: true })
  .then(response => {
    console.log(response.data);
    setIsLoggedIn(true);
  })
  .catch(err => {
    console.error(err);
  });
};
```

# テストと検証
## コード変更後のテスト手順
初回執筆時、2023/12/31時点では、ユニットテストのコードは書いておらず、バックエンドサーバーのコンソールログとRedisのGUIでのキーと値の目視確認である。実装したコードを実行すると以下のような結果得られた。

## 問題が解決されたことの確認方法

```zsh
#login処理
Session ID: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
Session Values: map[authenticated:true]
Login successful

#認証確認処理
Session ID from cookie: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
2023/12/31 06:25:07 Session is loaded to Redis: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
Session Values: map[authenticated:true]

#認証確認処理にRedisGUIで値を追加する
Session ID from cookie: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
2023/12/31 06:25:45 Session is loaded to Redis: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
Session Values: map[authenticated:true foo:bar]

#ログアウト処理
Session ID: IE4G47U54NIDB4EDL3QU5EFVJIIKFHNGDSIFSA6ZEL25GQVXS4XA
Session Values: map[]
Logout successful
```

# 結論と今後の展望
## 問題解決による効果
セッションIDが空にならなくなったので、目標であるセッション管理とRedisへのセッションIDをキーとするキーバリューのキャッシュが可能になった。

## 今後の開発における応用可能性と改善点
+ セッション管理には、SessionIDを使う方法とJWTトークンの認証による方法が
ある。またFirebase認証も流行っているため、他の手法についても理解を深めたい。
+ 経験者のアドバイスがなく、セッションIDの管理を実装したが、より効率t系な実装があるかもしれない。

# TODO
+ 今回実装した[セッション管理のコード](https://github.com/inai17ibar/session-manager）)を大まかに解説できる記事がかけるとよい。
+ またRedisの活用をはじめていないので、つかいどころを勉強したい

# 参考文献
## 関連文献や資料のリスト
- [Go言語公式ドキュメント](https://golang.org/doc/)
- [gorilla/sessions GitHubページ](https://github.com/gorilla/sessions)
- [Go gorilla/sessions - matsu tech blog](https://shikatech.hatenablog.com/entry/2021/11/21/180253)