Hi,亲爱的用户:

最近我们正以「LeanCloud Web 应用开发实践」为主题,进行一系列的直播及文章分享。并将重写后的 LeanTicket (工单)作为范例,介绍使用 LeanCloud 开发 Web 应用的一些最佳实践,内容包括且不限于:

  1. 使用 LeanCloud 账号系统以及三方 OAuth 授权。
  2. 使用 ACL 保障数据安全。
  3. 使用云引擎的云函数和 hook 函数简化客户端逻辑。
  4. LiveQuery 的使用。
  5. 与微信或其他外部系统对接。
  6. 邮件的发送即接收。
  7. 批量处理/订正数据的实践。
  8. 使用数据缓存提高响应速度,减少存储服务查询次数。

欢迎您加入微信群(文末扫码)进行交流。

第四期课程:使用 ACL 保护数据安全

内容:

  • 为什么不能通过隐藏 appKey 来保证数据安全
  • 如何方便的使用 ACL
  • 老旧数据如何增加 ACL

讲师

LeanCloud 工程师 陈伟

直播时间

2017 年 8 月 22 日(周二) 20:00

课程地址:https://www.douyu.com/158796942

扫描下方二维码即可加入「LeanCloud Web 应用开发实践」,如有疑问也可加微信「 guixiaoTL」咨询。


往期回顾(末尾文字讲解):

第一期课程:示例应用(LeanTicket)开发环境搭建及功能简介

第二期课程:理清 Web 应用的登录状态

第三期课程:使用 OAuth 接入第三方用户信息

本主题已全局置顶,它将始终显示在它所属分类的顶部。可由职员对所有人解除置顶,或者由用户自己取消置顶。

视频: https://www.bilibili.com/video/av13700356/20

本期视频简介 00:00:00

前后端分离应用部署形式 00:01:51

对于前后端分离的应用,一般会由服务端提供 DAL(Data Access Layer 数据访问层),将操作数据(比如数据的增删查改)的接口以 REST API 的方式对外提供服务。客户端(Android、iOS、浏览器等)通过 REST API 操作数据,并进行客户端渲染。

LeanCloud 提供的数据存储服务就是一个 DAL,而各客户端 SDK 就是对各数据访问 API 的封装。

数据安全 00:03:40

几乎所有应用都需要考虑数据安全,简单来讲就是几个方面:

  • 谁可以创建数据
  • 谁可以读取数据
  • 谁可以修改(删除)数据

即使是一个非常开放的应用,比如博客平台来说,可能会宣称「谁都可以写博客,谁都能读到所有博客」,但是往往会有默认的限制「只有注册用户可以写博客」,「只有博文的作者可以修改和删除该博文」,可见具体到某个数据对象时,读写权限还是会有区别。

对于前后端分离的应用,如何做数据访问限制?

误区:努力加强客户端权限校验 00:05:24

对于前后端分离的应用,客户端的权限校验带来的帮助非常有限,很多时候带来的效果都是「更早告知用户数据访问存在问题」,比如一个需要 email 格式的文本框,用户填写了其他格式内容时尽快的提醒用户。

为什么客户端校验无法保证数据安全?

因为对于前后端分离的应用,DAL 直接暴露在广域网,任何人(包括恶意用户)可以不使用应用提供的客户端直接访问 DAL。对于这种访问,应用客户端所做的一切防护都没有任何意义。

比如直接使用 curl 命令直接发起 HTTP 请求(appIdappKey 部分请自行替换):

# 查询某应用 Todo 表的内容
curl 'https://leancloud.cn/1.1/classes/Todo' \
  -H 'X-LC-Id: kdrt5GNCjojUjiIujawd5A4n-gzGzoHsz' \
  -H 'X-LC-Key: Xvxjo6SVUITIqet69q3mudlF'

# 向某应用 Todo 表增加一条记录
curl 'https://leancloud.cn/1.1/classes/Todo' \
  -H 'x-lc-id: kdrt5GNCjojUjiIujawd5A4n-gzGzoHsz' \
  -H 'x-lc-key: Xvxjo6SVUITIqet69q3mudlF' \
  -H 'content-type: application/json' \
  -d '{"content":"OMG","status":0}'

相关文档: 数据存储 REST API2

所以作为服务端有一个基本原则:假设所有客户端都可能是恶意的,要验证所有请求的合法性。其中合法性主要包括:请求者是否有合法的权限,以及对数据操作是否合法(比如越权,或者写入的数据字段超长)。

如何在 DAL 保证数据安全?

误区:通过保护 appKey 不泄漏,来保证数据安全 00:19:53

对于 LeanCloud 上的应用,拥有 appIdappKey 就可以访问该应用的数据,所以很多开发者会试图使用客户端混淆技术让恶意用户无法拿到 appKey 从而达到保护数据安全的目的。其实这样做没有任何意义,因为对一个熟悉相关技能的人,搭建一个 HTTPS 代理就可以拦截应用客户端于服务器端通信的所有数据,appKey 暴漏无疑。

ACL 介绍

ACL 全称 Access Control List,能提供细粒度的数据访问权限控制。

leanengine-nodejs-demos4 为示例(00:23:50)

保存数据时增加 ACL(代码3):

...
// 设置 ACL,可以使该 todo 只允许创建者修改,其他人只读
var acl = new AV.ACL(req.currentUser);
acl.setPublicReadAccess(true);
todo.setACL(acl);
...

数据保存成功后,在 LeanCloud 数据控制台就能看到 Todo 对象的 ACL 列有相关的信息:

{
    "59887b8b570c350062430143": {
        "read": true,
        "write": true
    },
    "*": {
        "read": true
    }
}

表示:_User 表中,objectId59887b8b570c350062430143 的用户拥有该记录的读和写权限;所有人(* 表示)拥有读权限。

当一条 Todo 对象有了「只有指定用户可修改」的 ACL 限制之后,如果「删除」请求不提供特定用户标示会得到 403 响应,操作被禁止:

$ curl -v -X DELETE \
  -H "X-LC-Id: kdrt5GNCjojUjiIujawd5A4n-gzGzoHsz" \
  -H "X-LC-Key: Xvxjo6SVUITIqet69q3mudlF" \
  https://api.leancloud.cn/1.1/classes/Todo/599c2335a22b9df82fc22509

< HTTP/1.1 403 Forbidden
...
Forbidden writing by object's ACL, the object id is 599c2335a22b9df82fc22509.

只有请求中提供了 objectId59887b8b570c350062430143 用户的 sessionToken ,请求才能正常被执行:

$ curl -v -X DELETE \
  -H "X-LC-Id: kdrt5GNCjojUjiIujawd5A4n-gzGzoHsz" \
  -H "X-LC-Key: Xvxjo6SVUITIqet69q3mudlF" \
  -H "X-LC-Session: u2xtq3dxxvonapqn5uc9snbz7" \
  https://api.leancloud.cn/1.1/classes/Todo/599c2335a22b9df82fc22509

< HTTP/1.1 200 OK
...
{}

LeanTicket3 为示例(00:30:50)

应用 没有 在保存 Ticket 对象的时候做 ACL 的设置(代码2):

      return new AV.Object('Ticket').save({
        title: this.state.title,
        category: getTinyCategoryInfo(category),
        content: this.state.content,
        files,
      })

而是在云引擎的 beforeHook 中设置(代码4):

  return getTicketAcl(ticket, req.currentUser).then((acl) => {
    ticket.setACL(acl)
    ...
  })
...
const getTicketAcl = (ticket, author) => {
  const acl = new AV.ACL()
  acl.setWriteAccess(author, true)
  acl.setReadAccess(author, true)
  acl.setRoleWriteAccess(new AV.Role('customerService'), true)
  acl.setRoleReadAccess(new AV.Role('customerService'), true)
  return Promise.resolve(acl)
}

将 ACL 的设置(包括其他一些默认值设置)的代码放在云引擎的 Hook 函数中有很多好处:

  • 降低维护成本:不需要每个客户端(Android、iOS、浏览器等)重复编写类似的代码。从更高层面来说,在不同客户端拥有大段功能一致的代码也属于「重复代码」,避免重复代码能降低维护成本。
  • 易于修改:云引擎上部署的代码可以随时修改随时发布,相比客户端发布要走慢长的审批流程要方便很多。

在上面设置 ACL 的代码中出现了 acl.setRoleWriteAccess()acl.setRoleReadAccess() 的 API 来设置 role 的权限,而 role 简单来说就是一个 _User 的集合。通过 role 可以方便的进行批量用户权限的管理。role 的信息对应了 LeanCloud 数据存储服务的 _Role 表。

关于工单回复 Reply 的 ACL 设置(代码):

  getReplyAcl(req.object, req.currentUser).then((acl) => {
    req.object.setACL(acl)
    ...
  })
...
const getReplyAcl = (reply, author) => {
  return reply.get('ticket').fetch({
    include: 'author'
  }, {user: author}).then((ticket) => {
    const acl = new AV.ACL()
    acl.setWriteAccess(author, true)
    acl.setReadAccess(author, true)
    acl.setReadAccess(ticket.get('author'), true)
    acl.setRoleReadAccess(new AV.Role('customerService'), true)
    return acl
  })
}

对于该回复来说,上面的 ACL 设置了:

  • 该回复所有者可读可写
  • 该工单的所有者可读
  • 所有客服人员可读

因为对于回复来说,不存在类似于 Ticket 的状态修改,所以客服角色等不需要写权限。

可以看出,对于设置 ACL 有个比较麻烦的地方:需要在数据产生的时刻预料到该数据会如何使用,并设置合理的权限。这的确是一个比较令人头痛的事情,甚至可能因为考虑不周或业务变化导致需要订正现有 ACL 的数据,这个后面会提到。

另外,在 Ticket 中会判断「当前登录用户是否是客服」,从而决定页面如何渲染(比如客服在工单详情页可以看到修改工单负责人的选项),该方法定义如下(代码):

exports.isCustomerService = (user) => {
  if (!user) {
    return Promise.resolve(false)
  }
  return new AV.Query(AV.Role)
    .equalTo('name', 'customerService')
    .equalTo('users', user)
    .first()
    .then((role) => {
      return !!role
    })
}

其中:.equalTo('users', user)数组查询1,匹配 users 中有 user 对象的记录。

Role 本身的 ACL 00:57:12

一个新的应用,往往需要初始化一个管理员,Ticket 的做法是第一个注册的用户自动成为「管理员」角色和「客服」角色(代码):

    Promise.all([
      addRole('admin', req.object),
      addRole('customerService', req.object)
    ])
...
const addRole = (name, initUser) => {
  const roleAcl = new AV.ACL()
  roleAcl.setPublicReadAccess(true)
  roleAcl.setRoleWriteAccess(name, true)
  const role = new AV.Role(name, roleAcl)
  role.getUsers().add(initUser)
  return role.save()
}

有一点需要注意:role 本身也有 ACL,决定了这个 role 谁可以读谁可以修改,如果拥有修改权限,则可以向这个 role 添加或者修改 usersroles 的内容。

千万不要忘记给 role 设置 ACL,否则可能使应用的所有 ACL 设置新同虚设。

对于 role 的 ACL 设置,一般为:

  • 所有人可读
  • 只有该 role 可以修改自己:因为 user 是易变的,如果 role 的写权限绑定到某个人,很容易因为该用户的变动导致该 role 不可操作。
  • users 中至少有一个用户:该用户可以添加其他人到 users 中,达到维护该角色的目的。

最后附上 ACL 权限管理开发指南11

在 ACL 的保护下,恶意用户的操作会如何?01:07:30

假设有一个 Todo List 的应用,每个 todo 对象都通过 ACL 限制创建者可读可写。那么对于恶意用户来说:

  • 如果不携带任何登录用户的信息,查询 Todo 表内容将返回空列表。
  • 可以自己创建用户,但是也只能操作这个用户创建的 todo 对象。
  • 因为没办法得知其他用户的账号密码,所以也没办法查看和操作其他用户的 Todo 列表。
  • 唯一可以进行的破坏可能就是无休止的创建 todo 对象,导致数据库记录暴增;或者无休止的查询,导致数据存储服务工作线程耗光。

关于云引擎中使用 masterKey 的问题 01:11:33

因为云引擎应用运行在服务端,是一个相对安全的环境。所以早期为了方便,我们建议云引擎中使用 masterKey 权限,这样访问数据存储服务时可以忽略 ACL 的限制(代码3):

AV.Cloud.useMasterKey();

但是实际使用中,云引擎中的一些查询还是希望有 ACL 的约束,而另一些操作又不希望受到 ACL 的约束,所以我们改进了使用方式:将是否使用 masterKey 权限细化到一次请求,而不是全局。

所以建议大家不要再启用全局 masterKey 权限,而是在请求存储服务的时候,明确的指定 currentuser 或者使用 masterKey 权限:

  • 查询对象时明确指定 currentUser(代码3):

    new AV.Query('Ticket')
    .include('files')
    .include('author')
    .get(ticketId, {user: req.currentUser}),
  • 保存对象时明确指定 currentUser(代码1):

    return ticket.save(null, {user: req.currentUser})
  • 或者需要的时候,明确使用 masterKey 权限(代码):

  return new AV.Query('_User')
  .limit(1)
  .find({useMasterKey: true})

一般的原则是:

  • 如果当前上下文环境有 currentUser,就使用 currentUser,这在很多时候都符合预期。
  • 某些特殊情况会使用 masterKey 权限,如:
    • 定时任务,或者其他情况触发的请求,此时一般没有 currentUser。
    • 某些表只允许 masterKey 操作时。

为现有应用增加 ACL 01:22:41

有可能现有应用没有使用 ACL,或者因为业务变化等原因,ACL 策略需要调整,推荐的步骤:

  • 修改部分数据,测试增加(变更) ACL 之后,客户端行为是否正常,有些时候需要一些过度代码来兼容之前的操作。
  • 使用云引擎 Hook 函数增加 ACL 的设置:通过 Hook 函数来设置 ACL 能非常方便的修改,而且减少各个客户端的维护成本(有可能增加 ACL 的过程不需要客户端修改代码)。
  • 云引擎中所有与存储服务的交互都需要明确指定 currentUser 或者使用 masterKey 权限。
  • 待前两步完成之后(特别是客户端升级可能是一个慢长的过程),新的数据就已经拥有新的 ACL 设置了,然后写脚本订正老数据。

订正脚本可以很简单的在本地开发并运行,下面提供一个简单的示例:

const AV = require('leancloud-storage')

AV.init({
  appId: process.env.LEANCLOUD_APP_ID,
  appKey: process.env.LEANCLOUD_APP_KEY,
  masterKey: process.env.LEANCLOUD_APP_MASTER_KEY,
})
AV.Cloud.useMasterKey()

const updateTicket = () => {
  return new AV.Query('Ticket')
  .notEqualTo('version', 1)
  .limit(1000)
  .find(tickets => {
    if (tickets.length === 0) {
      return
    }
    tickets.forEach(ticket => {
      const acl = new AV.ACL()
      acl.setWriteAccess(ticket.get('author'), true)
      acl.setReadAccess(ticket.get('author'), true)
      acl.setRoleWriteAccess(new AV.Role('customerService'), true)
      acl.setRoleReadAccess(new AV.Role('customerService'), true)
      ticket.setACL(acl)
      ticket.set('version', 1)
    })
    console.log('update count:', tickets.length)
    return AV.Object.saveAll(tickets)
  })
  .then(updateTicket)
}

updateTicket()

假设上面的脚本保存到项目根目录的 updateDate.js 文件,可以很方便的执行:

& $(lean env) && node updateDate.js

updateTicket 方法是递归调用,每次查询出来没有被订正的 1000 条记录(假设 version 不为 1 就是没有订正),然后修改并设置订正标记(设置 version1)。当所有数据都订正完成后,方法退出。

为了测试,可以将 .then(updateTicket) 注释起来,并且将 .limit(1000) 改为一个较小的值,就可以订正少量数据确认效果。

脚本运行期间即使出错退出也没有关系,修正后重新执行即可,因为每条订正的数据都有标记,所以只会处理没有订正的数据。如果有需要,修改订正标记(如 version 改为 2)重新执行即可重新订正所有数据。

本主题已被解除置顶,它将不再显示在它所属分类的顶部。