😃

GinでCookieの取得・検証はhandlerとmiddlewareのどちらで行うべきか

に公開

はじめに

Go + Ginでアプリを作っていると、「このCookieのチェック、handlerに書くべきかmiddlewareに書くべきか」と迷うことがあります。

たとえば提携先サービスとの連携で使う partner_code Cookie を考えると、次の確認が必要になります。

  • Cookieが存在するかどうかの確認
  • Cookieの値が規定のフォーマットかどうかの確認
  • その提携コードが業務上許可された値かどうかの確認

ここで分けて考えたいのは、次の2つです。

  • HTTP入力としての検証
    Cookieがあるか、形式が正しいかを確認する話です。
  • 業務上の妥当性確認
    その値を業務的に信じてよいか、許可された値かを確認する話です。

これをすべてhandlerに書くと、HTTP入力の処理と業務上の判定が混ざります。逆に、何でもmiddlewareに寄せると単発のAPIでは重くなりがちです。

この記事では、「Cookieをどこで読むか」と「取り出した値を業務的にどう検証するか」を分けて整理します。そのうえで、handlerで処理する場合とmiddlewareで処理する場合をコードで見比べます。主題はCookie処理そのものより、handler / middleware / service の責務をどう分けるかです。

サンプルコード

https://github.com/tonbiattack/gin-cookie-boundary-sample

基本的な考え方

先に結論を書くと、責務は次のように分けるのが自然です。

レイヤー やること
handler / middleware Cookieを読む、未設定・形式不正を弾く(HTTP入力の話)
service / usecase その値が業務上有効かどうかを判定する(業務ロジックの話)

「Cookieがない」「フォーマットが壊れている」はHTTP入力の問題です。これはhandlerかmiddlewareで弾きます。

「このコードは許可された提携先か」はビジネスルールの話です。これはserviceで判断します。

middlewareはHTTPレイヤーで共通化しやすい処理に向いています。一方で、業務ルールまで持ち込むと責務が曖昧になりやすく、別のhandlerや別の経路で再利用しにくくなります。

この記事では便宜上 service と書きますが、設計によっては usecase や domain service、policy のような名前で置いても考え方は同じです。

大事なのは、serviceにCookie取得の責務を持ち込まないことです。serviceは partnerCode string を受け取るだけにして、gin.Context*http.Cookie を知らない形にしておくと扱いやすくなります。

プロジェクト構成

gin-cookie-boundary-sample/
├── main.go
├── handler/
│   ├── partner.go               # パターン1: handlerでCookie取得
│   └── partner_via_middleware.go # パターン2: middlewareから受け取る
├── middleware/
│   └── partner_cookie.go        # パターン2のmiddleware
└── service/
    └── partner.go               # 両パターン共通のservice

パターン1: handlerでCookieを取得する

コード

// handler/partner.go

var partnerCodePattern = regexp.MustCompile(`^[a-zA-Z0-9\-]{3,32}$`)

func (h *PartnerHandler) GetPartnerInfo(c *gin.Context) {
    // 1. Cookieを読む
    partnerCode, err := c.Cookie("partner_code")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "partner_code cookieが設定されていません",
        })
        return
    }

    // 2. 形式チェック
    if partnerCode == "" || !partnerCodePattern.MatchString(partnerCode) {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "partner_code cookieの形式が不正です",
        })
        return
    }

    // 3. serviceへ渡す(業務判定はここから先)
    info, err := h.svc.GetPartnerInfo(partnerCode)
    if err != nil {
        c.JSON(http.StatusForbidden, gin.H{
            "error": err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, info)
}

動作フロー

GET /handler/partner-info
  → handler: c.Cookie("partner_code") でCookieを取得
  → handler: フォーマットチェック
  → service: 業務的に有効な提携コードか判定
  → レスポンス

どういうときに向くか

  • そのAPIにしかCookieが不要な場合
  • 後から共通化する必要がないと分かっている場合

責務がhandlerに集まるぶん、流れを追いやすいのが利点です。

パターン2: middlewareでCookieを取得する

middlewareのコード

// middleware/partner_cookie.go

const PartnerCodeKey = "partnerCode"

var partnerCodePattern = regexp.MustCompile(`^[a-zA-Z0-9\-]{3,32}$`)

func RequirePartnerCookie() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. Cookieを読む
        partnerCode, err := c.Cookie("partner_code")
        if err != nil {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "error": "partner_code cookieが設定されていません",
            })
            return
        }

        // 2. 形式チェック
        if partnerCode == "" || !partnerCodePattern.MatchString(partnerCode) {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                "error": "partner_code cookieの形式が不正です",
            })
            return
        }

        // 3. contextに詰めて次のhandlerへ
        c.Set(PartnerCodeKey, partnerCode)
        c.Next()
    }
}

handlerのコード(middleware版)

// handler/partner_via_middleware.go

func (h *PartnerMiddlewareHandler) GetPartnerInfo(c *gin.Context) {
    // middlewareがcontextに詰めた値を取り出すだけ
    partnerCodeValue, exists := c.Get(middleware.PartnerCodeKey)
    if !exists {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "partnerCodeがcontextにありません(middlewareの設定を確認してください)",
        })
        return
    }

    partnerCode, ok := partnerCodeValue.(string)
    if !ok {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "partnerCodeの型が不正です",
        })
        return
    }

    info, err := h.svc.GetPartnerInfo(partnerCode)
    if err != nil {
        c.JSON(http.StatusForbidden, gin.H{
            "error": err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, info)
}

ルーティング

// main.go

// グループにmiddlewareを適用するだけで、配下の全handlerにCookie検証が入る
middlewareGroup := r.Group("/middleware", middleware.RequirePartnerCookie())
{
    middlewareGroup.GET("/partner-info", partnerMiddlewareHandler.GetPartnerInfo)
    // 同じmiddlewareを使う別エンドポイントをここに追加できる
    // middlewareGroup.GET("/other-endpoint", otherHandler.DoSomething)
}

動作フロー

GET /middleware/partner-info
  → middleware: c.Cookie("partner_code") でCookieを取得
  → middleware: フォーマットチェック
  → middleware: c.Set("partnerCode", partnerCode) でcontextに詰める
  → handler: c.Get("partnerCode") で取り出す
  → service: 業務的に有効な提携コードか判定
  → レスポンス

どういうときに向くか

  • 同じCookieを複数のAPIで使う場合
  • 認証・認可の仕組みとして共通化したい場合

複数のエンドポイントで同じCookieを扱うなら、handlerごとに同じ処理を書くよりmiddlewareにまとめた方が保守しやすくなります。ただし、そこで扱うのはHTTP入力として共通化しやすい範囲に留めた方が安全です。

middlewareに寄せすぎると困ること

middleware化は便利ですが、寄せすぎると扱いにくさも出てきます。

context経由の受け渡しが見えにくくなる

handlerの引数を見ても「何を受け取っているか」が分かりません。c.Get("partnerCode") という文字列キーの一致と、middlewareが先に実行されることに依存するためです。つまり、middlewareとhandlerの結合がコード上に明示されず、実行時まで崩れたことに気づきにくい構造になります。middlewareを外したり差し替えたりしてもコンパイルエラーにはならず、exists == false や型不一致で初めて気づく、という状況になりがちです。

テストにmiddlewareを前提とする必要が出る

handlerのテストを書くとき、middlewareが c.Set("partnerCode", ...) を済ませている前提で動くため、テスト側でその状態を事前に作る必要があります。handler単体のテストがやや煩雑になります。

「このAPIだけCookieが任意」という例外が扱いにくくなる

Cookieが全ルートで必須ならmiddlewareは有効ですが、「このエンドポイントだけCookieなしでも動く」という例外が出てきたとき、middleware側で条件分岐が増えてロジックが複雑になります。その場合はhandlerで個別に処理する方がシンプルです。

service層: 両パターン共通

// service/partner.go

// 許可された提携コードの一覧(実務ではDBや設定ファイルから取得する)
var validPartnerCodes = map[string]bool{
    "partner-a": true,
    "partner-b": true,
}

// GetPartnerInfo は提携コードを受け取り、業務的に有効かどうかを検証して情報を返す。
// Cookieや gin.Context には一切依存しない。
func (s *PartnerService) GetPartnerInfo(partnerCode string) (map[string]string, error) {
    if !validPartnerCodes[partnerCode] {
        return nil, errors.New("許可されていない提携コードです: " + partnerCode)
    }

    return map[string]string{
        "partnerCode": partnerCode,
        "status":      "active",
        "message":     "提携先として有効です",
    }, nil
}

serviceはstringを受け取るだけです。gin.Context*http.Cookie も登場しません。

この形にしておくと、serviceのテストは純粋な関数呼び出しとして書けます。HTTPリクエストを組み立てる必要がなく、入出力の確認に集中できます。

Cookieはクライアント入力である

ここで一点、Cookieに固有の話をしておきます。

Cookieはクライアント(ブラウザや外部サービス)から送られてくる値です。ヘッダーやクエリパラメータと同様、サーバーから見れば「ユーザーが自由に操作できる入力」です。

そのため、扱い方には注意が必要です。

形式が正しくても信用しすぎない

形式チェック(正規表現など)はあくまで「壊れた入力」を弾くためのものです。フォーマットが正しい値でも、業務的に許可された値かどうかは必ずサーバー側で確認します。今回のサンプルでいえば、それがserviceの validPartnerCodes によるチェックです。

改ざんリスクを意識する

提携コードのような値がセキュリティ上重要な場合(例: これによってアクセス可能なリソースが変わる場合)は、ただの文字列Cookieは信頼の根拠として弱いです。JWTのような署名付きトークンやサーバー側セッションと組み合わせることで、改ざんを検出できます。

今回のサンプルは「提携先の導線識別」を想定しており、最終的な認可はservice内のホワイトリストで行っているため、Cookieが改ざんされても許可されていない値として弾かれます。ただし実務では、Cookieがどこまでの権限に影響するかに応じて設計を検討してください。

認証用Cookieと識別用Cookieの違い

middlewareに寄せるか、handlerで処理するかを決めるときは、そのCookieが何のためにあるのかを先に整理すると判断しやすくなります。

識別用Cookie(今回のサンプルの想定)

「どの提携導線から来たか」を識別するためのCookieです。

  • セッション管理とは分離している
  • Cookieがなくてもサービスは動く場合がある(提携なしのアクセスも許容するなど)
  • 特定のAPIでのみ必要なことが多い

この場合は、必要なhandlerで個別に読むか、特定のルートグループにだけmiddlewareを適用するのが自然です。アプリ全体にかけるほどではないことが多いです。

認証用Cookie(セッションIDやトークンなど)

「このリクエストを送ってきたのが誰か」を特定するためのCookieです。

  • 認証が通らなければ全て弾く
  • ほぼ全ての保護されたルートに必要
  • ログインチェックとセットで動く

この場合は、認証middlewareとして共通化するのが向いています。保護対象のルート全体で必要になることが多いためです。

動作確認

レスポンスの考え方も分けておくと分かりやすくなります。

  • Cookieがない、形式が壊れている
    HTTP入力の問題なので 400 Bad Request を返します。
  • 形式は正しいが、業務上は許可されていない
    入力としては受理できるが利用は許可しないため、このサンプルでは 403 Forbidden を返します。
# Cookieなし → 400
curl http://localhost:8080/handler/partner-info
# {"error":"partner_code cookieが設定されていません"}

# 形式不正(短すぎる) → 400
curl --cookie "partner_code=ab" http://localhost:8080/handler/partner-info
# {"error":"partner_code cookieの形式が不正です"}

# 形式OK・業務的に不正な値 → 403
curl --cookie "partner_code=unknown-code" http://localhost:8080/handler/partner-info
# {"error":"許可されていない提携コードです: unknown-code"}

# 正常 → 200
curl --cookie "partner_code=partner-a" http://localhost:8080/handler/partner-info
# {"message":"提携先として有効です","partnerCode":"partner-a","status":"active"}

middleware版も同じです。/middleware/partner-info に対して同じcurlを実行すれば確認できます。

どちらを選ぶか

状況 向くパターン
そのAPIだけにCookieが必要 handler
複数のAPIで同じCookieを使う middleware(ルートグループに適用)
認証Cookie(ほぼ全ルートに必要) middleware
識別用Cookie(一部のルートに必要) handler または限定的なmiddleware
Cookieが任意で例外ルートがある handler

どちらを選ぶ場合でも、serviceにCookie取得の処理を持ち込まないことは共通です。

serviceはHTTPから独立したビジネスロジックの層です。gin.Context に依存し始めると、テストが書きにくくなり、別の場所で再利用しづらくなります。

まとめ

GinでCookieを扱う場合の責務の分け方を整理すると、こうなります。

  • Cookieを読む、形式を確認するのはhandlerまたはmiddlewareの役割です
  • その値が業務的に有効かどうかを判定するのはserviceの役割です
  • serviceはCookieを知らず、文字列を受け取る形にしておくと責務が崩れません

handler版とmiddleware版を選ぶ判断軸はシンプルです。

  • 単発のAPIならhandler
  • 複数のAPIで共通ならmiddleware
  • 認証用Cookieならmiddleware、識別用Cookieは状況に応じて選ぶ

大事なのは、どこでCookieを読むかより、責務をどこで分けるかです。Cookieはクライアント入力です。業務上の許可判定はserviceで行い、handlerやmiddlewareはHTTP入力の窓口として扱うのが基本です。

Discussion