视频: https://www.bilibili.com/video/av13700356/
本期视频简介 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 请求(appId
和 appKey
部分请自行替换):
# 查询某应用 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 API
所以作为服务端有一个基本原则:假设所有客户端都可能是恶意的,要验证所有请求的合法性。其中合法性主要包括:请求者是否有合法的权限,以及对数据操作是否合法(比如越权,或者写入的数据字段超长)。
如何在 DAL 保证数据安全?
误区:通过保护 appKey 不泄漏,来保证数据安全 00:19:53
对于 LeanCloud 上的应用,拥有 appId
和 appKey
就可以访问该应用的数据,所以很多开发者会试图使用客户端混淆技术让恶意用户无法拿到 appKey
从而达到保护数据安全的目的。其实这样做没有任何意义,因为对一个熟悉相关技能的人,搭建一个 HTTPS 代理就可以拦截应用客户端于服务器端通信的所有数据,appKey 暴漏无疑。
ACL 介绍
ACL 全称 Access Control List,能提供细粒度的数据访问权限控制。
保存数据时增加 ACL(代码):
...
// 设置 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
表中,objectId
为 59887b8b570c350062430143
的用户拥有该记录的读和写权限;所有人(*
表示)拥有读权限。
当一条 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.
只有请求中提供了 objectId
为 59887b8b570c350062430143
用户的 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
...
{}
应用 没有 在保存 Ticket
对象的时候做 ACL 的设置(代码):
return new AV.Object('Ticket').save({
title: this.state.title,
category: getTinyCategoryInfo(category),
content: this.state.content,
files,
})
而是在云引擎的 beforeHook 中设置(代码):
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)
是 数组查询,匹配 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
添加或者修改 users
和 roles
的内容。
千万不要忘记给 role
设置 ACL,否则可能使应用的所有 ACL 设置新同虚设。
对于 role
的 ACL 设置,一般为:
- 所有人可读
- 只有该
role
可以修改自己:因为 user
是易变的,如果 role
的写权限绑定到某个人,很容易因为该用户的变动导致该 role
不可操作。
-
users
中至少有一个用户:该用户可以添加其他人到 users
中,达到维护该角色的目的。
最后附上 ACL 权限管理开发指南 。
在 ACL 的保护下,恶意用户的操作会如何?01:07:30
假设有一个 Todo List 的应用,每个 todo 对象都通过 ACL 限制创建者可读可写。那么对于恶意用户来说:
- 如果不携带任何登录用户的信息,查询 Todo 表内容将返回空列表。
- 可以自己创建用户,但是也只能操作这个用户创建的 todo 对象。
- 因为没办法得知其他用户的账号密码,所以也没办法查看和操作其他用户的 Todo 列表。
- 唯一可以进行的破坏可能就是无休止的创建 todo 对象,导致数据库记录暴增;或者无休止的查询,导致数据存储服务工作线程耗光。
关于云引擎中使用 masterKey 的问题 01:11:33
因为云引擎应用运行在服务端,是一个相对安全的环境。所以早期为了方便,我们建议云引擎中使用 masterKey 权限,这样访问数据存储服务时可以忽略 ACL 的限制(代码):
AV.Cloud.useMasterKey();
但是实际使用中,云引擎中的一些查询还是希望有 ACL 的约束,而另一些操作又不希望受到 ACL 的约束,所以我们改进了使用方式:将是否使用 masterKey 权限细化到一次请求,而不是全局。
所以建议大家不要再启用全局 masterKey 权限,而是在请求存储服务的时候,明确的指定 currentuser
或者使用 masterKey 权限:
- 查询对象时明确指定 currentUser(代码):
new AV.Query('Ticket')
.include('files')
.include('author')
.get(ticketId, {user: req.currentUser}),
- 保存对象时明确指定 currentUser(代码):
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
就是没有订正),然后修改并设置订正标记(设置 version
为 1
)。当所有数据都订正完成后,方法退出。
为了测试,可以将 .then(updateTicket)
注释起来,并且将 .limit(1000)
改为一个较小的值,就可以订正少量数据确认效果。
脚本运行期间即使出错退出也没有关系,修正后重新执行即可,因为每条订正的数据都有标记,所以只会处理没有订正的数据。如果有需要,修改订正标记(如 version
改为 2
)重新执行即可重新订正所有数据。