🤳

[Amplify Auth] V6でパスワードレスなSMS認証をやってみた

2024/08/13に公開

では、初めて行きます。

今回やること

題名の通り、SMSのみのパスワードレス認証を実装する

ユーザーから見た視点でいくと以下となります。

  • 電話番号を入力
  • SMSコードを入力
  • サインアップ・サインイン
    • サインイン時は既存のセッションコードを再利用してカスタム認証のチャレンジを通します。
    • また、最初のサインイン時はSMSコードが送られますが、以降のサインイン時は最初に使用した認証コードを再利用するように設定しています。(コストの関係から)

カスタム認証の設定

基本的には、公式に則って実装をしています。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-challenge.html

ざっくり、カスタム認証(Lambda)の流れは以下となります。

  • 認証チャレンジの 定義 が行われる
  • 認証チャレンジの 作成 が行われる
    • 今回作成されるのは SMS
  • 認証チャレンジレスポンスの 検証 が行われる
    • ここで、SMSコードの答え合わせを行なっている
  • 認証チャレンジの 定義 が再度行われる
    • 認証状態となり、トークンを返す

以下は実際のコードです。

Define Auth

define-auth-challenge.js
/**
 * @type {import('@types/aws-lambda').DefineAuthChallengeTriggerHandler}
 */

exports.handler = async (event) => {
  if (
    event.request.session &&
    event.request.session.length >= 3 &&
    event.request.session.slice(-1)[0].challengeResult === false
  ) {
    // ユーザの入力コードが3回間違っていた場合(認証失敗)
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  } else if (
    event.request.session &&
    event.request.session.length &&
    event.request.session.slice(-1)[0].challengeResult === true
  ) {
    // ユーザの入力コードが正しい場合(認証成功)
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
  } else {
    // それ以外: ユーザの入力コードが正しくなく、3回間違えてない場合 (認証チャレンジ継続)
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  }

  return event;
};

Create Auth

create-auth-challenge.js
/**
 * @type {import('@types/aws-lambda').CreateAuthChallengeTriggerHandler}
 */

const { SNS } = require("aws-sdk");
const { randomDigits } = require("crypto-secure-random-digit");

const sns = new SNS({
  region: REGION, 
});

exports.handler = async (event) => {
  let secretLoginCode;

 if (!event.request.session || !event.request.session.length) {
    // 新しいセッションの場合
    // 新しいシークレットログインコードを生成して、メール送信
    console.log("新たしいセッション", event.request.session);
    secretLoginCode = randomDigits(6).join("");
    await sendSMS(event.request.userAttributes.phone_number, secretLoginCode);
  } else {
    // 既存のセッションの場合
    // 新規にコードは生成せず、既存セッションのコードを再利用
    console.log("既存のセッション", event.request.session);
    const previousChallenge = event.request.session.slice(-1)[0];
    secretLoginCode =
      previousChallenge.challengeMetadata.match(/CODE-(\d*)/)[1];
  }

  // クライアントアプリに送り返す
  event.response.publicChallengeParameters = {
    phone: event.request.userAttributes.phone_number,
  };

  // ログインコードをパラメータに追加し、"Verify Auth Challenge Response"トリガーによって認証されるようにする
  event.response.privateChallengeParameters = { secretLoginCode };

  // ログインコードをセッションに追加し、次回の"Create Auth Challenge"トリガーで利用できるようにする
  event.response.challengeMetadata = `CODE-${secretLoginCode}`;

  return event;
};

// sms送信
async function sendSMS(phoneNumber, secretLoginCode) {
  const params = {
    PhoneNumber: phoneNumber,
    Message: `Your login code: ${secretLoginCode}`,
  };
  await sns.publish(params).promise();
}

ここでSMSを送信するために、SNSを使用しているため、以下のポリシーを Create Auth のLambdaに付与してあげる必要があります。

  • sns:Publish

Verify Auth

verify-auth-challenge-response.js
/**
 * @type {import('@types/aws-lambda').VerifyAuthChallengeResponseTriggerHandler}
 */

exports.handler = async (event) => {
  const expectedAnswer =
    event.request.privateChallengeParameters.secretLoginCode;

  if (event.request.challengeAnswer === expectedAnswer) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }

  return event;
};

フロント設定

フロントは基本的な以下の実装のみとしております。

  • サインアップ (SMSコードの確認含む)
  • サインイン (SMSコードの確認含む)
  • サインアウト
App.tsx
import { useState } from "react";
import * as Auth from "aws-amplify/auth";

// ランダムにパスワードを生成
const randomPass = () => {
  // 使用する英数字を変数charに指定
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";

  // 空文字列を用意
  let randomStr = "";

  // 用意した空文字列にランダムな英数字を格納(7桁)
  for (let i = 0; i < 8; i++) {
    while (true) {
      // ランダムな英数字を一文字生成
      const random = chars.charAt(Math.floor(Math.random() * chars.length));

      // randomStrに生成されたランダムな英数字が含まれるかチェック
      if (!randomStr.includes(random)) {
        // 含まれないなら、randomStrにそれを追加してループを抜ける
        randomStr += random;
        break;
      }
    }
  }

  return randomStr;
};

const JAPAN_PHONE_CODE = "+81";
const PASSWORD = randomPass();
function App() {
  const [signUpPhoneNumber, setSignUpPhoneNumber] = useState("");
  const [confirmSignUpCode, setConfirmSignUpCode] = useState("");

  const [signInPhoneNumber, setSignInPhoneNumber] = useState("");
  const [cognitoUser, setCognitoUser] = useState<Auth.SignInOutput | null>(
    null
  );
  const [confirmSignInCode, setConfirmSignInCode] = useState("");

  const signUp = async () => {
    console.log(PASSWORD);
    try {
      const user = await Auth.signUp({
        username: JAPAN_PHONE_CODE + signUpPhoneNumber,
        password: PASSWORD,
      });
      console.log("仮登録をしました。user:", { user });
    } catch (error) {
      console.log("error signing up:", { error });
    }
  };

  const confirmSignUp = async () => {
    try {
      await Auth.confirmSignUp({
        username: JAPAN_PHONE_CODE + signUpPhoneNumber,
        confirmationCode: confirmSignUpCode,
      });
      alert("登録完了しました。");
    } catch (error) {
      console.log("error confirming sign up", error);
    }
  };

  const signIn = async () => {
    try {
      console.log("Signing in...");
      const user = await Auth.signIn({
        username: JAPAN_PHONE_CODE + signInPhoneNumber,
        options: {
          authFlowType: "CUSTOM_WITHOUT_SRP",
        },
      });
      setCognitoUser(user);
    } catch (error) {
      console.log("error sign in", error);
    }
  };

  const signInChallenge = async () => {
    if (!cognitoUser) {
      return;
    }
    try {
      const loggedUser = await Auth.confirmSignIn({
        challengeResponse: confirmSignInCode,
      });
      console.log(loggedUser);
      alert("ログインしました。");
    } catch (error) {
      console.log("error confirming sign in", error);
    }
  };

  const logout = () => {
    console.log("ログアウト");
    return Auth.signOut();
  };

  return (
    <div>
      <div className="signUpSeriesContainer">
        <div className="signUpContainer">
          <input
            type="text"
            placeholder="signUpPhoneNumber"
            value={signUpPhoneNumber}
            onChange={(e) => setSignUpPhoneNumber(e.target.value)}
          />
          <button onClick={() => signUp()}>仮登録</button>
        </div>
        <div className="confirmSignUpContainer">
          <input
            type="text"
            placeholder="confirmSignUpCode"
            value={confirmSignUpCode}
            onChange={(e) => setConfirmSignUpCode(e.target.value)}
          />
          <button onClick={() => confirmSignUp()}>登録</button>
        </div>
      </div>
      <div className="signInSeriesContainer">
        <div className="signInContainer">
          <input
            type="text"
            placeholder="signInPhoneNumber"
            value={signInPhoneNumber}
            onChange={(e) => setSignInPhoneNumber(e.target.value)}
          />
          <button onClick={() => signIn()}>SMS送信</button>
        </div>
        <div className="confirmSignUpContainer">
          <input
            type="text"
            placeholder="confirmSignInCode"
            value={confirmSignInCode}
            onChange={(e) => setConfirmSignInCode(e.target.value)}
          />
          <button onClick={() => signInChallenge()}>確認</button>
        </div>
      </div>
      <div
        className="singOutContainer"
        onClick={() => {
          Auth.signOut();
        }}
      >
        <button onClick={() => logout()}>ログアウト</button>
      </div>
    </div>
  );
}

export default App;

Amplifyの情報は以下の index.tsx でインポートしてきています。

index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

import { Amplify } from "aws-amplify";
import awsExports from "./aws-exports";

Amplify.configure(awsExports);

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

電話番号の場合は、国際電話識別番号と国番号を付ける必要があり、付けないと Cognito User Pool に登録できないので注意が必要です。

これでSMSのパスワードレス認証が完了しました。

今後やりたいこと

サインインの際もSMSコードが新たに送られ、それで認証が通るように設定する
→ 単純に、認証チャレンジ作成のLambdaコードを一部修正すれば良さそうです。

まとめ

私自身、初めてカスタム認証を作成してみましたが、amplify cli で作成すると割と簡単にリソースを配置することができるので、楽に作成できて良い反面、ドキュメントをしっかり読まないとおかしな設定をしてしまいかねないので注意が必要だと感じました。

今回の記事が誰かのお役に立てれば幸いです。

NCDCエンジニアブログ

Discussion