🔩 ねじき教室 Go と Web の教室

Cookie とセッション

読了目安 約4分

Cookie とセッションでログイン状態を維持し、投稿をログインした本人のものにします。

この章の目次

前章の「ログインしても次のリクエストでは忘れられる」問題を解決します。

この章で足すものは次の 3 つです。

  • セッション ID の発行と、サーバー側の対応表
  • ログイン時の Set-Cookie と、POST /logout
  • 投稿を「ログインしている本人」のものにする変更

リクエストを結びつける目印

HTTP はステートレスです。 ステートレスとは、各リクエストが独立し、サーバーが前のリクエストとのつながりを覚えない性質です。 であれば、リクエスト自体に目印を持たせて結びつけるしかありません。

目印の受け渡しを担うのが Cookie です。 サーバーが Set-Cookie ヘッダーで渡した小さなデータを、ブラウザが保存して以後同じサイトへのリクエストに自動で添えます。 ログイン成功のレスポンスには、こう付きます。

テキスト
HTTP/1.1 303 See Other
Set-Cookie: session_id=3c9f...(64 文字の乱数)
Location: /

以後のリクエストには、こう付きます。

テキスト
GET / HTTP/1.1
Host: localhost:8080
Cookie: session_id=3c9f...(同じ値)

「ユーザー ID をそのまま入れる」のは危険です。

  • Cookie ヘッダーはブラウザ側が自由に書き換えられる。Cookie: user_id=2 と付けた curl を送るだけで他人になりすませる
  • ブラウザから送られてくる値は信用できない、というのはフォームの値も Cookie も同じ

Cookie に入れるのはセッション ID(推測不能な乱数)だけにします。 サーバー側に「セッション ID からユーザー ID を引く対応表」を置き、ログイン状態の本体を持たせる仕組みをセッションと呼びます。

セッション ID の生成には crypto/rand を使います。

Go
func newSessionID() string {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
  • math/rand は擬似乱数で、内部状態が分かれば以後の値を予測できる。「当てられないこと」が安全性なので crypto/rand を使う
  • 32 バイトの乱数を hex.EncodeToString で 64 文字の 16 進文字列にして、Cookie に入れられる形にする

対応表と排他制御

対応表は、まず map で持ちます。 グローバル変数に 2 つ足し、import に crypto/rand、encoding/hex、sync を加えます。

Go
var (
	sessions   = map[string]int64{} // セッション ID → ユーザー ID
	sessionsMu sync.Mutex
)
  • Mutex が要るのは、goroutine と並行性 のとおり、net/http がリクエストごとに goroutine を割り当てるから
  • map は同時アクセスに耐えないので、読み書きを sessionsMu で囲んで守る

この対応表はメモリ上にあるので、再起動で全員ログアウトします。 本番では Redis や DB に置きますが、「セッション ID を鍵にユーザーを引く」原理は同じです。

ログインで発行し、ログアウトで破棄する

handleLogin の末尾の fmt.Fprintln を次に置き換えます(fmt は import から外します)。

Go
	sessionID := newSessionID()
	sessionsMu.Lock()
	sessions[sessionID] = userID
	sessionsMu.Unlock()

	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
	})
	http.Redirect(w, r, "/", http.StatusSeeOther)
  • Path: "/" は「このサイトのどのパスへのリクエストにも添える」という指定
  • HttpOnly: true は、この Cookie をページ内の JavaScript から読めなくする印。2 章で見た XSS がもし成立しても、仕込まれたスクリプトにセッション ID を盗み出されない

ログアウトは逆に、対応表から消します。

Go
func handleLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie("session_id"); err == nil {
		sessionsMu.Lock()
		delete(sessions, c.Value)
		sessionsMu.Unlock()
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	})
	http.Redirect(w, r, "/", http.StatusSeeOther)
}
  • MaxAge: -1 はブラウザに Cookie の削除を指示するが、本命は対応表からの削除の方

ルーティングにも 1 行足します。

Go
	mux.HandleFunc("POST /logout", handleLogout)

投稿を本人のものにする

リクエストからユーザーを引く関数を用意します。

Go
func currentUserID(r *http.Request) (int64, bool) {
	c, err := r.Cookie("session_id")
	if err != nil {
		return 0, false
	}
	sessionsMu.Lock()
	defer sessionsMu.Unlock()
	userID, ok := sessions[c.Value]
	return userID, ok
}
  • r.Cookie は該当する名前の Cookie が無ければエラーを返すので、「ログインしていない」と読み替える
  • Cookie があっても対応表に無ければ(ログアウト済みやサーバー再起動後)、同じく未ログイン扱い

handleCreatePost を書き換えて、「投稿者を仮ユーザーに固定」をやめます。

Go
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
	userID, ok := currentUserID(r)
	if !ok {
		http.Error(w, "ログインしてください", http.StatusUnauthorized)
		return
	}

	body := r.FormValue("body")
	if body == "" {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	if _, err := db.Exec("INSERT INTO posts (user_id, body) VALUES (?, ?)", userID, body); err != nil {
		serverError(w, err)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

未ログインの投稿はひとまず 401 Unauthorized で拒みます(次章でログイン画面へ誘導します)。

動作確認

ブラウザで http://localhost:8080/login からログインして投稿してください。 一覧に neziko ではなく自分の名前が出れば、セッションが機能しています。

curl なら Cookie の受け渡しを目で追えます(-c が保存、-b が送信)。

シェル
# ログインして、返ってきた Cookie を cookies.txt に保存する
curl -i -c cookies.txt -d 'name=alice' -d 'password=himitsu' http://localhost:8080/login

# 保存した Cookie を添えて投稿する
curl -b cookies.txt -d 'body=セッション越しの投稿' http://localhost:8080/posts

# ログアウトする
curl -b cookies.txt -X POST http://localhost:8080/logout

# 同じ Cookie で投稿してみる
curl -i -b cookies.txt -d 'body=これは失敗するはず' http://localhost:8080/posts
  • 1 本目: 応答ヘッダーに Set-Cookie が見える
  • 2 本目: alice の投稿として一覧に載る
  • 4 本目: Cookie を送っているのに 401 になる。ログアウトで対応表から消えたため