next-firebase-auth-edge
简介您可能在寻找将 Firebase 身份验证添加到现有或新的 Next.js 应用程序的方法时发现了这篇文章。您的目标是做出明智、公正且面向未来的决策,从而最大限度地提高应用程序的成功机会。作为 next-firebase-auth-edge 的创建者,我必须承认提供完全公正的意见不是我的强项,但至少我会尝试证明我在设计库时所采取的方法的合理性。希望读完本指南后,您会发现该方法既简单又长期可行。
我会省去你冗长的介绍。我只想说,建立图书馆的想法是受到可能与您类似的情况的启发。当时 Next.js 发布了App Router的金丝雀版本。我正在开发严重依赖重写和内部重定向的应用程序。为此,我们使用自定义 Node.js express
服务器渲染 Next.js 应用程序。
我们对App Router和服务器组件感到非常兴奋,但意识到它与我们的自定义服务器不兼容。中间件似乎是一个强大的功能,我们可以利用它来消除对自定义 Express 服务器的需求,而是选择仅依赖 Next.js 的内置功能来动态地将用户重定向和重写到不同的页面。
当时,我们使用的是next-firebase-auth 。我们真的很喜欢这个库,但它通过next.config.js
、 pages/_app.tsx
、 pages/api/login.ts
、 pages/api/logout.ts
文件分散了我们的身份验证逻辑,这些文件将被视为遗留文件很快就好了。此外,该库与中间件不兼容,导致我们无法重写 URL 或根据用户的上下文重定向用户。
因此,我开始搜索,但令我惊讶的是,我发现中间件中没有支持 Firebase 身份验证的库。 –为什么会这样?不可能!作为一名在 Node.js 和 React 领域拥有超过 11 年商业经验的软件工程师,我正准备解决这个难题。
所以,我开始了。答案变得显而易见。中间件在Edge Runtime内运行。 Edge Runtime中没有与Web Crypto API兼容的 Firebase 库。我注定了。我感到很无助。这是我第一次需要真正等待才能使用新奇的 API 吗? -没有。守望的锅永远不会沸腾。我很快停止了哭泣,并开始对next-firebase-auth 、 firebase-admin和其他几个 JWT 身份验证库进行逆向工程,使它们适应 Edge Runtime。我抓住这个机会解决了我在以前的身份验证库中遇到的所有问题,旨在创建最轻、最容易配置且面向未来的身份验证库。
大约两周后, next-firebase-auth-edge 0.0.1
版本诞生了。这是一个可靠的概念证明,但您不会想使用版本0.0.1
。相信我。
近两年后,我很高兴地宣布,经过372 次提交、 110 个已解决的问题以及来自全球优秀开发人员的大量宝贵反馈,该库已经达到了我的其他自我节点向我批准的阶段。
在本指南中,我将使用next-firebase-auth-edge版本 1.4.1从头开始创建经过身份验证的 Next.js 应用程序。我们将详细介绍每个步骤,从创建新的 Firebase 项目和 Next.js 应用程序开始,然后与next-firebase-auth-edge
和firebase/auth
库集成。在本教程结束时,我们将把应用程序部署到 Vercel,以确认一切都在本地和生产就绪环境中正常运行。
本部分假设您尚未设置 Firebase 身份验证。如果不是的话,请随意跳到下一部分。
让我们前往Firebase 控制台并创建一个项目
创建项目后,让我们启用 Firebase 身份验证。打开控制台并按照“构建”>“身份验证”>“登录方法”并启用电子邮件和密码方法。这就是我们将在我们的应用程序中支持的方法
启用第一种登录方法后,应为您的项目启用 Firebase 身份验证,并且您可以在项目设置中检索您的Web API 密钥
复制 API 密钥并妥善保管。现在,让我们打开下一个选项卡 –云消息传递,并记下发件人 ID 。我们稍后会需要它。
最后但并非最不重要的一点是,我们需要生成服务帐户凭据。这些将使您的应用能够完全访问您的 Firebase 服务。转到项目设置 > 服务帐户,然后单击生成新私钥。这将下载带有服务帐户凭据的.json
文件。将此文件保存在已知位置。
就是这样!我们已准备好将 Next.js 应用程序与 Firebase 身份验证集成
本指南假设您已安装Node.js和npm 。本教程中使用的命令已根据最新的 LTS Node.js v20进行验证。您可以通过在终端中运行node -v
来验证节点版本。您还可以使用NVM等工具在 Node.js 版本之间快速切换。
打开您最喜欢的终端,导航到您的项目文件夹并运行
npx create-next-app@latest
为了简单起见,我们使用默认配置。这意味着我们将使用TypeScript
和tailwind
✔ What is your project named? … my-app ✔ Would you like to use TypeScript? … Yes ✔ Would you like to use ESLint? … Yes ✔ Would you like to use Tailwind CSS? … Yes ✔ Would you like to use `src/` directory? … No ✔ Would you like to use App Router? (recommended) … Yes ✔ Would you like to customize the default import alias (@/*)? … No
让我们导航到项目的根目录并确保安装了所有依赖项
cd my-app npm install
为了确认一切按预期工作,让我们使用npm run dev
命令启动 Next.js 开发服务器。当您打开http://localhost:3000时,您应该看到 Next.js 欢迎页面,类似于:
在开始与 Firebase 集成之前,我们需要一种安全的方式来存储和读取 Firebase 配置。幸运的是,Next.js 附带内置的dotenv支持。
打开您最喜欢的代码编辑器并导航到项目文件夹
让我们在项目的根目录中创建.env.local
文件并填充以下环境变量:
FIREBASE_ADMIN_CLIENT_EMAIL=... FIREBASE_ADMIN_PRIVATE_KEY=... AUTH_COOKIE_NAME=AuthToken AUTH_COOKIE_SIGNATURE_KEY_CURRENT=secret1 AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS=secret2 USE_SECURE_COOKIES=false NEXT_PUBLIC_FIREBASE_PROJECT_ID=... NEXT_PUBLIC_FIREBASE_API_KEY=AIza... NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=....firebaseapp.com NEXT_PUBLIC_FIREBASE_DATABASE_URL=....firebaseio.com NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...
请注意,以NEXT_PUBLIC_
为前缀的变量将在客户端捆绑包中可用。我们需要这些来设置Firebase Auth 客户端 SDK
可以从生成服务帐户凭据后下载的.json
文件中检索NEXT_PUBLIC_FIREBASE_PROJECT_ID
、 FIREBASE_ADMIN_CLIENT_EMAIL
和FIREBASE_ADMIN_PRIVATE_KEY
AUTH_COOKIE_NAME
将是用于存储用户凭据的 cookie 的名称
AUTH_COOKIE_SIGNATURE_KEY_CURRENT
和AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS
是我们将用来签署凭据的秘密
NEXT_PUBLIC_FIREBASE_API_KEY
是从项目设置常规页面检索的Web API 密钥
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
是您的项目 ID .firebaseapp.com
NEXT_PUBLIC_FIREBASE_DATABASE_URL
是您的项目 ID .firebaseio.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
可以从项目设置>云消息传递页面获取
USE_SECURE_COOKIES
不会用于本地开发,但当我们将应用程序部署到 Vercel 时会派上用场
next-firebase-auth-edge
和初始配置通过运行npm install next-firebase-auth-edge@^1.4.1
将库添加到项目的依赖项中
让我们创建config.ts
文件来封装我们的项目配置。这不是必需的,但会使代码示例更具可读性。
不要花太多时间思考这些价值观。我们将在后续内容中更详细地解释它们。
export const serverConfig = { cookieName: process.env.AUTH_COOKIE_NAME!, cookieSignatureKeys: [process.env.AUTH_COOKIE_SIGNATURE_KEY_CURRENT!, process.env.AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS!], cookieSerializeOptions: { path: "/", httpOnly: true, secure: process.env.USE_SECURE_COOKIES === "true", sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, }, serviceAccount: { projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!, clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, "\n")!, } }; export const clientConfig = { projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID };
在项目的根目录中创建middleware.ts
文件并粘贴以下内容
import { NextRequest } from "next/server"; import { authMiddleware } from "next-firebase-auth-edge"; import { clientConfig, serverConfig } from "./config"; export async function middleware(request: NextRequest) { return authMiddleware(request, { loginPath: "/api/login", logoutPath: "/api/logout", apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, cookieSerializeOptions: serverConfig.cookieSerializeOptions, serviceAccount: serverConfig.serviceAccount, }); } export const config = { matcher: [ "/", "/((?!_next|api|.*\\.).*)", "/api/login", "/api/logout", ], };
不管你相信与否,我们刚刚将应用程序的服务器与 Firebase 身份验证集成在一起。在我们实际使用它之前,让我们先解释一下配置:
loginPath
将指示 authMiddleware 公开GET /api/login
端点。当使用Authorization: Bearer ${idToken}
* 标头调用此端点时,它会使用包含签名自定义和刷新令牌的 HTTP(S)-Only Set-Cookie
标头进行响应
* idToken
通过Firebase Client SDK中提供的getIdToken
函数检索。稍后会详细介绍这一点。
类似地, logoutPath
指示中间件公开GET /api/logout
,但它不需要任何额外的标头。调用时,它会从浏览器中删除身份验证 cookie。
apiKey
是 Web API 密钥。中间件使用它来刷新自定义令牌并在凭据过期后重置身份验证 cookie。
cookieName
是由/api/login
和/api/logout
端点设置和删除的 cookie 的名称
cookieSignatureKeys
用于签署用户凭据的密钥列表。凭证始终使用列表中的第一个密钥进行签名,因此您需要至少提供一个值。您可以提供多个密钥来执行密钥轮换
cookieSerializeOptions
是生成Set-Cookie
标头时传递给cookie 的选项。请参阅cookie自述文件以获取更多信息
serviceAccount
授权库使用您的 Firebase 服务。
匹配器指示 Next.js 服务器针对/api/login
、 /api/logout
、 /
以及任何其他不是文件或 api 调用的路径运行中间件。
export const config = { matcher: [ "/", "/((?!_next|api|.*\\.).*)", "/api/login", "/api/logout", ], };
您可能想知道为什么我们不为所有/api/*
调用启用中间件。我们可以,但在 API 路由处理程序本身内处理未经身份验证的调用是一个很好的做法。这有点超出了本教程的范围,但如果您有兴趣,请告诉我,我将准备一些示例!
正如您所看到的,配置是最少的并且具有明确定义的目的。现在,让我们开始调用/api/login
和/api/logout
端点。
为了让事情尽可能简单,让我们清除默认的 Next.js 主页并用一些个性化内容替换它
打开./app/page.tsx
并粘贴以下内容:
import { getTokens } from "next-firebase-auth-edge"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { clientConfig, serverConfig } from "../config"; export default async function Home() { const tokens = await getTokens(cookies(), { apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, serviceAccount: serverConfig.serviceAccount, }); if (!tokens) { notFound(); } return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-xl mb-4">Super secure home page</h1> <p> Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom! </p> </main> ); }
让我们一点一点地分解这个问题。
getTokens
函数旨在验证并从cookie中提取用户凭据
const tokens = await getTokens(cookies(), { apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, serviceAccount: serverConfig.serviceAccount, });
如果用户未经身份验证,则解析为null
,或者解析为包含两个属性的对象:
token
是 idToken string
,您可以使用它来授权对外部后端服务的 API 请求。这有点超出范围,但值得一提的是,该库支持分布式服务架构。该token
与所有平台上的所有官方 Firebase 库兼容并可供使用。
顾名思义, decodedToken
是token
的解码版本,其中包含识别用户身份所需的所有信息,包括电子邮件地址、个人资料图片和自定义声明,这进一步使我们能够根据角色和权限限制访问。
获取tokens
后,我们使用next/navigation
中的notFound函数来确保该页面只能由经过身份验证的用户访问
if (!tokens) { notFound(); }
最后,我们呈现一些基本的、个性化的用户内容
<main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-xl mb-4">Super secure home page</h1> <p> Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom!" </p> </main>
让我们运行一下吧。
如果您关闭了开发服务器,只需运行npm run dev
。
当您尝试访问http://localhost:3000/时,您应该看到404: 找不到此页面。
成功!我们一直保守着我们的秘密,不让别人窥探!
firebase
并初始化 Firebase Client SDK在项目根目录运行npm install firebase
安装客户端SDK后,在项目根目录中创建firebase.ts
文件并粘贴以下内容
import { initializeApp } from 'firebase/app'; import { clientConfig } from './config'; export const app = initializeApp(clientConfig);
这将初始化 Firebase Client SDK 并公开客户端组件的应用程序对象
如果没有人可以查看,那么拥有超级安全的主页有什么意义呢?让我们构建一个简单的注册页面,让人们进入我们的应用程序。
让我们在./app/register/page.tsx
下创建一个新的精美页面
"use client"; import { FormEvent, useState } from "react"; import Link from "next/link"; import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"; import { app } from "../../firebase"; import { useRouter } from "next/navigation"; export default function Register() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [error, setError] = useState(""); const router = useRouter(); async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); if (password !== confirmation) { setError("Passwords don't match"); return; } try { await createUserWithEmailAndPassword(getAuth(app), email, password); router.push("/login"); } catch (e) { setError((e as Error).message); } } return ( <main className="flex min-h-screen flex-col items-center justify-center p-8"> <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700"> <div className="p-6 space-y-4 md:space-y-6 sm:p-8"> <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"> Pray tell, who be this gallant soul seeking entry to mine humble abode? </h1> <form onSubmit={handleSubmit} className="space-y-4 md:space-y-6" action="#" > <div> <label htmlFor="email" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" > Your email </label> <input type="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} id="email" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="[email protected]" required /> </div> <div> <label htmlFor="password" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" > Password </label> <input type="password" name="password" value={password} onChange={(e) => setPassword(e.target.value)} id="password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> </div> <div> <label htmlFor="confirm-password" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" > Confirm password </label> <input type="password" name="confirm-password" value={confirmation} onChange={(e) => setConfirmation(e.target.value)} id="confirm-password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> </div> {error && ( <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert" > <span className="block sm:inline">{error}</span> </div> )} <button type="submit" className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800" > Create an account </button> <p className="text-sm font-light text-gray-500 dark:text-gray-400"> Already have an account?{" "} <Link href="/login" className="font-medium text-gray-600 hover:underline dark:text-gray-500" > Login here </Link> </p> </form> </div> </div> </main> ); }
我知道。文字很多,但请耐心等待。
我们从"use client";
指示注册页面将使用客户端 API
const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [error, setError] = useState("");
然后,我们定义一些变量和设置器来保存表单状态
const router = useRouter(); async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); if (password !== confirmation) { setError("Passwords don't match"); return; } try { await createUserWithEmailAndPassword(getAuth(app), email, password); router.push("/login"); } catch (e) { setError((e as Error).message); } }
在这里,我们定义表单提交逻辑。首先,我们验证password
和confirmation
是否相同,否则我们更新错误状态。如果值有效,我们将使用firebase/auth
中的createUserWithEmailAndPassword
创建用户帐户。如果此步骤失败(例如,电子邮件被盗取),我们将通过更新错误来通知用户。
如果一切顺利,我们会将用户重定向到/login
页面。你现在可能很困惑,你这样想是对的。 /login
页面尚不存在。我们只是为接下来的事情做准备。
当您访问http://localhost:3000/register时,页面应大致如下所示:
现在,用户可以注册,让他们证明自己的身份
在./app/login/page.tsx
下创建登录页面
"use client"; import { FormEvent, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { getAuth, signInWithEmailAndPassword } from "firebase/auth"; import { app } from "../../firebase"; export default function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const router = useRouter(); async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); try { const credential = await signInWithEmailAndPassword( getAuth(app), email, password ); const idToken = await credential.user.getIdToken(); await fetch("/api/login", { headers: { Authorization: `Bearer ${idToken}`, }, }); router.push("/"); } catch (e) { setError((e as Error).message); } } return ( <main className="flex min-h-screen flex-col items-center justify-center p-8"> <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700"> <div className="p-6 space-y-4 md:space-y-6 sm:p-8"> <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"> Speak thy secret word! </h1> <form onSubmit={handleSubmit} className="space-y-4 md:space-y-6" action="#" > <div> <label htmlFor="email" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" > Your email </label> <input type="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} id="email" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="[email protected]" required /> </div> <div> <label htmlFor="password" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" > Password </label> <input type="password" name="password" value={password} onChange={(e) => setPassword(e.target.value)} id="password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> </div> {error && ( <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert" > <span className="block sm:inline">{error}</span> </div> )} <button type="submit" className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800" > Enter </button> <p className="text-sm font-light text-gray-500 dark:text-gray-400"> Don't have an account?{" "} <Link href="/register" className="font-medium text-gray-600 hover:underline dark:text-gray-500" > Register here </Link> </p> </form> </div> </div> </main> ); }
如您所见,它与注册页面非常相似。让我们关注关键的一点:
async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); try { const credential = await signInWithEmailAndPassword( getAuth(app), email, password ); const idToken = await credential.user.getIdToken(); await fetch("/api/login", { headers: { Authorization: `Bearer ${idToken}`, }, }); router.push("/"); } catch (e) { setError((e as Error).message); } }
这是所有魔法发生的地方。我们使用firebase/auth
中的signInEmailAndPassword
来检索用户的idToken
。
然后,我们调用中间件公开的/api/login
端点。该端点使用用户凭据更新我们的浏览器 cookie。
最后,我们通过调用router.push("/");
将用户重定向到主页。
登录页面应该大致如下所示
我们来测试一下吧!
访问http://localhost:3000/register ,输入一些随机电子邮件地址和密码来创建帐户。在http://localhost:3000/login页面中使用这些凭据。单击Enter后,您应该被重定向到超级安全主页
我们终于看到了我们自己的、个人的、超安全的主页!可是等等!我们怎么出去?
我们需要添加一个注销按钮,以免永远(或 12 天)将自己与世隔绝。
在开始之前,我们需要创建一个客户端组件,该组件能够使用 Firebase 客户端 SDK 注销我们。
让我们在./app/HomePage.tsx
下创建一个新文件
"use client"; import { useRouter } from "next/navigation"; import { getAuth, signOut } from "firebase/auth"; import { app } from "../firebase"; interface HomePageProps { email?: string; } export default function HomePage({ email }: HomePageProps) { const router = useRouter(); async function handleLogout() { await signOut(getAuth(app)); await fetch("/api/logout"); router.push("/login"); } return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-xl mb-4">Super secure home page</h1> <p className="mb-8"> Only <strong>{email}</strong> holds the magic key to this kingdom! </p> <button onClick={handleLogout} className="text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800" > Logout </button> </main> ); }
您可能已经注意到,这是我们的./app/page.tsx
的稍微修改版本。我们必须创建一个单独的客户端组件,因为getTokens
仅在服务器组件和API 路由处理程序内部工作,而signOut
和useRouter
需要在客户端上下文中运行。我知道有点复杂,但它实际上非常强大。我稍后会解释。
让我们重点关注注销过程
const router = useRouter(); async function handleLogout() { await signOut(getAuth(app)); await fetch("/api/logout"); router.push("/login"); }
首先,我们从 Firebase 客户端 SDK 注销。然后,我们调用中间件公开的/api/logout
端点。我们通过将用户重定向到/login
页面来完成。
让我们更新我们的服务器主页。转到./app/page.tsx
并粘贴以下内容
import { getTokens } from "next-firebase-auth-edge"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { clientConfig, serverConfig } from "../config"; import HomePage from "./HomePage"; export default async function Home() { const tokens = await getTokens(cookies(), { apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, serviceAccount: serverConfig.serviceAccount, }); if (!tokens) { notFound(); } return <HomePage email={tokens?.decodedToken.email} />; }
现在,我们的Home
服务器组件仅负责获取用户令牌并将其传递给HomePage
客户端组件。这实际上是非常常见且有用的模式。
让我们测试一下:
瞧!我们现在可以按照自己的意愿登录和注销应用程序。那很完美!
或者是吗?
当未经身份验证的用户尝试通过打开http://localhost:3000/进入主页时,我们会显示404:找不到此页面。
此外,经过身份验证的用户仍然可以访问http://localhost:3000/register和http://localhost:3000/login页面,而无需注销。
我们可以做得更好。
看来我们需要添加一些重定向逻辑。让我们定义一些规则:
/register
和/login
页面时,我们应该将它们重定向到/
/
页面时,我们应该将其重定向到/login
中间件是处理 Next.js 应用中重定向的最佳方法之一。幸运的是, authMiddleware
支持许多选项和辅助函数来处理各种重定向场景。
让我们打开middleware.ts
文件并粘贴此更新版本
import { NextRequest, NextResponse } from "next/server"; import { authMiddleware, redirectToHome, redirectToLogin } from "next-firebase-auth-edge"; import { clientConfig, serverConfig } from "./config"; const PUBLIC_PATHS = ['/register', '/login']; export async function middleware(request: NextRequest) { return authMiddleware(request, { loginPath: "/api/login", logoutPath: "/api/logout", apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, cookieSerializeOptions: serverConfig.cookieSerializeOptions, serviceAccount: serverConfig.serviceAccount, handleValidToken: async ({token, decodedToken}, headers) => { if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); } return NextResponse.next({ request: { headers } }); }, handleInvalidToken: async (reason) => { console.info('Missing or malformed credentials', {reason}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); }, handleError: async (error) => { console.error('Unhandled authentication error', {error}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); } }); } export const config = { matcher: [ "/", "/((?!_next|api|.*\\.).*)", "/api/login", "/api/logout", ], };
应该是这样。我们已经实施了所有重定向规则。让我们来分解一下。
const PUBLIC_PATHS = ['/register', '/login'];
handleValidToken: async ({token, decodedToken}, headers) => { if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); } return NextResponse.next({ request: { headers } }); },
当有效的用户凭据附加到请求时,即调用handleValidToken
。用户已通过身份验证。它以tokens
对象作为第一个参数和修改的请求标头作为第二个参数来调用。它应该通过NextResponse
解决。
next-firebase-auth-edge
中的redirectToHome
是一个辅助函数,它返回一个可以简化为NextResponse.redirect(new URL(“/“))
的对象
通过检查PUBLIC_PATHS.includes(request.nextUrl.pathname)
,我们验证经过身份验证的用户是否尝试访问/login
或/register
页面,如果是这种情况,则重定向到主页。
handleInvalidToken: async (reason) => { console.info('Missing or malformed credentials', {reason}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); },
当发生预期的事情时,将调用handleInvalidToken
。其中一个预期事件是用户第一次从其他设备或在凭据过期后看到您的应用程序。
知道handleInvalidToken
是为未经身份验证的用户调用的,我们可以继续执行第二条规则:当未经身份验证的用户尝试访问/
页面时,我们应该将它们重定向到/login
因为没有其他条件需要满足,所以我们只返回redirectToLogin
的结果,可以简化为NextResponse.redirect(new URL(“/login”))
。它还确保用户不会陷入重定向循环。
最后,
handleError: async (error) => { console.error('Unhandled authentication error', {error}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); }
与handleInvalidToken
相反,当发生意外*并且可能需要调查时,会调用handleError
。您可以在文档中找到可能的错误列表及其描述
如果出现错误,我们会记录此事实并安全地将用户重定向到登录页面
* Google 公钥更新后,可以调用handleError
并返回INVALID_ARGUMENT
错误。
这是密钥轮换的一种形式并且是预期的。 请参阅此 Github 问题以获取更多信息
现在,就是这样。最后。
让我们从 Web 应用程序中注销并打开http://localhost:3000/ 。我们应该被重定向到/login
页面。
我们再次登录,尝试输入http://localhost:3000/login 。我们应该被重定向到/
页面。
我们不仅提供无缝的用户体验。 next-firebase-auth-edge
是零捆绑包大小的库,仅在应用程序的服务器中工作,不会引入额外的客户端代码。最终的捆绑包确实是最小的。这就是我所说的完美。
我们的应用程序现已在服务器和客户端组件中与 Firebase 身份验证完全集成。我们已准备好释放 Next.js 的全部潜力!
该应用程序的源代码可以在next-firebase-auth-edge/examples/next-typescript-minimal中找到
在本指南中,我们介绍了新的 Next.js 应用与 Firebase 身份验证的集成。
尽管内容相当广泛,但本文省略了身份验证流程的一些重要部分,例如密码重置表单或电子邮件和密码以外的登录方法。
如果您对该库感兴趣,可以预览完整的next-firebase-auth-edge 入门演示页面。
它具有Firestore 集成、服务器操作、应用程序检查支持等
该库提供了一个包含大量示例的专用文档页面
如果您喜欢这篇文章,我将不胜感激为next-firebase-auth-edge存储库加星标。干杯! 🎉
本奖励指南将教您如何将 Next.js 应用程序部署到 Vercel
为了能够部署到 Vercel,您需要为新应用程序创建一个存储库。
前往https://github.com/并创建一个新的存储库。
create-next-app
已经为我们启动了一个本地 git 存储库,因此您只需进入项目的根文件夹并运行:
git add --all git commit -m "first commit" git branch -M main git remote add origin [email protected]:path-to-your-new-github-repository.git git push -u origin main
访问https://vercel.com/并使用您的 Github 帐户登录
登录后,转到 Vercel 的概述页面,然后单击“添加新项目”>“项目”
单击我们刚刚创建的 Github 存储库旁边的“导入” 。还没有部署。
在部署之前,我们需要提供项目配置。让我们添加一些环境变量:
请记住将USE_SECURE_COOKIES
设置为true
,因为 Vercel 默认使用 HTTPS
现在,我们准备好单击“部署”
等待一两分钟,您应该能够使用类似于以下的 URL 访问您的应用程序: https://next-typescript-minimal-xi.vercel.app/
完毕。我敢打赌你没想到事情会这么容易。
如果您喜欢该指南,我将不胜感激为next-firebase-auth-edge存储库加星标。
您也可以在评论中让我知道您的反馈。干杯! 🎉