JMDC TECH BLOG

JMDCのエンジニアブログです

GoでAmazon Cognito認証を実装してみた

みなさん、こんにちは!プロダクト開発部の西原です。

現在、製薬企業向けに提供しているヘルスビッグデータの分析を行うプロダクトであるJMDC Data Martのバックエンドの開発を担当しています。

プロダクト開発している中で、Go言語でAmazon Cognitoを使った認証を実装しました。今回は基本的な実装方法をぜひ共有させていただければと存じます。

Amazon Cognitoとは

Amazon Cognito(以下、Cognito)は、ウェブおよびモバイルアプリケーション向けのユーザー認証とアクセス管理を提供するAWSのサービスです。これにより、ユーザーはアプリケーションに対して簡単にサインアップ、サインイン、パスワードリセットなどの多様な機能を利用できます。

事前準備

まず初めにCognitoコンソールから、今回はユーザプールを使って実装をします。そちらについては今回は割愛させて頂きます。

ユーザープールの作成後、AWS SDK for Go V2をインストールします。Goモジュールを使用して、プロジェクトにSDKを追加します。

go get github.com/aws/aws-sdk-go-v2
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider

補足

AWS SDK for Go V1にはモックの作成やインターフェースの使用を容易にするために、ifaceパッケージが提供されていました。これにより、各サービスクライアントのインターフェースを定義し、依存関係の注入やテストが簡単に行えていました。しかし、現在V1はメンテナンスモードになっています。

一方で、AWS SDK for Go V2はifaceパッケージが廃止されました。

以下の2つが廃止理由として挙げられていました。

理由1: インターフェースの破壊的変更を防ぐため

  • クライアントAPIに新しいメソッドが追加されると、インターフェースにも新しいメソッドが追加される必要がある
  • これにより、既存のユーザーのモックが壊れる可能性があり、モジュールのセマンティックバージョニングの目的に反する

理由2: アプリケーション側でのインターフェース定義が推奨されるため

  • Goの慣習として、依存関係に対するインターフェースはアプリケーション側で定義するのが一般的
  • SDKが提供するインターフェースは、ユーザーが独自の型を拡張したり、プラグインするためのものであるべき

本記事では詳細には触れませんがiface廃止の背景はこちらに記載されています。ご興味あればご覧いただければと存じます。

その他準備すること

AWS SDK for Go V2のインストールが完了後、クレデンシャルを設定します。クレデンシャルは環境変数、共有クレデンシャルファイル、またはIAMロールを使用して設定できます。

加えて以下の情報も必要になるため、セキュリティ面を考慮して環境変数を使って設定することをお勧めします。

項目 説明
ClientId ユーザープール作成時にアプリクライアントを作成すると発行
ClientSecret ユーザープールの設定で「クライアントシークレットを使用」が有効になっている場合のみ発行
Region リージョンを指定

サンプルコードの実装

今回は、サインインとサインアウトのサンプルコードを用意しました。

本実装では環境変数を直接定義していますが、実際のプロダクト開発においては環境設定ファイルやシークレット管理サービスを用いてこれらの情報を安全に取得することをお勧めします。

Cognitoクライアントの初期化

Cognito Identity Providerのクライアントを初期化します。このクライアントを使用して、CognitoのAPIにアクセスし、ユーザー認証や管理などの操作を行うことができます。

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
)

func main() {
    // 環境変数の定義
    profile := "your-profile"
    region := "ap-northeast-1"
    clientID := "your-client-id"
    clientSecret := "your-client-secret"

    // AWS設定のロード
    cfg, err := config.LoadDefaultConfig(context.TODO(),
        config.WithSharedConfigProfile(profile),
        config.WithRegion(region),
    )
    if err != nil {
        log.Fatalf("failed to load configuration: %v", err)
    }

    // Cognitoクライアントの初期化
    cognitoClient := cognitoidentityprovider.NewFromConfig(cfg)
}

サインイン

ユーザーのメールアドレスとパスワードを使用して認証を行い、Cognitoからの認証結果を返します。

InitiateAuthメソッドを呼び出すことで、簡単にユーザー認証を実装できます。

今回はユーザープールの設定で「クライアントシークレットを使用」を有効にしているため、サインインリクエストにSECRET_HASHが必要になります。 ハッシュ値は、ユーザープールクライアントシークレット、ユーザー名、クライアント ID を連結して HmacSHA256 を使用して HMAC 値を生成し、結果は Base64 でエンコードする必要があります。(詳細はこちらに記載されています)

認証に成功すれば戻り値(output)の AuthenticationResult にトークン情報が格納されます。

func signIn(
    ctx context.Context,
    client *cognitoidentityprovider.Client,
    clientID, clientSecret, email, password string,
) (*cognitoidentityprovider.InitiateAuthOutput, error) {
    // SECRET_HASHを計算
    secretHash := calculateSecretHash(clientID, clientSecret, email)
    // InitiateAuthInputを作成して認証フローを指定する
    input := &cognitoidentityprovider.InitiateAuthInput{
        AuthFlow: "USER_PASSWORD_AUTH",
        AuthParameters: map[string]string{
            "USERNAME":    email,
            "PASSWORD":    password,
            "SECRET_HASH": secretHash,
        },
        ClientId: aws.String(clientID),
    }

    // サインインリクエストを送信し、結果を返す
    output, err := client.InitiateAuth(ctx, input)
    if err != nil {
        // AWS SDK V2のエラーを処理するためにerrors.Asを使用
        var unauthorizedException *types.NotAuthorizedException
        var userNotFoundException *types.UserNotFoundException

        switch {
        // 認証されていない場合のエラー処理
        case errors.As(err, &unauthorizedException):
            return nil, fmt.Errorf("not authorized: %w", err)
        // ユーザーが見つからない場合のエラー処理
        case errors.As(err, &userNotFoundException):
            return nil, fmt.Errorf("user not found: %w", err)
        // それ以外の不明なエラーを処理
        default:
            return nil, fmt.Errorf("unknown error: %w", err)
        }
    }

    return output, nil
}

func calculateSecretHash(clientID, clientSecret, email string) string {
    // HMAC-SHA256のハッシュを計算
    h := hmac.New(sha256.New, []byte(clientSecret))
    h.Write([]byte(email + clientID))
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

サインアウト

RevokeTokenメソッドを呼び出し、リフレッシュトークンを引数にCognitoに送信します。

RevokeTokenInputには、クライアントID、クライアントシークレット、およびリフレッシュトークンが含まれます。

func signOut(
    ctx context.Context,
    client *cognitoidentityprovider.Client,
    clientID, clientSecret, refreshToken string,
) error {
    // RevokeTokenInputを作成してリフレッシュトークンを指定する
    input := &cognitoidentityprovider.RevokeTokenInput{
        ClientId:     aws.String(clientID),
        ClientSecret: aws.String(clientSecret),
        Token:        aws.String(refreshToken),
    }

    // サインアウトリクエストを送信し、エラーがあれば詳細なエラーメッセージを返す
    if _, err := client.RevokeToken(ctx, input); err != nil {
        // AWS SDK V2の例外タイプを処理するためにerrors.Asを使用
        var unauthorizedException *types.NotAuthorizedException
        var userNotFoundException *types.UserNotFoundException

        switch {
        // 認証されていない場合のエラー処理
        case errors.As(err, &unauthorizedException):
            return fmt.Errorf("not authorized: %w", err)
        // ユーザーが見つからない場合のエラー処理
        case errors.As(err, &userNotFoundException):
            return fmt.Errorf("user not found: %w", err)
        // それ以外の不明なエラーを処理
        default:
            return fmt.Errorf("failed to sign out: %w", err)
        }
    }

    return nil
}

完成したサンプルコードが以下になります。

package main

import (
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "errors"
    "fmt"
    "log"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
    "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types"
)

func main() {
    // 環境変数の定義
    profile := "your-profile"
    region := "ap-northeast-1"
    clientID := "your-client-id"
    clientSecret := "your-client-secret"

    // AWS設定のロード
    cfg, err := config.LoadDefaultConfig(context.TODO(),
        config.WithSharedConfigProfile(profile),
        config.WithRegion(region),
    )
    if err != nil {
        log.Fatalf("failed to load configuration: %v", err)
    }

    // Cognitoクライアントの初期化
    cognitoClient := cognitoidentityprovider.NewFromConfig(cfg)

    // コンテキストの作成
    ctx := context.Background()

    // サインインのサンプル
    email := "user@example.com"
    password := "your-password"
    output, err := signIn(ctx, cognitoClient, clientID, clientSecret, email, password)
    if err != nil {
        log.Fatalf("failed to sign in: %v", err)
    }

    fmt.Println("SignIn successful")

    // ログ出力用にJSON形式の文字列に変換
    result, err := json.MarshalIndent(output.AuthenticationResult, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(result))

    // サインアウトのサンプル
    refreshToken := output.AuthenticationResult.RefreshToken
    if err = signOut(ctx, cognitoClient, clientID, clientSecret, *refreshToken); err != nil {
        log.Fatalf("failed to sign out: %v", err)
    }
    fmt.Println("SignOut successful")
}

func signIn(
    ctx context.Context,
    client *cognitoidentityprovider.Client,
    clientID, clientSecret, email, password string,
) (*cognitoidentityprovider.InitiateAuthOutput, error) {
    // SECRET_HASHを計算
    secretHash := calculateSecretHash(clientID, clientSecret, email)
    // InitiateAuthInputを作成して認証フローを指定する
    input := &cognitoidentityprovider.InitiateAuthInput{
        AuthFlow: "USER_PASSWORD_AUTH",
        AuthParameters: map[string]string{
            "USERNAME":    email,
            "PASSWORD":    password,
            "SECRET_HASH": secretHash,
        },
        ClientId: aws.String(clientID),
    }

    // サインインリクエストを送信し、結果を返す
    output, err := client.InitiateAuth(ctx, input)
    if err != nil {
        // AWS SDK V2のエラーを処理するためにerrors.Asを使用
        var unauthorizedException *types.NotAuthorizedException
        var userNotFoundException *types.UserNotFoundException

        switch {
        // 認証されていない場合のエラー処理
        case errors.As(err, &unauthorizedException):
            return nil, fmt.Errorf("not authorized: %w", err)
        // ユーザーが見つからない場合のエラー処理
        case errors.As(err, &userNotFoundException):
            return nil, fmt.Errorf("user not found: %w", err)
        // それ以外の不明なエラーを処理
        default:
            return nil, fmt.Errorf("unknown error: %w", err)
        }
    }

    return output, nil
}

func calculateSecretHash(clientID, clientSecret, email string) string {
    // HMAC-SHA256のハッシュを計算
    h := hmac.New(sha256.New, []byte(clientSecret))
    h.Write([]byte(email + clientID))
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

func signOut(
    ctx context.Context,
    client *cognitoidentityprovider.Client,
    clientID, clientSecret, refreshToken string,
) error {
    // RevokeTokenInputを作成してリフレッシュトークンを指定する
    input := &cognitoidentityprovider.RevokeTokenInput{
        ClientId:     aws.String(clientID),
        ClientSecret: aws.String(clientSecret),
        Token:        aws.String(refreshToken),
    }

    // サインアウトリクエストを送信し、エラーがあれば詳細なエラーメッセージを返す
    if _, err := client.RevokeToken(ctx, input); err != nil {
        // AWS SDK V2の例外タイプを処理するためにerrors.Asを使用
        var unauthorizedException *types.NotAuthorizedException
        var userNotFoundException *types.UserNotFoundException

        switch {
        // 認証されていない場合のエラー処理
        case errors.As(err, &unauthorizedException):
            return fmt.Errorf("not authorized: %w", err)
        // ユーザーが見つからない場合のエラー処理
        case errors.As(err, &userNotFoundException):
            return fmt.Errorf("user not found: %w", err)
        // それ以外の不明なエラーを処理
        default:
            return fmt.Errorf("failed to sign out: %w", err)
        }
    }

    return nil
}

実際にサンプルコードを実行し、以下のように出力されていればOKです。

$ go run main.go 
SignIn successful
{
  "AccessToken": "eyJraW...省略",
  "ExpiresIn": 86400,
  "IdToken": "eyJraWQi...省略",
  "NewDeviceMetadata": null,
  "RefreshToken": "eyJjd...省略",
  "TokenType": "Bearer"
}

SignOut successful

まとめ

まとめ 今回のサンプルコードではよく利用されるユーザーのサインイン、サインアウトの処理を実装しました。実際のプロダクトでは「ユーザーリストの取得」や「パスワード変更、リセット」なども実装してます。

cognitoidentityproviderパッケージを使えばCognitoを比較的簡単に利用することができます。しかし、Cognitoの制限によりメソッドを呼ぶだけでは想定通りの結果にならない場合があります。例えば、「ユーザーリストの取得」で使用するListUsersメソッドは、一度に最大60ユーザーまでしか取得できないため、全てのユーザーを取得するにはページネーション処理を実装する必要があります。実装する際はCognitoの制限があるかどうかを確認したほうが良いと思います。

おわりに

今回はGoでAmazon Cognito認証する基本的な実装方法について紹介させていただきました!

Cognitoを使用することで、強力かつスケーラブルな認証システムを簡単に実装することができます。また、Go言語との組み合わせにより、効率的な開発が可能となります。今回紹介した基本的な実装方法を参考に、ぜひご自身のプロダクトに取り入れてみてください。

JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です!フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。詳細は下記の募集一覧からご確認ください。 hrmos.co

まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。 hrmos.co

★最新記事のお知らせはぜひ X(Twitter)、またはBlueskyをご覧ください!

twitter.com

bsky.app