こちらはエムスリー Advent Calendar 2024 8日目の記事です。
AI・機械学習チーム(以下、AIチーム)の中村伊吹(@inakam)がお送りします。
社内横断的に機械学習周りをなんでもやるをモットーにするAIチームですが、現在私はクライアント向けに認証機能が組み込まれたプロダクトを開発しています。
認証要件は次のようになっており、これをFirebase Authenticationで実現する方法をこの記事では解説します。 なお、Firebaseの導入についてはこの記事の範囲外とします。*1
- メールリンクを用いてパスワードレスに認証する
- セッション情報をCookieに保持する
- セッションは半永続的に維持する
- つまり一定期間内にアクセスがあれば、セッション時間を延長したい
またコード例として、フロントエンドはReact(Firebase SDK)、サーバーサイドはGo言語(Firebase Admin SDK)を例に解説していきます。
- Firebase Authenticationによるパスワードレス認証
- メールアドレスによるパスワードレス認証を実装する
- セッションCookieによる認証を実装する
- セッションCookieを定期的に更新する
- まとめ
Firebase Authenticationによるパスワードレス認証
Firebase Authenticationでは、ログイン用の認証URLを含んだメールをユーザーに送信し、その認証URLからパスワードレスで認証できる機能が用意されています。 Firebaseプロジェクトでメールリンクログインを有効にすることで利用できます。
メールアドレスによるパスワードレス認証を実装する
送信部分の作成
まずはFirebaseのSDKを利用して、フロントエンドで送信部を作成します。例えば、メールアドレスを入力するフォームを用意し、ボタンのonClick()要素に次のような関数を組み込むことで、メールを送信できます。
import { getAuth, sendSignInLinkToEmail } from "firebase/auth"; const auth = getAuth(); const [email, setEmail] = useState(""); // フォームなどからセットする ... const onClick = async () => { const actionCodeSettings = { // ここでは現在のURLを設定しているが、認証URLを別に設定したい場合は異なるものを設定する url: window.location.origin, // handleCodeInAppは常にtrueである必要がある handleCodeInApp: true, }; try { // メールアドレスに認証URLを含んだメールを送信 await sendSignInLinkToEmail(auth, email, actionCodeSettings); // 認証後に正しく戻ってきているかを確認するために、入力されたemailをlocalStorageに保存する window.localStorage.setItem("emailForSignIn", email); } catch (error) { alert("認証メールの送信に失敗しました"); } };
送信処理を行うと、次のようなメールが届きます。
注意点として、この認証メールテンプレートは仕様上変更できません。*2
もし変更が必要な場合には、generateSignInWithEmailLink
という関数でLinkのみを生成し、別のメールシステムで送信する必要があります。
認証URLからログイン処理を行う
送信部分が完成したら、次は認証URLから認証を行います。メールで認証URLをクリックすると、リダイレクトを繰り返し、最終的に先のactionCodeSettings
で設定したurlにapiKey
oobCode
mode
lang
のパラメータが付与された状態で返ってきます。
isSignInWithEmailLink
を使うことで、指定したURLが認証URLのリンクかを検証できます。*3
例えば/login
というURLを用意した場合、その先で呼び出すコンポーネントに次の処理を書くことになります。
import { getAuth, isSignInWithEmailLink } from "firebase/auth"; const auth = getAuth(); if (isSignInWithEmailLink(auth, window.location.href)) { ... // 現在のURLが認証URLである場合の処理をここに書く }
ここでさらにsignInWithEmailLink
を呼び出すことで、メールアドレスと認証URLに付与されているパラメータを使って認証できます。
前節の「送信部分の作成」において、localStorageに入力されたメールアドレスを保存していたのは、ここで用いるためです。
signInWithEmailLink
が成功すると、認証が完了したことになります。
import { getAuth, isSignInWithEmailLink } from "firebase/auth"; const auth = getAuth(); if (isSignInWithEmailLink(auth, window.location.href)) { // 現在のURLが認証URLであるので、認証処理を行う // localStorageから入力されたメールアドレスを取り出す let email = window.localStorage.getItem('emailForSignIn'); if (!email) { // 入力時と別のブラウザで認証URLを開くと、メールアドレスがlocalStorageに保存されていない // ここでプロンプトを出すことで、メールアドレスを再度入力してもらう email = window.prompt('フォームに入力したメールアドレスを再度入力してください'); } // メールアドレスと認証URLの組み合わせで認証する signInWithEmailLink(auth, email, window.location.href) .then((result) => { // localStorageに保存しているメールアドレスを削除する window.localStorage.removeItem('emailForSignIn'); // 認証リンクのパラメータが残るのを防ぐ history.replaceState(null, "", "/"); // 認証が完了したので、認証後の任意の処理を行う // getAdditionalUserInfo(result)を行うことで、ユーザー情報を取得できる window.location.assign('/profile'); // 例えばどこかに遷移させる }) .catch((error) => { // error.codeでエラーが取り出せるため、必要があれば処理する }); }
認証状態は永続化されるため、一度ログインが完了した後はgetAuth()
を呼ぶだけでログインしているユーザーの情報を取得することが可能です。
サーバーサイドで認証情報を取得する
ここまででFirebaseにおける基本的な認証が行えるようになりましたが、ここまでの処理はフロントエンドで完結しています。 バックエンドにWeb APIが存在している場合、Firebaseの認証情報を扱うためにIDTokenと呼ばれるアクセストークンをAPIに渡し、サーバーサイドのFirebase Admin SDKで再度ユーザー情報を取得します。
これでもユーザーの認証情報を利用することはできますが、よりサーバーサイドに処理を束ねるためにここからセッションCookieによる認証を実装します。セッションCookieで管理することで、フロントエンド上ではエンドポイントへのアクセス時にアクセストークンを意識する必要がなくなります。
セッションCookieによる認証を実装する
セッションCookieを発行するエンドポイントを作成する
まずサーバーサイドCookie*4を発行するためのエンドポイントを作成します。ここでは /sessionLogin
に実装されているとします。
type SessionLoginPayload struct { IDToken string `json:"idToken"` } ... return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() // リクエストからIDTokenを取得する var p SessionLoginPayload err := json.NewDecoder(r.Body).Decode(&p) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // IDTokenが有効であるかを検証する decoded, err := client.VerifyIDToken(r.Context(), idToken) if err != nil { http.Error(w, "Invalid ID token", http.StatusUnauthorized) return } // IDTokenの発行が直近5分以内であるかを確認する // (IDTokenが盗用された場合に、攻撃可能時間を最小限にするため) if time.Now().Unix()-decoded.Claims["auth_time"].(int64) > 5*60 { http.Error(w, "Recent sign-in required", http.StatusUnauthorized) return } // Cookieの有効期限を設定し、セッションCookieを発行する expiresIn := time.Hour * 24 * 5 // 5日間 cookie, err := client.SessionCookie(r.Context(), idToken, expiresIn) if err != nil { http.Error(w, "Failed to create a session cookie", http.StatusInternalServerError) return } // サーバーサイドCookieとしてセット http.SetCookie(w, &http.Cookie{ Name: "session", Value: cookie, MaxAge: int(expiresIn.Seconds()), HttpOnly: true, Secure: true, }) w.Write([]byte(`{"status": "success"}`)) }
セッションCookieをフロントエンドで受け取る
作成した/sessionLogin
にフロントエンドからアクセスして、セッションCookieを発行します。
auth.setPersistence(inMemoryPersistence);
を実行し、フロントではFirebase SDKによる認証情報を保持しないようにする点に注意してください。
import { getAuth, isSignInWithEmailLink, inMemoryPersistence } from "firebase/auth"; // IDTokenを含んだリクエストをセッションCookie発行APIに送信する const postIdTokenToSessionLogin = async ( idToken: string, ): Promise<Response> => { const res = await fetch([APIのホスト名] + "/sessionLogin", { method: "POST", headers: { ContentType: "application/json", }, body: JSON.stringify({ idToken }), }); return res; }; const auth = getAuth(); if (isSignInWithEmailLink(auth, window.location.href)) { // Firebase SDKで認証情報を永続化しないようにする auth.setPersistence(inMemoryPersistence); let email = window.localStorage.getItem('emailForSignIn'); if (!email) { email = window.prompt('フォームに入力したメールアドレスを再度入力してください'); } signInWithEmailLink(auth, email, window.location.href) .then((result) => { // IDTokenを取得 const idToken = result.user.getIdToken(); // IDTokenをセッションCookie発行APIに送信 postIdTokenToSessionLogin(idToken); window.localStorage.removeItem('emailForSignIn'); history.replaceState(null, "", "/"); }) .then(() => { window.location.assign('/profile'); }); }
セッションCookieをバックエンドで検証する
セッションCookieが発行できたら、エンドポイントそれぞれでセッションを検証する処理を組み込みます。検証にはFirebase Admin SDKのVerifySessionCookieAndCheckRevoked
を使用します。
Goにおける多くのWebフレームワークではmiddlewareをサポートしているため、middlewareに組み込んでエンドポイントへのアクセス時に検証すると管理的にも楽になると思います。
return func(w http.ResponseWriter, r *http.Request) { // セッションCookieを取得する cookie, err := r.Cookie("session") if err != nil { // セッションCookieがない場合はリダイレクトする http.Redirect(w, r, "/login", http.StatusFound) return } // セッションCookieが有効であるかを検証する decoded, err := client.VerifySessionCookieAndCheckRevoked(r.Context(), cookie.Value) if err != nil { // セッションCookieが無効な場合は再度ログインさせる http.Redirect(w, r, "/login", http.StatusFound) return } // APIで何らかのデータを返す処理 serveContentForUser(w, r, decoded) }
セッションCookieを定期的に更新する
セッションCookieを設定することができたら、最後に認証を定期的に更新する仕組みを作ります。というのも、実はFirebase AuthenticationのセッションCookieは最長でも2週間までしか認証期限を設定できません。したがって、このままでは一定期間が過ぎると強制的にログアウトしてしまいます。再度メール認証でログインしてもらっても良いですが、定期的に訪れるユーザーに対しては認証情報が生きていれば、認証情報を更新してあげる仕組みを作ってみます。
/sessionLogin
の仕様変更
セッションCookieの作成エンドポイントの仕様を変更します。これまではIDTokenのみを受け取っていましたが、IDTokenは1時間で期限切れになります。1時間以上後にCookieを更新するためにはリフレッシュトークンが必要になります。そこでセッションCookie発行時にリフレッシュトークンも保持しておくようにします。
type SessionLoginPayload struct { IDToken string `json:"idToken"` RefreshToken string `json:"refreshToken"` } ... return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() // リクエストからIDTokenとRefreshTokenを取得する var p SessionLoginPayload err := json.NewDecoder(r.Body).Decode(&p) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } decoded, err := client.VerifyIDToken(r.Context(), idToken) if err != nil { http.Error(w, "Invalid ID token", http.StatusUnauthorized) return } if time.Now().Unix()-decoded.Claims["auth_time"].(int64) > 5*60 { http.Error(w, "Recent sign-in required", http.StatusUnauthorized) return } expiresIn := time.Hour * 24 * 5 cookie, err := client.SessionCookie(r.Context(), idToken, expiresIn) if err != nil { http.Error(w, "Failed to create a session cookie", http.StatusInternalServerError) return } // サーバーサイドCookieとしてセット http.SetCookie(w, &http.Cookie{ Name: "session", Value: cookie, MaxAge: int(expiresIn.Seconds()), HttpOnly: true, Secure: true, }) // リフレッシュトークンも保持しておく http.SetCookie(w, &http.Cookie{ Name: "refreshToken", Value: refreshToken, MaxAge: int(expiresIn.Seconds()), HttpOnly: true, Secure: true, Path: "/", }) w.Write([]byte(`{"status": "success"}`)) }
リフレッシュトークンによるセッションCookie更新処理を実装する
リフレッシュトークンを使ってCookieを発行する処理を実装します。リフレッシュトークンからIDTokenを取得する処理はFirebase Admin SDKには実装されていません。*5 そこでトークン交換用のREST APIにアクセスすることで、リフレッシュトークンからIDTokenを取得します。そして、そのIDTokenを使ってセッションCookieを更新(再発行)する、という処理を実装します。
(firebase-config.js
に書かれているapiKey
が必要になります)
type TokenResponse struct { ExpiresIn string `json:"expires_in"` TokenType string `json:"token_type"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` UserID string `json:"user_id"` ProjectID string `json:"project_id"` } // https://firebase.google.com/docs/reference/rest/auth?hl=ja#section-refresh-token func fetchIdTokenByRefreshToken(apiKey, refreshToken string) (TokenResponse, error) { // RefreshTokenを使ってIDTokenを取得する処理はFirebase SDKにないため、REST APIを使って実装する endpoint := fmt.Sprintf("https://securetoken.googleapis.com/v1/token?key=%s", apiKey) form := url.Values{} form.Add("grant_type", "refresh_token") form.Add("refresh_token", refreshToken) req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) if err != nil { return TokenResponse{}, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{} resp, err := client.Do(req) if err != nil { return TokenResponse{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return TokenResponse{}, fmt.Errorf("fetchIdTokenByRefreshToken: %s", resp.Status) } var tokenResp TokenResponse err = json.NewDecoder(resp.Body).Decode(&tokenResp) if err != nil { return TokenResponse{}, err } return tokenResp, nil }
return func(w http.ResponseWriter, r *http.Request) { // refreshTokenをCookieから取得 refreshTokenCookie, err := r.Cookie("refreshToken") if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) return } // RefreshTokenを使ってIDTokenを取得 // firebaseApiKey := os.Getenv("FIREBASE_API_KEY") tokenResp, err := fetchIdTokenByRefreshToken(firebaseApiKey, refreshTokenCookie.Value) if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) return } // 以下、IDTokenを受け取ってセッションCookieを発行する場合と同じ処理 decoded, err := client.VerifyIDToken(r.Context(), idToken) if err != nil { http.Error(w, "Invalid ID token", http.StatusUnauthorized) return } if time.Now().Unix()-decoded.Claims["auth_time"].(int64) > 5*60 { http.Error(w, "Recent sign-in required", http.StatusUnauthorized) return } expiresIn := time.Hour * 24 * 5 cookie, err := client.SessionCookie(r.Context(), idToken, expiresIn) if err != nil { http.Error(w, "Failed to create a session cookie", http.StatusInternalServerError) return } // サーバーサイドCookieとしてセット http.SetCookie(w, &http.Cookie{ Name: "session", Value: cookie, MaxAge: int(expiresIn.Seconds()), HttpOnly: true, Secure: true, }) http.SetCookie(w, &http.Cookie{ Name: "refreshToken", Value: refreshTokenCookie.Value, MaxAge: int(expiresIn.Seconds()), HttpOnly: true, Secure: true, Path: "/", }) w.Write([]byte(`{"status": "success"}`))
あとはフロントエンドから/refreshSession
にアクセスすることで、既存のセッションCookieを更新できます。
まとめ
Firebase Authenticationは広く使われているライブラリだと思いますが、今回のようなパスワードレス+セッションCookie+半永続化という組み合わせでは、世の中に知見がない状態でした。 ややニッチな記事にはなりますが、エムスリーAI・機械学習チームは様々なことに挑戦しているのだということを感じてもらえればと思います。
We are hiring !!
エムスリーAI・機械学習チームでは、AI・機械学習はもちろん、どんなことにでも挑戦する意欲のあるエンジニアを募集しています。 新卒・中途それぞれの採用だけでなく、カジュアル面談やインターンも常時募集しています!