快速入门与 Firebase 身份验证
由于这涉及较多内容,我们撰写了一篇博客文章 (opens in a new tab),详细介绍了实现 Firebase 身份验证所需的每一步。
以下是快速入门的概述:
概述
对于 Web 开发者来说,浏览器扩展的身份验证可能需要一些调整,但其难度并不比直接在 React 或 Next.js 中使用 Firebase 高多少。
我们的目标是将令牌存储在 Cookie 中,以便它们可以被后台服务工作线程使用。
设置
首先,我们需要安装一些依赖项。在 Plasmo 中使用 Firebase 和操作 Cookie 需要三个包。
- firebase (客户端 Firebase SDK)
- @plasmohq/messaging 查看消息传递 (opens in a new tab)
- @plasmohq/storage 查看存储 (opens in a new tab)
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
}
}
您会注意到,在上述代码片段中,我们调用了两个后台进程:removeAuth
和 saveAuth
。现在让我们创建这些进程。
// 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)
}
}