ハウテレビジョンブログ

『外資就活ドットコム』『Liiga』『Mond』を開発している株式会社ハウテレビジョンのブログです。

AWS Cognito Migration: Enhancing Security and Efficiency

この記事は HowTelevision Advent Calendar 2024 24日目の記事です。

背景

稼働中のWebアプリケーションを部分的・段階的にリプレースしております。認証部分のリプレースにおいて、セキュリティや拡張性を向上させるために、実装されている独自のID・パスワード認証をAWS Cognitoに移行することにしました。

本記事では、AWS Cognitoへの移行プロセスと考慮した点についてまとめました。
一例として参考になれば幸いです。

作業の流れ

1. AWS Cognito User Poolの作成

AWS Management Consoleを使用して、アプリケーション要件に基づくユーザープールを作成します。

UserPool作成

  • カスタム属性 : 必要に応じて設定します。
  • パスワードポリシー : セキュリティ要件に応じて複雑性を設定します。
  • 多要素認証 ( MFA ) : あとから変更する可能性があるのでOPTIONALにしておきます

MFA設定
MFA設定

2. ユーザーのインポート

既存のユーザーをAWS Cognitoにインポートします。
インポートデータのCSVは、AWSが提供するフォーマットを使用して作成する必要があります。

CSVテンプレート
インポートされたユーザーの初期ステータスはRESET_REQUIRED に設定されます。インポート後、ユーザーにはパスワードの変更を促すプロセスが必要です。

3. AWS Secret Managerの構築

AWS Cognitoのclient_idやuser_pool_idなどの情報は、AWS Secrets Managerで管理するようにしました。CI/CDを構築している場合、ステージングや本番のような異なる環境にデプロイする際に安全で便利です。

4. Webアプリケーション側の認証ロジックの対応

稼働中のシステムなので従来のRDB認証を活かしつつ、Cognitoへの移行を進めます。

まず AWS Cognito への連携が済んでいないユーザーに対応します。

  1. 入力されたIDとパスワードを使ってRDBでユーザー認証を実施します。
  2. 認証が成功したら、AWS Cognitoにインポートされたユーザーを取得して、ステータスを確認します。
  3. インポート後の初期ステータスはRESET_REQUIREDになっているので、パスワード変更画面に遷移させて、ユーザーにパスワードの変更を要求します。

パスワード変更後、AWS Cognito の認証が成功すると以下のトークンを取得できます。

  • ID Token: ユーザーの識別情報を含みます。
  • Access Token: クライアントからのリクエストを認可するために使用します。
  • Refresh Token: トークンの有効期限切れ時に新しいトークンを取得するために使用します。

これで AWS Cognito へユーザーを連携できたので、以降は AWS Cognito で認証していきます。
※コードはGoの実装イメージです。

ID Token は AWS Cognito でユーザーを識別する情報になるので、Webアプリケーション側でユーザーと紐づけを行っておきます。

package main

import (
    "context"
    "fmt"
    "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() {
    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        panic("unable to load SDK config, " + err.Error())
    }

    client := cognitoidentityprovider.NewFromConfig(cfg)

    input := &cognitoidentityprovider.InitiateAuthInput{
        AuthFlow: aws.String("USER_PASSWORD_AUTH"),
        AuthParameters: map[string]string{
            "USERNAME": "example-user",
            "PASSWORD": "example-password",
        },
        ClientId: aws.String("your-client-id"),
    }

    authResp, err := client.InitiateAuth(context.TODO(), input)
    if err != nil {
        panic("failed to authenticate user, " + err.Error())
    }

    fmt.Println("ID Token:", *authResp.AuthenticationResult.IdToken)
    // ID TokenをWebアプリケーションのデータベースと紐づける処理
}

ユーザーが入力したIDとPassword を使って AWS Cognito で認証。

// 上記のInitiateAuthInputと同様に、AuthFlowでUSER_PASSWORD_AUTHを指定して認証を実施します。
authResp, err := client.InitiateAuth(context.TODO(), input)
if err != nil {
    fmt.Println("Authentication failed:", err)
    return
}
fmt.Println("Authentication successful. Access Token:", *authResp.AuthenticationResult.AccessToken)

リクエストに AWS Cognito から取得した AccessToken を含めてWebアプリケーションにアクセス。

accessToken := *authResp.AuthenticationResult.AccessToken

// リクエストにAccessTokenを含める例
req, err := http.NewRequest("GET", "https://your-web-app.example.com/resource", nil)
if err != nil {
    panic(err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)

// HTTPクライアントを使ってリクエストを送信
resp, err := http.DefaultClient.Do(req)
if err != nil {
    panic(err)
}
defer resp.Body.Close()
fmt.Println("Response status:", resp.Status)

WebアプリケーションではAccessTokenを検証し有効であればアクセスを認可する。

検証メソッド

// JWKs(公開鍵) を取得しておく
// https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/jwks.json

// VerifyAccessToken verifies an AccessToken using the RSA public key
func VerifyAccessToken(tokenString string, publicKey *rsa.PublicKey) (*jwt.Token, error) {
    return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // Ensure the signing method is RSA
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return publicKey, nil
    })
}

AccessTokenの検証とClaimのチェック

        // Verify token
    verifiedToken, err := VerifyAccessToken(accessToken, publicKey)
    if err != nil {
        fmt.Println("Token verification failed:", err)
        return
    }
    // Validate claims (issuer, audience, etc.)
    if claims, ok := verifiedToken.Claims.(jwt.MapClaims); ok && verifiedToken.Valid {
        // Validate the `iss` claim (Issuer)
        issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, userPoolID)
        if claims["iss"] != issuer {
            fmt.Println("Invalid issuer:", claims["iss"])
            return
        }

        // Validate the `aud` claim (Audience, i.e., Client ID)
        clientID := "your-client-id"
        if claims["aud"] != clientID {
            fmt.Println("Invalid audience:", claims["aud"])
            return
        }

        // Validate expiration time
        if !claims.VerifyExpiresAt(jwt.TimeFunc().Unix(), true) {
            fmt.Println("Token is expired")
            return
        }

        // All validations passed
        fmt.Println("Token is valid!")
        fmt.Printf("Claims: %v\n", claims)
    } else {
        fmt.Println("Invalid token")
    }

ID TokenとAccessTokenには有効期限があり、期限切れになった場合、さらに有効期限の長い Refresh Tokenをつかってこの2つを更新します。

refreshInput := &cognitoidentityprovider.InitiateAuthInput{
    AuthFlow: aws.String("REFRESH_TOKEN_AUTH"),
    AuthParameters: map[string]string{
        "REFRESH_TOKEN": "your-refresh-token",
    },
    ClientId: aws.String("your-client-id"),
}

refreshResp, err := client.InitiateAuth(context.TODO(), refreshInput)
if err != nil {
    panic("failed to refresh token, " + err.Error())
}

fmt.Println("New Access Token:", *refreshResp.AuthenticationResult.AccessToken)
fmt.Println("New ID Token:", *refreshResp.AuthenticationResult.IdToken)

考慮・注意した点

1. ユーザー移行のプロセス

この方式で認証機構を変更した場合、パスワード変更を伴う AWS Cognito への連携は、ユーザーがログインしたタイミングで実行されます。連携をしていないユーザーに対して、ログインを促すメールを送付するなどの施策を打つ必要があります。ユーザーの AWS Cognito への移行の進捗をみるための管理画面などがあると便利です。

2. AWS Cognito 移行後に新規ユーザーを作成した場合

AWS Cognito の移行後に新規ユーザーを作成する場合、監理者権限により作成されたユーザーの初期ステータスは通常 FORCE_CHANGE_PASSWORD になります。ユーザーが初回ログインしたときに、パスワードの変更が必要になる点は、インポートユーザーと同様ですが、ステータスが異なるので注意が必要です。

おわりに

AWS Cognito を調べていく中で便利な機能が満載されており、これを活用することでセキュリティの強化や運用の効率化が期待できる一方、Cognito 独自のルールや UserPool 作成後には変更不可のものもあったりと、事前の調査・検討を十分に行わないと運用開始後の変更コストが大きくなりそうという印象も持ちました。
今後は、高度なセキュリティ設定やユーザーグループの導入など多々ある機能を使いこなせるようになりたいと考えています。