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関数が認証を弾く目的でのみ利用するものであるという固定観念があり、なかなか思いつかなかったのですが思いついたら実装したくなり、実装をしてみました。反省はしていないです。
この方法だとこういった問題がありそう!などのコメントがあればいただけると幸いです。
宣伝
株式会社オプティマインドでは、一緒に働く仲間を大募集中です。
カジュアル面談も大歓迎ですので、気軽にお声がけください。