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したりリファクタリングしたりしてくださいね。