エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Firebase AuthenticationでメールリンクパスワードレスかつCookieによる半永続的セッションを実現する

こちらはエムスリー Advent Calendar 2024 8日目の記事です。

AI・機械学習チーム(以下、AIチーム)の中村伊吹(@inakam)がお送りします。

社内横断的に機械学習周りをなんでもやるをモットーにするAIチームですが、現在私はクライアント向けに認証機能が組み込まれたプロダクトを開発しています。

認証要件は次のようになっており、これをFirebase Authenticationで実現する方法をこの記事では解説します。 なお、Firebaseの導入についてはこの記事の範囲外とします。*1

  • メールリンクを用いてパスワードレスに認証する
  • セッション情報をCookieに保持する
  • セッションは半永続的に維持する
    • つまり一定期間内にアクセスがあれば、セッション時間を延長したい

またコード例として、フロントエンドはReact(Firebase SDK)、サーバーサイドはGo言語(Firebase Admin SDK)を例に解説していきます。

ChatGPTにサムネイルを頼んだら、クッキーとパスワードが組み合わせられて、わりと気に入ってます

Firebase Authenticationによるパスワードレス認証

firebase.google.com

パスワードレス認証のフロー

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("認証メールの送信に失敗しました");
    }
  };

送信処理を行うと、次のようなメールが届きます。

メール内の認証リンクをクリックすると、認証パラメータが付与されurlに遷移する

注意点として、この認証メールテンプレートは仕様上変更できません*2 もし変更が必要な場合には、generateSignInWithEmailLinkという関数でLinkのみを生成し、別のメールシステムで送信する必要があります。

firebase.google.com

認証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で再度ユーザー情報を取得します。

firebase.google.com

これでもユーザーの認証情報を利用することはできますが、よりサーバーサイドに処理を束ねるためにここからセッションCookieによる認証を実装します。セッションCookieで管理することで、フロントエンド上ではエンドポイントへのアクセス時にアクセストークンを意識する必要がなくなります。

セッションCookieによる認証を実装する

firebase.google.com

セッション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が必要になります)

firebase.google.com

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・機械学習はもちろん、どんなことにでも挑戦する意欲のあるエンジニアを募集しています。 新卒・中途それぞれの採用だけでなく、カジュアル面談やインターンも常時募集しています!

エンジニア採用ページはこちら

jobs.m3.com

カジュアル面談もお気軽にどうぞ

jobs.m3.com

インターンも常時募集しています

open.talentio.com

*1:

ウェブサイトで Firebase Authentication を使ってみる

などを参考に導入が済んでいるものとします

*2:メールを容易に送れる機能である故、スパム利用防止のために変更できなくなっているようです

*3:内部実装としては、mode=signInのパラメータがついているかを検証しているようです

https://github.com/firebase/firebase-js-sdk/blob/cb4309f13a01a6c66eb502ae6f5d6fa93560ab06/packages/auth/src/core/strategies/email_link.ts#L130

*4:サーバーサイドのみでアクセスできるCookie。httpOnlyオプションを付与することでJavascriptからアクセスできなくなるため、XSSに対する耐性がある。

*5:issueは立っていますが、あまり必要と思われている機能ではないようです。