3
1

More than 1 year has passed since last update.

AWS Cognitoでワンタイムパスワード(MFA認証)を設定していても自動テスト(Cypress)をする方法

Last updated at Posted at 2022-07-06

Cypressとは

cypressはブラウザをクライアントとして、WebアプリケーションのE2Eテスト(End to End Test)を実現するためのテストツールです。テストコードをjavascriptのコードで記述することでテストを自動化することができます。

Cypressと認証

cypressでE2Eテストを行う際の対象Webシステムが認証を必要とする場合、cypress側が認証を通過する必要があります。IDとパスワードのみの認証であれば、テストコード側に認証情報を渡してキーボード入力で認証を通過することが可能です。ただ、最近のアプリケーションではSNS認証や多要素認証など様々な認証方法があります。
cypress公式でもAuth0やAWS Cognitoなど最近よく使われる認証サービスに関するガイドがあり、実装の参考になります。

私がやりたかったAWS Cognitoの多要素認証(ワンタイムパスワード)に関しては公式のガイドにも情報がなかったんですが、最近Githubのissueに投稿があり参考に試してみました。

事前準備

ワンタイムパスワード(TOTP)で認証するサンプルアプリ

今回テストの対象システムとしてAWS Cognitoを利用して、多要素認証(TOTP)を実装した単純なログイン画面を実装します。amplifyでuiが提供されているため、今回はこれを使っていきます。AWSアカウント準備については省略します。

Reactプロジェクトを作成します。

npx create-react-app cognito-totp-sample --template typescript
cd cognito-react-app
yarn add aws-amplify @aws-amplify/ui-react

amplify init で初期化し、amplify add auth で cognito user pool を作成します。このときの設定は適当で構いませんが、TOTPを有効化するように設定してください。

amplify init
amplify add auth

amplify add auth は対話的に設定していきます。この辺の設定がワンタイムパスワード関連です。

For user login, select the MFA types:
  Time-Based One-Time Password (TOTP)

設定ができたらpushします。

amplify push

App.tsxを以下のように修正します。

import { Amplify } from "aws-amplify";
import { Authenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";

import awsconfig from "./aws-exports";
Amplify.configure(awsconfig);

export default function App() {
  return (
    <Authenticator>
      {({ signOut, user }) => (
        <main>
          {user && <h1>Hello {user.username}</h1>}
          <button onClick={signOut}>Sign out</button>
        </main>
      )}
    </Authenticator>
  );
}

public/index.html にフォントを追加します。

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:slnt,wght@-10..0,100..900&display=swap"
      rel="stylesheet"
    />

yarn start で起動し、以下のような画面が表示されたらOKです。

cy-cognito1.png

あとは以下の画面が表示されるようにテストユーザーの作成とパスワード再設定まで進めてください。(QRコードはダミー画像に置き換えてます)
通常であれば、このQRコードをGoogle Authenticatorなどで読み込んで設定しますが、今回はテストで自動的にワンタイムパスワードを取得したいため、ここで止めておきます。

cy-cognito2_dummy.png

e2eテスト作成

Cypressインストール

cypressをインストールします。(以下yarn前提で記載しますが、npmの方は適宜読み替えてください)

yarn add --dev cypress

インストール後にcypressを起動します。

yarn cypress open

起動すると設定を聞かれるのでE2E Testing を選びます。

cypress1.png

次の画面ではそのまま下にスクロールし、[Continue]をクリックします。

cypress2.png

[Start E2E Testing in Chrome] をクリックします。

cypress3.png

新しくchromeが起動して、以下の画面になりますので[Create new empty spec]をクリックします。

cypress4.png

これでテストコード(cypress/e2e/spec.cy.ts)やその他ファイルが自動生成されますので以降はVSCode側で作業を行います。

テストコード作成

typescriptでテストコードを書いていくため、cypress/tsconfig.jsonを以下の内容で作成します。

{
    "extends": "../tsconfig.json",
    "include": ["./**/*.ts"],
    "exclude": [],
    "compilerOptions": {
      "types": ["cypress"],
      "lib": ["es2015", "dom"],
      "isolatedModules": false,
      "allowJs": true,
      "noEmit": true
    }
}

今回利用するライブラリをインストールします。

yarn add --dev otplib dotenv path

cypress.config.tsを以下のように修正します。baseUrlはReactデフォルトのポートを入力していますが、テストを実行したいサイトに変更してください。

import path from "path";
import { defineConfig } from "cypress";

// eslint-disable-next-line @typescript-eslint/no-var-requires
require("dotenv").config();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const awsConfig = require(path.join(__dirname, "./src/aws-exports.js"));

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
    baseUrl: "http://localhost:3000",
    env: {
      cognito_username: process.env.CY_AWS_COGNITO_USERNAME,
      cognito_password: process.env.CY_AWS_COGNITO_PASSWORD,
      awsConfig: awsConfig.default,
    },
  },
});

.envファイルを作成して、CognitoユーザーのIDとパスワードを設定します。ここの情報は事前に作成したユーザー情報を入力してください。

CY_AWS_COGNITO_USERNAME="test"
CY_AWS_COGNITO_PASSWORD="test1234"

cypress/support/commands.ts にCognitoのログイン処理を実装します。42行目のchallengeSecretの部分はテスト初回時のconsoleから取得しますのでとりあえずこのままでOKです。

import { authenticator } from "otplib";
import Amplify, { Auth } from "aws-amplify";

Amplify.configure(Cypress.env("awsConfig"));

export function generateOTP(secret: string, offset = 0) {
  if (offset === 0) {
    return authenticator.generate(secret);
  }
  const allOptions = authenticator.allOptions();
  const delta = allOptions.step * 1000;
  authenticator.options = { epoch: Date.now() + offset * delta };
  const offsetToken = authenticator.generate(secret);
  authenticator.resetOptions();
  return offsetToken;
}

Cypress.Commands.add("loginByCognitoApi", (email: string, password: string) => {
  const log = Cypress.log({
    displayName: "COGNITO LOGIN",
    message: [`🔐 Authenticating | ${email} `],
    autoEnd: false,
  });

  log.snapshot("before");

  const signIn = Auth.signIn({ username: email, password });

  cy.wrap(signIn, { log: false })
    .then(async (user) => {
      let offset = 0;
      while (offset < 10) {
        try {
          if (user.challengeName === "MFA_SETUP") {
            const secret = await Auth.setupTOTP(user);
            console.log(secret);
            const code = generateOTP(secret, offset++);
            await Auth.verifyTotpToken(user, code);
            await Auth.setPreferredMFA(user, "TOTP");
            return;
          } else {
            const challengeSecret = "初回テスト時に出力されたシークレットを入力";
            const code = generateOTP(challengeSecret, offset++);
            const result = await Auth.confirmSignIn(
              user,
              code,
              "SOFTWARE_TOKEN_MFA"
            );
            return result;
          }
          // eslint-disable-next-line no-empty
        } catch {}
      }
    })
    .then((cognitoResponse: any) => {
      const prefix = `${cognitoResponse.keyPrefix}.${cognitoResponse.username}`;
      const { idToken, accessToken, refreshToken, clockDrift } = cognitoResponse.signInUserSession;

      localStorage.setItem(`${prefix}.idToken`, idToken.jwtToken);
      localStorage.setItem(`${prefix}.accessToken`, accessToken.jwtToken);
      localStorage.setItem(`${prefix}.refreshToken`, refreshToken.token);
      localStorage.setItem(`${prefix}.clockDrift`, clockDrift);
      localStorage.setItem(`${cognitoResponse.keyPrefix}.LastAuthUser`, cognitoResponse.username);
      localStorage.setItem("amplify-signin-with-hostedUI", "false");
      log.snapshot("after");
      log.end();
    });
});

このままだと、型チェックのエラーが出力されるので cypress/global.d.ts を以下の内容で作成します。

/// <reference types="cypress" />

declare namespace Cypress {
  interface Chainable {
    loginByCognitoApi(username: string, password: string): Chainable<any>;
  }
}

cypress/e2e/spec.cy.tsを以下のように修正します。

describe("TOTP認証テスト", () => {
  before(() => {
    cy.loginByCognitoApi(
      Cypress.env("cognito_username"),
      Cypress.env("cognito_password")
    );
  });

  it("ログイン後のトップページが表示されること", () => {
    cy.visit("/");
    // 画面遷移と描画を待つ
    cy.get("h1", { timeout: 3000 }).should("be.visible");
    cy.get("h1").contains("Hello");
  });
});

テスト実行

ここまで来たらcypressで作成したテストを実行します。spec.cy.tsをクリックします。

cypress6.png

初めてテストを実行すると以下のようにcypressのchromeのコンソール上にシークレットが出力されます。

cypress7.png

2回目以降のテスト用にこのシークレットの文字列をcypress/support/commands.ts のchallengeSecret の部分に設定してください。このやり方改善できると思うんですが、cypress初心者の私には思いつきませんでした。。

// const challengeSecret = "初回テスト時に出力されたシークレットを入力";
const challengeSecret = "上記キャプチャの VCJ7GPS〜 の部分";

再度テストを流します。認証を通過し、ログイン画面ではなく、認証後のトップページ(Hello ユーザー名とSign outボタン)が出力されれば成功です。

cypress8.png

感想

いままで多要素認証があるWebシステムのE2Eテスト(テスト自動化)どうするんだろ、認証方式の違う環境を作成するしかないのかなと思っていましたが、今回の方法でより本番の環境に近いテストができそうです。今回の多要素認証はTOTP(ワンタイムパスワード)でしたが、SMSも試してみたいです。

参考

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1