13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

朝日新聞社Advent Calendar 2024

Day 7

Goで認可サーバーことはじめ

Last updated at Posted at 2024-12-06

はじめに

朝デジ事業センター開発部、兼CTO室の金子(theoden9014)です。

近年のWebでは、OAuth 2.0の登場により認証と認可の分離が一般化し、それぞれ高度に進化していき、複雑化しています。
特に昨今はパスキーの登場により、パスワードレス認証が徐々に主流となりつつあり、認証認可はさらに複雑化しています。

今回は認証認可のベースとなるOAuth 2.0について、Go言語を利用し、簡易的な認可サーバーの実装についてご紹介します。
認証認可の全体像を把握する上でOAuth 2.0はとても重要な技術です。
簡易的な実装を通して、少しでも認証/認可に関する理解を深めていただければと思います。

OAuth 2.0について

OAuth 2.0は、現代のWebアプリケーションにおいて欠かせない認証と認可の仕組みを提供するプロトコルです。
このプロトコルは、リソース所有者(通常はユーザー)が、特定のリソースへのアクセス権を信頼できるサードパーティのクライアントに委譲することを可能にします。認証と認可を分離し、セキュリティや拡張性を確保する設計が特徴で、特にマイクロサービスやモバイルアプリケーションではその恩恵が顕著です。
このプロトコルの中核をなすのがRFC 6749で、ここではOAuth2.0の基本的な概念とフローが定義されています。
他にも関連するRFCとして、Bearer Tokenの使用方法を記述したRFC 6750、ネイティブアプリケーションにおけるセキュリティのベストプラクティスを示したRFC 8252、さらにPKCE(Proof Key for Code Exchange)を定義したRFC 7636など、OAuth2.0には様々な拡張が存在します。
本記事では、OAuth 2.0の基礎であるRFC 6749をもとに、Go言語を使った実装を通じて理解を深めていきます。

RFC 6749ではアクセストークンを要求するためのフローが以下の4つが定義されています。

  • Authorization Code Grant
    • クライアントが認可コードを使用してアクセストークンを取得する
  • Implicit Grant
    • 認可コードをスキップし、アクセストークンを直接取得する
  • Resource Owner Password Credentials Grant
    • IDとパスワードを直接クライアントに渡してアクセストークンを取得する
  • Client Credentials Grant
    • クライアント自身が認証されてアクセストークンを取得する

他にもRFC 8628で定義されるDevice Authorization Grantなどのフローが定義されていますが、
今回は最も一般的な「Authorization Code Grant」、通称、認可コードフローについて扱っていきます。

認可コードフローについて

OAuth2.0で最も一般的に利用されるフローが「認可コードフロー」です。
このフローは、クライアントがユーザーに代わって認可コードを取得し、それを用いてアクセストークンを交換する一連の手順を指します。認可コードフローの特徴は、アクセストークンを直接ユーザーエージェント(ブラウザなど)に渡さない点にあり、これによりトークン漏洩のリスクが低減されます。
認可コードフローでは、以下のアクターが登場します。

  • リソースオーナー
    • リソースへのアクセス権を持つ実際のユーザー。認可を許可/不許可する役割
  • ユーザーエージェント(ブラウザ)
    • ユーザーが操作するブラウザやアプリケーション。クライアントと認可サーバー間の通信を仲介する
  • クライアント
    • リソースオーナーに代わってリソースサーバーにリクエストを送るアプリケーション
  • 認可サーバー
    • リソースオーナーを認証し、クライアントに認可コードやアクセストークンを発行するサーバー
  • リソースサーバー
    • アクセストークンを検証し、保護されたリソースに対するアクセスを提供するサーバー

以下は認可コードフローの処理フローです。

Goのサンプル実装

今回は認可サーバーの実装サンプルをご紹介し、
E2EテストでWebブラウザ、クライアントの実装とテストについてご紹介します。

これからご紹介するサンプル実装は、以下のGo Playgroundからもテストができるようになっています。

Goによるクライアント登録の実装サンプル

クライアントにはConfidential ClientとPublic Clientの二つの種類が存在します。
Confidential Clientは主にWebアプリケーションで利用されます。
Public Clientは主にネイティブアプリケーションで利用されます。
今回の認可コードフローはWebアプリケーションベースで簡易実装をするので、Confidential Clientで実装をしています。

以下に、Goでの実装例を示します。
AuthorizationServerという構造体が認可サーバーの役割を果たし、登録されたクライアント情報と生成された認可コード情報をメモリ上に保存するようになっています。

// 認可サーバーの実装
type AuthorizationServer struct {
    // 登録するクライアントアプリケーション
    clients map[ClientID]*ConfidentialClient

    // 生成した認可コードを保存する
    authorizationCodeInfos map[string]*authorizationCodeInfo    
}

// 認可サーバーの初期化
func NewAuthorizationServer() *AuthorizationServer {
    return &AuthorizationServer{
        clients:                make(map[ClientID]*ConfidentialClient),
        authorizationCodeInfos: make(map[string]*authorizationCodeInfo),
    }
}


// 認可サーバーにクライアントを登録する
func (as *AuthorizationServer) RegisterClient(c *ConfidentialClient) {
    as.clients[c.ID] = c
}

// 登録済みのクライアントを取得する
func (as *AuthorizationServer) GetClient(clientID ClientID) (*ConfidentialClient, bool) {
    client, exists := as.clients[clientID]
    return client, exists
}

// Confidentialタイプのクライアント
type ConfidentialClient struct {
    ID          ClientID
    Secret      string
    RedirectUri string
}

// クライアントID
type ClientID string

// クライアントの検証
func (c *ConfidentialClient) Validate(secret string) bool {
    return c.Secret == secret
}

認可エンドポイント実装サンプル

認可エンドポイントは、認可コードフローの最初のステップを担当します。ここでは、認可コードを発行する際に必要なRFC 6749準拠の要件を押さえつつ、Go言語での実装を進めていきます。
RFC 6749では、認可エンドポイントにおいて以下の項目を満たすことが必須(MUST)とされています。まず、クライアントが送信するresponse_typeやclient_id、redirect_uriの正当性を検証する必要があります。また、認可コードは一意であり、一定の有効期限を持つ必要があります。これらを満たさない場合、不正なクライアントに認可コードを渡すリスクが生じます。

以下に、Goでの実装例を示します。

// 認可サーバーの認可エンドポイントのHTTP Handler
func (as *AuthorizationServer) AuthorizeEndpoint(w http.ResponseWriter, r *http.Request) {
    // 必須のパラメーターを取得
    responseType := r.URL.Query().Get("response_type")
    clientID := r.URL.Query().Get("client_id")
    redirectUri := r.URL.Query().Get("redirect_uri")

    // 今回は認可コードフローのみをサポート
    if responseType != "code" {
        http.Error(w, "Invalid response_type", http.StatusBadRequest)
        return
    }

    // 事前にクライアントに設定されているリダイレクトURIと一致するか確認する
    client, exists := as.GetClient(ClientID(clientID))
    if !exists {
        http.Error(w, "Invalid client_id", http.StatusUnauthorized)
        return
    }
    if client.RedirectUri != redirectUri {
        http.Error(w, "Invalid redirect_uri", http.StatusBadRequest)
        return
    }

    // 認可コードを生成する
    code := as.generateAuthorizationCode(client, r.URL.Query().Get("redirect_uri"))

    // リダイレクトURIのクエリパラメーターに認可コードを付与してユーザーエージェントにリダイレクト
    params := url.Values{"code": {code.Code.String()}}
    redirectUri += "?" + params.Encode()
    http.Redirect(w, r, redirectUri, http.StatusFound)
}

// 認可コードを生成し、認可コード情報をサーバーに保存する
func (as *AuthorizationServer) generateAuthorizationCode(client *ConfidentialClient, redirectUri string) *authorizationCodeInfo {
    aci := &authorizationCodeInfo{
        Code:        uuid.New().String(),
        ClientID:    client.ID,
        RedirectUri: redirectUri,
        ExpiresAt: time.Now().Add(time.Minute * 10).Unix(),
    }
    as.authorizationCodeInfos[aci.Code] = aci
    return aci
}

// 生成されている認可コード情報を取得する
func (as *AuthorizationServer) getAuthorizationCodeInfo(code string) (*authorizationCodeInfo, bool) {
    aci, exists := as.authorizationCodeInfos[code]
    return aci, exists
}

// サーバーで保持するための、認可コード情報
type authorizationCodeInfo struct {
    Code        string   // 認可コード
    ClientID    ClientID // 紐づくクライアントID
    RedirectUri string   // リダイレクトURI
    ExpiresAt   int64    // 有効期限(UNIXタイムスタンプ)
}

func (aci *authorizationCodeInfo) Validate(code string, clientID ClientID) bool {
    // 認可コードが一致し、かつクライアントIDが一致し、かつ有効期限が切れていない
    return aci.Code == code && aci.ClientID == clientID && time.Now().Unix() < aci.ExpiresAt
}

トークンエンドポイント実装サンプル

トークンエンドポイントは、認可コードをアクセストークンに交換するためのエンドポイントです。ここでは、RFC 6749準拠の仕様に基づき、クライアント認証や認可コードの検証、アクセストークンの発行を実装します。
特に、アクセストークンの形式や有効期限の設定は、セキュリティ上重要です。一般的には、JWTを採用することでトークンにクライアントやスコープ情報をエンコードしつつ、署名を加えることで改ざんを防ぎます。また、レート制限やHTTPS通信の強制もセキュリティ強化に不可欠です。

以下に、Goでの実装例を示します。

// 認可サーバーのトークンエンドポイントのHTTP Handler
func (as *AuthorizationServer) TokenEndpoint(w http.ResponseWriter, r *http.Request) {
    switch r.Header.Get("Content-Type") {
    case "application/x-www-form-urlencoded":
        clientID := r.PostFormValue("client_id")
        clientSecret := r.PostFormValue("client_secret")
        code := r.PostFormValue("code")
        grantType := r.PostFormValue("grant_type")

        if grantType != "authorization_code" {
            http.Error(w, "Invalid grant_type", http.StatusBadRequest)
            return
        }

        // クライアントの検証
        client, exists := as.GetClient(ClientID(clientID))
        if !exists {
            http.Error(w, "Invalid client_id", http.StatusUnauthorized)
            return
        }
        if client.Validate(clientSecret) {
            http.Error(w, "Invalid client_secret", http.StatusUnauthorized)
            return
        }

        // 認可コードの検証
        codeInfo, exists := as.getAuthorizationCodeInfo(code)
        if !exists || !codeInfo.Validate(code, client.ID) {
            http.Error(w, "Invalid code", http.StatusBadRequest)
            return
        }
    default:
        http.Error(w, "Invalid Content-Type", http.StatusUnsupportedMediaType)
        return
    }

    // 今回は適当な値のアクセストークンを生成
    // RFC 6749ではトークンの形式については定められていないが、一般的にはJWT形式のケースが多い
    accessToken := "this is an access token"
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "access_token": accessToken,
        "token_type":   "Bearer",
        "expires_in": 3600,
        // OpenID Connectのプロトコルを利用する場合はここでID Tokenも一緒に返却する
        // "id_token": idToken,        
    })
}

// 認可コードを元に、認可コード情報を取得
func (as *AuthorizationServer) getAuthorizationCodeInfo(code string) (*authorizationCodeInfo, bool) {
    aci, exists := as.authorizationCodeInfos[code]
    return aci, exists
}

type authorizationCodeInfo struct {
    Code        string   // 認可コード
    ClientID    ClientID // 紐づくクライアントID
    RedirectUri string   // リダイレクトURI
    ExpiresAt   int64    // 有効期限(UNIXタイムスタンプ)
}

func (aci *authorizationCodeInfo) Validate(code string, clientID ClientID) bool {
    // 認可コードが一致し、かつクライアントIDが一致し、かつ有効期限が切れていない
    return aci.Code == code && aci.ClientID == clientID && time.Now().Unix() < aci.ExpiresAt
}

HTTPハンドラーの実装

func (as *AuthorizationServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/authorize":
        switch r.Method {
        case http.MethodGet:
            as.AuthorizeEndpoint(w, r)
        }
    case "/token":
        switch r.Method {
        case http.MethodPost:
            as.TokenEndpoint(w, r)
        }
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

認可サーバーのE2Eテスト実装

最後に、認可サーバーのE2Eテストを通じて、実装が正しく機能するかを確認します。
Goのgolang.org/x/oauth2ライブラリを活用し、認可コードフロー全体をテストすることで、正常系・異常系の動作を検証します。例えば、無効な認可コードや不正なクライアント認証情報に対するエラーハンドリングが適切に行われているかをチェックします。

import (
    "context"
    "net/http"
    "net/http/httptest"
    "testing"

    "golang.org/x/oauth2"
)

func TestAuthorizationE2E(t *testing.T) {
    // テスト対象の認可サーバーを作成し、テスト用のTLSサーバーとして起動
    as := NewAuthorizationServer()
    authServer := httptest.NewTLSServer(as)
    defer authServer.Close()

    // テスト用のHTTPクライアントを取得
    httpClient := authServer.Client()

    // OAuth2.0クライアントの設定
    config := oauth2.Config{
        ClientID:     "test-client-id",
        ClientSecret: "test-client-secret",
        Scopes:       []string{},
        Endpoint: oauth2.Endpoint{
            AuthURL:  authServer.URL + "/authorize",
            TokenURL: authServer.URL + "/token",
        },
    }

    // リダイレクトURIのCallbackサーバーを作成
    callbackServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // リクエストパラメーターに認可コードが含まれていることを確認するテスト    
        code := r.URL.Query().Get("code")
        if code == "" {
            t.Errorf("code is empty")
        }

        // oauth2のHTTPクライアントをテスト用のHTTPクライアントに差し替える
        ctx := context.Background()
        ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
        // 認可サーバーのトークンエンドポイントを実行し、トークンを取得する
        token, err := config.Exchange(ctx, code)
        if err != nil {
            t.Errorf("failed to exchange code: %s", err)
        }

        // アクセストークンが取得できていることを確認する
        if len(token.AccessToken) == 0 {
            t.Errorf("access token is invalid: %s", token.AccessToken)
        }
    }))
    defer callbackServer.Close()

    // 認可サーバーにクライアントを登録
    as.RegisterClient(&ConfidentialClient{
        ID:          ClientID("test-client-id"),
        Secret:      "test-client-secret",
        RedirectUri: callbackServer.URL,
    })

    // 認可エンドポイントのURLを取得
    authURL := config.AuthCodeURL("")
    // 認可エンドポイントを叩いて、エラーが発生しないことを確認する。
    // この中で、認可サーバーからCallbackサーバーにリダイレクトされる 
    _, err := httpClient.Get(authURL)
    if err != nil {
        t.Errorf("failed to get authorize endpoint: %s", err)
    }
}

まとめ

Go言語を用いてOAuth 2.0の認可サーバーを簡易的に実装する方法を解説しました。
認可コードフローを中心に、クライアントの登録、認可コードの発行・検証、アクセストークンの生成といった基本機能を具体例を交えて説明しました。
OAuth 2.0は、現代のWebアプリケーションやAPIセキュリティにおいて欠かせないフレームワークであり、その理解はセキュアなシステム設計の第一歩です。このブログを通じて、認証・認可の仕組みを基礎から学び、プロジェクトやサービスに応用できる知識を得るきっかけになれば幸いです。

13
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?