Firebase Authenticationで公式のMFAを使わずにTOTPを導入する。

# Firebase Authenticationで公式のMFAを使わずにTOTPを導入する。

この記事は、Firebase AuthenticationのMFAを使わずにTOTPで2要素認証を実現した例です。
現在、TOTP による 2 要素認証はすでに[Identity Platform でプレビュー段階](https://cloud.google.com/identity-platform/docs/admin/enabling-totp-mfa?hl=ja)にあり、可能であればそちらをお勧めします。
ただ、考え方自体は別にTOTPに限った話ではなく、Firebase Authenticationを用いて他の2要素認証を実現したければ同じ方法で実現することはできるかもしれないです。

実装例

https://github.com/Optimind-llc/firebase-mfa-example

## 具体的にどうやるの？

端的には、Firebase Authenticationには[Blocking関数](https://firebase.google.com/docs/auth/extend-with-blocking-functions?hl=ja&gen=1st)を利用して認証トークンを発行し、そのトークンと2要素目のトークンで認証して[customToken](https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja)を発行します。

```mermaid
sequenceDiagram
    Client->>beforeSignIn: 認証情報の検証
    beforeSignIn-->>Client: 認証トークンの発行
    Client->>customToken発行API: 認証トークンとTOTPをリクエスト
    customToken発行API-->>Client: customTokenの発行
```

### Blocking関数の実装

Blocking関数のbeforeSignInは**ユーザーの認証情報が検証されてから**、Firebase Authentication からクライアント アプリに ID トークンが返らないうちにトリガーされます。

この関数がcallされるとき、ユーザの認証情報が検証済みであるため、ここでTOTP認証が必要なのかを判定しトークンを発行してやれば具合が良さそうです。

```typescript
export const beforeSignIn = functions.auth.user().beforeSignIn(async (user, context) => {
  const customClaims = user.customClaims;

  // totpで検証が必要なユーザであるかをカスタムクレームで判定
  if (!customClaims?.totp) {
    return;
  }

  // 認証用のtokenの発行
  const token = crypto.createHash('sha1').update(crypto.randomBytes(40).toString('hex')).digest('hex');

  await admin.firestore().doc(`onetimeToken/${token}`).set({
    uid: user.uid,
    signInTime: (new Date()).getTime(),
  });

  // HttpsErrorの第三引数でtokenを渡す。
  throw new functions.auth.HttpsError(
      "cancelled",
      "need-totp-auth",
      { token },
  );
});
```

### customToken発行API

次に、tokenを受け取ってカスタムクレームを発行する関数を実装します。

```typescript
export const createCustomClaim = functions.https.onRequest(async (req, res) => {
  const now = admin.firestore.Timestamp.now().toMillis();
  const token = req.get('authorization')?.substring('Bearer '.length) ?? '';
  const doc = admin.firestore().doc(`onetimeToken/${token}`);
  const snap = await doc.get();
  const user = await admin.auth().getUser(snap.get('uid'));
  const signInTime = snap.get('signInTime');
  const isAlreadyUsedToken = (Date.parse(user.metadata.lastSignInTime)) > signInTime;
  const isTimeout = (signInTime < (now - (5 * 60 * 1000)));

  // tokenがtimeoutしていないか、発行後からログインが行われていないかを検証。
  if (isAlreadyUsedToken || isTimeout) {
    res.status(400).send('Token timeout');
    return;
  }

  // TOTPの検証
  /**
   * 🙅これはサンプルコードです🙅
   * 様々なところで使われるFirebase AuthenticationのuidをTOTPのキーとして扱わないようにしましょう。
   */
  const totpUserId = convert('ascii', 'base32', user.uid);
  const {totp} = getTOTP(totpUserId, 'base32', now);

  if (totp !== req.body.totp) {
    res.status(400).send('invalid');
    return;
  }

  const customToken = await admin.auth().createCustomToken(user.uid);

  // Considering that the user fails to receive the response, it would be good to remove it using firestore TTL.
  await doc.delete();

  res.send({customToken});
});
```

### クライアントコードの実装

あとはクライアント側で、認証失敗時に得られるtokenからAPIをcallしcustomTokenを発行して、ログインしてやれば良さそうです。

```tsx
import { useState } from 'react'
import './App.css'
import {auth} from './firebase';
import { signInWithEmailAndPassword, signInWithCustomToken } from "firebase/auth";

const App = () => {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState('');
  const [totp, setTOTP] = useState("");
  const [temporaryToken, setTemporaryToken] = useState('');

  const handleLogin = () => {
    signInWithEmailAndPassword(auth, email, password)
      .then((userCredential) => {
        console.log(userCredential);
        setIsLoggedIn(true);
      })
      .catch((error) => {
        if (!error.message.includes('need-totp-auth')) {
          setError(error.message);
          return;

        }

        // login時にneed-totp-authが理由で失敗している場合は、エラーメッセージからJSONをパースしてtokenを取得する。
        const token = JSON.parse(error.message.match(/returned HTTP error 499: ({.*})\)/)?.[1] ?? '{}')?.error.details.token ?? '';

        setTemporaryToken(token);
      });
  };

  const handleLoginForTOTP = async () => {
    // TOTPと認証用トークンをリクエストする。
    const res = await fetch(`/createCustomClaim`, {
      method: 'post',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${temporaryToken}`
      },
      body: JSON.stringify({
        totp,
      })
    });

    if (!res.ok) {
      setTemporaryToken('');
      setError('Fail TOTP');
    }

    const {customToken} = await res.json();

    // 得られたカスタムトークンでログインする。(このときに、beforeSignInは動かない。)
    const userCredential = await signInWithCustomToken(auth, customToken);

    console.log(userCredential);
    setIsLoggedIn(true);
  };

  return (
    <div>
      {isLoggedIn && <div>
         Success!!!
      </div>}
      {!isLoggedIn && !temporaryToken && <div>
        <h2>Login</h2>
        {error && <div>{error}</div>}
        <div>
          <label htmlFor="email">Email:</label>
          <input
            type="email"
            id="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <div>
          <button id="sign-in-button" onClick={handleLogin}>Login</button>
        </div>
      </div>}
    {!isLoggedIn && !!temporaryToken && <div>
        <input
          type="text"
          value={totp}
          onChange={(e) => setTOTP(e.target.value)}
        />
        <button onClick={handleLoginForTOTP}>verify</button>
      </div>}
    </div>
  );
};

export default App;
```

このような形でBlocking関数のエラーメッセージをうまく使うことで公式のMFAの機能を利用せずにMFAを実現することができました。

Blocking関数が認証を弾く目的でのみ利用するものであるという固定観念があり、なかなか思いつかなかったのですが思いついたら実装したくなり、実装をしてみました。反省はしていないです。

この方法だとこういった問題がありそう！などのコメントがあればいただけると幸いです。

# 宣伝

株式会社オプティマインドでは、一緒に働く仲間を大募集中です。
カジュアル面談も大歓迎ですので、気軽にお声がけください。

https://recruit.optimind.tech/