🤢

Cognitoでメールアドレス編集するとログインできなくなる問題

2021/02/24に公開
5

2022/05/13 追記

記事の下の方で実現している解決方法について、最近うまく動いていないことを確認しました。サポートに連絡したところ、UpdateUserAttributesの完了のタイミングによってはこちらのコードはうまく動作しないことがわかりました。もしこちらの問題を現時点で解決したい場合はこちらの記事のように、独自でメールアドレス検証機構を作成するのが安定してよさそうです。

https://kohei1116.hateblo.jp/entry/2020/02/16/aws-cognito

ユーザーロックされる

こんにちはハトです。cognitoで詰まったので共有。

cognitoでメールアドレス変更機能を実装していた。すると以下のことに気づく。

  • 新しいメールアドレスを申請して、メールで検証をする前にその新しいメールでログインできてしまう
  • 逆に検証が成功していないのに古いメールアドレスでログインできない
  • つまり間違ったメールアドレスをリクエストするとその時点で詰む(ユーザーロックというらしい)

cognitoはどうやらupdateUserAttributesが実行された時点でメールアドレスを新しいものに変更してしまっているみたい。ただし、Eメール検証はfalseになっている。
本来であれば、メールアドレス検証が終わったあとにメールアドレスが変更されていることを期待する。これでは動作がちぐはぐである。また上記の振る舞いをするので、上3つの現象がおきてしまう。

情報収集

日本語で探したところ以下の2つの記事がヒットした。なぜもっとないのだろう。同じ問題ぶつかっている人多いはずなのに。。。

https://kohei1116.hateblo.jp/entry/2020/02/16/aws-cognito
https://doroidpanic.com/aws-cognitoでemailを変更した場合、認証してなくても古いメ/

issueみつけた

またこちらのissueがこの問題を中心に扱っており、ひととおり情報が収集できるはず。
https://github.com/aws-amplify/amplify-js/issues/987

結論から言うと、Cognitoのサービス側の問題であり、解決策は提供されていない。上のissueとか3年前からずっと続いている。

またこちらに対するamazonのチームからのフィードバックコメントがこちら。
https://github.com/aws-amplify/amplify-js/issues/987#issuecomment-531025897

端的にいうと、問題は認識してるし、今後のTODOリストには載っているよとのこと。(こちらとしては解決策求めてたのに、ちょっとまってねって。。)

issueで有志から提案された解決策は2つ

  1. 最初に共有した記事の人が行っているように、独自に検証プロセスを設ける方法。
  2. cognitoのカスタムトリガーを利用してちょっとトリッキーだけど、変更を塗りつぶしてしまう方法。参照

1のほうは最初に共有した記事がまんま同じことをやっている。

2つめのほうをここで共有したい。ただし先にいうと2つめにはユーザーが実際の検証なしに検証させてしまうセキュリティホールがあるよとのこと。

セキュリティホールあるかもよ。
https://github.com/aws-amplify/amplify-js/issues/987#issuecomment-561731822

@jaga さんがコメントにてセキュリティホールまわりの解説をくださいました。感謝!

2つめのほうの解説

前提知識

cognitoにはtriggerという機能があり、それを使って例えばサインアップ処理前後に処理を挟めたりする。フック的なもの。

その中の一つに、カスタムメッセージトリガーがある。こいつはもともとメールアドレス認証とかのメール本文(やSMS)をカスタマイズするために提供されている。これを利用して最初に説明した動作を握りつぶしてしまおうということ。

step1: メールアドレスを更新する

フロントにてメールアドレスを変更する部分の処理。通常であればemailだけを指定するが、ここでcustome:validated_email に古い方のメールアドレスを指定する。

const result = await Auth.updateUserAttributes(cognitoUser, {
  email: NEW_EMAIL,
  'custom:validated_email': CURRENTLY_VALIDATED_OLD_EMAIL,
});

次にLambdaを作成してカスタムメッセージイベントをハンドリングする。カスタムメッセージtriggerに関する詳細はこちら

最初に述べたようにupdateUserAttributesが実行されている時点でemailが変わってしまい、validatedがfalseになる。そこでそれらを先程のcustome:validated_emailを利用してもとに戻す。

exports.handler = (event, context, callback) => {
    // ユーザープールidが一致するか確認
    if(event.userPoolId === "theSpecialUserPool") {
        // Identify why was this function invoked
	// ここがUpdateUserAttributeになることに注意。
        if(event.triggerSource === "CustomMessage_UpdateUserAttribute") {
	  const validated_email = event.request.userAttributes['custom:validated_email'];
	  const params: AdminUpdateUserAttributesRequest = {
	    // cognitoによって勝手に変えられていた2つをもとにもどす。
	    UserAttributes: [
	      {
		Name: 'email_verified',
		Value: 'true',
	      },
	      {
		Name: 'email',
		Value: validated_email,
	      },
	    ],
	    UserPoolId: event.userPoolId,
	    Username: event.userName,
	  };
	  const result = await cognitoIdServiceProvider.adminUpdateUserAttributes(params).promise();
	  // メールアドレス検証後にadminUpdateUserAttributeが実行される。このときにこのラムダが事項されてはまずいので、それを回避する。
	  if (validated_email === event.request.userAttributes.email) {
	    throw new Error('failed to prevent sending unnecessary verification code');
	  }
        }
    }

    callback(null, event);
};

step2: 新しいメールアドレスを検証する

検証コードを入力するやつ。このとき成功すれば、もう一度ユーザー情報をupdateする。なぜなら、step1で古いメールになっているので更新しなければいけないから。このときemailと'custom:validated_email'の両方に新しいメールアドレスをいれる。

const result = await Auth.verifyUserAttributeSubmit(cognitoUser, 'email', code);
if (result === 'SUCCESS') {
  const result2 = await Auth.updateUserAttributes(cognitoUser, {
    email: NEW_EMAIL, // ここと
    'custom:validated_email': NEW_EMAIL, // ここに新しいメールアドレスをいれる。 
  });
}

このとき再びstep1のラムダが起動してしまうが(updateUserAttributesで発火するようにしているから)、以下のコードの部分おかげで、特に何も起きない。

  if (validated_email === event.request.userAttributes.email) {
    throw new Error('failed to prevent sending unnecessary verification code');
  }

step3

cognitoのユーザープールの設定でcustom: validated_email を加える。triggerの設定でカスタムメッセージにStep1で作成したLambdaを指定する。

以上でOK!他にdynamodb等を利用することもないし、一番シンプルかも。

さいご

なんでずっと放置されてるんだろう。はやく根本的に解決してほしい。

Discussion

shiva_itshiva_it

有益な情報ありがとうございます。
lambdaコード中のcognitoIdServiceProvider

const AWS = require('aws-sdk');
const cognitoIdServiceProvider = new AWS.CognitoIdentityServiceProvider();

などで生成する感じでしょうか?

[追記]
上記の実装方法で解決致しました。失礼しました。

じゃがじゃが

セキュリティホールは、custom:validated_emailemail の引数に、任意の同じアドレスをセットすると、検証メールによる検証ステップ飛ばして任意のメールアドレスに変えてしまえる(=アカウントの乗っ取りやなりすましが可能)という脆弱性になりそうです

その際の挙動をコードのコメントに追記してみました

exports.handler = (event, context, callback) => {
    // ユーザープールidが一致するか確認
    if(event.userPoolId === "theSpecialUserPool") {
        // Identify why was this function invoked
	// ここがUpdateUserAttributeになることに注意。
        if(event.triggerSource === "CustomMessage_UpdateUserAttribute") {
	  const validated_email = event.request.userAttributes['custom:validated_email'];
	  const params: AdminUpdateUserAttributesRequest = {
	    // cognitoによって勝手に変えられていた2つをもとにもどす。
            // [追記]validated_emailに任意の値を入れられるので、検証していないメールアドレスがセットでき、かつemail_verifiedがtrueになる
	    UserAttributes: [
	      {
		Name: 'email_verified',
		Value: 'true',
	      },
	      {
		Name: 'email',
		Value: validated_email,
	      },
	    ],
	    UserPoolId: event.userPoolId,
	    Username: event.userName,
	  };
	  const result = await cognitoIdServiceProvider.adminUpdateUserAttributes(params).promise();
	  // メールアドレス検証後にadminUpdateUserAttributeが実行される。このときにこのラムダが事項されてはまずいので、それを回避する。
          // [追記] if文の評価はtrueになるので、検証用のコードは送付されず、元のアカウントの持ち主に気づかれることもない
	  if (validated_email === event.request.userAttributes.email) {
	    throw new Error('failed to prevent sending unnecessary verification code');
	  }
        }
    }

    callback(null, event);
};

参考になれば幸いです & 自分のコメントに疑問点あれば教えて下さい!

ハトすけハトすけ

なるほど!

新しいメールアドレス申請項目と何かしらの方法でフロントをハックしてCURRENTLY_VALIDATED_OLD_EMAILに同じ第三者のメールアドレスを指定することができれば、検証メールを相手に飛ばさずに自分のメールアドレスをその人のものにすることができるということですね。

となると、ステップ1でフロントで実行してた処理はバックエンドに移した方がよさそうですね。