React + AWS Amplify (AWS Cognito User Pools)でTOTP認証 – 自前実装編

この記事はReact + AWS Amplify (AWS Cognito User Pools)でTOTP認証の続きです。 前回やったこと AWS Amplifyが提供するReactのHOCでTOTP (MFA)を実装 […]

広告ここから
広告ここまで

目次

    この記事はReact + AWS Amplify (AWS Cognito User Pools)でTOTP認証の続きです。

    前回やったこと

    AWS Amplifyが提供するReactのHOCでTOTP (MFA)を実装しました。

    今回やること

    デザインの関係などで自前のコンポーネントを利用しているケースでは、自力の実装が必要となります。

    実装の方針

    • 動くものにすることを優先し、実案件投入は考慮しない
    • React Hooksを使い、Reduxなしで実装する
    • Contextの分割やTypeScriptの型定義はあとで考える

    親コンポーネント

    まず親を定義します。

    前回のコードではこのように定義されていまいた。

    import React from "react"
    import { Link } from "gatsby"
    // 2行追加
    import Amplify, { Auth } from 'aws-amplify';
    import { withAuthenticator } from 'aws-amplify-react';
    
    import Layout from "../components/layout"
    import Image from "../components/image"
    import SEO from "../components/seo"
    
    // 追加
    Amplify.configure({
      Auth: {
          region: 'ap-northeast-1', 
          userPoolId: 'ap-northeast-1_xxxxx',
          userPoolWebClientId: 'xxxxxxxxxxx'
      }
    });
    
    const IndexPage = () => (
      <Layout>
        <SEO title="Home" />
        <h1>Hi people</h1>
        {/* ログアウトボタンも追加する */}
        <button onClick={() => Auth.signOut()}>Sign out</button>
        <p>Welcome to your new Gatsby site.</p>
        <p>Now go build something great.</p>
        <div style={{ maxWidth: `300px`, marginBottom: `1.45rem` }}>
          <Image />
        </div>
        <Link to="/page-2/">Go to page 2</Link>
      </Layout>
    )
    
    // withAuthenticatorを追加
    export default  withAuthenticator(IndexPage)

    今回は自前で実装していくので、ContextのProviderなどが登場します。

    import React, { useState } from "react"
    import { Link } from "gatsby"
    // 2行追加
    import Amplify, { Auth } from 'aws-amplify';
    import { Authenticator } from 'aws-amplify-react';
    
    import Layout from "../components/layout"
    import Image from "../components/image"
    import SEO from "../components/seo"
    
    // 追加
    Amplify.configure({
      Auth: {
          region: 'ap-northeast-1', 
          userPoolId: 'ap-northeast-1_xxxxx',
          userPoolWebClientId: 'xxxxxxxxxxx'
      }
    });
    
    const MyAuthContext = React.createContext()
    const IndexPage = () => {
      // AmplifyのAuthenticatorからくるstateをハンドルする
      const [ authState, updateAuthState ] = useState('')
      // 孫で発生するログイン処理のステータス(challengeName)をハンドルする
      const [ authStatus, updateAuthStatus ] = useState('')
      // TOTPセットアップ時のQRコードをハンドルする
      const [ qrCode,setQRCode ] = useState('')
      // 認証のためのCognito Userをハンドルする
      const [ user, updateUser ] = useState(null)
    
      const onStateChange = (state) => {
        updateAuthState(state)
      }
      return (
        <MyAuthContext.Provider
          value={{
            authStatus,
            updateAuthStatus,
            updateAuthState,
            user,
            updateUser,
            qrCode,
            setQRCode
          }}
        >
          <Layout>
            <SEO title="Home" />
            <h1>{authState}</h1>
            {authState !== 'signedIn' ? (
            <Authenticator hideDefault onStateChange={onStateChange}>
              <AuthContents />
            </Authenticator>
            ): (
              <>
                <button onClick={() => Auth.signOut()}>Sign out</button>
                <p>Welcome to your new Gatsby site.</p>
                <p>Now go build something great.</p>
                <div style={{ maxWidth: `300px`, marginBottom: `1.45rem` }}>
                  <Image />
                </div>
                <Link to="/page-2/">Go to page 2</Link>
              </>
            )}
          </Layout>
        </MyAuthContext.Provider>
      )
    }
    
    export default IndexPage

    stateを4種類定義し、Providerで流し込んでいます。

    認証処理をハンドルする

    authState !== 'signedIn'のときに認証処理系のコンポーネントだけ表示させます。

    パスワードの強制リセットや2段階認証など、認証ステータスによって表示する要素が変わるのでハンドルする層を用意します。

    const AuthContents = () => {
      const { authStatus } = useContext(MyAuthContext)
      if (authStatus === 'SMS_MFA'|| authStatus === 'SOFTWARE_TOKEN_MFA') {
        return <MFACodeForm mfaType={authStatus} />
      }
      if (authStatus === 'MFA_SETUP') return <TOTPSetup />
      // 今回はやらない
      // if (authStatus === 'NEW_PASSWORD_REQUIRED') return <NewPassword />
      return <SignIn />
    }
    

    これでauthStatusの値がなければログイン処理へ、値がある場合はそれに応じたフォームを出すようになります。

    ログインリクエストを処理する

    続いてログインのリクエストを処理するフォームを持つコンポーネントを用意します。

    const SignIn = () => {
      const [username, updateUsername] = useState('')
      const [password, updatePassword] = useState('')
      const onChange= ({target}) => {
        const { name, value } = target
        if (name === 'password') updatePassword(value)
        if (name === 'username') updateUsername(value)
      }
      const { updateAuthStatus, updateAuthState, updateUser, setQRCode } = useContext(MyAuthContext)
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const user = await Auth.signIn(username, password)
          updateUser(user)
          const { challengeName } = user
          // なにもないならそのままログイン完了扱いにする
          if (!challengeName) updateAuthState('signedIn')
    
          // MFA_SETUPの時はTOTPのQRコードを作成する
          if (challengeName === 'MFA_SETUP') {
            const token = await Auth.setupTOTP(user)
            const issuer = encodeURI('AWSCognito')
            const code = "otpauth://totp/" + issuer + ":" + user.username + "?secret=" + token + "&issuer=" + issuer;
            setQRCode(code)
          }
          // 認証ステータスをstateにわたす
          updateAuthStatus(challengeName)
        } catch (e) {
          console.log(e)
        }
      }
      return (
        <>
          <h2>Sign In</h2>
          <form onSubmit={onSubmit}>
            <label>
              username
              <input type="text" value={username} onChange={onChange} name="username" />
            </label>
            <label>
              password
              <input type="password" value={password} onChange={onChange} name="password" />
            </label>
            <button type="submit">Sign In</button>
          </form>
        </>
      )
    }

    submit時の処理が少し長いですが、要は「ID / Passwordで認証後、追加認証がいるならそのことをContext (Hooks)に通知する」ということをしています。

    TOTPのセットアップコンポーネントを作る

    続いてQRコードの表示と、認証を行うコンポーネントを作ります。

    import QRCode from 'qrcode.react'
    
    const TOTPSetup = () => {
      const {
        qrCode,
        user, updateAuthState,
        updateUser,
      } = useContext(MyAuthContext)
      const [code, updateCode] = useState('')
      const onChange= ({target}) => {
        const { value } = target
        updateCode(value)
      }
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const loggedUser = await Auth.verifyTotpToken(
            user,
            code
          );
          updateUser(loggedUser)
          updateAuthState('signedIn')
        } catch (e) {
          console.log(e)
        }
      }
      return (
        <>
          <h2>Confirm code</h2>
          <QRCode value={qrCode} />
          <form onSubmit={onSubmit}>
            <label>
              code
              <input type="text" value={code} onChange={onChange} name="code" />
            </label>
            <button type="submit">Confirm</button>
          </form>
        </>
      )
    }

    qrcode.reactというライブラリでQRコードを出力していますが、これはAWS Amplify Reactでも使われているライブラリでしたので採用しました。

    二回目以降の認証画面を作る

    最後に2回目以降のワンタイムパスワードを入力するフォームを追加します。

    
    const MFACodeForm = ({mfaType}) => {
      const [code, updateCode] = useState('')
      const { user, updateUser, updateAuthState } = useContext(MyAuthContext)
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const loggedUser = await Auth.confirmSignIn(
            user,
            code,
            mfaType
          );
          updateUser(loggedUser)
          updateAuthState('signedIn')
        } catch (e) {
          console.log(e)
        }
      }
      const onChange= ({target}) => {
        const { value } = target
        updateCode(value)
      }
      return (
        <>
          <h2>MFA Code</h2>
          <form onSubmit={onSubmit}>
            <label>
              Code
              <input type="text" value={code} onChange={onChange} name="username" />
            </label>
            <button type="submit">Confirm</button>
          </form>
        </>
      )
    }

    完了

    これで準備ができました。

    ログイン後、後からTOTPを追加したい時など、このやり方を覚えておくと実装の幅が増えます。

    おまけ:フルコード

    今回のフルコードです。

    リファクタもユニットテストもしていない完全習作ですが、Amplifyでログインフォームを作る上で必要なものは一通り揃っているはずです。

    import React, { useState , useContext} from "react"
    import { Link } from "gatsby"
    
    import Amplify, { Auth } from 'aws-amplify';
    import { Authenticator } from 'aws-amplify-react';
    import QRCode from 'qrcode.react'
    
    import Layout from "../components/layout"
    import Image from "../components/image"
    import SEO from "../components/seo"
    
    
    Amplify.configure({
      Auth: {
          region: 'ap-northeast-1', 
          userPoolId: 'ap-northeast-1_xxxxx',
          userPoolWebClientId: 'xxxxxxxxxxx'
      }
    });
    
    const PrivContent = () => (
      <>
        <button onClick={() => Auth.signOut()}>Sign out</button>
        <p>Welcome to your new Gatsby site.</p>
        <p>Now go build something great.</p>
        <div style={{ maxWidth: `300px`, marginBottom: `1.45rem` }}>
          <Image />
        </div>
        <Link to="/page-2/">Go to page 2</Link>
      </>
    )
    
    const MyAuthContext = React.createContext()
    
    const SignIn = () => {
      const [username, updateUsername] = useState('')
      const [password, updatePassword] = useState('')
      const onChange= ({target}) => {
        const { name, value } = target
        if (name === 'password') updatePassword(value)
        if (name === 'username') updateUsername(value)
      }
      const { updateAuthStatus, updateAuthState, updateUser, setQRCode } = useContext(MyAuthContext)
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const user = await Auth.signIn(username, password)
          updateUser(user)
          const { challengeName } = user
          if (!challengeName) updateAuthState('signedIn')
          if (challengeName === 'MFA_SETUP') {
            const token = await Auth.setupTOTP(user)
            const issuer = encodeURI('AWSCognito')
            const code = "otpauth://totp/" + issuer + ":" + user.username + "?secret=" + token + "&issuer=" + issuer;
            setQRCode(code)
          }
          updateAuthStatus(challengeName)
        } catch (e) {
          console.log(e)
        }
      }
      return (
        <>
          <h2>Sign In</h2>
          <form onSubmit={onSubmit}>
            <label>
              username
              <input type="text" value={username} onChange={onChange} name="username" />
            </label>
            <label>
              password
              <input type="password" value={password} onChange={onChange} name="password" />
            </label>
            <button type="submit">Sign In</button>
          </form>
        </>
      )
    }
    
    const NewPassword = () => {
      const [password, updatePassword] = useState('')
      const onChange= ({target}) => {
        const { value } = target
        updatePassword(value)
      }
      const { updateAuthStatus, updateAuthState, updateUser, user } = useContext(MyAuthContext)
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const loggedUser = await Auth.completeNewPassword(user, password)
          updateUser(loggedUser)
          updateAuthStatus('')
          updateAuthState('signedIn')
        } catch (e) {
          console.log(e)
        }
      }
      return (
        <>
          <h2>New Password</h2>
          <form onSubmit={onSubmit}>
            <label>
              password
              <input type="password" value={password} onChange={onChange} name="password" />
            </label>
            <button type="submit">Sign In</button>
          </form>
        </>
      )
    }
    
    const TOTPSetup = () => {
      const {
        qrCode,
        user, updateAuthState,
        updateUser,
      } = useContext(MyAuthContext)
      const [code, updateCode] = useState('')
      const onChange= ({target}) => {
        const { value } = target
        updateCode(value)
      }
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const loggedUser = await Auth.verifyTotpToken(
            user,
            code
          );
          updateUser(loggedUser)
          updateAuthState('signedIn')
        } catch (e) {
          console.log(e)
        }
      }
      return (
        <>
          <h2>Confirm code</h2>
          <QRCode value={qrCode} />
          <form onSubmit={onSubmit}>
            <label>
              code
              <input type="text" value={code} onChange={onChange} name="code" />
            </label>
            <button type="submit">Confirm</button>
          </form>
        </>
      )
    }
    
    const MFACodeForm = ({mfaType}) => {
      const [code, updateCode] = useState('')
      const { user, updateUser, updateAuthState, updateAuthStatus } = useContext(MyAuthContext)
      const onSubmit = async (event) => {
        event.preventDefault()
        try {
          const loggedUser = await Auth.confirmSignIn(
            user,
            code,
            mfaType
          );
          updateUser(loggedUser)
          updateAuthStatus('')
          updateCode('')
          updateAuthState('signedIn')
        } catch (e) {
          console.log(e)
        }
      }
      const onChange= ({target}) => {
        const { value } = target
        updateCode(value)
      }
    
      return (
        <>
          <h2>MFA Code</h2>
          <form onSubmit={onSubmit}>
            <label>
              Code
              <input type="text" value={code} onChange={onChange} name="username" />
            </label>
            <button type="submit">Confirm</button>
          </form>
        </>
      )
    }
    const AuthContents = () => {
      const { authStatus } = useContext(MyAuthContext)
      if (authStatus === 'SMS_MFA'|| authStatus === 'SOFTWARE_TOKEN_MFA') {
        return <MFACodeForm mfaType={authStatus} />
      }
      if (authStatus === 'MFA_SETUP') return <TOTPSetup />
      if (authStatus === 'NEW_PASSWORD_REQUIRED') return <NewPassword />
      return <SignIn />
    }
    
    
    const IndexPage = () => {
      const [ authState, updateAuthState ] = useState('')
      const [ authStatus, updateAuthStatus ] = useState('')
      const [ qrCode,setQRCode ] = useState('')
      const [ user, updateUser ] = useState(null)
      const onStateChange = (state) => {
        updateAuthState(state)
      }
      return (
        <MyAuthContext.Provider
          value={{
            authStatus,
            updateAuthStatus,
            updateAuthState,
            user,
            updateUser,
            qrCode,
            setQRCode
          }}
        >
          <Layout>
            <SEO title="Home" />
            <h1>{authState}</h1>
            {authState !== 'signedIn' ? (
            <Authenticator hideDefault onStateChange={onStateChange}>
              <AuthContents />
            </Authenticator>
            ): (
              <PrivContent />
            )}
          </Layout>
        </MyAuthContext.Provider>
      )
    }
    
    
    export default IndexPage

    あとはここにパスワードリセット・再発行などがあればよいでしょう。

    実際に投入する時は適宜ReduxへConnectしたりリファクタリングしたりしてくださいね。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark