跳转至

Authany 与 Firebase

如何使用第三方身份验证系统保护 Firestore 和其他 Firebase 服务

Firebase 是 Google 用于构建无服务器 Web 和移动应用的旗舰解决方案。Firebase 提供的服务包括 Firestore 数据库、Cloud Storage、Cloud Functions 和 Authentication 等。

您可以对 Firestore、Cloud Storage 等 Firebase 服务进行安全设置,使只有应用中已通过身份验证的用户才能对数据库的部分或全部内容进行读写。当您的应用允许用户生成内容,且需要控制谁可以添加或查看哪些内容时,这会很有用。

本指南将介绍如何使用 Authany 作为自定义身份验证系统来保护 Firestore 数据库。

为什么要将 Authany 与 Firebase 结合使用?

Authany 是一个完全专注于用户身份验证和 IAM 的云端解决方案。而 Firebase 则提供了 Cloud Storage、实时数据库 Firestore 等其他云服务,让开发者无需自行搭建后端就能构建全栈应用。因此,如果您的应用使用了 Firebase,也可以将 Authany 用作身份验证提供方,享受它提供的所有安全与登录功能。

什么是 Firebase Custom Tokens?

Custom Tokens 是 Firebase 的一项功能,允许您使用来自第三方身份验证系统的安全 JWT(JSON Web Tokens)对用户进行身份验证。

通常,要通过 Custom Tokens 对用户进行身份验证,您的身份验证系统(例如 Authany)会先使用您选择的方式(例如用户名和密码)对用户进行身份验证,然后向客户端应用返回一个有效的 JWT。

客户端应用会将该 JWT 传递给 Firebase,使用来自 Authany 的用户 sub 属性创建一个 Firebase 用户实例。

下面的流程图展示了使用 Firebase Custom Tokens 与 Authany 的完整流程。

Authany-Firebase 集成流程图

如何使用 Custom Tokens 将 Authany 与 Firebase 集成

本节将逐步介绍如何将 Authany JWT 与 Firebase Custom Tokens 配合使用。

说明

代码示例

本指南使用的示例应用基于 Authany React 示例仓库(点击这里克隆或下载代码)。获取代码后,您需要在 Authany 管理台中创建一个类型为 "Single Page Application" 的客户端应用。更多细节请参见该仓库的 README.md 文件。

此外,您也可以从另一个单独的 GitHub 仓库中查看/下载包含 Firebase 示例的完整代码,点击这里。

作为指南的一部分,我们将构建一个演示用的 Todo 应用。该应用具有以下功能:

  • 用户登录/注册
  • 添加待办事项
  • 删除待办事项
  • 只有已登录用户才能添加任务
  • 数据存储在云端,用户只能查看/删除自己创建的任务。

前提条件

要跟随本指南完成操作,您需要准备以下内容:

  • Authany 账号(可以在这里免费注册)
  • 一个 Firebase 项目
  • 电脑上已安装 Node.js

第 1 部分:配置 Firebase

在这一部分,您将登录 Firebase 控制台并配置项目。

步骤 1:创建 Cloud Function

首先,我们要创建一个 Cloud Function,用于验证来自 Authany 的访问令牌(JWT),并使用该 JWT 关联的 uid 创建 Firebase Custom Token。

注意: 这可能需要将您的 Firebase 项目升级到至少 Blaze 套餐。

  1. 要创建 Cloud Function,打开 https://console.firebase.google.com/,进入 Build > Functions,如果项目尚未启用 Cloud Function,请先启用它。
  2. 接下来,在您之前克隆的 Authany React 示例项目根目录下创建一个 cloud_functions 文件夹。
  3. 将工作目录切换到 cloud_functions 文件夹:
cd cloud_functions
  1. 运行以下命令安装 Firebase CLI:
npm install -g firebase-tools
  1. CLI 安装完成后,运行 firebase login 命令,并按照提示在 CLI 中登录您的 Firebase 项目。
  2. 运行以下命令,在您之前创建的 cloud_functions 文件夹中初始化 Cloud Function:
firebase init

请按照提示创建一个 v2 版本的 Cloud Function。此时您的 cloud_functions 文件夹中应该已经有一个 function/index.js 文件,这就是您编写云函数代码的地方。

步骤 2:为 Cloud Functions 启用令牌创建者权限

编辑与您项目关联的服务账号的 IAM 设置。具体操作是:打开 https://console.cloud.google.com/iam-admin/iam,为 {randomNumbers}-compute@developer.gserviceaccount.com 添加 Service Account Token Creator 角色。

步骤 3:实现 Cloud Function

在这一步,我们将实现一个执行以下操作的云函数:

  • 通过一个端点接受来自客户端应用的 HTTPS 请求。
  • 从 HTTPS 请求的 authorization 请求头中读取 Authany JWT 的值。
  • 验证该 JWT 是否有效(未过期)。
  • 如果 JWT 有效,该函数会从中读取 uid 属性,并使用该值调用 firebaseAdmin.auth().createCustomToken(uid),生成一个新的 Firebase Custom Token,供客户端应用登录 Firebase 使用。

要实现上述函数,请打开 cloud_functions 文件夹中的 function/index.js,并将其内容替换为以下代码:

const { onRequest } = require("firebase-functions/v2/https");
const jwt = require("jsonwebtoken");
const jwks = require("jwks-rsa");
const firebaseAdmin = require("firebase-admin");
const authgearEndpoint = ""; //place your authgear app endpoint here

// Get JWKs URI from Open ID Configuration documents
const getJwksUri = async (authgearEndpoint) => {
  const config_endpoint =
    authgearEndpoint + "/.well-known/openid-configuration";
  const response = await fetch(config_endpoint);
  const data = await response.json();
  return data.jwks_uri;
};

// Options for JWT verification
const options = {
  algorithms: ["RS256"],
  issuer: authgearEndpoint,
};

const checkReq = (req) => {
  const headerErr = ["must specify an Authorization header", null];
  const formatErr = ["format is 'Authorization: Bearer <token>'", null];
  if (!req) return "server error (request was invalid)";
  const { headers } = req;
  if (!headers) return headerErr; // missing header
  const { authorization } = headers;
  if (!authorization) return headerErr; // missing Authorization Header
  const parts = authorization.split(" ");
  if (parts.length != 2) return formatErr; // Authorization header format invalid
  const [scheme, credentials] = parts;
  if (!/^Bearer$/i.test(scheme)) return formatErr; // Authorization header is not Bearer
  return [false, credentials];
};

firebaseAdmin.initializeApp();

exports.getFirebaseToken = onRequest(async (req, res) => {
  res.set("Access-Control-Allow-Origin", "*");
  if (req.method === "OPTIONS") {
    res.set("Access-Control-Allow-Methods", "GET");
    res.set("Access-Control-Allow-Headers", "Authorization");
    res.set("Access-Control-Max-Age", "3600");
    return res.status(204).send("");
  }

  const [message, token] = checkReq(req);
  if (message) {
    return res.status(500).send({ message }); // return error message if checkReq failed
  } else {
    const decodedAccessToken = jwt.decode(token, { complete: true });
    // get signing key from JWKs with the "kid" in decoded access token
    jwksUri = await getJwksUri(authgearEndpoint);
    const client = jwks({
      rateLimit: true,
      strictSsl: true,
      jwksUri: jwksUri,
    });
    const signingKey = await client.getSigningKey(
      decodedAccessToken.header.kid
    );
    try {
      const decoded = jwt.verify(token, signingKey.publicKey, options); // verify jwt
      const uid = decoded.sub; // if the jwt is verified, use "sub" in JWT as uid
      const firebaseToken = await firebaseAdmin.auth().createCustomToken(uid); // create Firebase custom token with uid
      return res.json({ firebaseToken }); // return Firebase custom token
    } catch (err) {
      return res.status(500).send(err);
    }
  }
});

将您的 Authany 端点填入 const authgearEndpoint = "";。

运行以下命令安装依赖:

npm i jwks-rsa
npm i jsonwebtoken

最后,运行 firebase deploy 部署您的新函数。

部署成功后,Firebase CLI 会在终端中输出该函数的公共端点。请复制该端点,之后会在 React 应用中用到它。

Firebase 函数 URL

第 2 部分:实现 React 应用

在这一部分,我们将实现使用 Authany 和 Firebase 的客户端应用。

正如本文前提条件中提到的,您可以从我们的 React 示例应用仓库获取该应用的起始代码。

步骤 4:将 Firebase 添加到 React 应用

  1. 打开 Firebase 控制台并选择您的项目,然后点击 Web 作为目标平台。
  2. 输入应用名称(例如 My todo app),然后点击 Register app。
  3. 在 Add Firebase SDK 部分,选择 Use npm,然后在 Authany React 示例仓库的根目录下运行 npm install firebase(请确认您不是在 cloud_functions 文件夹中运行该命令)。
  4. 在 React 项目的根目录下创建一个新的 firebase.js 文件。
  5. 复制包含您 Firebase 配置的代码,粘贴到 firebase.js 文件中。

Firebase 应用配置

  1. 更新 firebase.js,暴露您将使用的 Firebase 服务:
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from "firebase/firestore";

// Your web app's Firebase configuration
const firebaseConfig = {
    apiKey: "",
    authDomain: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);

步骤 5:创建 Todo 组件

这里我们将在 React 应用中实现待办事项功能。Todo 组件将包含实现该功能所需的全部代码。

该组件将执行以下操作:

  • 使用 Authany SDK 从 Authany 获取当前用户的访问令牌。
  • 使用 Authany SDK 的 fetch() 方法向您的 Firebase 函数端点发起 HTTPS 请求。fetch() 方法会在 HTTPS 请求的 authorization 请求头中带上用户的访问令牌。
  • 使用云函数返回的 custom token 登录 Firebase。
  • 向 Firestore 数据库添加一个新的待办事项
  • 显示待办事项列表
  • 删除一个待办事项

在 React 项目的 src 文件夹中创建一个 Todo.tsx 文件,然后将以下代码添加到该文件中:

import React, { useEffect, useState, useContext } from "react";
import authgear from "@authgear/web";
import { UserContext } from "./context/UserProvider.js";

import { signInWithCustomToken, UserCredential } from "firebase/auth";
import { collection, getDocs, addDoc, deleteDoc, doc } from "firebase/firestore";
import { auth, db } from "../firebase.js";

interface Todo {
  id: string;
  text: string;
  userId: string;
}

const Todo: React.FC = () => {
  const { isLoggedIn } = useContext(UserContext);
  const [firebaseUser, setFirebaseUser] = useState<object>({});
  const [authError, setAuthError] = useState<boolean>(false);

  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTodo, setNewTodo] = useState("");

  useEffect(() => {
    async function getFirebaseUser() {
      if (isLoggedIn) {
        const firebaseFunctionEndpoint = ""; // place your cloud function endpoint here.
        try {
          const getCustomToken = await authgear
            .fetch(firebaseFunctionEndpoint)
            .then((response) => response.json());
          if (getCustomToken.firebaseToken !== null) {
            const firebaseUserObj = await signInWithCustomToken(
              auth,
              getCustomToken.firebaseToken
            );
            setFirebaseUser(firebaseUserObj);
            setAuthError(false);
          }
        } catch (e) {
          console.log(e);
          setAuthError(true);
        }
      } else {
        setAuthError(true);
      }
    }

    getFirebaseUser()
      .then(() => {
        fetchTodos();
      })
      .catch((e) => {
        console.error(e);
      });
  }, [authgear]);

  async function fetchTodos() {
    try {
      const querySnapshot = await getDocs(collection(db, "todos"));
      const todoList = querySnapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
      })) as Todo[];
      setTodos(todoList);
    } catch (error) {
      console.error("Error fetching todos:", error);
    }
  }

  async function addTodo(e: React.FormEvent) {
    e.preventDefault();
    if (!newTodo.trim() || authError) return;

    try {
      await addDoc(collection(db, "todos"), {
        text: newTodo,
        userId: (firebaseUser as UserCredential).user.uid,
        createdAt: new Date(),
      });
      setNewTodo("");
      fetchTodos();
    } catch (error) {
      console.error("Error adding todo:", error);
    }
  }

  async function deleteTodo(id: string) {
    try {
      await deleteDoc(doc(db, 'todos', id));
      fetchTodos();
    } catch (error) {
      console.error('Error deleting todo:', error);
    }
  }

  if (!authError) {
    return (
      <div className="app">
        <div className="todo-container">
          <form onSubmit={addTodo}>
            <input
              type="text"
              value={newTodo}
              onChange={(e) => setNewTodo(e.target.value)}
              placeholder="Add a new todo"
            />
            <button type="submit">Add Todo</button>
          </form>

          <ul>
            {todos.map((todo) => (
              <li key={todo.id}>
                {todo.text}
                <button onClick={() => deleteTodo(todo.id)}>Delete</button>
              </li>
            ))}
          </ul>
        </div>
      </div>
    );
  } else {
    return (
      <div>
        <p>User not logged in</p>
      </div>
    );
  }
};

export default Todo;

将您的 Cloud Function 端点填入 const firebaseFunctionEndpoint = "";

步骤 6:为 Todo 组件创建路由

打开 App.tsx,在 <Routes> 中添加以下路由:

<Route path="/todos" element={<Todo />} />

请确保在 App.tsx 中导入 Todo。

import Todo from "./Todo";

最后,在 Home.tsx 中 User Settings 链接之后,添加一个路由链接:

<Link to="/todos">My Todos</Link>

您可以点击 My Todos 链接打开 Todo 页面。

步骤 7:更新 Firestore 安全规则

返回 Firebase 控制台中的项目,进入 Build > Firestore Database。如果项目尚未启用 Firestore,请先启用它。

打开 Rules 标签页,将安全规则修改为以下内容:

service cloud.firestore {
  match /databases/{database}/documents {

    match /todos/{userId} {
      allow read: if request.auth.uid != null;
      allow write: if request.auth.uid != null;
    }

  }
}

上述规则会将 /todos/{userID} 中数据的读写权限限制为仅已通过身份验证的用户。点击 Publish 激活新规则。

步骤 8:运行 React 应用

按照 Authany React 示例应用 README.md 文件中的说明(点击这里)配置应用以对接您的 Authany 项目,然后运行该应用。

此时您应该能够使用 Authany 完成身份验证,并向待办事项列表中添加内容。

演示应用界面