LoginSignup
4
7

More than 1 year has passed since last update.

AWS Cognitoを使わずに多要素認証を実装したいんよ

Posted at

こんにちは。エンジニアブログの楽しさを知ったと同時にクソリプをもらってネット社会の恐ろしさを知った、むっそです。

先日スタートアップ転職あるあるという、科学的根拠も情報の出典もあまり書いていないようなN=1のポエムが、大変恐縮ながらちょっとばかりおバズりいたしました。不確実性の高い時代だからかこういう体験談ってみんな好きなんですかね。
物好きなかたもいるんですね。

はじめに

じつは上のリンクの記事の中にちょっとだけ伏線があって、こんなことを書きました。

入社1日目でバグ修正させられたり、多要素認証実装しろとか言われたり、なんなんってなります。(経験談)

そうです。多要素認証です。(伏線回収決め顔)

なので今回は多要素認証を実装してみたという記事を書いてみようかと思います。仕事ではフロントエンドをReact、バックエンドをPythonで書いていて、主にAWSのインフラを使って開発をしておりまして、なにかと多要素認証の記事は役に立つんじゃないかと思います。

多要素認証?なにそれ

野村総合研究所の定義から引用すると

ID・パスワードなどの「知識情報」および「所持情報」「生体情報」という
認証の3要素の中から、2つ以上の異なる認証要素を用いて認証する方法

ということです。

所持情報とは携帯電話、ハードウェアトークンといったユーザーが所有している情報であり、生体情報とは指紋、静脈などのユーザーの身体的な情報ですね。

なんかのアプリにログインしたあとにSMSでパスワードが送られてきて、送られたコードを入力するみたいなことは誰しもやった経験があるんじゃないかなと思います。それも多要素認証ですね。

2022年2月10日の記事によると多要素認証を実装している企業の割合が少ないようで
Microsoftが「君らそろそろやばない?」 って言ってる記事を見つけました。
今後より一層、多要素認証を実装する機会は増えてきそうですね。

AWSでの多要素認証方式

自分たちのAWS環境で開発しているソフトウェアサービスで多要素認証を提供しようとしたときに、実装方法としては主に2種類あります。

SMS(Short Message Service)認証
携帯電話宛てに4桁や6桁のショートコード(ワンタイムパスワード)が送信され、そのコードを入力してアプリやシステムにログインする仕組み

TOTP(Time-based One-Time Password)認証
サーバーとクライアントで同じ秘密鍵を共有して、その秘密鍵と時間を元に30秒間有効なワンタイムパスワードを生成する。クライアント側がショートコード(ワンタイムパスワード)をサーバーに送信して、一致していればログインできる仕組み

ただ 「SMS認証は危険なのではないか」 という記事を見かけたりするので、この記事でもTOTP認証に限定して書いていこうと思います。

AWSでTOTP認証をさくっと実装できる?

秘密鍵とか出てきて、実装が難しそうに見えるのですが、AWSには一応AWS Cognitoというユーザー認証/承認/管理サービスはあってTOTP認証も実装はできるのですが、AWS Cognitoを使わずに独自に多要素認証を実装したい場面がいくつかあるのではないかと思います。

AWS Cognitoを使わずにTOTP認証を実装したいケース例

  • AWS CognitoフェデレーティッドユーザーにMFAを設定したい場合
    ※ 現時点ではフェデレーティッドユーザーに MFA を設定できないみたいです。

  • AWS Cognitoを使用するよりも独自にアルゴリズムを実装するアーキテクチャのほうが効率が良い場合

  • AWS Cognitoになにがなんでも触りたくない場合

  • ログイン管理でそもそもAWS Cognitoを使用してないのでPythonで多要素認証をサクッと実装したい場合

このようなケースに当てはまっている方はぜひこの記事を参考程度に読んでいただければ嬉しいです。

PythonにはOne-Time Password Libraryというライブラリがありまして、そのライブラリを使用することで比較的容易にTOTP認証を実現できます。

上記ライブラリの力を借りても良いですし、インターネット上にはTOTPアルゴリズムを独自で実装しているような記事もあるので、独自に書いても良いと思います。

ざっくりTOTP認証の流れを説明

ざっくりTOTP認証の流れを説明すると、下記のような感じです。

全ユーザーに多要素認証を強制しているウェブアプリやモバイルアプリを開発/運用しているソフトウェアエンジニアの気持ちになって、お読みいただければ流れが理解しやすいかと思います。

TOTP認証の流れ

1. ユーザー(クライアント側)がユーザーID/パスワードを入力してウェブアプリに初回ログインする

2. サーバー側でユーザーID/パスワードを認証する(1段階目の認証)

3. 多要素認証をするためにサーバー側で秘密鍵/QRコードを生成してユーザー側画面に出力

python
import pyotp
from utils import crypto_util
from utils.dynamodb import UserAttribute

# 秘密鍵生成して暗号化する
issuer = "サービス名(ウェブアプリの名前など)"
otp_secret_raw = pyotp.random_base32()
otp_secret = crypto_util.encrypt(otp_secret_raw)

# データベースに保存
#ここではDynamoDBに秘密鍵を入れてますがDBなら何でもよい
UserAttribute.update(
    expr="set mfa_secret=:mfa_secret",
    values={
        ":mfa_secret": otp_secret,
    },
)

# QRコードを出力
qrcode_str = pyotp.totp.TOTP(otp_secret_raw).provisioning_uri(
    name=email_address, issuer_name=issuer
)
response = {"statusCode": 200, "body": qrcode_str}
return response

4. クライアント側はQRコードを読み取るために認証アプリをインストールする
Microsoft Authenticator
Google Authenticator

※上記どちらの認証アプリでも問題なくQRコードを読み取れます

5. クライアント側でQRコードを読み取り多要素認証のコードを画面に入力する
こんな感じのフォーム

6. サーバー側でデータベースに保持している秘密鍵から生成したワンタイムパスワードと、クライアント側で入力した認証コードが一致していればログインできる(2段階目の認証)

python
import pyotp
from utils.dynamodb import UserAttribute

# ユーザーから認証コードを受け取る
mfa_code = "mfacode_from_user"

# データベースに保存してる秘密鍵を復号化
# ここではDynamoDBに入れてますが、DBなら何でもよい
otp_secret = UserAttribute.decrypt_mfa_secret
totp = pyotp.TOTP(otp_secret)
result = totp.verify(mfa_code)
if result:
    ## 認証成功
    response = {"statusCode": 200, "body": "success"}
    return response

7. 2回目のログインからユーザー側は認証アプリの認証コードを入力してログインする
こんな感じ

初回ログイン時のみ、ユーザー側はQRコードを認証アプリで読み込んで認証コードを入力するが、初回以降のログインは認証アプリの認証コードを入力するだけでログインできる。
サーバー側は初回だけQRコードを出力するという実装になるので、その画面出力の違いを意識して実装する必要がある。

このような感じで手順を追うと
認証機能はフロントエンドとバックエンド行き来するので、フルスタックみが必要です...
認証系の実装、私やりたくないですって最初はなりました。笑

そんでまぁ実は上記の流れがこの記事の本質であり、重要なのはここだけと言ってしまっても良いのかもしれない、とも言い切れないかもしれないです。(どっち)

この後に書く内容は私の仕事でこういう場面に出くわしたので、それに対処するためにどのようにバックエンド(Python/DynamoDB)とフロントエンド(React/TypeScript)でTOTP認証を実現したのかって話になるのでまぁ例示してるだけです。ここで書かれている内容以降の話は、私と同じような境遇の方にとってはまぁ有益な内容になるかなぁと思います。

多要素認証を実装した経緯(実例パート)

このあとの流れは私が仕事をしていてAWS Cognitoを使用せずにTOTP認証を実装する必要があったという実例紹介パートになります。

おおまかにいうと下記のような事情がありました。

  • 自分が携わっている仕事での認証基盤はAWS Cognitoで作られていたのですがフェデレーティッドユーザーに対して多要素認証を実装する必要があったため、AWS Cognitoを使わずにTOTP認証を実装する必要がありました。

  • オーナー権限のユーザーが各一般ユーザーに対して多要素認証を有効にしたり、無効にしたり制御したいという要件がありました。つまり多要素認証ログイン画面/APIに加えて、オーナー権限ユーザーが各ユーザーの多要素認証設定を有効化/無効化するための画面/APIも追加で必要そうでした。

※多要素認証という言葉は長いので省略してMFA(Multi-Factor Authentication) と書くことがあります。ご了承ください。

実装アーキテクチャ

フロントエンド側でAmplifyを使っていたのでAmplifyで多要素認証させるか、バックエンド側APIで使用しているPython Cognito Clientで多要素認証させるか? という悩みもあったのですが、
AmplifyだとPython Cognito Clientでは持っているadmin_*系の関数や機能が不足していそうだったため、多要素認証のメイン機能はバックエンド側APIに任せる方針にしました。

MFA設定画面アーキテクチャMFAログイン画面アーキテクチャの2つのアーキテクチャについてシーケンス図で書いたり日本語で表現してみました。ただ実装コードを日本語で表現してみたのですが案外難しいので、ここのアーキテクチャを軽く見た後に、下のほうに書いてある実装コードを見に行くほうが理解しやすい人もいると思うのでお好きな感じでお読みいただければと思います。

MFA設定画面アーキテクチャ

まずオーナー権限ユーザーが一般ユーザーのMFA設定を有効化したり無効化するための画面アーキテクチャです。
DynamoDBのテーブルやスキーマは後述します。

1. /mfa/enableでMFA有効化
オーナー権限のユーザーが一般ユーザーの多要素認証を有効化したいときに、MFA設定画面にアクセスして /mfa/enable APIにPUTリクエスト を送信する

2. UserAttributeテーブルの更新
DynamoDBのUserAttributeテーブルの
mfa_enableキー(MFAが有効化されているかどうかを表すフラグ)をTrueにする

3. 処理結果出力
4. 処理結果出力

5. /mfa/disableでMFA無効化
オーナー権限のユーザーが一般ユーザーの多要素認証を無効化したいときに、MFA設定画面にアクセスして /mfa/disable APIにPUTリクエストを送信する

6. UserAttributeテーブルの更新
DynamoDBのUserAttributeテーブルの
mfa_enableキー(MFAが有効化されているかどうかを表すフラグ)をFalseにして
mfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)をFalseにして
mfa_secretキー(TOTP認証のときに使う暗号化された秘密鍵の文字列)をNoneにする

7. 処理結果出力
8. 処理結果出力

MFAログイン画面アーキテクチャ

続いて一般ユーザーがMFA設定からログイン処理を実施するための画面アーキテクチャです。
DynamoDBのテーブルやスキーマは後述します。

1./mfa/loginでユーザーMFA設定の確認
ユーザーがMFAログイン画面にアクセスして /mfa/login APIにPUTリクエストを送信する。

/mfa/login APIでUserAttributeテーブルの
mfa_enableキー(MFAが有効化されているかどうかを表すフラグ)がFalseの場合、MFA設定ユーザーではないので処理を終了します。

/mfa/login APIでUserAttributeテーブルの
mfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)がFalseの場合、MFA初回ログインと判断して秘密鍵/QRコードを生成してQRコードの文字列を返す。

/mfa/login APIでUserAttributeテーブルの mfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)がTrueの場合、MFA設定済みログインと判断して秘密鍵/QRコードなどは生成せず200番のステータスコードを返す。

2.UserAttributeテーブルの更新(MFA初回ログインのみ)
MFA初回ログインのときはUserAttributeテーブルの
mfa_secretキー(TOTP認証のときに使う暗号化された秘密鍵の文字列)に秘密鍵を登録する

3.処理結果出力
4.処理結果出力

5.ユーザーが認証コードを/mfa/verifyに送信
/mfa/verify APIでUserAttributeテーブルのmfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)がFalseで、なおかつ認証コードが一致していた場合、MFA初回ログインと判断して6.UserAttributeテーブルの更新をしてログイン処理を完了する。

/mfa/verify APIでUserAttributeテーブルのmfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)がTrueで、なおかつ認証コードが一致していた場合、MFA設定済みログインと判断して特になにもせずログイン処理を完了する。

6.UserAttributeテーブルの更新(MFA初回ログインのみ)
MFA初回ログインのときは
mfa_verifyキー(MFAの初回ログインが完了しているかどうかを表すフラグ)をTrueにする。

7.処理結果出力
8.処理結果出力

MFA設定画面アーキテクチャとMFAログイン画面アーキテクチャをシーケンス図で書いたり日本語で表現しましたが、うーん難しい。
コードのロジックを日本語で書くとちょっと説明がくどい感じになってしまうので、下記の実際のコードを見て理解したほうがもしかしたらわかりやすいかもしれないですね。日本語ムズイお


実装の詳細

DynamoDBのモデル(UserAttributeテーブル)と暗号化関連

DynamoDBのテーブルや型、秘密鍵の暗号化/復号化のコードは以下の通りです。

models/user_attribute.py
from datetime import datetime
from typing import Any, Dict, List, Optional, Union

import boto3
from boto3.dynamodb.conditions import Key
from pydantic import BaseModel
from cryptography.fernet import Fernet
from settings import Settings

# 環境変数などで鍵を用意しておく
settings = Settings()
crypto_encoder = Fernet(settings.crypto_key)

def encrypt(value: str) -> str:
    return crypto_encoder.encrypt(value.encode("utf-8")).decode("utf-8")

def decrypt(value: str) -> str:
    return crypto_encoder.decrypt(value.encode("utf-8")).decode("utf-8")

# DynamoDBのスキーマ
class UserAttribute(BaseModel):
    __tablename__ = "user_attributes"
    user_id: str
    user_type: str
    user_role: Optional[str] = None
    email_address: str
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    account_status: str
    created_at: int
    created_by: str
    updated_at: Optional[int]
    updated_by: Optional[str]

    # MFA関連のキー
    mfa_enable: Optional[bool] = False
    mfa_verify: bool = False
    mfa_secret: Optional[str] = None

    @classmethod
    def get_table(cls):
        return boto3.resource("dynamodb").Table(cls.__tablename__)

    @classmethod
    def find_by_email(cls, email: str) -> Optional[UserAttribute]:
        table = cls.get_table()
        user_response = table.scan(FilterExpression=Key("email_address").eq(email))
        user: List[dict] = user_response.get("Items", [])
        if not user:
            return None
        return UserAttribute.parse_obj(user)

    @property
    def decrypt_mfa_secret(self) -> str:
        if self.mfa_secret:
            return decrypt(self.mfa_secret)
        return ""

    def update(self, expr: str, values: dict):
        table = self.get_table()
        data = values.copy()
        data[":updated_at"] = datetime.now().timestamp()
        table.update_item(
            Key={"user_id": self.user_id},
            UpdateExpression=f"{expr}, updated_at=:updated_at",
            ExpressionAttributeValues=data,
            ReturnValues="UPDATED_NEW",
        )

MFA設定画面(再掲)

まずMFA設定画面から実装していきましょう。

ユーザーの多要素認証を有効化する(/mfa/enable API)

/api/mfa/enable.py
import json
from datetime import datetime
from typing import List
from botocore.exceptions import ClientError
from fastapi import APIRouter
from models.user_attribute import UserAttribute

router = APIRouter(prefix="/mfa")

# FastAPIを使っているがその他のフレームワークでも問題ない
@router.put("/enable")
def handler(email_address: str) -> dict:
    user_attr: UserAttribute = UserAttribute.find_by_email(email_address)
    # ユーザーのuser_attributesテーブルにmfa_enable:Trueを追加
    user_attr.update(
        expr="set mfa_enable=:mfa_enable",
        values={
            ":mfa_enable": True,
        },
    )
    response = {"label": "mfa_enable", "body": "successed"}
    return response

ユーザーの多要素認証を無効化する(/mfa/disable API)

/api/mfa/disable.py
import json
from datetime import datetime
from typing import Dict, List

import boto3
from fastapi import APIRouter
from models.user_attribute import UserAttribute

router = APIRouter(prefix="/mfa")

@router.put("/disable")
def handler(email_address: str) -> dict:
    user_attr: UserAttribute = UserAttribute.find_by_email(email_address)
    user_attr.update(
        expr="set mfa_enable=:mfa_enable, mfa_verify=:mfa_verify, mfa_secret=:mfa_secret",
        values={
            ":mfa_enable": False,
            ":mfa_verify": False,
            ":mfa_secret": None,
        },
    )
    response = {"label": "mfa_disable", "body": "successed"}
    return response

MFA設定画面(ユーザーインターフェース)

Typescript/React側(フロントエンド)でのMFA設定画面です。
react routerなどで良い感じにMFA設定画面にルーティングしてあげてください。

MfaEnablePage.tsx
import React from "react";
import { Box, Button, Typography } from "@mui/material";
import styled from "styled-components";
import { useSnackbar } from "notistack";
import axiosbase, { AxiosRequestConfig } from "axios";
import useSWR from "swr";

// styled-componentsを使ってCSS調整
const Div = styled.div`
  padding: 24px;
  background-color: #fdfcfc;
  width: 445px;
  border-radius: 6px;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.04);
  border-color: #ccc;
  border-width: 1px;
  border-style: solid;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
`;

const Container = styled.div`
  background-color: #f0f0f0;
  color: #9ea0a5;
  flex: 1;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
`;

const Page = styled.div`
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #f0f0f0;
  justify-content: center;
  align-items: center;
  display: flex;
  flex-direction: column;
`;

const ButtonContainer = styled.div`
  text-align: center;
  justify-content: center;
`;

type enableResponseType = {
  data: {
    label: string;
    body: string;
  };
};

// UserAttributeテーブルの型情報
type RestUser = {
  user: {
    user_id: string;
    user_type: string;
    user_role: string | null;
    email_address: string;
    first_name: string | null;
    last_name: string | null;
    account_status: string;
    created_at: number;
    created_by: string;
    updated_at: number | null;
    updated_by: string | null;
    mfa_enable: boolean | null;
    mfa_verify: boolean;
    mfa_secret: string | null;
  };
};

// axiosでAPIをたたいているがSWRとかでも良い
export const api = axiosbase.create({
  baseURL: process.env.REACT_APP_BACKEND_API_URL,
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
});

api.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    return {
      ...config,
      headers: {
        ...config.headers,
        Authorization: `Bearer ${localStorage.getItem("authToken")}`,
      },
    };
  },
  (error) => Promise.reject(error)
);

const MfaEnablePage: React.FC = () => {
  // ローカル環境にて確認するためURLはローカルに向けている
  const BACKEND_API_URL = "http://127.0.0.1:8000";

  // RestAPI経由でUserAttributeテーブルを参照している
  const { data: user } = useSWR<RestUser>(`${BACKEND_API_URL}/user`);
  const userMfaEnable = user?.user.mfa_enable;
  const userEmail = user?.user.email_address;
  const { enqueueSnackbar } = useSnackbar();

  const onClickEnable = async () => {
    // ユーザーのMFA情報をもとにMFA無効化またはMFA有効化を実施する
    const mfaType = userMfaEnable === true ? "disable" : "disable";
    try {
      await api
        .put(`${BACKEND_API_URL}/mfa/${mfaType}`, {
          email_address: userEmail,
        })
        .then((res: enableResponseType) => {
          console.log(res);
          enqueueSnackbar("多要素認証設定が更新されました", {
            variant: "success",
          });
        });
    } catch (e) {
      console.error(e);
      enqueueSnackbar("多要素認証設定の更新に失敗しました", {
        variant: "error",
      });
    }
  };

  return (
    <Page>
      <Container>
        <Div>
          <ButtonContainer>
            <Box
              sx={{
                justifyContent: "center",
                alignItems: "center",
                width: "100%",
              }}
            >
              <Box>
                {userMfaEnable ? (
                  <Typography>
                    多要素認証を無効にしようとしています
                  </Typography>
                ) : (
                  <Typography>
                    多要素認証を有効にしようとしています
                  </Typography>
                )}
              </Box>
            </Box>
            <Box
              sx={{
                justifyContent: "center",
                alignItems: "center",
                width: "100%",
              }}
            >
              <Button
                type="submit"
                variant="contained"
                onClick={onClickEnable}
                sx={{ m: "10px", backgroundColor: "#F3A800" }}
              >
                {userMfaEnable ? (
                  <Typography>無効にする</Typography>
                ) : (
                  <Typography>有効にする</Typography>
                )}
              </Button>
            </Box>
          </ButtonContainer>
        </Div>
      </Container>
    </Page>
  );
};

export default MfaEnablePage;

MFA設定画面の動作確認

めちゃくちゃ簡素なUIですが、有効にするボタンを押すとMFAが有効化して、無効にするボタンを押すとMFAが無効化します。
業務での使い方に合わせて、良い感じにフォームを作ってみてください。

MFAログイン画面(再掲)

次にMFAログイン画面を実装していきます。

ユーザーの多要素認証設定を確認する(/mfa/login API)

/api/mfa/login.py
from typing import Dict
import boto3
import pyotp
from fastapi import APIRouter
from models.user_attribute import UserAttribute, encrypt

router = APIRouter(prefix="/mfa")

@router.put("/login")
def handler(email_address: str) -> dict:
    user_attr: UserAttribute = UserAttribute.find_by_email(email_address)
    is_mfa = True if user_attr.mfa_enable else False
    is_verify = True if user_attr.mfa_verify else False

    if is_mfa:
      # MFAが有効化されているユーザー
      if is_verify:
          # MFA設定済みユーザー:特になにもしない
          response = {"label": "mfa_login", "body": "successed"}
          return response
      else:
          # MFA初回設定ユーザー:秘密鍵/QRコードを生成しDBに登録
          issuer = "ServiceName"
          otp_secret_raw = pyotp.random_base32()
          otp_secret = encrypt(otp_secret_raw)
          user_attr.update(
              expr="set mfa_secret=:mfa_secret",
              values={
                  ":mfa_secret": otp_secret,
              },
          )
          qrcode_str = pyotp.totp.TOTP(otp_secret_raw).provisioning_uri(
              name=email_address, issuer_name=issuer
          )
          response = {"label": "mfa_init_login", "body": qrcode_str}
          return response
    else:
      # MFAが無効化されているユーザー
      response = {"label": "no_mfa", "body": "failed"}
      return response

ユーザーの認証コードを検証する(/mfa/verify API)

/api/mfa/verify.py
from typing import Dict

import boto3
import pyotp
from fastapi import APIRouter
from fastapi.exceptions import HTTPException
from models.user_attribute import UserAttribute
from settings import Settings

router = APIRouter(prefix="/mfa")

@router.put("/verify")
def handler(email_address: str, access_token: str, mfa_code: str) -> MfaResponse:
    user_attr: UserAttribute = UserAttribute.find_by_email(email_address)
    is_verify = True if user_attr.mfa_verify else False
    otp_secret = user_attr.decrypt_mfa_secret
    totp = pyotp.TOTP(otp_secret)
    result = totp.verify(mfa_code)
    if result:
        ## 認証成功
        if is_verify:
            ## MFA設定済みユーザー:特になにもしない
            response = {"label": "mfa_verify", "body": "successed"}
            return response
        else:
            ## MFA初回設定ユーザー: mfa_verifyをTrueにする
            user_attr.update(
                expr="set mfa_verify=:mfa_verify",
                values={
                    ":mfa_verify": True,
                },
            )
            response = {"label": "mfa_init_verify", "body": "successed"}
            return response
    else:
        message = "Not Verify Error: failed to verify MFA"
        raise HTTPException(status_code=422, detail=message)

MFAログイン画面(ユーザーインターフェース)

Typescript/React側(フロントエンド)でのMFAログイン画面です。
react routerなどで良い感じにMFAログイン画面にルーティングしてあげてください。

MfaVerifyPage.tsx
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import { I18n } from "@aws-amplify/core";
import { useHistory } from "react-router-dom";
import axiosbase, { AxiosRequestConfig } from "axios";
import QRCode from "qrcode";
import { useForm, Controller, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box, Button, Typography, TextField, FormControl } from "@mui/material";
import { z } from "zod";

// styled-componentsを使ってCSS調整
const Div = styled.div`
  padding: 24px;
  background-color: #fdfcfc;
  width: 445px;
  border-radius: 6px;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.04);
  border-color: #ccc;
  border-width: 1px;
  border-style: solid;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
`;

const Container = styled.div`
  background-color: #f0f0f0;
  color: #9ea0a5;
  flex: 1;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
`;

const Page = styled.div`
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #f0f0f0;
  justify-content: center;
  align-items: center;
  display: flex;
  flex-direction: column;
`;

const ErrorMessageBox = styled.div`
  height: 24px;
  padding: 0 5px;
  text-align: center;
`;

const TextFieldWithLabel = styled.div`
  width: 100%;
  margin-top: 16px;
  .textField {
    width: 100%;
  }
`;

const NextButton = styled(Button)`
  color: white !important;
  font-weight: 600 !important;
  height: 40px;
  width: 100%;
  background: linear-gradient(0deg, #f38200, #f3a800);
  align-self: center;

  &:disabled {
    background: lightgray;
  }
`;

// 入力フォームの値チェック
const Schema = z.object({
  mfaCode: z
    .string({
      required_error: "必須入力項目です",
    })
    .length(6, { message: "6文字で記入してください" }),
});

type FormType = z.infer<typeof Schema>;

type ApiResponseType = {
  data: {
    label: string;
    body: string;
  };
};

// axiosでAPIをたたいているがSWRとかでも良い
export const api = axiosbase.create({
  baseURL: process.env.REACT_APP_BACKEND_API_URL,
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
});

api.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    return {
      ...config,
      headers: {
        ...config.headers,
        Authorization: `Bearer ${localStorage.getItem("authToken")}`,
      },
    };
  },
  (error) => Promise.reject(error)
);


const MfaVerifyPage: React.FC = () => {
  // ローカル環境にて確認するためURLはローカルに向けている
  const BACKEND_API_URL = "http://127.0.0.1:8000";
  const [authError, setAuthError] = useState<string>("");
  const [qrCode, setQrCode] = useState<string>("");
  const [noQrcodeFlag, setNoQrcodeFlag] = useState<boolean>(false);
  const history = useHistory();
  const { control, handleSubmit, formState } = useForm<FormType>({
    mode: "all",
    resolver: zodResolver(Schema),
  });

  // RestAPI経由でUserAttributeテーブルを参照している
  const { data: user } = useSWR<RestUser>(`${BACKEND_API_URL}/user`);
  const userEmail = user?.user.email_address;

  // QRコード生成関数
  const generateQR = async (qrUrl: string) => {
    try {
      const QrData = await QRCode.toDataURL(qrUrl);
      if (QrData) {
        setQrCode(QrData);
      }
    } catch (err) {
      console.error(err);
    }
  };

  // MFA初回ログインかどうか/mfa/loginに問い合わせる
  useEffect(() => {
    try {
      (async () => {
        await api
          .put(`${BACKEND_API_URL}/mfa/login`, {
            email_address: userEmail,
          })
          .then((res: ApiResponseType) => {
            if (res.data.label === "mfa_init_login") {
              // MFA初回ログインなのでQRコードを出力
              generateQR(res.data.body);
            } else if (res.data.label === "mfa_login") {
              // MFA設定済みなのでQRコードは出力しない
              setNoQrcodeFlag(true);
            } else if (res.data.label === "no_mfa") {
              // MFAが無効なのでログイン画面に戻す
              history.push("/login");
            }
          })
          .catch(() => {
            const message = "ページを再読み込みしてください";
            setAuthError(message);
          });
      })();
    } catch (e) {
      console.error(e);
      const message = "ページを再読み込みしてください";
      setAuthError(message);
    }
  }, []);

  // MFAコード入力後に/mfa/verifyに送信
  const onSubmit: SubmitHandler<FormType> = async (values) => {
    const { mfaCode } = values;
    try {
      await api
        .put(`${BACKEND_API_URL}/mfa/verify`, {
          email_address: userEmail,
          mfa_code: mfaCode,
        })
        .then((res: ApiResponseType) => {
          if (
            ["mfa_verify", "mfa_init_verify"].includes(
              res.data.label
            )
          ) {
            // MFA認証成功後はメインページに飛ぶ
            history.push("/main");
          }
        })
        .catch(() => {
          const message = "ワンタイムパスワードをもう一度入力ください";
          setAuthError(message);
        });
    } catch (e) {
      console.error(e);
      const message = "ワンタイムパスワードをもう一度入力ください";
      setAuthError(message);
    }
  };

  return (
    <Page>
      <Container>
        <Div>
          <Box />
          <form onSubmit={handleSubmit(onSubmit)}>
            <TextFieldWithLabel>
              {noQrcodeFlag ? (
                <Typography>
                  登録した認証アプリを起動してワンタイムパスワードを入力してください
                </Typography>
              ) : (
                <Typography>
                  QRコードを認証アプリで読み込むことでワンタイムパスワードを出力することができます
                </Typography>
              )}
              {qrCode ? (
                <Box sx={{ textAlign: "center" }}>
                  <img src={qrCode} />
                </Box>
              ) : null}
              <FormControl sx={{ width: "100%" }}>
                <Controller
                  name="mfaCode"
                  control={control}
                  render={({ field }) => {
                    return (
                      <TextField
                        color="error"
                        margin="none"
                        size="small"
                        placeholder="パスワード"
                        {...field}
                      />
                    );
                  }}
                />
                {formState.errors.mfaCode ? (
                  <Box
                    sx={{
                      color: "error.main",
                      fontSize: "14px",
                      mt: 1,
                      ml: 1,
                    }}
                    role="alert"
                  >
                    {formState.errors.mfaCode?.message}
                  </Box>
                ) : (
                  <Box
                    sx={{
                      m: 1,
                    }}
                  ></Box>
                )}
              </FormControl>
            </TextFieldWithLabel>
            <NextButton type="submit">次へ</NextButton>
            <ErrorMessageBox>
              <Typography
                sx={{ color: "red", height: "16px" }}
                variant="subtitle2"
              >
                {I18n.get(authError)}
              </Typography>
            </ErrorMessageBox>
          </form>
        </Div>
      </Container>
    </Page>
  );
};

export default MfaVerifyPage;

MFAログイン画面の動作確認

  • MFAの初回ログインはこのようにQRコードが出力されて、認証アプリでQRコードを読み取り認証コードを入力する(Google Authenticatorを使用)
  • MFA設定後のログイン(2回目以降)はこのようにQRコード読み取りはなく認証アプリでコードを入力する形になる。

終わりに

バックエンドやフロントエンドの実装を見ていただきありがとうございます。
ちょっとボリュームが多くて読むのが大変かと思いますが、私と同じように実装に困った誰かに響いてくれたらありがたいです。

実はAWS CognitoのTOTP認証も実装しているので、余力があるときに続編として記事を書こうと思いますが、ちょっと今回はフルスタックみが強くて記事を書くのが大変だったので、続編はもうすこしお待ちください笑

4
7
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
4
7