Next.js:Firebase 身份验证和 API 路由中间件

2025-06-04

Next.js:Firebase 身份验证和 API 路由中间件

本文最初发表在我的博客上。如果你喜欢这篇文章,并想阅读其他类似的文章,欢迎访问我的博客。

最近我开发了一个需要用户账户的小型 Web 应用。我学习了很多关于如何在客户端使用 Firebase 设置身份验证,以及如何在服务器端使用类似 Express.js 的中间件模式来保护 API 路由的知识。这篇文章是对我在这个项目中所学内容的回顾,以供将来参考。你可以在 GitHub 上找到这个项目的代码

身份验证 - 客户端

初始化

设置 Firebase 非常简单。您可以在此处创建一个项目,并启用您计划使用的登录提供程序以及授权域名。从 Firebase 控制台的“项目设置”中获取凭据,然后我们就可以像这样在客户端初始化 Firebase SDK。

//lib/firebase.js
import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';

const clientCredentials = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

if (!firebase.apps.length) {
  firebase.initializeApp(clientCredentials);
}

export default firebase;
Enter fullscreen mode Exit fullscreen mode

(实际项目中的文件和文件夹结构请参见此处)

React Hooks 和 Context Provider

由于用户的身份验证状态是“全局”状态,我们可以避免使用Context将其作为 prop 递归地传递到多层组件中

为此,我们需要一个上下文提供者 (Provider) 和一个上下文消费者 (Consumer)。提供者会附带一个由 创建的上下文 (Context) createContext()value我们传递给提供者的 prop 将被其子级访问。

    //lib/auth.js
    const authContext = createContext();

    export function AuthProvider({ children }) {
      const auth = /* something we'll fill in later */;
      return <authContext.Provider value={auth}>{children}</authContext.Provider>;
    }

For the descendant components to use the value, i.e., consume the Context, we can use `Context.Consumer`, or more conveniently, the `useContext` [hook](https://reactjs.org/docs/hooks-reference.html#usecontext).

    //lib/auth.js
    export const useAuth = () => {
      return useContext(authContext);
    };

    //components/SomeComponent.js
    const SomeComponent = () => {
      const { user, loading } = useAuth();
      // later we can use the object user to determine authentication status
      // ...
      }
Enter fullscreen mode Exit fullscreen mode

在 Next.js 中,AuthProvider我们上面实现的 可以插入到 中,_app.js以便应用程序中的所有页面都可以使用它。请参阅此处

实施细节AuthProvider

在上面的框架中AuthProvider,我们传递了一个auth对象作为valueprop,这是所有消费者消费的关键内容。现在我们需要弄清楚实现这个auth对象需要什么。

需要实现的关键auth是订阅用户登录状态(以及相关用户信息)的变化。这些变化可以通过 Firebase SDK 触发,具体来说是登录 / 退出功能,例如firebase.auth.GoogleAuthProvider()身份验证状态观察器功能firebase.auth().onAuthStateChanged()

因此,我们的最小实现可以如下所示,主要关注 newgetAuth函数。我们肯定需要从中返回一些内容getAuth,这些内容将是auth所使用的对象AuthProvider。为此,我们实现了handleUser更新状态的函数user,如下所示

    //lib/auth.js
    import React, { useState, useEffect, useContext, createContext } from 'react'
    import firebase from './firebase'

    const authContext = createContext()

    export function AuthProvider({ children }) {
      const auth = getAuth()
      return <authContext.Provider value={auth}>{children}</authContext.Provider>
    }

    export const useAuth = () => {
      return useContext(authContext)
    }

    function getAuth() {
      const [user, setUser] = useState(null)
      const handleUser = (user) => {
        if(user){
          setUser(user)
        }
      }

      useEffect(() => {
        const unsubscribe = firebase.auth().onAuthStateChanged(handleUser);
        return () => unsubscribe();
      }, []);

      /* TBA: some log in and log out function that will also call handleUser */

      return {user}
    }
Enter fullscreen mode Exit fullscreen mode

由于我们调用了其他 React Hooks,例如userEffect,  因此getAuth需要是 React 函数组件或自定义 Hook 才能遵循此处的规则。由于我们不渲染任何内容,仅返回一些信息,  getAuth因此是一个自定义 Hook,因此我们应该将其重命名为类似的名称useFirebaseAuth(即,自定义 Hook 的名称应始终以 开头use,请参阅此处的注释)。主要功能userFirebaseAuth是在组件之间共享状态。实际上,由于我们在 中user使用了 Provider,因此在所有组件之间共享状态Context_app.js

下面是 的完整实现userFirebaseAuth。我们在这里添加了不少内容:

  1. 公开登录和退出逻辑,以便上下文使用者可以使用它们。由于它们会user像 一样触发状态更改firebase.auth().onAuthStateChanged,因此最好将它们放在此处。
  2. 我们实际上需要更改firebase.auth().onAuthStateChangedfirebase.auth().onIdTokenChanged捕获令牌刷新事件并user使用新的访问令牌相应地刷新状态。
  3. 添加一些格式以使user对象仅包含我们应用程序的必要信息而不是 Firebase 返回的所有内容。
  4. 添加重定向以便在用户登录或退出后将用户发送到正确的页面。
    import React, { useState, useEffect, useContext, createContext } from 'react';
    import Router from 'next/router';
    import firebase from './firebase';
    import { createUser } from './db';

    const authContext = createContext();

    export function AuthProvider({ children }) {
      const auth = useFirebaseAuth();
      return <authContext.Provider value={auth}>{children}</authContext.Provider>;
    }

    export const useAuth = () => {
      return useContext(authContext);
    };

    function useFirebaseAuth() {
      const [user, setUser] = useState(null);
      const [loading, setLoading] = useState(true);

      const handleUser = async (rawUser) => {
        if (rawUser) {
          const user = await formatUser(rawUser);
          const { token, ...userWithoutToken } = user;

          createUser(user.uid, userWithoutToken);
          setUser(user);

          setLoading(false);
          return user;
        } else {
          setUser(false);
          setLoading(false);
          return false;
        }
      };

      const signinWithGoogle = (redirect) => {
        setLoading(true);
        return firebase
          .auth()
          .signInWithPopup(new firebase.auth.GoogleAuthProvider())
          .then((response) => {
            handleUser(response.user);

            if (redirect) {
              Router.push(redirect);
            }
          });
      };

      const signout = () => {
        return firebase
          .auth()
          .signOut()
          .then(() => handleUser(false));
      };

      useEffect(() => {
        const unsubscribe = firebase.auth().onIdTokenChanged(handleUser);
        return () => unsubscribe();
      }, []);

      return {
        user,
        loading,
        signinWithGoogle,
        signout,
      };
    }

    const formatUser = async (user) => {
      return {
        uid: user.uid,
        email: user.email,
        name: user.displayName,
        provider: user.providerData[0].providerId,
        photoUrl: user.photoURL,
      };
    };
Enter fullscreen mode Exit fullscreen mode

授权 - 服务器端

Firebase 身份验证的另一个用例是确保用户拥有对服务器端资源的正确访问权限,即只有满足特定访问条件才能访问特定的 API 路由。我想这叫做授权。例如,对于/api/users/[uid]路由,我们只返回用户请求自身信息的结果。

Firestore 安全规则

管理对后端资源(主要是数据库访问)的访问的一种模式是同时使用 Firestore 和 Firebase 身份验证,并使用 Firestore 的安全规则来强制执行访问权限。

例如,在上面的例子中,为了限制对用户信息的访问,在客户端,我们尝试像往常一样检索用户记录

    export async function getUser(uid) {
      const doc = await firestore.collection('users').doc(uid).get();
      const user = { id: doc.id, ...doc.data() };
      return user;
    }
Enter fullscreen mode Exit fullscreen mode

但是我们定义了以下一组安全规则,只有当用户的uid与文档的匹配时才允许读/写uid

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        match /users/{uid} {
          allow read, write: if isUser(uid);
        }
      }
    }
    function isUser(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }
    function isSignedIn() {
      return request.auth.uid != null;
    }
Enter fullscreen mode Exit fullscreen mode

您可以使用此设置做很多事情。例如,为了确定对某个文档的访问权限,您可以对其他集合和文档进行一些额外的查询。以下是我使用的安全规则,其中涉及了一些这方面的内容。

这种客户端设置和安全规则也存在一些缺点。主要包括:

  • 我们正在使用此安全规则语法定义访问权限,这比在服务器端编写任意代码的灵活性要差。
  • Firestore 还限制了每个请求中用于验证访问权限的查询次数。这可能会限制权限方案的复杂程度。
  • 某些数据库操作可能非常繁重,例如递归删除大型文档集合,这些操作应该只在服务器端执行。(更多详情请参阅 Firestore 的文档。
  • 测试安全规则需要额外的工作。(Firebase 确实提供了友好的用户界面和模拟器来测试安全规则)。
  • 最后,有些数据库访问逻辑在客户端(代码指针),有些在服务器端(代码指针) ,这有点分散。我可能应该把它们合并到服务器端。

在服务器端使用 Firebase Admin

好的,现在介绍更“经典”的服务器端授权方式。一般的工作流程如下:

  • 客户端代码应该随每个请求发送一个访问令牌。
  • 服务器端代码有一个 的实例firebase-admin,它可以验证和解码访问令牌并提取用户信息,例如uid用户的
  • 基于这些信息,服务器端代码可以执行更多查询并应用更多逻辑来确定应该继续执行还是拒绝请求。(服务器firebase-admin将拥有所有 Firebase 资源的特权访问权限,并会忽略所有仅与客户端请求相关的安全规则)。

这就是我初始化的方式firebase-admin

    //lib/firebase-admin.js

    import * as admin from 'firebase-admin';

    if (!admin.apps.length) {
      admin.initializeApp({
        credential: admin.credential.cert({
          projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
          clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
          privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
        }),
        databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
      });
    }

    const firestore = admin.firestore();
    const auth = admin.auth();

    export { firestore, auth }
Enter fullscreen mode Exit fullscreen mode

此处的文档建议生成一个私钥 JSON 文件。该文件包含许多不同的字段,上面的三个字段:projectIdclientEmailprivateKey似乎足以使其正常工作。

现在我们可以提取uid每个请求并验证用户的访问权限

    import { auth } from '@/lib/firebase-admin';

    export default async (req, res) => {
      if (!req.headers.token) {
        return res.status(401).json({ error: 'Please include id token' });
      }

      try {
        const { uid } = await auth.verifyIdToken(req.headers.token);
        req.uid = uid;
      } catch (error) {
        return res.status(401).json({ error: error.message });
      }

      // more authorization checks based on uid 
      // business logic
    }
Enter fullscreen mode Exit fullscreen mode

Next.js API 路由的身份验证中间件

上述代码的一个小问题是,随着我们需要身份验证的 API 路由越来越多,这些 API 路由函数中的代码也需要重复。我发现 Next.js 的开箱即用功能对服务器端开发的支持不够强大。我希望 Next.js 能够从 Express.js 中借鉴以下功能:路由器和中间件

在这种情况下,将身份验证作为中间件来工作会很方便。中间件是可以插入到请求处理生命周期中的组件;中间件可以丰富请求和/或响应对象,并在发生错误时提前终止请求。

事实证明这非常简单,我们只需要为我们的常规处理程序函数创建一个包装器,并且在包装器中我们可以修改reqres对象并在发生错误时提前返回。

以下是我定义withAuth中间件的方式

    import { auth } from '@/lib/firebase-admin';

    export function withAuth(handler) {
      return async (req, res) => {
        const authHeader = req.headers.authorization;
        if (!authHeader) {
          return res.status(401).end('Not authenticated. No Auth header');
        }

        const token = authHeader.split(' ')[1];
        let decodedToken;
        try {
          decodedToken = await auth.verifyIdToken(token);
          if (!decodedToken || !decodedToken.uid)
            return res.status(401).end('Not authenticated');
          req.uid = decodedToken.uid;
        } catch (error) {
          console.log(error.errorInfo);
          const errorCode = error.errorInfo.code;
          error.status = 401;
          if (errorCode === 'auth/internal-error') {
            error.status = 500;
          }
          //TODO handlle firebase admin errors in more detail
          return res.status(error.status).json({ error: errorCode });
        }

        return handler(req, res);
      };
    }
Enter fullscreen mode Exit fullscreen mode

这就是我们的使用方法,注意,handler我们不是导出,而是导出withAuth(handler)

    // get all sites of a user
    import { withAuth } from '@/lib/middlewares';
    import { getUserSites } from '@/lib/db-admin';

    const handler = async (req, res) => {
      try {
        const { sites } = await getUserSites(req.uid);
        return res.status(200).json({ sites });
      } catch (error) {
        console.log(error);
        return res.status(500).json({ error: error.message });
      }
    };

    export default withAuth(handler);
Enter fullscreen mode Exit fullscreen mode

以下是 GitHub 上的相关文件:middleware.jssites route


这就是我使用 Next.js 和 Firebase 在客户端和服务器端进行身份验证的全部内容。总的来说,这是一个非常棒的开发者体验,而且很容易上手。

本文最初发表在我的博客上。如果你喜欢这篇文章,并想阅读其他类似的文章,欢迎访问我的博客。

文章来源:https://dev.to/dingran/next-js-firebase-authentication-and-middleware-for-api-routes-29m1
PREV
我们如何使用 Google 和 Outlook OAuth 弹出窗口 我们如何实现它
NEXT
如何编写好的软件测试