- 论坛徽章:
- 0
|
了解如何使用 Agavi 框架实现可伸缩 Web 应用程序
Agavi 是开放源码的、灵活的和可伸缩的应用程序开发框架。它的关键特性之一是用于身份验证和基于角色的访问控制的全功能 API。详细探索这个 API,了解如何为 Web 应用程序添加高级的应用程序级特权管理和操作。
简介
在该系列文章的上一篇文章中,我介绍了 Agavi MVC 框架,演示了如何使用它快速高效地构建可伸缩 Web 应用程序。我选择 Agavi 作为开发框架的主要原因之一是其高级的输入过滤和验证系统,它能确保无效或未经过过滤的输入不能进入应用程序。这个任务是构建安全的 Web 应用程序的关键要素,但借助 Agavi 的各种内置验证器(比如针对字符串、数字、时间戳、电子邮件地址和文件的验证器)以及它对定制验证器的支持,该任务大大简化了。
Agavi 对应用程序的安全的关注不仅仅局限于输入验证。该框架还公开一个强大的用户身份验证和访问控制系统,通过定制它几乎可以满足任意 Web 应用程序的需求。这个子系统支持简单的基于登录的身份验证和复杂的基于角色的访问控制(RBAC),并且为应用程序级特权管理和操作提供坚实的基础。我将在本文进行详细讨论。
![]()
![]()
![]()
![]()
回页首
理解基础概念
![]()
常用缩略词
- API:应用编程接口
- MVC:模型-视图-控制器
- XML:可扩展标记语言
当您为 Agavi 应用程序的操作定义访问控制时,一定要先认识到有许多可用的安全级别。这些安全级别可以使用以下概念进行描述:
密码。基于密码的访问是最简单的访问控制类型。从根本上说,它允许开发人员将某些操作标记为安全验证,并要求用户在访问该操作之前输入一组有效的登录凭证。这种类型的访问控制适用于不需要多级特权的应用程序,或访问系统的用户大致可以分为用户 和管理员 的场景。
特权。基于特权的访问控制系统比基于密码的访问控制系统具有更多访问级别。在这个系统下,开发人员定义执行每个操作所需的特权,仅当请求访问的用户拥有必要的特权,系统才允许其访问相应的操作。这种方法包含了身份验证和授权。用户不仅需要一组有效的登录凭证,还需要具有一组特权,这样才能访问特定的操作。但是,随着用户类型和特权级别的增加,管理很快就变得非常困难。
角色。基于角色的访问控制(RBAC)是刚才提到的基于特权的方法的改进,它更加先进,并且具有可管理性。这种方法为应用程序定义一组用户角色;每个角色包含一组特权,并且根据用户的职能为其分配一个或多个角色。这种类型的访问控制适用于有多种用户类型和多个特权级别的应用程序。并且 RBAC 拥有足够的灵活性,可以满足身份验证和授权的多样性需求。
![]()
![]()
![]()
![]()
回页首
设置样例应用程序
在开始实现访问控制之前需要注意几个事项。在整篇文章中,我假设您有一个可用的 Apache/PHP/MySQL 开发环境,并且假设您熟悉 SQL 和 XML 和以下内容:
- 使用 Agavi 开发应用程序的基本原理
- 了解操作、视图、模型和路由之间的交互
- 熟悉在 Agavi 应用程序中使用 Doctrine 模型
如果您不熟悉以上内容,请在继续本文之前先阅读 Agavi 系列文章(在
参考资料
部分提供相关链接)。
步骤 1:初始化一个新的应用程序
首先,您要创建一个简单的 Agavi 应用程序,用于测试本文的开发目标。使用 Agavi 构建脚本初始化一个新的项目,接受如下所示的默认值:
shell> agavi project-wizard
Project name [New Agavi Project]: ExampleApp
Project prefix (used, for example, in the project base action) []: ExampleApp
Should an Apache .htaccess file with rewrite rules be generated (y/n) [n]? y
...
完成之后,在您的 Apache 配置中为测试应用程序定义一个新的虚拟主机,比如 http://example.localhost/,然后在浏览器中打开该地址。您应该看到默认的 Agavi 欢迎页面,如
图 1
所示。
图 1. 默认的 Agavi 应用程序欢迎页面
![]()
步骤 2:添加新的模块和对应的操作
为了保持简单,我假设您已经将需要保护的所有操作都放到一个模块中,但不是 Default 模块。返回到命令提示符,并使用 Agavi 构建脚本创建一个包含 6 个操作的 Book 模块:
shell> agavi module-wizard
Module name: Book
Space-separated list of actions to create for Book:
Create Index Delete Display Search Edit
...
这 6 个操作 —— CreateAction、DeleteAction、DisplayAction、IndexAction、EditAction 和 SearchAction —— 是您稍后需要添加访问控制的操作。此刻,更新每个操作的 * 包含描述其目的的简短消息的成功模板(其中 * 是操作的名称)。下面的例子展示了 CreateSuccess 模板应该包含的内容:
If you can see this page, you are authorized to create and add new books to the database.
在这里,根据 Agavi 文档的推荐,您还应该删除 Welcome 模块。
shell> rm -rf app/modules/Welcome
shell> rm -rf app/pub/welcome
步骤 3:更新应用程序的路由表
最后,在 $ROOT/app/config/routing.xml 更新应用程序的路由表,让添加到路由指向新的操作,如
清单 1
所示。
清单 1. 示例应用程序的路由
现在,您应该可以使用
清单 1
中的路由访问新创建的操作。要进行检验,请尝试访问 http://example.localhost/book/create,并注意是否看到类似于
图 2
的页面。
图 2. CreateAction 的默认页面
![]()
在阅读下一个小节之前,以类似的方式检验
清单 1
中的其他路由。如果不能顺利完成,请参阅这个 Agavi 系列文章的第 1 部分中描述的步骤(查看
参考资料
部分提供的链接)。或者从本文的
下载
部分下载示例应用程序的完整代码压缩文件。
![]()
![]()
![]()
![]()
回页首
设置登录和注销操作
不管应用程序的访问控制基于密码还是基于角色,您都需要一个登录和注销系统来处理用户身份验证。因此,在创建了基础的应用程序之后,下一步就是实现登录和注销操作。
步骤 1:初始化用户数据库和模型
因为 Web 应用程序的用户信息通常储存在数据库中,所以在这里还需初始化数据库。首先,创建一个新的 MySQL 表来保存用户凭证,如下所示:
mysql> CREATE TABLE IF NOT EXISTS `user` (
-> UserID int(4) NOT NULL AUTO_INCREMENT,
-> Username varchar(50) CHARACTER SET utf8 NOT NULL,
-> `Password` text CHARACTER SET utf8 NOT NULL,
-> PRIMARY KEY (UserID),
-> UNIQUE KEY Username (Username)
-> ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
Query OK, 0 rows affected (0.13 sec)
为这个表添加一些帐户:
mysql> INSERT INTO user (UserID, Username, Password)
VALUES(1, 'james', PASSWORD('james'));
Query OK, 1 row affected (0.08 sec)
mysql> INSERT INTO user (UserID, Username, Password)
VALUES(2, 'susan', PASSWORD('susan'));
Query OK, 1 row affected (0.08 sec)
mysql> INSERT INTO user (UserID, Username, Password)
VALUES(3, 'marco', PASSWORD('marco'));
Query OK, 1 row affected (0.08 sec)
mysql> INSERT INTO user (UserID, Username, Password)
VALUES(4, 'donald', PASSWORD('donald'));
Query OK, 1 row affected (0.08 sec)
您还可以创建另一个表来保存特权信息,稍后将要使用它:
mysql> CREATE TABLE IF NOT EXISTS `user_access` (
-> RecordID int(4) NOT NULL AUTO_INCREMENT,
-> UserID int(4) NOT NULL,
-> UserAccess varchar(255) NOT NULL,
-> PRIMARY KEY (RecordID),
-> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Query OK, 0 rows affected (0.1 sec)
然后下载 Doctrine ORM(在
参考资料
部分提供相关链接),并将 Doctrine 库添加到 $ROOT/libs/doctrine。您必须在 $ROOT/app/config/settings.xml 中更新应用程序设置以激活数据库支持,然后在 $ROOT/app/config/databases.xml 中更新数据库配置文件以在 Agavi 中使用 Doctrine 适配器。
清单 2
的示例展示了该配置:
清单 2. Agavi Doctrine 适配器配置
mysql://user:pass@localhost/example
%core.lib_dir%/doctrine
至此,您可以使用 Doctrine 为这些表生成模型了。记得手动地将生成的模型类复制到 $ROOT/app/lib/doctrine/ 目录。
shell> cp /tmp/models/User.php app/lib/doctrine/
shell> cp /tmp/models/generated/BaseUser.php app/lib/doctrine/
shell> cp /tmp/models/UserAccess.php app/lib/doctrine/
shell> cp /tmp/models/generated/BaseUserAccess.php app/lib/doctrine/
这个 Agavi 系列的第 3 部分详细讨论了 Doctrine 与 Agavi 的集成,以及使用 Doctrine 从数据库表生成模型(在
参考资料
部分提供相关链接)。
步骤 2:添加各种登录和注销视图
在创建了模型之后,下一步是添加 LoginAction 和 LogoutAction。默认情况下,Agavi 的构建脚本已经在创建项目时创建了一个 LoginAction,因此您仅需创建一个 LogoutAction。
shell> agavi action-wizard
Module name: Default
Action name: Logout
Space-separated list of views to create for Save [Success]: Success
...
你还需要为 LoginInputView、LoginSuccessView 和 LoginErrorView 生成模板,如下所示:
shell> agavi template-create
Module name: Default
Template name: LoginSuccess
...
shell> agavi template-create
Module name: Default
Template name: LoginInput
...
shell> agavi template-create
Module name: Default
Template name: LoginError
...
在这些视图中,LoginInputView 可能是最重要的。它负责生成当用户试图访问受限制的操作时显示的登录表单。它还负责储存原始请求 URL,并在用户成功登录之后将其重定向到该 URL。根据 Agavi cookbook(在
参考资料
部分提供相关链接),最简单的方式是将原始请求 URL 储存在执行上下文中,如
清单 3
所示:
清单 3. LoginInputView 定义
getContainer()->hasAttributeNamespace(
'org.agavi.controller.forwards.login')) {
$this->getContext()->getUser()->setAttribute(
'redirect', $this->getContext()->getRequest()->getUrl(),
'org.agavi.example.login');
} else {
$this->getContext()->getUser()->removeAttribute(
'redirect', 'org.agavi.example.login');
}
$this->setupHtml($rd);
$this->setAttribute('_title', 'Login');
}
}
?>
清单 4
是对应的 LoginInput 模板的代码。
清单 4. LoginInput 模板
gen('login'); ?>" method="post">
Username:
Password:
和您期待的一样,这非常标准:一个包含用户名和密码字段的表单。
图 3
显示了它。
图 3. 应用程序的登录页面
![]()
清单 5
为
清单 4
中的表单提供了输入验证规则:
清单 5. LoginAction 验证器
username
ERROR: Username is missing
true
password
ERROR: Password is missing
true
假设成功登录之后,LoginSuccessView 将获取原始的 URL 请求并将客户端重定向到它(
清单 6
):
清单 6. LoginSuccessView 定义
getContext()->getUser()->hasAttribute(
'redirect', 'org.agavi.example.login')) {
$this->getResponse()->setRedirect($this->getContext()
->getUser()->removeAttribute(
'redirect', 'org.agavi.example.login'));
return true;
}
$this->setupHtml($rd);
$this->setAttribute('_title', 'Login');
}
}
?>
如果没有执行重定向,LoginSuccessView 将仅呈现包含登录确认消息的 LoginSuccess 模板(
清单 7
)。
清单 7. LoginSuccess 模板
You were successfully logged in.
另外,如果登录操作失败,将呈现 LoginError 模板(
清单 8
):
清单 8. LoginError 模板
There was an error logging you in. Please try again.
步骤 3:实现登录和注销操作
现在,您将向 LoginAction 添加一些代码。
清单 9
显示了一个这样的 LoginAction,它负责读取通过
清单 4
的表单提交的用户凭证,并根据储存在 MySQL 数据库中的信息验证它们。如果用户凭证有效,LoginAction 将使用 setAuthenticated() 方法设置一个身份验证标志并呈现 LoginSuccessView;否则,将返回 LoginErrorView。
清单 9. LoginAction 定义
getParameter('username');
$p = $rd->getParameter('password');
// check user credentials
$q = Doctrine_Query::create()
->from('User u')
->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
$result = $q->fetchArray();
// set authentication flag if valid
if (count($result) == 1) {
$this->getContext()->getUser()->setAuthenticated(true);
return 'Success';
} else {
return 'Error';
}
}
}
?>
LogoutAction 的作用刚好相反:它重置用户身份验证标志并结束用户会话。
清单 10
显示了它的代码:
清单 10. LogoutAction 定义
getContext()->getUser()->setAuthenticated(false);
return 'Success';
}
}
?>
步骤 4:更新应用程序的路由表
最后一个步骤是更新应用程序的路由表,为登录和注销操作添加更多路由。
清单 11
显示了更新后的路由:
清单 11. 更新后的应用程序路由
至此,您已经有了一个登录和注销系统。要试用,请访问 http://example.localhost/login 并输入与前面在用户表中设置的凭证匹配的凭证。如果一切顺利,您将看到一条成功登录消息,如
图 4
所示。
图 4. 成功登录的结果
![]()
如果您输入不正确的用户名和密码,将看到一条错误消息,如
图 5
所示。
图 5. 未成功登录的结果
![]()
![]()
![]()
![]()
![]()
回页首
基于密码的访问控制
有了可用的登录和注销框架之后,限制对某个操作的访问就非常简单了。为了进行演示,假设 Book 模块中的所有操作都需要身份验证。为此,只需编辑每个操作类并添加一个返回 true 的 isSecure() 方法。
清单 12
显示了修改后的 CreateAction:
清单 12. CreateAction 定义
现在,当您尝试访问任何这些操作时 —— 例如在 http://example.localhost/book/create 上访问 CreateAction —— Agavi 首先将您重定向到 LoginAction,并且在您输入有效的用户名和名称空间之后才显示所请求的 URL。
需要注意的是,当您为访问某个操作登录之后,同时也可以访问其他操作,并且不再收到登录提示。这很重要,因为它暴露了简单的、仅基于密码的方法的重大缺陷 —— 只要成功登录,就可以访问所有受保护的操作。换句话说,这种简单的方法不能区分不同的用户类型,因此不能用于需要细粒度访问控制的应用程序。对于这种情况,就应该使用基于特权的方法了。
![]()
![]()
![]()
![]()
回页首
基于特权的访问控制
使用基于特权的访问控制时,每个操作都需要特定的特权才能访问,并且每个获得授权的用户都拥有某些特权。只有具有特定操作的必要访问特权的用户才能访问该操作。这允许基于用户限制操作,从而提供更细粒度的用户访问控制。
步骤 1:设置所需的操作特权
为了演示该步骤的工作原理,假设对操作添加以下限制:
- CreateAction 和 DeleteAction 仅能被具有 “book.create” 特权的用户调用。
- EditAction 仅能被具有 “book.edit” 特权的用户调用。
- IndexAction 仅能被具有 “book.index” 特权的用户调用。
- DisplayAction 仅能被具有 “book.display” 特权的用户调用。
- SearchAction 仅能被同时具有 “book.index” 和 “book.display” 特权的用户调用。
在操作中使用操作的 getCredentials() 方法来设置这些特权,该方法返回使用操作所需的特权。考虑
清单 13
,它是修改后的 CreateAction:
清单 13. CreateAction 定义
类似地,
清单 14
更新了 IndexAction 以反映只有具有 “book.index” 特权的用户才能访问它:
清单 14. IndexAction 定义
清单 15
更新了 SearchAction,以反映需要 “book.index” 和 “book.display” 特权才能访问它,即通过 getCredentials() 方法返回一个数组:
清单 15. SearchAction 定义
步骤 2:设置用户特权
在配置好操作之后,下一步就是为用户分配特权。假设:
- 用户 “james” 拥有 “book.index” 特权
- 用户 “susan” 拥有 “book.index” 和 “book.display” 特权
- 用户 “marco” 拥有 “book.edit” 和 “book.display” 特权
- 用户 “donald” 拥有 “book.index”、“book.display” 和 “book.create” 特权
使用以下的 SQL 将这些特权添加到 MySQL 数据库:
mysql> INSERT INTO user_access (UserID, UserAccess) VALUES
-> (1, 'book.index'),
-> (2, 'book.index'),
-> (2, 'book.display'),
-> (3, 'book.display'),
-> (3, 'book.edit'),
-> (4, 'book.index'),
-> (4, 'book.display'),
-> (4, 'book.create');
Query OK, 8 rows affected (0.05 sec)
Records: 8 Duplicates: 0 Warnings: 0
图 6
显示了 MySQL 数据库中的用户和特权的关系(查看
文本格式的图 6
)。
图 6. 用户-特权映射
![]()
步骤 3:在运行时获取和分配用户特权
最后一个步骤是更新 LoginAction 以在登录时从数据库获取每个用户的特权,并将它们分配给用户对象。
清单 16
显示了更新后的代码:
清单 16. 更新后的 LoginAction 定义
getParameter('username');
$p = $rd->getParameter('password');
// check user credentials
$q = Doctrine_Query::create()
->from('User u')
->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
$result = $q->fetchArray();
// set authentication flag if valid
if (count($result) == 1) {
$this->getContext()->getUser()->setAuthenticated(true);
// get credentials and attach to user object
$this->getContext()->getUser()->clearCredentials();
$q = Doctrine_Query::create()
->from('UserAccess ua')
->where('ua.UserID = ?', array($result[0]['UserID']));
$rs = $q->fetchArray();
foreach ($rs as $r) {
$this->getContext()->getUser()->addCredential(trim($r['UserAccess']));
}
return 'Success';
} else {
return 'Error';
}
}
}
?>
在这里,当验证了用户的登录凭证之后,将执行另一个查询来获取其特权。然后使用 addCredential() 方法将这些特权分配给用户对象并呈现 LoginSuccessView。还要注意 clearCredentials() 方法,使用它来清除所有用户凭证。为了确保最高的安全性,应该在操作的用户凭证变化之前或在用户注销时执行清除。
要查看实际效果,请尝试作为 james 登录。在登录之后,您应该可以访问 IndexAction。不过,尝试访问其他操作时将收到一个 Access Denied 响应,如
图 7
所示。
图 7. 尝试访问带有特权的操作的结果
![]()
如果您作为 marco 登录,那么将可以访问 DisplayAction 和 EditAction,但不能访问其他操作。如果您作为 susan 登录,那么将可以访问 IndexAction、DisplayAction 和 SearchAction。如果您作为 donald 登录,那么将可以访问 IndexAction、DisplayAction、CreateAction、DeleteAction 和 SearchAction。
这种方法显然比前面显示的基于密码的方法提供更加精确的访问,并且推荐需要多级访问控制的应用程序使用该方法。不过,随着特权级别和用户的增加,维护用户-特权映射将变得越来越复杂和耗时。对于这种情况,就应该使用基于角色 的访问了。
![]()
![]()
![]()
![]()
回页首
实现基于角色的访问控制
RBAC 是用于在多用户、多特权应用程序中处理授权任务的流行技术。最关键的是,它允许您定义用户角色,并将这些角色分配给应用程序的用户。当用户登录时,他们将与一个或多个角色相关联起来,并且自动获得该角色自带的所有特权。
步骤 1:定义角色和特权
与 Agavi 中的许多其他东西一样,角色和特权是在一个 XML 配置文件中定义的,其默认位置为 $ROOT/app/config/rbac_definitions.xml。这个定义文件自动被 AgaviRbacSecurityUser 对象读取。
清单 17
显示了一个例子:
清单 17. 示例应用程序中的角色定义
book.index
book.display
book.create
book.display
book.edit
这个文件定义 4 个角色:manager、librarian、student 和 visitor。每个角色都与不同的特权相关联。如 XML 文件所示,角色是可以嵌套的:子角色继承父角色的特权。因此,manager 不仅拥有自己的定制特权,还有 student 和 visitor 的特权。
步骤 2:为用户分配角色
下一个步骤是为用户分配角色。假设:
- 用户 james 是一个 visitor。
- 用户 susan 是一个 student。
- 用户 marco 是一个 librarian。
- 用户 donald 是一个 manager。
您可以重用现有的 MySQL 表为每个用户分配角色,如下所示:
mysql> TRUNCATE TABLE user_access;
Query OK, 0 rows affected (0.06 sec)
mysql> INSERT INTO user_access (UserID, UserAccess) VALUES
-> (1, 'visitor'),
-> (2, 'student'),
-> (3, 'librarian'),
-> (4, 'manager');
Query OK, 4 rows affected (0.05 sec)
Records: 4 Duplicates: 0 Warnings: 0
图 8
显示了 MySQL 数据库中的用户和角色之间的关系(
查看文本格式的图 8
)。
图 8. 用户-角色映射
![]()
步骤 3:实例化 AgaviRbacSecurityUser 对象
默认情况下,Agavi 使用 AgaviSecurityUser 对象表示应用程序用户。不过,这个对象缺少运行时角色分配和撤销所需的方法。因此,您必须告诉 Agavi 通过编辑 $ROOT/app/config/factories.xml 实例化 AgaviRbacSecurityUser 对象(而不是 AgaviSecurityUser 对象),并更新类工厂列表,如
清单 18
所示:
清单 18. Agavi 类工厂配置
...
...
步骤 4:在运行时获取和分配用户角色
最后的步骤是更新 LoginAction 以在身份验证成功时从数据库为每个用户获取角色,然后将这些角色分配给用户对象。
清单 19
是 LoginAction 的更新后代码:
清单 19. 更新后的 LoginAction 定义
getParameter('username');
$p = $rd->getParameter('password');
// check user credentials
$q = Doctrine_Query::create()
->from('User u')
->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
$result = $q->fetchArray();
// set authentication flag if valid
if (count($result) == 1) {
$this->getContext()->getUser()->setAuthenticated(true);
// get and grant roles
$this->getContext()->getUser()->revokeAllRoles();
$q = Doctrine_Query::create()
->from('UserAccess ua')
->where('ua.UserID = ?', array($result[0]['UserID']));
$rs = $q->fetchArray();
foreach ($rs as $r) {
$this->getContext()->getUser()->grantRole(trim($r['UserAccess']));
}
return 'Success';
} else {
return 'Error';
}
}
}
?>
当用户的登录凭证通过验证之后,将执行另一个查询来获取他的角色。然后使用 grantRole() 方法将这些角色附加到 AgaviRbacSecurityUser 对象,接着呈现 LoginSuccessView。此外,还需要注意 revokeAllRoles() 方法,它负责在授予新角色之前或在用户注销时清除用户的现有角色。
如果您作为 james 登录,您将仅能够访问 IndexAction。如果您作为 marco 登录,您将仅能够访问 DisplayAction 和 EditAction,但不能访问其他操作。如果您作为 susan 登录,您将仅能够访问 IndexAction、DisplayAction 和 SearchAction。如果您作为 donald 登录,您将仅能够访问 IndexAction、DisplayAction、CreateAction、DeleteAction 和 SearchAction。
因为一个角色可以包含多个特权,并且一个用户可以拥有多个角色,所以 Agavi 的 RBAC 实现可以轻松创建一个多级特权结构。这也大大简化了维护任务,因为您可以根据每个角色更改特权,并且这些更改将自动映射到角色中的所有用户。为此,您仅需编辑角色定义 XML 文件。这尤其适合非常复杂、并且要求对用户能够访问哪些功能进行细粒度控制的应用程序。很明显,您可以(也应该)使用与用户管理需求相关的其他方法扩展基础的 AgaviRbacSecurityUser 对象。
![]()
![]()
![]()
![]()
回页首
结束语
从本文的讨论可以清楚的看到,Agavi 的访问控制机制具有广泛的适用性。不管您寻找的是平面特权系统还是结构化特权系统,Agavi 的内置对象都能够帮助您轻松实现一个安全的、健壮的架构,从而以简单、优雅的方式保护对应用程序的访问。Agavi 访问控制实现的模块化本质还意味着,您可以在实现阶段的任意时刻为应用程序添加访问控制,或者在应用程序部署之后添加,这也不会对现有的业务逻辑产生很大的影响。
下载
部分包含了本文例子的所有代码。我建议您下载使用它,并尝试为它添加新的东西。我可以保证不会给您带来任何损失,而是让您学到更多东西。祝您愉快!
![]()
![]()
![]()
![]()
回页首
下载
描述
名字
大小
下载方法
本文示例应用程序的压缩文件
example-app-rbac.zip
3784KB
HTTP
![]()
![]()
关于下载方法的信息
![]()
参考资料
学习
获得产品和技术
讨论
关于作者
![]()
![]()
![]()
Vikram Vaswani 是 Melonfire 的创始人和 CEO,该公司是一家专门研究开源工具和技术的咨询服务公司。他还著有 PHP Programming Solutions 和 How to do Everything with PHP and MySQL 等著作。
本文来自ChinaUnix博客,如果查看原文请点:http://blog.chinaunix.net/u2/86974/showart_2117981.html |
|