【第17回】Amazon Cognito + Spring Sercurityを使ったOAuth2 Loginの実装(6)


前回から、以下のイメージのようにOAuth2 Loginをベースとしたアーキテクチャを想定した環境構築を進めています。


../_images/oauth2-login-flow.png


前回は、CloudFormationで構築したAmazon Cognitoのアプリクライアントからクライアントシークレットを取得し、 Parameter Storeへ設定するLambdaファンクションを実装しました。今回は引き続き、Cognitoへユーザを登録・サインアップしテータスを更新するLambdaファンクションの実装方法を解説していきます。

なお、実際のソースコードは GitHub 上にコミットしています。以降のソースコードでは本質的でない記述を一部省略しているので、実行コードを作成する場合は、必要に応じて適宜GitHub上のソースコードも参照してください。


Cognitoにユーザを登録するLambdaファンクションの実装


続いて、Cognitoユーザプールへユーザを追加するLambdaファンクションを実装します。ファンクションクラスの基本的な構成は、前回実装したParameter Storeへクライアントシークレットを設定するものと同じです。


package org.debugroom.mynavi.sample.aws.microservice.lambda.app.function;

// omit

@Slf4j
public class AddCognitoUserFunction implements Function<Map<String, Object>, Message<String>> { //(A)

    @Autowired
    ServiceProperties serviceProperties; //(B)

    @Autowired
    CloudFormationStackResolver cloudFormationStackResolver; //(C)

    @Autowired
    AWSCognitoIdentityProvider awsCognitoIdentityProvider;

    @Override
    public Message<String> apply(Map<String, Object> stringObjectMap) {
        log.info(this.getClass().getName() +  "  has started!");
        String userPoolId = cloudFormationStackResolver.getExportValue(
          serviceProperties.getCloudFormation().getCognito().getUserPoolId()); //(D)
        ListUsersRequest listUsersRequest = new ListUsersRequest().withUserPoolId(userPoolId);
        ListUsersResult listUsersResult = awsCognitoIdentityProvider.listUsers(
          listUsersRequest);

       int numberOfCognitoUser = listUsersResult.getUsers().size(); //(E)
       List<AttributeType> attributeTypes = new ArrayList<>();
       AdminCreateUserRequest adminCreateUserRequest = new AdminCreateUserRequest()
          .withUserPoolId(userPoolId)
          .withTemporaryPassword("test01");
       attributeTypes.add(new AttributeType().withName("family_name").withValue("mynavi"));
       attributeTypes.add(new AttributeType().withName("given_name").withValue("taro"));
       attributeTypes.add(new AttributeType().withName("custom:isAdmin").withValue("1"));
       if(numberOfCognitoUser != 0){
           adminCreateUserRequest.withUsername("taro.mynavi" + Integer.toString(numberOfCognitoUser));
           attributeTypes.add(new AttributeType()
              .withName("custom:loginId").withValue("taro.mynavi" + Integer.toString(numberOfCognitoUser)));//(F)
       }else {
           adminCreateUserRequest.withUsername("taro.mynavi");
           attributeTypes.add(new AttributeType().withName("custom:loginId").withValue("taro.mynavi"));
       }
           adminCreateUserRequest.withUserAttributes(attributeTypes);
           AdminCreateUserResult adminCreateUserResult = awsCognitoIdentityProvider.adminCreateUser(
               adminCreateUserRequest); //(G)
           return MessageBuilder.withPayload("Complete!").build();
       }

}


項番 説明
A java.util.function.Functionを実装します。Input型としてハンドラクラスで生成したMapオブジェクトクラスを、Output型としてorg.springframework.messaging.Messageを指定します。
B CloudFormationのスタックから取得するためのエクスポート名をプロパティクラスに保持します。実際のエクスポート名は設定ファイルであるapplicaiton.ymlに記載します。設定ファイルの記載要領は AWSで作るクラウドネイティブアプリケーションの応用 第7回 も参考にしてください。
C CloudFormationのスタックからOutput要素で出力した値を取得するユーティリティクラスをインジェクションします。実装は こちら ですが、CloudFormationのSDKクライアントを使ってエクスポート値を取得します。
D ユーザプールIDを(C)を使って取得します。
E ユーザプールに既に登録されているユーザの数を取得します。
F Eの数に応じて、ログインIDや、ユーザIDに相当するusernameが重複しないように設定します。
G awsCognitoIdentityProvider.adminCreateUser()メソッドを実行して、ユーザを登録します。


Cognitoユーザのサインアップステータスを変更するLambdaファンクションの実装


続いて、前節で実装したCognitoユーザ作成後にサインアップステータスを変更するLambdaファンクションを実装します。前節と同様、基本的なLambdaファンクション実装の構成は同じです。


package org.debugroom.mynavi.sample.aws.microservice.lambda.app.function;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

// omit

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

@Slf4j
public class ChangeCognitoUserStatusFunction implements Function<Map<String, Object>, Message<String>> {

    @Autowired
    ServiceProperties serviceProperties;

    @Autowired
    CloudFormationStackResolver cloudFormationStackResolver;

    @Autowired
    AWSCognitoIdentityProvider awsCognitoIdentityProvider;

    @Autowired
    AWSSimpleSystemsManagement awsSimpleSystemsManagement;

    @Override
    public Message<String> apply(Map<String, Object> stringObjectMap) {
        log.info(this.getClass().getName() +  "  has started!");

        String userPoolId = cloudFormationStackResolver.getExportValue(
          serviceProperties.getCloudFormation().getCognito().getUserPoolId());
        String appClientId = cloudFormationStackResolver.getExportValue(
          serviceProperties.getCloudFormation().getCognito().getAppClientId());
        String clientSecret = getParameterFromParameterStore(
          serviceProperties.getSystemsManagerParameterStore().getCognito().getAppClientSecret(), true);
        ListUsersRequest listUsersRequest = new ListUsersRequest().withUserPoolId(userPoolId);
        ListUsersResult listUsersResult = awsCognitoIdentityProvider.listUsers(
          listUsersRequest); //(A)

        listUsersResult.getUsers().stream()
          .filter(userType -> Objects.equals(userType.getUserStatus(), "FORCE_CHANGE_PASSWORD")) //(B)
          .forEach(userType -> {
              AdminInitiateAuthRequest adminInitiateAuthRequest
                      = adminInitiateAuthRequest(userType.getUsername(), userPoolId, appClientId, clientSecret); //(C)
              AdminInitiateAuthResult  adminInitiateAuthResult =
                      awsCognitoIdentityProvider.adminInitiateAuth(adminInitiateAuthRequest); //(D)

              if(Objects.equals(adminInitiateAuthResult.getChallengeName(), "NEW_PASSWORD_REQUIRED")){ //(E)
                  Map<String, String> challengeResponses = new HashMap<>();
                  challengeResponses.put("USERNAME", userType.getUsername());
                  challengeResponses.put("NEW_PASSWORD", "test02"); //(F)
                  challengeResponses.put("userAttributes.given_name",
                          userType.getAttributes().stream().filter(attributeType ->
                              Objects.equals(attributeType.getName(), "given_name")).findFirst().get().getValue()); //(G)
                  challengeResponses.put("SECRET_HASH", calculateSecretHash(appClientId, clientSecret, userType.getUsername())); //(H)

                  AdminRespondToAuthChallengeRequest adminRespondToAuthChallengeRequest =
                          new AdminRespondToAuthChallengeRequest()
                          .withChallengeName(adminInitiateAuthResult.getChallengeName())
                          .withUserPoolId(userPoolId)
                          .withClientId(appClientId)
                          .withSession(adminInitiateAuthResult.getSession()) //(I)
                          .withChallengeResponses(challengeResponses); //(J)

                  AdminRespondToAuthChallengeResult adminRespondToAuthChallengeResult =
                          awsCognitoIdentityProvider.adminRespondToAuthChallenge(adminRespondToAuthChallengeRequest); //(K)
              }

          });

        return MessageBuilder.withPayload("Complete!").build();
    }

    private AdminInitiateAuthRequest adminInitiateAuthRequest(
      String userName, String userPoolId, String appClientId, String clientSecret){
        AdminInitiateAuthRequest adminInitiateAuthRequest = new AdminInitiateAuthRequest();
        adminInitiateAuthRequest.setAuthFlow(AuthFlowType.ADMIN_USER_PASSWORD_AUTH); //(L)
        adminInitiateAuthRequest.setUserPoolId(userPoolId);
        adminInitiateAuthRequest.setClientId(appClientId);
        Map<String, String> authParameters = new HashMap<>();
        authParameters.put("USERNAME", userName);
        authParameters.put("PASSWORD", "test01");
        authParameters.put("SECRET_HASH", calculateSecretHash(appClientId, clientSecret, userName)); //(M)
        adminInitiateAuthRequest.setAuthParameters(authParameters);
        return adminInitiateAuthRequest;
    }

    private static String calculateSecretHash(String userPoolClientId,
        String userPoolClientSecret, String userName) { //(N)
        final String HMAC_SHA256_ALGORITHM = "HmacSHA256";

        SecretKeySpec signingKey = new SecretKeySpec(
          userPoolClientSecret.getBytes(StandardCharsets.UTF_8),
          HMAC_SHA256_ALGORITHM);
        try {
            Mac mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
            mac.init(signingKey);
            mac.update(userName.getBytes(StandardCharsets.UTF_8));
            byte[] rawHmac = mac.doFinal(userPoolClientId.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(rawHmac);
        } catch (Exception e) {
            throw new RuntimeException("Error while calculating ");
        }
    }

    private String getParameterFromParameterStore(String paramName, boolean isEncripted){
        GetParameterRequest request = new GetParameterRequest();
        request.setName(paramName);
        request.setWithDecryption(isEncripted);
        return awsSimpleSystemsManagement.getParameter(request).getParameter().getValue();
    }

}


項番 説明
A ユーザプールID、アプリクライアントIDをCloudFormationのスタック情報から、クライアントシークレットをSystems Manager Parameter Storeから取得し、ユーザプールに存在するユーザの一覧を取得します。
B 取得したユーザの中で、ステータスが「FORCE_CHANGE_PASSWORD」である初期ユーザを抽出します。
C Cognitoユーザをサーバ側でサインアップ処理を完了する場合、ユーザプールの認証フロー サーバ側の認証フロー に従って、ユーザを認証するために、AdminInitiateAuthRequestを生成します。
D AdminInitiateAuth APIを呼び出し、AdminRespondToAuthChallengeRequestの生成に必要なセッションパラメータを含むAdminInitiateAuthResultを取得します。
E AdminInitiateAuthResultのチャレンジ名が「NEW_PASSWORD_REQUIRED」だった場合に、チャレンジレスポンスを生成し、AdminRespondToAuthChallenge APIを呼び出します。
F 新たなパスワードを「test02」に変更します。
G チャレンジレスポンスに「userAttributes.given_name」を含めておきます。これはユーザプールで自身が必須定義した内容に応じて必要となるパラメータです。
H チャレンジレスポンスに「SECRET_HASH」を含めておく必要があります。SECRET_HASHの実装の詳細は(N)を参照してください。
I AdminRespondToAuthChallengeRequestにAdminInitiateAuthResultが持つ「セッションパラメータ」が必要になります。詳細は、AdminRespondToAuthChallenge Session も参照してください。
J AdminRespondToAuthChallengeRequestにチャレンジレスポンスを設定します。
K AdminRespondToAuthChalleng APIを呼び出して、ユーザのステータスを変更します。
L AdminInitiateAuthRequestでは、認証フローを「ADMIN_USER_PASSWORD_AUTH」で設定します。
M AdminInitiateAuthRequestにおいても、「SECRET_HASH」を含めておきます。SECRET_HASHの実装の詳細は(N)を参照してください
N Amazon CognitoのユーザプールAPIは、ユーザプールIDやアプリクライアントID、クライアントシークレットを元にSecretHash値が必要なものがあります。SecretHash値が必要なAPI及びその計算方法は ユーザーアカウントのサインアップと確認 SecretHash 値の計算 を参照してください。


今回は、Cognitoユーザプールにユーザを追加し、サインアップステータスを変更するLambdaファンクションを実装しました。 次回は、前回実装したクライアントシークレットをParameter Storeへ登録するLambdaファンクションを含めて、カスタムリソースとして実行するCloudFormationテンプレートを作成し、実行してみます。


著者紹介

川畑 光平(KAWABATA Kohei) - NTTデータ エグゼクティブ ITスペシャリスト ソフトウェアアーキテクト・デジタルテクノロジーストラテジスト(クラウド)

../_images/aws_361383_0752.jpeg

金融機関システム業務アプリケーション開発・システム基盤担当、ソフトウェア開発自動化関連の研究開発を経て、デジタル技術関連の研究開発・推進に従事。

Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。

AWS Top Engineers & Ambassadors 選出。

本連載記事の内容に対するご意見・ご質問は Facebook まで。