内容目录
一个 Web 应用的全部功能并非总是无条件地向所有用户开放,不同的用户角色拥有不同的功能操作权限,这在现实的场景中随处可见:
- 某些功能(比如发表文章、评论等)只有登录的用户才有权操作,未登录的普通访客则无权操作;
- 某些功能(比如转账、提现等)只有通过实名认证的用户才有权操作;
- 在后台管理系统中,不同部门不同角色的工作人员各自拥有不同的功能权限……
不过,在部分 Web 应用开发中,权限控制的严谨性却很少得到重视。
「表面」权限控制
「表面兄弟」是一个比较流行的互联网新词,它表示两个人表面上称兄道弟,实际上却可以随时为了利益出卖对方(表面上,为了兄弟两肋插刀;实际上,为了xx插兄弟两刀)。
实际上,在我们技术领域也可以说有一个类似的新词——「表面权限控制」:表面上看起来好像已经实现了为不同的角色分配不同的权限,实际上功能权限并没有真正得到有效的控制。
我们先来看一个简单的【商城后台管理系统】的案例:
<?php
public function index(){
// 所有的功能菜单
$all_menus = array(
array('name'=>'商品列表', 'url'=>'/product/list'),
array('name'=>'用户列表', 'url'=>'/member/list'),
array('name'=>'订单列表', 'url'=>'/order/list'),
array('name'=>'评价列表', 'url'=>'/comment/list')
);
$session_user = get_current_session_user();
// 筛选出当前会话用户具有权限的功能菜单集合
$allowed_menus = array();
foreach($all_menus as $menu){
if( $session_user -> has_permission_for( $menu ) ){
array_push($allowed_menus, $menu);
}
}
View::assign('menus', $allowed_menus);
return View::fetch();
}
?>
在模板页面上,我们可以将菜单数据渲染为 页面左侧 的导航菜单列表。
<h3>商城后台</h3>
<ul>
{foreach $menus as $menu}
<li><a href="{$menu.url}">{$menu.name}</a></li>
{/foreach}
</ul>
如果当前登录的管理人员只拥有上述4个权限中的3个权限(没有「评价列表」的功能权限),那么在页面上我们可以看到菜单的显示效果大致如下图:
此外,以「商品」模块为例,进入「商品列表」后,我们一般还可以进行【添加】、【编辑】、【删除】等业务操作。
<!-- 如果用户有对应的权限,就在页面输出对应的操作按钮 -->
<div>
{if $current_user_can_add }
显示添加按钮
{/if}
</div>
<h3>商品列表</h3>
<ul>
{foreach $goods_items as $goods}
<li>
<a href="{$goods.url}">{$goods.name}</a>
{if $current_user_can_edit }
显示编辑按钮
{/if}
{if $current_user_can_remove }
显示删除按钮
{/if}
</li>
{/foreach}
</ul>
通过类似上面的代码,我们就实现了:只有具备相应操作权限的用户才能在页面上看到相应的操作入口。
但是,这样的业务逻辑是有严重的安全隐患的!因为——我们只是在前端页面上隐藏了当前用户无权操作的功能入口,但是如果攻击者本身就已经知道了业务操作的请求地址,那么他完全可以自行直接向后台服务器发起业务请求。
譬如,如果攻击者已经知道【评价列表】的请求地址为:/comment/list,那么,哪怕我们没有给他分配访问【评价列表】的权限,页面上也已经隐藏了该功能的菜单链接,他也能够直接在浏览器中输入该网址从而进行访问!
同理,实际上无权进行对应操作的用户也可以直接发起【添加】(/goods/add)、【编辑】(/goods/edit?id=123)、【删除】(/goods/remove?id=123)等请求。
因此,为了确保原本无权操作的用户无论如何都无权操作,我们还需要在服务器端进行严格的访问权限检查!
<?php
/**
* 【编辑】
*/
public function edit(){
if( !session_user_has_permission() ){
// 如果当前用户无法进行该业务操作,则直接拒绝访问
return access_denied();
}
// TODO ……
}
?>
对于上述代码中的session_user_has_permission()
方法,其主要实现逻辑大致如下:
- 每个业务操作都应该有一个独一无二的、一一对应的标识。一般地,我们可以使用该业务操作的请求路径URI(一般不包含参数部分)来作为标识,例如:/goods/edit。此外,我们也可以使用完整的请求处理方法名来作为标识,例如 Java 中的vip.codeplayer.controller.UserController.add(如果所有的Controller都位于同一个包下,还可以省略掉相同的前缀,只保留类似UserController.add部分)。
- 在初始化用户权限数据时,为该用户设置任意多个业务操作权限,在代码中就表现为具有对应操作权限的标识的集合。
- 根据 当前请求的URI 或 请求处理方法的方法名 获取到该业务操作所对应的标识,判断当前用户的权限标识集合中是否包含该标识。如果包含,就表示该用户有权进行该业务操作;反之,则无权操作,将被拒绝访问。
实际上,我们不建议在每个业务请求的处理方法中去一个个添加这种高度重复的代码。一般地,我们会使用 编程语言或应用框架提供的API(路由器、拦截器、过滤器等)去统一拦截所有的请求,然后统一进行权限检查。
一个业务操作对应多个请求方法
在实际的系统开发过程中,还有一个比较常见的问题也不容忽视,那就是一个业务操作一般对应后台的至少两个请求方法。
以【添加用户】这一业务操作为例,操作人员需要先进入【添加用户】的表单界面,在完成表单录入后,才会提交真正的【添加用户】的业务请求。
为了完成这一操作,操作人员至少要向服务器发起两次请求:第一次,请求/member/addView以显示表单界面;第二次,请求/member/add以提交表单数据,真正完成业务操作。
按照更加合理的逻辑:如果操作人员没有【添加用户】的操作权限,那么他既不能请求/member/add发起真正的业务操作,也无权请求/member/addView并显示表单界面。因为,如果操作人员能够访问表单界面,在完成表单录入并点击【提交】时,才发现自己无权操作——耗费大量时间,结果却发现做的只是无用功,这也会严重伤害操作人员的操作体验。
因此,当用户不具备【添加用户】的操作权限时,我们在服务器端对/member/addView和/member/add的访问请求都要进行检查。
在这里,我们提供两种常规的实现思路:
- 在方法标识命名上,对【业务处理方法】和对应的【表单展示方法】进行统一的约定。比如【表单展示方法】的标识命名都是在【业务处理方法】的标识名称后面加一个View。那么我们在编写权限分配、权限判断的代码逻辑时,可以只针对【业务处理方法】的标识。在统一拦截所有业务请求时,对于【表单展示方法】,我们可以去除掉其View后缀,再进行相应的后续判断或处理。
- 【业务处理】和对应的【表单展示】都使用同一个方法,然后通过请求方式(GET 和 POST)或额外的请求参数(action=view 和 action=submit)来区分 表单显示 和 业务处理。
基于具体业务状态的权限控制
在某些特定的业务场景中,我们可以需要根据具体的业务状态来进行权限控制。
以【编辑用户】(/member/edit?id=123)为例,某些系统可能有这样的业务规则:如果是普通用户(type=0),则普通工作人员即可编辑;如果是VIP用户(type=1),则必须是部门主管才能够进行编辑。
对于这种特殊的业务需求,在实现上并没有什么捷径,我们只能老老实实地在对应的请求方法中,加上相应的权限判断:
/**
* 【编辑用户】
*/
public function edit(){
$id = intval( $_GET['id'] );
$member = get_member( $id );
if( $member == null || $member.is_vip() && !$session_user.is_manager() ){
// 如果用户是VIP,且 当前会话操作人员 不是 部门主管,则拒绝访问
return access_denied();
}
// TODO ……
}
注意:有些开发人员出于偷懒或者所谓的节省性能开销的考虑,直接在【用户列表】表格操作栏的【编辑】链接中提前附带了用户的VIP状态参数,例如:/member/edit?id=123&is_vip=1,然后在edit()
方法中直接通过请求中的is_vip参数值来进行权限判断。这种做法是完全错误的!因为外部的参数是可以被任意篡改的,我们随时可以将链接中的is_vip参数值改为0!我们只能根据id查询数据库,才能得到id=123的用户所对应的可靠的业务状态数据!
0 条评论
撰写评论