5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-03

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

この記事は、Firebase AuthenticationのMFAを使わずにTOTPで2要素認証を実現した例です。
現在、TOTP による 2 要素認証はすでにIdentity Platform でプレビュー段階にあり、可能であればそちらをお勧めします。
ただ、考え方自体は別にTOTPに限った話ではなく、Firebase Authenticationを用いて他の2要素認証を実現したければ同じ方法で実現することはできるかもしれないです。

実装例

具体的にどうやるの?

端的には、Firebase AuthenticationにはBlocking関数を利用して認証トークンを発行し、そのトークンと2要素目のトークンで認証してcustomTokenを発行します。

Blocking関数の実装

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

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

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を受け取ってカスタムクレームを発行する関数を実装します。

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を発行して、ログインしてやれば良さそうです。

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

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

宣伝

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

5
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
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?