使用授权和证明实现安全的数据连接

Firebase Data Connect 提供强大的客户端安全性,具有以下特点:

  • 移动和 Web 客户端授权
  • 个别查询和更改级授权控制
  • 使用 Firebase App Check 进行应用认证。

Data Connect 通过以下方式扩展了此安全功能:

  • 服务器端授权
  • 使用 IAM 实现 Firebase 项目和 Cloud SQL 用户安全。

授权客户端查询和变更

Data ConnectFirebase Authentication 完全集成,因此您可以在设计中使用有关访问您数据的用户的丰富数据(身份验证),以确定这些用户可以访问哪些数据(授权)。

Data Connect 为查询和更改提供了 @auth 指令,可让您设置授权操作所需的身份验证级别。本指南介绍了 @auth 指令并提供了示例

此外,Data Connect 支持执行嵌入在更改中的查询,因此您可以检索存储在数据库中的其他授权条件,并在 @check 指令中使用这些条件来确定封闭更改是否已获授权。对于这种授权情形,@redact 指令可让您控制是否将查询结果返回给线协议中的客户端,以及是否在生成的 SDK 中省略嵌入式查询。查看这些指令的简介(包含示例)

了解 @auth 指令

您可以对 @auth 指令进行参数化,以遵循多种预设访问权限级别之一,这些级别涵盖许多常见的访问场景。这些级别从 PUBLIC(允许所有客户端在不进行任何身份验证的情况下执行查询和更改)到 NO_ACCESS(禁止在使用 Firebase Admin SDK 的特权服务器环境之外执行查询和更改)不等。这些级别各与 Firebase Authentication 提供的身份验证流程相关联。

级别 定义
PUBLIC 任何人(无论是否经过身份验证)都可以执行此操作。
PUBLIC 任何人(无论是否经过身份验证)都可以执行此操作。
USER_ANON 任何已识别身份的用户(包括使用 Firebase Authentication 匿名登录的用户)都有权执行查询或更改。
USER 除匿名登录用户外,使用 Firebase Authentication 登录的任何用户都有权执行查询或更改。
USER_EMAIL_VERIFIED 使用已验证电子邮件地址通过 Firebase Authentication 登录的任何用户都有权执行查询或更改。
NO_ACCESS 此操作无法在 Admin SDK 上下文之外执行。

以这些预设的访问权限级别为起点,您可以在 @auth 指令中使用 where 过滤器和在服务器上评估的通用表达式语言 (CEL) 表达式定义复杂且稳健的授权检查。

使用 @auth 指令实现常见的授权场景

预设的访问权限级别是授权的起点

USER 访问权限级别是最常用的起始级别。

完全安全的访问权限将基于 USER 级别,以及用于检查用户属性、资源属性、角色和其他检查的过滤器和表达式。USER_ANONUSER_EMAIL_VERIFIED 级别是 USER 情形的变体。

借助表达式语法,您可以使用表示通过操作传递的身份验证数据的 auth 对象来评估数据,包括身份验证令牌中的标准数据和令牌中的自定义数据。如需查看 auth 对象中可用字段的列表,请参阅参考部分

当然,在某些用例中,PUBLIC 是开始时正确的访问权限级别。再次强调,访问权限级别始终是起点,需要额外的过滤条件和表达式才能实现强大的安全保障。

本指南现在提供了有关如何在 USERPUBLIC 上构建的示例。

一个激励性的示例

以下最佳实践示例参考了博客平台的以下架构,其中某些内容需要付费才能访问。

这样的平台可能会对 UsersPosts 进行建模。

type User @table(key: "uid") {
  uid: String!
  name: String
  birthday: Date
  createdAt: Timestamp! @default(expr: "request.time")
}

type Post @table {
  author: User!
  text: String!
  # "one of 'draft', 'public', or 'pro'"
  visibility: String! @default(value: "draft")
  # "the time at which the post should be considered published. defaults to
  # immediately"
  publishedAt: Timestamp! @default(expr: "request.time")
  createdAt: Timestamp! @default(expr: "request.time")
  updatedAt: Timestamp! @default(expr: "request.time")
}

归用户所有的资源

Firebase 建议您编写用于测试用户对资源的所有权的过滤条件和表达式,在以下情况下,即测试对 Posts 的所有权。

在以下示例中,系统会使用表达式读取和比较来自身份验证令牌的数据。典型模式是使用 where: {authorUid: {eq_expr: "auth.uid"}} 等表达式将存储的 authorUid 与身份验证令牌中传递的 auth.uid(用户 ID)进行比较。

创建

此授权实践首先将授权令牌中的 auth.uid 添加到每个新 Post 中作为 authorUid 字段,以便在后续授权测试中进行比较。

# Create a new post as the current user
mutation CreatePost($text: String!, $visibility: String) @auth(level: USER) {
  post_insert(data: {
    # set the author's uid to the current user uid
    authorUid_expr: "auth.uid"
    text: $text
    visibility: $visibility
  })
}
更新

当客户端尝试更新 Post 时,您可以针对存储的 authorUid 测试传递的 auth.uid

# Update one of the current user's posts
mutation UpdatePost($id: UUID!, $text: String, $visibility: String) @auth(level:USER) {
  post_update(
    # only update posts whose author is the current user
    first: { where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}
    }}
    data: {
      text: $text
      visibility: $visibility
      # insert the current server time for updatedAt
      updatedAt_expr: "request.time"
    }
  )
}
删除

同样的方法也用于授权删除操作。

# Delete one of the current user's posts
mutation DeletePost($id: UUID!) @auth(level: USER) {
  post_delete(
    # only delete posts whose author is the current user
    first: { where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}
    }}
  )
}
# Common display information for a post
fragment DisplayPost on Post {
  id, text, createdAt, updatedAt
  author { uid, name }
}
列表
# List all posts belonging to the current user
query ListMyPosts @auth(level: USER) {
  posts(where: {
    userUid: {eq_expr: "auth.uid"}
  }) {
    # See the fragment above
    ...DisplayPost
    # also show visibility since it is user-controlled
    visibility
  }
}
获取
# Get a post only if it belongs to the current user
query GetMyPost($id: UUID!) @auth(level: USER) {
  post(key: {id: $id},
    first: {where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}}
      }}, {
      # See the fragment above
      ...DisplayPost
      # also show visibility since it is user-controlled
      visibility
  }
}

过滤数据

借助 Data Connect 的授权系统,您可以编写复杂的过滤条件,并将其与 PUBLIC 等预设访问权限级别以及身份验证令牌中的数据结合使用。

授权系统还允许您仅使用表达式,而无需使用基本访问权限级别,如以下部分示例所示。

按资源属性过滤

在这里,授权不是基于身份验证令牌,因为基本安全级别设置为 PUBLIC。不过,我们可以明确将数据库中的记录设置为适合公开访问;假设我们的数据库中有 Post 条记录,并且 visibility 已设置为“public”。

# List all posts marked as 'public' visibility
query ListPublicPosts @auth(level: PUBLIC) {
  posts(where: {
    # Test that visibility is "public"
    visibility: {eq: "public"}
    # Only display articles that are already published
    publishedAt: {lt_expr: "request.time"}
  }) {
    # see the fragment above
    ...DisplayPost
  }
}
按用户声明过滤

在此示例中,假设您设置了自定义用户声明,用于传入身份验证令牌,以便识别应用的“专业版”方案中的用户(在身份验证令牌中标记了 auth.token.plan 字段)。您的表达式可以针对此字段进行测试。

# List all public or pro posts, only permitted if user has "pro" plan claim
query ProListPosts @auth(expr: "auth.token.plan == 'pro'") {
  posts(where: {
    # display both public posts and "pro" posts
    visibility: {in: ['public', 'pro']},
    # only display articles that are already published
    publishedAt: {lt_expr: "request.time"},
  }) {
    # see the fragment above
    ...DisplayPost
    # show visibility so pro users can see which posts are pro\
    visibility
  }
}
按订单 + 限制过滤

或者,您也可以在 Post 记录中设置 visibility,以标识这些记录是面向“专业”用户提供的内容,但对于数据的预览或预告片列表,进一步限制返回的记录数量。

# Show 2 oldest Pro post as a preview
query ProTeaser @auth(level: USER) {
  posts(
    where: {
      # show only pro posts
      visibility: {eq: "pro"}
      # that have already been published more than 30 days ago
      publishedAt: {lt_time: {now: true, sub: {days: 30}}}
    },
    # order by publish time
    orderBy: [{publishedAt: DESC}],
    # only return two posts
    limit: 2
  ) {
    # See the fragment above
    ...DisplayPost
  }
}
按角色过滤

如果您的自定义声明定义了 admin 角色,您可以相应地测试和授权操作。

# List all posts unconditionally iff the current user has an admin claim
query AdminListPosts @auth(expr: "auth.token.admin == true") {
  posts { ...DisplayPost }
}

了解 @check@redact 指令

@check 指令用于验证查询结果中是否存在指定字段。通用表达式语言 (CEL) 表达式用于测试字段值。该指令的默认行为是检查并拒绝 null 值的节点。

@redact 指令会隐去来自客户端的部分响应。系统仍会对隐去的数据进行评估,以确定是否会产生副作用(包括数据更改和 @check),并且结果仍可供 CEL 表达式中的后续步骤使用。

Data Connect 中,@check@redact 指令最常用于授权检查上下文;请参阅授权数据查询的讨论

添加 @check@redact 指令以查询授权数据

常见的授权用例包括在数据库(例如特殊权限表)中存储自定义授权角色,并使用这些角色授权变更以创建、更新或删除数据。

使用授权数据查询功能,您可以根据用户 ID 查询角色,并使用 CEL 表达式来确定是否有权进行更改。例如,您可能需要编写一个 UpdateMovieTitle 更改,以允许已获授权的客户端更新电影名。

在本讨论的其余部分中,假设电影评论应用数据库在 MoviePermission 表中存储授权角色。

# MoviePermission
# Suppose a user has an authorization role with respect to records in the Movie table
type MoviePermission @table(key: ["doc", "userId"]) {
  movie: Movie! # implies another field: movieId: UUID!
  userId: String! # Can also be a reference to a User table, doesn't matter
  role: String!
}

在以下示例实现中,UpdateMovieTitle 更改包含一个 query 字段,用于从 MoviePermission 检索数据,以及以下指令,以确保操作安全可靠:

  • 一个 @transaction 指令,用于确保所有授权查询和检查以原子方式完成或失败。
  • @redact 指令,用于从响应中省略查询结果,这意味着我们的授权检查是在 Data Connect 服务器上执行的,但敏感数据不会向客户端公开。
  • 一对 @check 指令,用于对查询结果评估授权逻辑,例如测试给定用户 ID 是否具有进行修改的适当角色。

mutation UpdateMovieTitle($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
  # Step 1: Query and check
  query @redact {
    moviePermission( # Look up a join table called MoviePermission with a compound key.
      key: {movieId: $movieId, userId_expr: "auth.uid"}
    # Step 1a: Use @check to test if the user has any role associated with the movie
    # Here the `this` binding refers the lookup result, i.e. a MoviePermission object or null
    # The `this != null` expression could be omitted since rejecting on null is default behavior
    ) @check(expr: "this != null", message: "You do not have access to this movie") {
      # Step 1b: Check if the user has the editor role for the movie
      # Next we execute another @check; now `this` refers to the contents of the `role` field
      role @check(expr: "this == 'editor'", message: "You must be an editor of this movie to update title")
    }
  }
  # Step 2: Act
  movie_update(id: $movieId, data: {
    title: $newTitle
  })
}

授权中应避免的反模式

上一节介绍了使用 @auth 指令时应遵循的模式。

您还应了解一些重要的反模式,以免犯错。

避免在查询和更改参数中传递用户属性 ID 和身份验证令牌参数

Firebase Authentication 是一款强大的工具,可用于呈现身份验证流程并安全地捕获身份验证数据,例如已注册的用户 ID 和存储在身份验证令牌中的众多字段。

建议不要在查询和更改参数中传递用户 ID 和身份验证令牌数据。

# Antipattern!
# This incorrectly allows any user to view any other user's posts
query AllMyPosts($userId: String!) @auth(level: USER) {
  posts(where: {authorUid: {eq: $userId}}) {
    id, text, createdAt
  }
}

避免使用不带任何过滤条件的 USER 访问权限级别

正如本指南中多次提到的那样,USERUSER_ANONUSER_EMAIL_VERIFIED 等核心访问权限级别是授权检查的基准和起点,可通过过滤条件和表达式进行增强。如果使用这些级别,但没有相应的过滤条件或表达式来检查执行请求的用户是谁,则本质上等同于使用 PUBLIC 级别。

# Antipattern!
# This incorrectly allows any user to view all documents
query ListDocuments @auth(level: USER) {
  documents {
    id
    title
    text
  }
}

避免使用 PUBLICUSER 访问权限级别进行原型设计

为了加快开发速度,您可能会很想将所有操作都设置为 PUBLIC 访问权限级别或 USER 访问权限级别,而无需进一步增强功能来授权所有操作,并让您快速测试代码。

通过这种方式完成初始原型设计后,开始从 NO_ACCESS 切换到使用 PUBLICUSER 级别的正式版授权。不过,请勿在未添加额外逻辑的情况下将其部署为 PUBLICUSER,如本指南所示。

# Antipattern!
# This incorrectly allows anyone to delete any post
mutation DeletePost($id: UUID!) @auth(level: PUBLIC) {
  post: post_delete(
    id: $id,
  )
}

使用 Firebase App Check 进行应用认证

身份验证和授权是 Data Connect 安全性的关键组成部分。身份验证和授权与应用证明相结合,可构成非常强大的安全解决方案。

通过 Firebase App Check 进行证明时,运行您的应用的设备将使用应用或设备证明提供程序,证明 Data Connect 操作来自您的正版应用,以及请求来自真实的、未经篡改的设备。此证明会附加到您的应用向 Data Connect 发出的每个请求。

如需了解如何为 Data Connect 启用 App Check 并在应用中添加其客户端 SDK,请查看 App Check 概览

@auth(level) 指令的身份验证级别

下表列出了所有标准访问权限级别及其 CEL 等效级别。身份验证级别的列表从广到窄排列,每个级别涵盖与以下级别匹配的所有用户。

级别 定义
PUBLIC 任何人(无论是否经过身份验证)都可以执行此操作。

注意事项:任何用户都可以读取或修改数据。 Firebase 建议为可公开浏览的数据(例如商品或媒体详情)使用此级别的授权。请参阅最佳实践示例和替代方案

等同于 @auth(expr: "true")

@auth 过滤条件和表达式不能与此访问权限级别结合使用。任何此类表达式都将失败并显示 400 错误“Bad Request”。
USER_ANON 任何已识别身份的用户(包括使用 Firebase Authentication 匿名登录的用户)都有权执行查询或更改。

注意:USER_ANONUSER 的超集。

注意事项:请注意,您必须针对此级别的授权仔细设计查询和更改。此级别允许用户使用 Authentication 匿名登录(自动登录仅与用户设备相关联),但不会自行执行其他检查,例如数据是否属于用户。请参阅最佳实践示例和替代方案

由于 Authentication 匿名登录流程会发出 uid,因此 USER_ANON 级别等同于
@auth(expr: "auth.uid != nil")
USER 除匿名登录用户外,使用 Firebase Authentication 登录的任何用户都有权执行查询或更改。

注意事项:请注意,您必须针对此级别的授权仔细设计查询和更改。此级别仅检查用户是否已使用 Authentication 登录,不会自行执行其他检查(例如数据是否属于用户)。请参阅最佳实践示例和替代方案

等同于 @auth(expr: "auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'")"
USER_EMAIL_VERIFIED 使用已验证电子邮件地址通过 Firebase Authentication 登录的任何用户都有权执行查询或更改。

注意事项:由于电子邮件验证是使用 Authentication 执行的,因此它基于更为稳健的 Authentication 方法,因此与 USERUSER_ANON 相比,此级别可提供额外的安全保障。此级别仅检查用户是否已使用经过验证的电子邮件地址登录 Authentication,而不会自行执行其他检查(例如数据是否属于用户)。请参阅最佳实践示例和替代方案

等同于 @auth(expr: "auth.uid != nil && auth.token.email_verified")"
NO_ACCESS 此操作无法在 Admin SDK 上下文之外执行。

等同于 @auth(expr: "false")

@auth(expr)@check(expr) 的 CEL 参考文档

如本指南其他部分的示例所示,您可以并应使用通用表达式语言 (CEL) 中定义的表达式,通过 @auth(expr:)@check 指令控制对 Data Connect 的授权。

本部分介绍了与为这些指令创建表达式相关的 CEL 语法。

如需查看有关 CEL 的完整参考信息,请参阅 CEL 规范

在查询和更改中传入的测试变量

借助 @auth(expr) 语法,您可以访问和测试查询和更改中的变量。

例如,您可以使用 vars.status 添加操作变量(例如 $status)。

mutation Update($id: UUID!, $status: Any) @auth(expr: "has(vars.status)")

表达式可用的数据

@auth(expr:)@check(expr:) CEL 表达式都可以对以下内容进行求值:

  • request.operationName
  • varsrequest.variables 的别名)
  • authrequest.auth 的别名)

此外,@check(expr:) 表达式还可以评估:

  • this(当前字段的值)

request.operationName 对象

request.operarationName 对象存储操作类型(查询或更改)。

vars 对象

借助 vars 对象,表达式可以访问在查询或更改中传递的所有变量。

您可以在表达式中使用 vars.<variablename> 作为完全限定的 request.variables.<variablename> 的别名:

# The following are equivalent
mutation StringType($v: String!) @auth(expr: "vars.v == 'hello'")
mutation StringType($v: String!) @auth(expr: "request.variables.v == 'hello'")

auth 对象

Authentication 可识别请求访问您数据的用户,并以对象的形式(您可在表达式中构建)提供信息。

在过滤条件和表达式中,您可以将 auth 用作 request.auth 的别名。

auth 对象包含以下信息:

  • uid:分配给请求用户的唯一身份用户 ID。
  • tokenAuthentication 收集的值映射。

如需详细了解 auth.token 的内容,请参阅身份验证令牌中的数据

this 绑定

绑定 this 的计算结果为 @check 指令附加到的字段。在基本情况下,您可以评估单值查询结果。

mutation UpdateMovieTitle($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
  # Step 1: Query and check
  query @redact {
    moviePermission( # Look up a join table called MoviePermission with a compound key.
      key: {movieId: $movieId, userId_expr: "auth.uid"}
    ) {
      # Check if the user has the editor role for the movie. `this` is the string value of `role`.
      # If the parent moviePermission is null, the @check will also fail automatically.
      role @check(expr: "this == 'editor'", message: "You must be an editor of this movie to update title")
    }
  }
  # Step 2: Act
  movie_update(id: $movieId, data: {
    title: $newTitle
  })
}

如果返回的字段因任何祖先都是列表而出现多次,则系统会使用绑定到每个值的 this 对每个出现情况进行测试。

对于任何给定路径,如果祖先为 null[],则不会到达该字段,并且系统会跳过对该路径的 CEL 评估。换句话说,只有当 thisnull 或非 null 时才会进行评估,而不会是 undefined

如果字段本身是列表或对象,this 会遵循相同的结构(包括在对象的情况下选择的所有后代),如以下示例所示。

mutation UpdateMovieTitle2($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
  # Step 1: Query and check
  query {
    moviePermissions( # Now we query for a list of all matching MoviePermissions.
      where: {movieId: {eq: $movieId}, userId: {eq_expr: "auth.uid"}}
    # This time we execute the @check on the list, so `this` is the list of objects.
    # We can use the `.exists` macro to check if there is at least one matching entry.
    ) @check(expr: "this.exists(p, p.role == 'editor')", message: "You must be an editor of this movie to update title") {
      role
    }
  }
  # Step 2: Act
  movie_update(id: $movieId, data: {
    title: $newTitle
  })
}

复杂表达式语法

您可以通过与 &&|| 运算符组合来编写更复杂的表达式。

mutation UpsertUser($username: String!) @auth(expr: "(auth != null) && (vars.username == 'joe')")

以下部分介绍了所有可用的运算符。

运算符和运算符优先级

请使用下表作为运算符及其相应优先级的参考。

给定任意表达式 ab、字段 f 和索引 i

运算符 说明 关联度
a[i] a() a.f 索引、调用、字段访问 从左到右
!a -a 一元否定 从右到左
a/b a%b a*b 乘法运算符 从左到右
a+b a-b 加法运算符 从左到右
a>b a>=b a<b a<=b 关系运算符 从左到右
a in b 存在于列表或映射中 从左到右
type(a) == t 类型比较,其中 t 可以是 bool、int、float、number、string、list、map、timestamp 或 duration 从左到右
a==b a!=b 比较运算符 从左到右
a && b 条件“与” 从左到右
a || b 条件“或” 从左到右
a ? true_value : false_value 三元表达式 从左到右

身份验证令牌中的数据

auth.token 对象可能包含以下值:

字段 说明
email 与账号关联的电子邮件地址(如果存在)。
email_verified 如果用户已验证他们可以访问 email 地址,则为 true。某些提供方会自动验证他们拥有的电子邮件地址。
phone_number 与账号关联的电话号码(如果有)。
name 用户的显示名(如果已设置)。
sub 用户的 Firebase UID。此 UID 在项目中是唯一的。
firebase.identities 与此用户账号关联的所有身份的字典。字典的键可以是以下任一值:emailphonegoogle.comfacebook.comgithub.comtwitter.com。字典的值是与账号关联的每个身份提供方的唯一标识符的数组。例如,auth.token.firebase.identities["google.com"][0] 包含与该账号关联的第一个 Google 用户 ID。
firebase.sign_in_provider 用于获取此令牌的登录服务提供方。可以是以下任一字符串:custompasswordphoneanonymousgoogle.comfacebook.comgithub.comtwitter.com
firebase.tenant 与账号关联的租户 ID(如果有)。例如 tenant2-m6tyz

JWT ID 令牌中的其他字段

您还可以访问以下 auth.token 字段:

自定义令牌声明
alg 算法 "RS256"
iss 颁发者 您项目的服务账号电子邮件地址
sub 主题 您项目的服务账号电子邮件地址
aud 受众 "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"
iat 颁发时间 当前时间(与 UNIX 计时原点之间相隔的秒数)
exp 到期时间 令牌到期的时间(与 UNIX 计时原点之间相隔的秒数),该时间可能比 iat 晚最多 3600 秒
注意:这仅会控制自定义令牌本身的过期时间。但是,一旦您使用 signInWithCustomToken() 让用户登录,他们将一直在设备上保持登录状态,直到其会话失效或用户退出账号为止。
<claims>(可选) 要包含在令牌中的可选自定义声明,可通过表达式中的 auth.token(或 request.auth.token)访问。例如,如果您创建自定义声明 adminClaim,则可以使用 auth.token.adminClaim 访问该声明。

后续步骤