快速入门: Firebase 认证

快速入门与 Firebase 身份验证

由于这涉及较多内容,我们撰写了一篇博客文章 (opens in a new tab),详细介绍了实现 Firebase 身份验证所需的每一步。

以下是快速入门的概述:

概述

对于 Web 开发者来说,浏览器扩展的身份验证可能需要一些调整,但其难度并不比直接在 React 或 Next.js 中使用 Firebase 高多少。

我们的目标是将令牌存储在 Cookie 中,以便它们可以被后台服务工作线程使用。

设置

首先,我们需要安装一些依赖项。在 Plasmo 中使用 Firebase 和操作 Cookie 需要三个包。

npm install firebase @plasmohq/messaging @plasmohq/storage

yarn add firebase @plasmohq/messaging @plasmohq/storage

pnpm install firebase @plasmohq/messaging @plasmohq/storage

初始化 Firebase

首先,我们可以创建一个 Firebase 客户端应用的单例实例。

// src/firebase/firebaseClient.ts
 
import { getApps, initializeApp } from "firebase/app"
import { GoogleAuthProvider, getAuth } from "firebase/auth"
import { getFirestore } from "firebase/firestore"
import { getStorage } from "firebase/storage"
 
const clientCredentials = {
  apiKey: process.env.PLASMO_PUBLIC_FIREBASE_PUBLIC_API_KEY,
  authDomain: process.env.PLASMO_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.PLASMO_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.PLASMO_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.PLASMO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.PLASMO_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.PLASMO_PUBLIC_FIREBASE_MEASUREMENT_ID
}
 
let firebase_app
 
// 检查 Firebase 应用是否已初始化,以避免热重载时创建新应用
if (!getApps().length) {
  firebase_app = initializeApp(clientCredentials)
} else {
  firebase_app = getApps()[0]
}
 
export const storage = getStorage(firebase_app)
export const auth = getAuth(firebase_app)
export const db = getFirestore(firebase_app)
export const googleAuth = new GoogleAuthProvider()
 
export default firebase_app
👀

注意: 您需要在 .env 文件中填充您的 Firebase 配置,可以在 Firebase 控制台 (opens in a new tab) 中找到。

PLASMO_PUBLIC_FIREBASE_CLIENT_ID=""
PLASMO_PUBLIC_FIREBASE_PUBLIC_API_KEY=""
PLASMO_PUBLIC_FIREBASE_AUTH_DOMAIN=""
PLASMO_PUBLIC_FIREBASE_PROJECT_ID=""
PLASMO_PUBLIC_FIREBASE_STORAGE_BUCKET=""
PLASMO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=""
PLASMO_PUBLIC_FIREBASE_APP_ID=""
PLASMO_PUBLIC_FIREBASE_MEASUREMENT_ID=""

处理令牌

现在,我们可以创建一个可复用的 Hook,提供登录和登出方法,以及在标签页/弹出窗口/选项页面中轻松访问用户信息的方式。

// src/firebase/useFirebaseUser.tsx
 
import {
  type User,
  browserLocalPersistence,
  onAuthStateChanged,
  setPersistence
} from "firebase/auth"
import { useEffect, useState } from "react"
 
import { sendToBackground } from "@plasmohq/messaging"
 
import { auth } from "./firebaseClient"
 
setPersistence(auth, browserLocalPersistence)
 
export default function useFirebaseUser() {
  const [isLoading, setIsLoading] = useState(false)
  const [user, setUser] = useState<User>(null)
 
  const onLogout = async () => {
    setIsLoading(true)
    if (user) {
      await auth.signOut()
 
      await sendToBackground({
        name: "removeAuth",
        body: {}
      })
    }
  }
 
  const onLogin = () => {
    if (!user) return
 
    const uid = user.uid
 
    // 获取当前用户的认证令牌
    user.getIdToken(true).then(async (token) => {
      // 将令牌发送到后台保存
      await sendToBackground({
        name: "saveAuth",
        body: {
          token,
          uid,
          refreshToken: user.refreshToken
        }
      })
    })
  }
 
  useEffect(() => {
    onAuthStateChanged(auth, (user) => {
      setIsLoading(false)
      setUser(user)
    })
  }, [])
 
  useEffect(() => {
    if (user) {
      onLogin()
    }
  }, [user])
 
  return {
    isLoading,
    user,
    onLogin,
    onLogout
  }
}

您会注意到,在上述代码片段中,我们调用了两个后台进程:removeAuthsaveAuth。现在让我们创建这些进程。

// background/messages/saveAuth.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Storage } from "@plasmohq/storage"
 
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  try {
    const { token, uid, refreshToken } = req.body
    const storage = new Storage()
 
    await storage.set("firebaseToken", token)
    await storage.set("firebaseUid", uid)
    await storage.set("firebaseRefreshToken", refreshToken)
 
    res.send({
      status: "success"
    })
  } catch (err) {
    console.log("出现错误")
    console.error(err)
    res.send({ err })
  }
}
 
export default handler
// background/messages/removeAuth.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Storage } from "@plasmohq/storage"
 
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  try {
    const storage = new Storage()
 
    await storage.set("firebaseToken", null)
    await storage.set("firebaseUid", null)
 
    res.send({
      status: "success"
    })
  } catch (err) {
    console.log("出现错误")
    console.error(err)
    res.send({ err })
  }
}
 
export default handler

示例用法:后台 SW

很好,现在您已经将刷新令牌、ID 令牌和 UID 存储在 Cookie 中。您可以在其他后台工作者中像这样使用它们。

// 示例后台 SW 从当前登录用户获取用户数据 /users/{uid}
 
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Storage } from "@plasmohq/storage"
 
import refreshFirebaseToken from "~util/refreshFirebaseToken"
 
const fetchUserData = async (
  token,
  uid,
  refreshToken,
  storage,
  retry = true
) => {
  const response = await fetch(
    "https://firestore.googleapis.com/v1/projects/<firebase_project_id>/databases/(default)/documents/users/" +
      uid,
    {
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json"
      }
    }
  )
 
  const responseData = await response.json()
 
  if (responseData?.error?.code === 401 && retry) {
    const refreshData = await refreshFirebaseToken(refreshToken)
    await storage.set("firebaseToken", refreshData.id_token)
    await storage.set("firebaseRefreshToken", refreshData.refresh_token)
    await storage.set("firebaseUid", refreshData.user_id)
 
    // 刷新令牌后重试请求
    return fetchUserData(
      refreshData.id_token,
      uid,
      refreshData.refresh_token,
      storage,
      false
    )
  }
 
  return responseData
}
 
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  try {
    const storage = new Storage()
 
    const token = await storage.get("firebaseToken")
    const uid = await storage.get("firebaseUid")
    const refreshToken = await storage.get("firebaseRefreshToken")
 
    const userData = await fetchUserData(token, uid, refreshToken, storage)
 
    res.send({
      status: "success",
      data: userData
    })
  } catch (err) {
    console.log("出现错误")
    console.error(err)
    res.send({ err })
  }
}
 
export default handler

在上面的示例中,如果响应失败,我们会刷新令牌并重试。以下是用于刷新 Firebase 令牌的工具方法。

type RefreshTokenResponse = {
  expires_in: string
  token_type: string
  refresh_token: string
  id_token: string
  user_id: string
  project_id: string
}
 
export default async function refreshFirebaseToken(
  refreshToken: string
): Promise<RefreshTokenResponse> {
  const response = await fetch(
    "https://securetoken.googleapis.com/v1/token?key=<firebase_api_key>",
    {
      method: "POST",
      body: JSON.stringify({
        grant_type: "refresh_token",
        refresh_token: refreshToken
      })
    }
  )
 
  const {
    expires_in,
    token_type,
    refresh_token,
    id_token,
    user_id,
    project_id
  } = await response.json()
 
  return {
    expires_in,
    token_type,
    refresh_token,
    id_token,
    user_id,
    project_id
  }
}

示例用法:React Hook

您还可以在 React 组件中使用此功能(无论是 CSUI 还是标签页/扩展页面)。

以下是一个使用 TailwindCSS 的 options.tsx 登录页面设置示例。

// src/options.tsx
import AuthForm from "~components/AuthForm"
 
import "./style.css"
 
import useFirebaseUser from "~firebase/useFirebaseUser"
 
export default function Options() {
  const { user, isLoading, onLogin } = useFirebaseUser()
 
  return (
    <div className="min-h-screen bg-black p-4 md:p-10">
      <div className="text-white flex flex-col space-y-10 items-center justify-center">
        {!user && <AuthForm />}
        {user && <div>您已登录!哇</div>}
      </div>
    </div>
  )
}
// src/components/AuthForm.tsx
 
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword
} from "firebase/auth"
import React, { useState } from "react"
 
import { auth } from "~firebase/firebaseClient"
import useFirebaseUser from "~firebase/useFirebaseUser"
 
export default function AuthForm() {
  const [showLogin, setShowLogin] = useState(true)
  const [email, setEmail] = useState("")
  const [confirmPassword, setConfirmPassword] = useState("")
  const [password, setPassword] = useState("")
  const { isLoading, onLogin } = useFirebaseUser()
 
  const signIn = async (e: any) => {
    if (!email || !password)
      return console.log("Please enter email and password")
 
    e.preventDefault()
    try {
      await signInWithEmailAndPassword(auth, email, password)
    } catch (error: any) {
      console.log(error.message)
    } finally {
      setEmail("")
      setPassword("")
      setConfirmPassword("")
      onLogin()
    }
  }
 
  const signUp = async (e: any) => {
    try {
      if (!email || !password || !confirmPassword)
        return console.log("Please enter email and password")
 
      if (password !== confirmPassword)
        return console.log("Passwords do not match")
 
      e.preventDefault()
 
      const user = await createUserWithEmailAndPassword(auth, email, password)
      onLogin()
    } catch (error: any) {
      console.log(error.message)
    } finally {
      setEmail("")
      setPassword("")
      setConfirmPassword("")
    }
  }
 
  return (
    <div className="flex items-center justify-center w-full p-4 overflow-x-hidden rounded-xl overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
      <div className="w-full max-w-2xl max-h-full">
        <div className="p-10 bg-white rounded-lg shadow">
          <div className="flex flex-row items-center justify-center">
            {!isLoading && (
              <span className="text-black font-bold text-3xl text-center">
                {showLogin ? "Login" : "Sign Up"}
              </span>
            )}
            {isLoading && (
              <span className="text-black font-bold text-3xl text-center">
                Loading...
              </span>
            )}
          </div>
 
          <div className="p-4 rounded-xl bg-white text-black">
            {!showLogin && !isLoading && (
              <form className="space-y-4 md:space-y-6" onSubmit={signUp}>
                <div>
                  <label
                    htmlFor="email"
                    className="block mb-2 text-sm font-medium text-gray-900">
                    Your email
                  </label>
                  <input
                    type="email"
                    name="email"
                    id="email"
                    onChange={(e) => setEmail(e.target.value)}
                    value={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"
                    placeholder="name@company.com"
                    required
                  />
                </div>
                <div>
                  <label
                    htmlFor="password"
                    className="block mb-2 text-sm font-medium text-gray-900">
                    Password
                  </label>
                  <input
                    type="password"
                    name="password"
                    id="password"
                    onChange={(e) => setPassword(e.target.value)}
                    value={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"
                    required
                  />
                </div>
                <div>
                  <label
                    htmlFor="confirm-password"
                    className="block mb-2 text-sm font-medium text-gray-900">
                    Confirm password
                  </label>
                  <input
                    type="password"
                    name="confirm-password"
                    id="confirm-password"
                    onChange={(e) => setConfirmPassword(e.target.value)}
                    value={confirmPassword}
                    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"
                    required
                  />
                </div>
                <div className="flex items-start">
                  <div className="flex items-center h-5">
                    <input
                      id="terms"
                      aria-describedby="terms"
                      type="checkbox"
                      className="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300"
                      required
                    />
                  </div>
                  <div className="ml-3 text-sm">
                    <label htmlFor="terms" className="font-light text-gray-500">
                      I accept the{" "}
                      <a
                        className="font-medium text-primary-600 hover:underline"
                        href="#">
                        Terms and Conditions
                      </a>
                    </label>
                  </div>
                </div>
                <button
                  type="submit"
                  className="w-full text-black bg-gray-300 hover:bg-primary-dark focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center">
                  Create an account
                </button>
                <p className="text-sm font-light text-gray-500">
                  Already have an account?{" "}
                  <button
                    onClick={() => setShowLogin(true)}
                    className="bg-transparent font-medium text-primary-600 hover:underline">
                    Login here
                  </button>
                </p>
              </form>
            )}
            {showLogin && !isLoading && (
              <form className="space-y-4 md:space-y-6" onSubmit={signIn}>
                <div>
                  <label
                    htmlFor="email"
                    className="block mb-2 text-sm font-medium text-gray-900">
                    Your email
                  </label>
                  <input
                    type="email"
                    name="email"
                    id="email"
                    onChange={(e) => setEmail(e.target.value)}
                    value={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"
                    placeholder="name@company.com"
                    required
                  />
                </div>
                <div>
                  <label
                    htmlFor="password"
                    className="block mb-2 text-sm font-medium text-gray-900">
                    Password
                  </label>
                  <input
                    type="password"
                    name="password"
                    id="password"
                    onChange={(e) => setPassword(e.target.value)}
                    value={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"
                    required
                  />
                </div>
                <div className="flex items-center justify-between">
                  <div className="flex items-start">
                    <div className="flex items-center h-5">
                      <input
                        id="remember"
                        aria-describedby="remember"
                        type="checkbox"
                        className="w-4 h-4 border border-gray-300 rounded accent-primary bg-gray-50 focus:ring-3 focus:ring-primary"
                      />
                    </div>
                    <div className="ml-3 text-sm">
                      <label htmlFor="remember" className="text-gray-500 ">
                        Remember me
                      </label>
                    </div>
                  </div>
                  <a
                    href="#"
                    className="text-sm font-medium text-primary-600 hover:underline">
                    Forgot password?
                  </a>
                </div>
                <button
                  type="submit"
                  className="w-full text-black bg-gray-300 hover:bg-primary-dark focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center">
                  Sign in
                </button>
                <p className="text-sm font-light text-gray-500">
                  Don’t have an account yet?{" "}
                  <button
                    onClick={() => setShowLogin(false)}
                    className="bg-transparent font-medium text-primary-600 hover:underline">
                    Sign up
                  </button>
                </p>
              </form>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

注销方法可能如下所示

const logout = async (e: any) => {
    e.preventDefault()
    try {
      await onLogout()
    } catch (error: any) {
      console.log(error.message)
    }
  }