- Overview
- 前提: そもそも Go で Cookie を操作するには?
- Cookie を使った認証機構がある Handler のテスト
- SecureCookie を使って Cookie で Session を扱う
- ちょっとだけリファクタリングする
- まとめ
- Memo
Overview
タイトルの通りです。Go で API を書くときなど HTTP の Handler を実装していくと思いますが、ここでログインなどの Cookie にセッションを持たせるような認証機構がセットになってる実装をする場合、この認証機構(特に管理画面でいうところの持ってる権限の判定など) を突破しないと Handler のテストが動かない、と言うケースがあると思います。もしかしたら認証機構を Middleware として切り出して Handler の処理本体では認証を行わない、と言う実装方針も考えることができますが、今回は Handler のなかで認証を実装してるケースにおける Cookie をセットした状態でテストを書くことを念頭におきます。最終的には Handler でチェックしてる認証機構を Middleware に切り出したり、と言ったことも考えられるかなと。
前提: そもそも Go で Cookie を操作するには?
Go の HTTP リクエストの処理において Cookie を操作するにはリクエストとレスポンスで扱うそれぞれのケースがあり、これらは全て net/http package の内部にあります。
- リクエストにセットするとき → https://pkg.go.dev/net/http#Request.AddCookie
- リクエストからCookie を取り出す → https://pkg.go.dev/net/http#Request.Cookie
- レスポンスにセットする時 → https://pkg.go.dev/net/http#SetCookie
Cookie に何を入れるかにもよりますが、おおよそ社内機能のようにアクセスが制限されてる環境であれば、セッション情報を埋め込んでログインだったりに利用することが多いと思います。
※ Cookie をログインセッションのために使うのはお手軽ですが、万能ではないのでちゃんと使われるサービスの特性を元に検討してください。ここでは Cookie に埋め込んだセッション情報をどう安全に取り扱うかについては言及しません。
Cookie を使った認証機構がある Handler のテスト
Cookie を取り出してその情報を何かしら検証してるテストにおいてはテストする時に Cookie をセットします。
サンプル
以下のような Handler の実装を考えます。
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte(http.StatusText(http.StatusMethodNotAllowed))) return } ck, err := r.Cookie("test") if err != nil { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(http.StatusText(http.StatusUnauthorized))) return } w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("OK. cookie value is %#v", ck))) }
これの Handler のテストにおいてテストを追加します。
Cookie を追加していない時
go test -v . === RUN TestRouter main_test.go:37: Unauthorized --- PASS: TestRouter (0.00s) PASS ok github.com/emahiro/il/cookiesessiontest 0.239s
以下のように Cookie を追加した時
req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) req.AddCookie(&http.Cookie{ Name: "test", Value: "test", }) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) }
この時のテストの結果は以下
go test -v . === RUN TestRouter main_test.go:41: OK. cookie value is &http.Cookie{Name:"test", Value:"test", Path:"", Domain:"", Expires:time.Time{wall:0x0, ext:0, loc:(*time.Location)(nil)}, RawExpires:"", MaxAge:0, Secure:false, HttpOnly:false, SameSite:0, Raw:"", Unparsed:[]string(nil)} --- PASS: TestRouter (0.00s) PASS ok github.com/emahiro/il/cookiesessiontest 0.933s
雑な実装としては上記のとおりです。
SecureCookie を使って Cookie で Session を扱う
単純な Cookie ではなく、Session とセットで扱うために gorilla が提供してる https://pkg.go.dev/github.com/gorilla/securecookie を紹介します。
個人的には go-chi も好きですが、gorilla はさらにいろんなツールセットが整っていて好みなフレームワークです。Routing の仕方が特徴的ですが、ne/http の ServerHTTP を満たすインターフェースをしてる点も go-chi と同じで標準に乗っ取っていて好みです。
net/http 互換なので gorilla のパッケージを go-chi から利用することも可能です。
securecookie を使って Session をやりとりする実装をします。
まず最初に Cookie をセットする箇所は以下のようになります。
req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) codec := securecookie.CodecsFromPairs(keyPairs) ckstr, err := codec[0].Encode("test", map [interface{}]interface{}{ "test": "test", }) if err != nil { t.Fatal(err) } req.AddCookie(&http.Cookie{ Name: "test", Value: ckstr, })
取り出す側は以下です
store := sessions.NewCookieStore(keyPairs) store.Options = &sessions.Options{} session, err := store.Get(r, ckName) if err != nil { // refresh session session = sessions.NewSession(store, ckName) }
テストする側では以下のように securecookie を設定します。
store := sessions.NewCookieStore(keyPairs) store.Options = &sessions.Options{} session, err := store.Get(r, ckName) if err != nil { // refresh session session = sessions.NewSession(store, ckName) }
securecookie では codec 後の encode 処理をかける際に上記 interface の map を渡します。key も interface なら value も interface を渡すのでこれは本当の意味でなんでも渡せる君になってます笑。
map を渡せるようになってるので受け取る側では key を指定して SessionStore に格納された値を指定して以下のように取り出すことが可能です。
store := sessions.NewCookieStore(keyPairs) store.Options = &sessions.Options{} session, err := store.Get(r, ckName) if err != nil { // refresh session session = sessions.NewSession(store, ckName) } w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("OK. cookie name is %s value is %#v", session.Name(), session.Values["test"]))) // map[interface{}]interface{} で指定した key で値を取り出せる
少しややこしいな、と思ったのは net/http の Cookie の実装と違い、ここは Cookie をセットする時は SecureCookie として扱うものの、Cookie からセッション情報を取り出した場合には Session として以下扱っているところです。
ちょっとだけリファクタリングする
最後に handler で Cookie の情報を取り出していた箇所を Middleware にしました -> https://github.com/emahiro/il/pull/31/commits/3ee20b9ead7fc24b8238360dee4e40f06ba87cbb
まとめ
このエントリーでざっと流した内容は https://github.com/emahiro/il/pull/31 にまとめてあります。
Memo
http: Request.RequestURI can't be set in client requests が出る
httptest.NewRequest で httptest.Newserver でインスタンス化したサーバーにリクエストを送ると http: Request.RequestURI can't be set in client requests
というエラーが出ます。
=== RUN TestRouter main_test.go:23: http://127.0.0.1:49592 main_test.go:29: Get "http://127.0.0.1:49592": http: Request.RequestURI can't be set in client requests
httptest で立ち上げたサーバーの URL を指定するとURI が指定されていない、というかクライアントではセットできないと言われてリクエストが不通です。
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(method + " " + target + " HTTP/1.0\r\n\r\n")))
パッと調べた感じ、httptest の中でより LowLevel な関数を Call しており、この辺が怪しそうでした。
// ReadRequest reads and parses an incoming request from b. // // ReadRequest is a low-level function and should only be used for // specialized applications; most code should use the Server to read // requests and handle them via the Handler interface. ReadRequest // only supports HTTP/1.x requests. For HTTP/2, use golang.org/x/net/http2. func ReadRequest(b *bufio.Reader) (*Request, error) { return readRequest(b, deleteHostHeader) }
この readRequest
関数の内部で parseRequestLine
という処理を読んでおり、この中で Request.RequestURI
が空になって返ってそうだったので、Request.RequestURI がないことによるリクエストの普通が発生してると考えてます。時間がある時に深掘りしてみようと思います。