第五章 安全

在本章中,我们将会讨论如下话题:

  • 身份验证
  • 使用控制器过滤器
  • 防止XSS
  • 防止SQL注入
  • 防止CSRF
  • 使用RBAC
  • 加密/解密数据

介绍

安全是任何web应用非常重要的一部分。

在本章中,你将会学习如何根据一般web应用安全准则(过滤输入,转义输出)来保持你应用的安全性。我们将会讨论多个话题,例如创建自己的应用过滤器、防止XSS、CSRF和SQL注入,转移输出,以及使用基于角色的访问控制。欲了解安全最佳实践,参考http://www.yiiframework.com/doc-2.0/guidesecurity-best-practices.html#avoiding-debug-info-and-tools-at-production

身份验证

大部分应用都会为用户提供登录或者重置忘记的密码的功能。在Yii2中,缺省情况下,我们没有这个机会。对于basic应用模板,默认情况下,Yii只提供了两个测试用户,这个两个用户是在User模型中写死的。所以,我们必须实现特殊的代码,来使得用户能数据库中登录。

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 在你的配置的组件部分,添加:
  1. 'user' => [
  2. 'identityClass' => 'app\models\User',
  3. 'enableAutoLogin' => true,
  4. ],
  1. 创建一个User表。输入如下命令创建migration:
  1. ./yii migrate/create create_user_table
  1. 更新刚刚创建的migration:
  1. <?php
  2. use yii\db\Schema;
  3. use yii\db\Migration;
  4. class m150626_112049_create_user_table extends Migration
  5. {
  6. public function up()
  7. {
  8. $tableOptions = null;
  9. if ($this->db->driverName === 'mysql') {
  10. $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB';
  11. }
  12. $this->createTable('{{%user}}', [
  13. 'id' => Schema::TYPE_PK,
  14. 'username' => Schema::TYPE_STRING . ' NOT NULL',
  15. 'auth_key' => Schema::TYPE_STRING . '(32) NOT NULL',
  16. 'password_hash' => Schema::TYPE_STRING . ' NOT NULL',
  17. 'password_reset_token' => Schema::TYPE_STRING,
  18. ], $tableOptions);
  19. }
  20. public function down()
  21. {
  22. $this->dropTable('{{%user}}');
  23. }
  24. }
  1. 更新已存在的模型models/User
  1. <?php
  2. namespace app\models;
  3. use yii\db\ActiveRecord;
  4. use yii\web\IdentityInterface;
  5. use yii\base\NotSupportedException;
  6. use Yii;
  7. class User extends ActiveRecord implements IdentityInterface
  8. {
  9. /**
  10. * @inheritdoc
  11. */
  12. public function rules()
  13. {
  14. return [
  15. ['username', 'required'],
  16. ['username', 'unique'],
  17. ['username', 'string', 'min' => 3],
  18. ['username', 'match', 'pattern' =>
  19. '~^[A-Za-z][A-Za-z0-9]+$~', 'message' => 'Username can contain only alphanumeric characters.'],
  20. [['username', 'password_hash',
  21. 'password_reset_token'],
  22. 'string', 'max' => 255
  23. ],
  24. ['auth_key', 'string', 'max' => 32],
  25. ];
  26. }
  27. /**
  28. * @inheritdoc
  29. */
  30. public static function findIdentity($id)
  31. {
  32. return static::findOne($id);
  33. }
  34. public static function findIdentityByAccessToken($token, $type = null)
  35. {
  36. throw new NotSupportedException('"findIdentityByAccessToken" is not implemented.');
  37. }
  38. /**
  39. * Finds user by username
  40. *
  41. * @param string $username
  42. * @return User
  43. */
  44. public static function findByUsername($username)
  45. {
  46. return static::findOne(['username' => $username]);
  47. }
  48. /**
  49. * @inheritdoc
  50. */
  51. public function getId()
  52. {
  53. return $this->getPrimaryKey();
  54. }
  55. /**
  56. * @inheritdoc
  57. */
  58. public function getAuthKey()
  59. {
  60. return $this->auth_key;
  61. }
  62. /**
  63. * @inheritdoc
  64. */
  65. public function validateAuthKey($authKey)
  66. {
  67. return $this->getAuthKey() === $authKey;
  68. }
  69. /**
  70. * Validates password
  71. *
  72. * @param string $password password to validate
  73. * @return boolean if password provided is valid for current
  74. user
  75. */
  76. public function validatePassword($password)
  77. {
  78. return Yii::$app->getSecurity()->validatePassword($password, $this->password_hash);
  79. }
  80. /**
  81. * Generates password hash from password and sets it to the model
  82. *
  83. * @param string $password
  84. */
  85. public function setPassword($password)
  86. {
  87. $this->password_hash =
  88. Yii::$app->getSecurity()->generatePasswordHash($password);
  89. }
  90. /**
  91. * Generates "remember me" authentication key
  92. */
  93. public function generateAuthKey()
  94. {
  95. $this->auth_key =
  96. Yii::$app->getSecurity()->generateRandomString();
  97. }
  98. /**
  99. * Generates new password reset token
  100. */
  101. public function generatePasswordResetToken()
  102. {
  103. $this->password_reset_token =
  104. Yii::$app->getSecurity()->generateRandomString() . '_' . time();
  105. }
  106. /**
  107. * Finds user by password reset token
  108. *
  109. * @param string $token password reset token
  110. * @return static|null
  111. */
  112. public static function findByPasswordResetToken($token)
  113. {
  114. $expire =
  115. Yii::$app->params['user.passwordResetTokenExpire'];
  116. $parts = explode('_', $token);
  117. $timestamp = (int) end($parts);
  118. if ($timestamp + $expire < time()) {
  119. return null;
  120. }
  121. return static::findOne([
  122. 'password_reset_token' => $token
  123. ]);
  124. }
  125. }
  1. 创建一个migration,它会添加一个测试用户:
  1. ./yii migrate/create create_test_user
  1. 更新刚刚创建的migrate:
  1. <?php
  2. use yii\db\Migration;
  3. use app\models\User;
  4. class m150626_120355_create_test_user extends Migration
  5. {
  6. public function up()
  7. {
  8. $testUser = new User();
  9. $testUser->username = 'admin';
  10. $testUser->setPassword('admin');
  11. $testUser->generateAuthKey();
  12. $testUser->save();
  13. }
  14. public function down()
  15. {
  16. User::findByUsername('turbulence')->delete();
  17. return false;
  18. }
  19. }
  1. 安装所有的migration:
  1. ./yii migrate up

如何做…

  1. 访问site/login,输入admin/admin凭证:

第五章 安全 - 图1

  1. 如果你完成了这些步骤,你就能登录。

工作原理…

  1. 首先,为用户表创建一个migration。除了ID和用户名,我们的表还包含特殊的字段,例如auth_key(主要用途是通过cookie验证用户的身份),password_hash(处于安全原因,我们不能存储密码本身,而应该只是存储密码的hash),以及password_reset_token(当用户需要重置密码时使用)。
  2. 安装和create_test_user migration之后的结果如下图所示:

第五章 安全 - 图2

我们已经为User模型添加了特殊的方法,并且修改了继承class User extends ActiveRecord implements IdentityInterface,因为我们需要能从数据库中找到用户。

你也可以从高级apphttps://github.com/yiisoft/yii2-appadvanced/blob/master/common/models/User.php中复制用户模型`User。

参考

欲了解更多信息,参考http://www.yiiframework.com/doc-2.0/guide-securityauthentication.html

使用控制器过滤器

在许多例子中,我们需要过滤输入的数据,或者基于这些数据执行一些动作。例如,使用自定义过滤器,我们可以使用IP过滤访问者,强制用户使用HTTPS,或者在使用应用之前,重定向用户到一个安装页面。

在Yii2中,过滤器本质上是一种特殊的behavior,所以使用过滤器和使用behavior是一样的。

Yii有许多内置的过滤器:

  • Core
  • Custom
  • Authentication
  • Content Negotiator
  • HttpCache
  • PageCache
  • RateLimiter
  • Verb
  • Cors

在本小节中,我们将实现如下内容:

  • 对控制器动作的访问限制到只有登录的用户
  • 对控制器动作的访问限制到指定的IP
  • 只允许指定用户角色访问

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 创建app/components/AccessRule.php
  1. <?php
  2. namespace app\components;
  3. use app\models\User;
  4. class AccessRule extends \yii\filters\AccessRule {
  5. /**
  6. * @inheritdoc
  7. */
  8. protected function matchRole($user)
  9. {
  10. if (empty($this->roles)) {
  11. return true;
  12. }
  13. $isGuest = $user->getIsGuest();
  14. foreach ($this->roles as $role) {
  15. switch($role) {
  16. case '?':
  17. return ($isGuest) ? true : false;
  18. case User::ROLE_USER:
  19. return (!$isGuest) ? true : false;
  20. case $user->identity->role: // Check if the user is logged in, and the roles match
  21. return (!$isGuest) ? true : false;
  22. default:
  23. return false;
  24. }
  25. }
  26. return false;
  27. }
  28. }
  1. 创建app/controllers/AccessController.php
  1. <?php
  2. namespace app\controllers;
  3. use app\models\User;
  4. use Yii;
  5. use yii\filters\AccessControl;
  6. use app\components\AccessRule;
  7. use yii\web\Controller;
  8. class AccessController extends Controller
  9. {
  10. public function behaviors()
  11. {
  12. return [
  13. 'access' => [
  14. 'class' => AccessControl::className(),
  15. // We will override the default rule config with the new AccessRule class
  16. 'ruleConfig' => [
  17. 'class' => AccessRule::className(),
  18. ],
  19. 'rules' => [
  20. [
  21. 'allow' => true,
  22. 'actions' => ['auth-only'],
  23. 'roles' => [User::ROLE_USER]
  24. ],
  25. [
  26. 'allow' => true,
  27. 'actions' => ['ip'],
  28. 'ips' => ['127.0.0.1'],
  29. ],
  30. [
  31. 'allow' => true,
  32. 'actions' => ['user'],
  33. 'roles' => [ User::ROLE_ADMIN],
  34. ],
  35. [
  36. 'allow' => false,
  37. ]
  38. ],
  39. ]
  40. ];
  41. }
  42. public function actionAuthOnly()
  43. {
  44. echo "Looks like you are authorized to run me.";
  45. }
  46. public function actionIp()
  47. {
  48. echo "Your IP is in our list. Lucky you!";
  49. }
  50. public function actionUser()
  51. {
  52. echo "You're the right man. Welcome!";
  53. }
  54. }
  1. 修改User类:
  1. <?php
  2. namespace app\models;
  3. class User extends \yii\base\Object implements \yii\web\IdentityInterface
  4. {
  5. // add roles contstants
  6. CONST ROLE_USER = 200;
  7. CONST ROLE_ADMIN = 100;
  8. public $id;
  9. public $username;
  10. public $password;
  11. public $authKey;
  12. public $accessToken;
  13. public $role;
  14. private static $users = [
  15. '100' => [
  16. 'id' => '100',
  17. 'username' => 'admin',
  18. 'password' => 'admin',
  19. 'authKey' => 'test100key',
  20. 'accessToken' => '100-token',
  21. 'role' => USER::ROLE_ADMIN // add admin role for admin user
  22. ],
  23. '101' => [
  24. 'id' => '101',
  25. 'username' => 'demo',
  26. 'password' => 'demo',
  27. 'authKey' => 'test101key',
  28. 'accessToken' => '101-token',
  29. 'role' => USER::ROLE_USER // add user role for admin user
  30. ],
  31. ];
  32. //…
  33. }

如何做…

  1. 为了使用AccessControl,在你的控制器的behaviors()方法中声明:
  1. public function behaviors()
  2. {
  3. return [
  4. 'access' => [
  5. 'class' => AccessControl::className(),
  6. 'rules' => [
  7. [
  8. 'allow' => true,
  9. 'actions' => ['auth-only'],
  10. 'roles' => ['@'],
  11. ],
  12. [
  13. 'allow' => true,
  14. 'actions' => ['ip'],
  15. 'ips' => ['127.0.0.1'],
  16. ],
  17. [
  18. 'allow' => true,
  19. 'actions' => ['user'],
  20. 'roles' => ['admin'],
  21. ],
  22. [
  23. 'allow' => true,
  24. 'actions' => ['user'],
  25. 'matchCallback' => function ($rule, $action) {
  26. return preg_match('/MSIE9/',$_SERVER['HTTP_USER_AGENT']) !== false;
  27. }
  28. ],
  29. ['allow' => false]
  30. ],
  31. ]
  32. ];
  33. }
  1. 尝试使用IE浏览器和其他浏览器运行控制器动作,使用admindemo用户。

工作原理…

我们开始限制控制器动作给已经登录的用户,查看如下rules数组中的代码:

  1. [
  2. 'allow' => true,
  3. 'actions' => ['auth-only'],
  4. 'roles' => [User::ROLE_USER]
  5. ],

每一个数组都是一个访问规则。你可以使用allow=true或者allow=>false。对于每一个规则,有若干个参数。

缺省情况下,Yii不会拒绝任何事情,所以如果你需要最大程度的安全,考虑添加['allow' => false]到规则的末尾。

在我们的规则中,我们使用了两个参数。第一个是动作参数,它指定的该规则会应用在哪些动作上。第二个参数时角色参数,它指定了该规则会应用于哪些角色上。

Yii2内置访问控制默认只支持两个角色:游客(未登录),用符号?指定,登录的用户,用服务@指定。

使用简单的访问控制,我们可以基于用户的登录状态限制对指定页面的访问。如果用户在未登录状态下访问这些页面,Yii会将他们重定向到登录页面。

规则会一个接一个执行,从第一个开始,直到能匹配上一个。如果没有一个能匹配,那么该访问被认为是允许的。

下一个任务是限制指定IP的访问。在这个例子中,涉及如下两个访问规则:

  1. [
  2. 'allow' => true,
  3. 'actions' => ['ip'],
  4. 'ips' => ['127.0.0.1'],
  5. ],

第一个规则允许指定IP列表中的IP访问。在我们的例子中,我们使用了一个回路地址,它指向我们自己的电脑。尝试将其修改为127.0.0.2,看看当IP地址不匹配时是什么表现。第二个规则是拒绝所以,包括其它所有IP。

接下来,我们只允许指定用户角色访问:

  1. [
  2. 'allow' => true,
  3. 'actions' => ['user'],
  4. 'roles' => [ User::ROLE_ADMIN],
  5. ],

上边的规则允许admin角色的用户访问user动作,因此,如果你以admin登录,你将可以访问,但如果你以demo登录,将会被拒绝。

第五章 安全 - 图3

我们重写了标准AccessRule类,存放在components/AccessRule.php。在我们的AccessRule类内部,我们重写了matchRole方法,在这我们获取并检查了当前用户的角色,并使用我们的规则进行匹配。

最后,我们需要拒绝指定浏览器的访问。在本小节中,我们只拒绝IE 9的访问。这个规则被放在了首位,所以它首先会执行:

  1. [
  2. 'all`ow' => true,
  3. 'actions' => ['user'],
  4. 'matchCallback' => function ($rule, $action) {
  5. return preg_match('/MSIE 9/',$_SERVER['HTTP_USER_AGENT'])!==false;
  6. }`
  7. ],

我们使用的这个检测技术是不可靠的,因为MSIE在其它很多用户代理中都会包含。想了解用户代理字符串的列表,可以参考http://www.useragentstring.com/

在上边的代码中,我们使用了另外一个过滤规则属性,名叫matchCallback。这个属性表明只有当函数中的属性返回true时规则会生效。

我们的函数检查用户代理字符串是否包含MSIE 9.0字符串。你可以根据自己的需求,指定任何PHP代码。

参考

为了了解更多有关访问控制和过滤器的信息,参考如下地址:

防止XSS

XSS代表跨站脚本,它允许注入一个客户端的脚本(通常是JavaScript)到被用户观看的网页。考虑客户端脚本的能力,这会导致非常严重的后果,比如绕过安全检查、获取其它用户的身份或者数据泄露。

在本小节中,我们将会看到如何使用\yii\helpers\Html\yii\helpers\HtmlPurifier来转义输出从而防止XSS。

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 创建controllers/XssController.php
  1. <?php
  2. namespace app\controllers;
  3. use Yii;
  4. use yii\helpers\Html;
  5. use yii\web\Controller;
  6. /**
  7. * Class SiteController.
  8. * @package app\controllers
  9. */
  10. class XssController extends Controller
  11. {
  12. /**
  13. * @return string
  14. */
  15. public function actionIndex()
  16. {
  17. $username = Yii::$app->request->get('username', 'nobody');
  18. return $this->renderContent(Html::tag('h1',
  19. 'Hello, ' . $username . '!'
  20. ));
  21. }
  22. }
  1. 通常情况下,他会被使用为/xss/simple?username=Administrator。然而,因为没有考虑安全准则过滤输入,转移输出,恶意的用户能够使用如下方式使用它:
  1. /xss/simple?username=<script>alert('XSS');</script>
  1. 上边的代码将会导致一个脚本注入,如下截图所示:

第五章 安全 - 图4

如何做…

执行如下步骤:

  1. 为了防止上边屏幕截图中的XSS警报,我们需要将其传给浏览器之前进行转义。方法如下:
  1. <?php
  2. namespace app\controllers;
  3. use Yii;
  4. use yii\helpers\Html;
  5. use yii\web\Controller;
  6. /**
  7. * Class SiteController.
  8. * @package app\controllers
  9. */
  10. class XssController extends Controller
  11. {
  12. /**
  13. * @return string
  14. */
  15. public function actionIndex()
  16. {
  17. $username = Yii::$app->request->get('username', 'nobody');
  18. return $this->renderContent(Html::tag('h1', Html::encode('Hello, ' . $username . '!')));
  19. }
  20. }
  1. 现在你就不会看到警告了,而是得到正确的转义的HTML,截图如下所示:

第五章 安全 - 图5

  1. 因此,基本的规则是,转义所有动态的数据。例如,我们应该为名字链接做同样的转义:
  1. use \yii\helpers\Html;
  2. echo Html::a(Html::encode($_GET['username']), array());

完成了。你的页面能防止XSS。如果我们希望一些HTML能通过怎么办?我们不能再使用\yii\helpers\Html::encode,因为它会将HTML输出为代码,而我们需要是的是实际的表示。幸运的是,Yii有一个工具,它能让你过滤恶意的HTML。这个工具名叫HTML Purifier,使用方法如下:

  1. <?php
  2. namespace app\controllers;
  3. use Yii;
  4. use yii\helpers\Html;
  5. use yii\helpers\HtmlPurifier;
  6. use yii\web\Controller;
  7. /**
  8. * Class SiteController.
  9. * @package app\controllers
  10. */
  11. class XssController extends Controller
  12. {
  13. /**
  14. * @return string
  15. */
  16. public function actionIndex()
  17. {
  18. $username = Yii::$app->request->get('username', 'nobody');
  19. $content = Html::tag('h1', 'Hello, ' . $username . '!');
  20. return $this->renderContent(
  21. HtmlPurifier::process($content)
  22. );
  23. }
  24. }

现在如果我们使用如下地址访问/xss/index?username=<i>username</i>!<script>alert('XSS')</script>,HTML净化器会移除恶意的部分,我们将会得到如下结果:

第五章 安全 - 图6

工作原理

  1. 深入底层看,\yii\helpers\Html::encode类似如下所示:
  1. public static function encode($content, $doubleEncode = true)
  2. {
  3. return htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE,
  4. Yii::$app ? Yii::$app->charset : 'UTF-8',
  5. $doubleEncode);
  6. }
  1. 所以本质上是用PHP的htmlspecialchars函数,只要第三个参数能正确传递,这个函数非常安全。

\yii\helpers\HtmlPurifier使用HTML净化库,这是解决HTML中XSS最先进的解决方案。我们使用了它的默认配置,这对绝大部分用户输入的内容都是有效的。

更多…

关于XSS和HTML净化器需要多了解一些东西。接下来的部分就会讨论。

XSS类型

XSS注入有两个主要的类型,如下:

  • 非持久
  • 持久

第一种类型是本小节中最常用的XSS类型;在大部分不安全的web应用中都可以发现。用户传递的数据不会被存储,所以注入脚本只有当用户输入的时候才会执行。但是,这看上去仍然不安全。恶意的用户可以将XSS放在一个链接中,然后放在别的网站上;当其它用户点击访问时,就会发生XSS注入。

第二种情况更验证,因为由恶意用户输入的数据被存储在了数据库中,并被展示给了很多网络用户。使用这种类型的XSS,恶意用户甚至可以通过命令,使其它用户删除他们能访问的数据,摧毁你的网站。

参考

欲了解更多关于XSS以及如何处理它,参考如下资源:

防止SQL注入

SQL注入是一种代码注入,它利用了数据库层的易损性,允许你执行任意SQL,允许恶意用户删除数据或者提升自己的权限。

在本小节中,我们将会看到易损代码的例子,以及如何修复他们。

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 执行如下SQL:
  1. DROP TABLE IF EXISTS `user`;
  2. CREATE TABLE `user` (
  3. `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  4. `username` varchar(100) NOT NULL,
  5. `password` varchar(32) NOT NULL,
  6. PRIMARY KEY (`id`)
  7. );
  8. INSERT INTO `user`(`id`,`username`,`password`) VALUES ('1','Alex','202cb962ac59075b964b07152d234b70');
  9. INSERT INTO `user`(`id`,`username`,`password`) VALUES ('2','Qiang','202cb962ac59075b964b07152d234b70');
  1. 使用Gii生成User模型。

如何做…

  1. 首先,我们将会实现一个简单的动作,检查通过URL传递过来的用户名和密码是否是正确的。创建app/controllers/SqlController.php
  1. <?php
  2. namespace app\controllers;
  3. use app\models\User;
  4. use Yii;
  5. use yii\base\Controller;
  6. use yii\base\Exception;
  7. use yii\helpers\ArrayHelper;
  8. use yii\helpers\Html;
  9. /**
  10. * Class SqlController.
  11. * @package app\controllers
  12. */
  13. class SqlController extends Controller
  14. {
  15. protected function renderContentByResult($result)
  16. {
  17. if ($result) {
  18. $content = "Success";
  19. } else {
  20. $content = "Failure";
  21. }
  22. return $this->renderContent($content);
  23. }
  24. public function actionSimple()
  25. {
  26. $userName = Yii::$app->request->get('username');
  27. $password = Yii::$app->request->get('password');
  28. $passwordHash = md5($password);
  29. $sql = "SELECT * FROM `user`"
  30. ." WHERE `username` = '".$userName."'"
  31. ." AND password = '".$passwordHash."' LIMIT |1";
  32. $result = Yii::$app->db->createCommand($sql)->queryOne();
  33. return $this->renderContentByResult($result);
  34. }
  35. }
  1. 访问/sql/simple?username=test&password=test。因为我们不知道两组用户名和密码,正如所预料的,会打印失败。
  2. 现在尝试访问/sql/simple?username=%27+or+%271%27%3D%271%27%3B+--&password=whatever。这一次,它让我们通过了,尽管实际上我们并不知道真正的身份。解压的部分username的值如下所示:
  1. ' or '1'='1'; --
  1. 关闭quote,从而语法是正确的。添加OR '1'='1',它使得条件永远是正确的。使用; --来结束查询并注释剩余的部分。
  2. 因为没有做转义,整个查询语句是:
  1. SELECT * FROM user WHERE username = '' or '1'='1'; --' AND password = '008c5926ca861023c1d2a36653fd88e2' LIMIT 1;
  1. 修复这个问题最好的方法是使用prepared statement,如下所示:
  1. public function actionPrepared()
  2. {
  3. $userName = Yii::$app->request->get('username');
  4. $password = Yii::$app->request->get('password');
  5. $passwordHash = md5($password);
  6. $sql = "SELECT * FROM `user`"
  7. ." WHERE `username` = :username"
  8. ." AND password = :password LIMIT 1";
  9. $command = Yii::$app->db->createCommand($sql);
  10. $command->bindValue(':username', $userName);
  11. $command->bindValue(':password', $passwordHash);
  12. $result = $command->queryOne();
  13. return $this->renderContentByResult($result);
  14. }
  1. 现在使用相同的恶意参数检查/sql/prepared。这一次是正常的并且收到了失败的消息。相同的准则被应用到了ActiveRecord上。唯一的不同是AR使用了其它语法:
  1. public function actionAr()
  2. {
  3. $userName = Yii::$app->request->get('username');
  4. $password = Yii::$app->request->get('password');
  5. $passwordHash = md5($password);
  6. $result = User::findOne([
  7. 'username' => $userName,
  8. 'password' => $passwordHash
  9. ]);
  10. return $this->renderContentByResult($result);
  11. }
  1. 在先前的代码中,我们以键值对的样式使用了usernamepassword参数。先前的代码我们只使用了第一个参数,这会很容易受到攻击:
  1. public function actionWrongAr()
  2. {
  3. $userName = Yii::$app->request->get('username');
  4. $password = Yii::$app->request->get('password');
  5. $passwordHash = md5($password);
  6. $condition = "`username` = '".$userName." AND `password` ='".$passwordHash."'";
  7. $result = User::find()->where($condition)->one();
  8. return $this->renderContentByResult($result);
  9. }
  1. 如果正确使用,prepared statement可以防止所有类型的SQL注入。但是,这里还会有一些常见的问题:
  • 你可以为一个参数绑定一个值,所以,如果你希望查询WHERE IN (1,2,3,4),你必须创建和绑定4个参数。
  • prepared statement不能用于表名,列名,以及其它关键词。
  1. 当使用ActiveRecord时,通过添加where可以解决第一个问题:
  1. public function actionIn()
  2. {
  3. $names = ['Alex', 'Qiang'];
  4. $users = User::find()->where(['username' => $names])->all();
  5. return $this->renderContent(Html::ul(
  6. ArrayHelper::getColumn($users, 'username')
  7. ));
  8. }
  1. 第二个问题有多种解决方法。第一种方法是依赖active record和PDO quoting:
  1. public function actionColumn()
  2. {
  3. $attr = Yii::$app->request->get('attr');
  4. $value = Yii::$app->request->get('value');
  5. $users = User::find()->where([$attr => $value])->all();
  6. return $this->renderContent(Html::ul(
  7. ArrayHelper::getColumn($users, 'username')
  8. ));
  9. }
  1. 但是最安全的方法是使用白名单:
  1. public function actionWhiteList()
  2. {
  3. $attr = Yii::$app->request->get('attr');
  4. $value = Yii::$app->request->get('value');
  5. $allowedAttr = ['username', 'id'];
  6. if (!in_array($attr, $allowedAttr)) {
  7. throw new Exception("Attribute specified is not allowed.");
  8. }
  9. $users = User::find()->where([$attr => $value])->all();
  10. return $this->renderContent(Html::ul(
  11. ArrayHelper::getColumn($users, 'username')
  12. ));
  13. }

工作原理…

防止SQL注入时,主要的目标是正确过滤输入。在所有的情况下,除了表名,我们使用了prepared statements——大多数关系数据库都支持的特性。他们允许你创建一次statement,然后多次使用,他们提供了安全的方法来绑定参数。

在Yii中,你可以为Active Record和DAO使用prepared statement。当使用DAO时,可以使用bindValuebindParam来达到目的。当我们希望执行多个同类型但值不同的查询时,非常有用。

  1. public function actionBind()
  2. {
  3. $userName = 'Alex';
  4. $passwordHash = md5('password1');
  5. $sql = "INSERT INTO `user` (`username`, `password`) VALUES (:username, :password);";
  6. // insert first user
  7. $command = Yii::$app->db->createCommand($sql);
  8. $command->bindParam('username', $userName);
  9. $command->bindParam('password', $passwordHash);
  10. $command->execute();
  11. // insert second user
  12. $userName = 'Qiang';
  13. $passwordHash = md5('password2');
  14. $command->execute();
  15. return $this->renderContent(Html::ul(
  16. ArrayHelper::getColumn(User::find()->all(), 'username')
  17. ));
  18. }

大部分Active Record方法接受参数。安全起见,你应该使用他们,而不是将原始数据传进去。

至于quoting表名,列和其它关键词,你可以依赖Active Record,或者使用白名单方法。

参考

欲了解更多关于SQL注入,以及使用Yii配合数据库工作,参考如下地址:

防止CSRF

CSRF是一个跨站请求伪造(cross-site request forgery)的缩写,恶意用户可以欺骗用户的浏览器,当用户登录时静默执行一次HTTP请求。

一个例子就是将一张不可见的图片标签的src属性指向http://example.com/site/logout。尽管image标签是在其他网站上,你仍然会从example.com上注销。CSRF的后果非常严重:破坏网站数据,阻止所有的用户登录,暴露私有数据等等。

关于CSRF的事实是:

  • 因为CSRF一般是由受害者的浏览器执行的,攻击者一般不能修改HTTP请求头。但是,仍有一些浏览器和Flash插件漏洞允许用户篡改请求头,所以我们不能只依赖于这些。
  • 攻击者应该传送和普通用户一样的参数和值。

处理CSRF一种比较好的方法是在表单提交,以及使用HTTP规范的GET请求时,传送和检查一个唯一的token。

Yii包括一个内置的token生成器和token检查。此外,它可以自动插入一个token到HTML表单中。

为了防止CSRF,你应该做到如下这些:

  • GET请求不能修改应用的状态
  • 保持Yii CSRF保护一直是开着的

在本小节中,我们将会看到如何确保我们的应用能免受CSRF攻击。

准备

按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。

如何做…

  1. 为了打开放CSRF保护,我们应该在config/main.php中添加如下内容:
  1. 'components' => [
  2. //..
  3. 'request' => [
  4. //..
  5. 'enableCsrfValidation' => true,
  6. //..
  7. ],
  8. //..
  9. ],
  1. 选项enableCsrfValidation的缺省值是true。当CSRF校验是激活状态时,提交到Yii web应用的表单必须来自同一应用。如果不是的话,将会返回400 HTTP exception

注意到这个特性要求用户的客户端接受cookies。

  1. 配置好应用以后,你应该使用ActiveForm::beginFormCHtml::endForm,而不是HTML表单标签:
  1. <?php $form = ActiveForm::begin(['id' => 'login-form']); ?>
  2. <input type='text' name='name'
  3. .........
  4. <?php ActiveForm::end(); ?>
  1. 或者手工添加:
  1. <form action='#' method='POST'>
  2. <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?=Yii::$app->request->getCsrfToken()?>" />
  3. ....
  4. </form>
  1. 在第一个例子中,Yii自动添加一个隐藏的token字段:
  1. <form action="/csrf/create" method="post">
  2. <div style="display:none"><input type="hidden" value="e4d1021e79ac269e8d6289043a7a8bc154d7115a" name="YII_CSRF_TOKEN" />
  1. 如果你将这个表单保存为HTML,并尝试提交,你将会得到如下一个错误信息,截图如下所示:

第五章 安全 - 图7

工作原理…

本质上,在渲染表单时,我们做如下代码:

  1. if ($request->enableCsrfValidation && !strcasecmp($method, 'post')) {
  2. $hiddenInputs[] = static::hiddenInput($request->csrfParam, $request->getCsrfToken());
  3. }
  4. if (!empty($hiddenInputs)) {
  5. $form .= "\n" . implode("\n", $hiddenInputs);
  6. }

在先前的代码中,getCsrfToken()生成一个唯一的token值,并将它写到一个cookie中。然后,在接下来的请求中,cookie和POST值做了比较。如果他们不匹配,将会展示一条错误信息,而不是正常的数据处理。

如果你需要执行一个POST请求,打不希望使用CHtml构建一个表单,你可以传递一个参数,名称从Yii::app()->request->csrfParam获取,值从Yii::$app->request->getCsrfToken()获取。

更多…

来看一些更多的特性。

为所有的动作禁用CSRF tokens。

  1. 如果你对使用enableCsrfValidation有问题,你可以关闭它。
  2. 为了禁用CSRF,将这个代码添加到你的控制器中:
  1. public function beforeAction($action) {
  2. $this->enableCsrfValidation = false;
  3. return parent::beforeAction($action);
  4. }

为一个指定的动作禁用CSRF tokens

  1. public function beforeAction($action) {
  2. $this->enableCsrfValidation = ($action->id !== "actionId");
  3. return parent::beforeAction($action);
  4. }

为Ajax调用执行CSRF校验

当在main布局中启用enableCsrfValidation选项时,添加csrfMetaTags

  1. <head>
  2. .......
  3. <?= Html::csrfMetaTags() ?>
  4. </head>

现在你可以将其添加到ajax调用上

  1. var csrfToken = $('meta[name="csrf-token"]').attr("content");
  2. $.ajax({
  3. url: 'request'
  4. type: 'post',
  5. dataType: 'json',
  6. data: {param1: param1, _csrf : csrfToken},
  7. });

更多

如果你的应用需要更高级别的安全,例如是一个银行管理系统,需要采取更多的措施。

首先,你可以在config/main.php中关闭“记住我”特性:

  1. 'components' => [
  2. ..
  3. 'user' => [
  4. ..
  5. 'enableAutoLogin' => false,
  6. ..
  7. ],
  8. ..
  9. ],

注意到如果enabledSession选项是true时,将不会有效。

然后,你可以降低session过期时间:

  1. 'components' => [
  2. ..
  3. 'session' => [
  4. ..
  5. 'timeout' => 200,
  6. ..
  7. ],
  8. ..
  9. ],

这为数据设置了过期时间,过期之后,数据会被当做垃圾,并被清理掉。

当然,这些措施会降低用户体验,但是会提升安全属性。

正确使用GET和POST

HTTP不建议使用GET方法来修改数据和状态。遵守这个规则是一个好的实践。它不会防止所有类型的CSRF,但是至少会防止一些注入,例如<img src=, pointless>

参考

为了了解更多SQL注入,使用Yii处理数据库,参考如下地址:

使用RBAC

基于角色的访问控制(RBAC)提供了简单但是非常强大的中心化访问控制。它是Yii中最强大的访问控制方法。在指导中有关于它的描述,但因为比较复杂和强大,如果不了解一些底层原理的话,比较难以理解。

在本小节中,我们将会take the roles hierarchy from the definitive guide, import it, and explain what is happening internally.

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 创建一个MySQL数据库并配置。
  3. config/main.phpconfig/console.php中配置authManager组件:
  1. return [
  2. // ...
  3. 'components' => [
  4. 'authManager' => [
  5. 'class' => 'yii\rbac\DbManager',
  6. ],
  7. // ...
  8. ],
  9. ];
  1. 运行migration:
  1. yii migrate --migrationPath=@yii/rbac/migrations

如何做…

执行如下步骤:

  1. 创建访问规则rbac/AuthorRule.php
  1. <?php
  2. namespace app\rbac;
  3. use yii\rbac\Rule;
  4. /**
  5. * Class AuthorRule.
  6. * @package app\rbac
  7. */
  8. class AuthorRule extends Rule
  9. {
  10. public $name = 'isAuthor';
  11. /**
  12. * @param int|string $user
  13. * @param \yii\rbac\Item $item
  14. * @param array $params
  15. *
  16. * @return bool
  17. */
  18. public function execute($user, $item, $params)
  19. {
  20. return isset($params['post']) ?
  21. $params['post']->createdBy == $user : false;
  22. }
  23. }
  1. 创建一个控制台命令command/RbacController.phpinitRBAC规则命令:
  1. <?php
  2. namespace app\commands;
  3. use app\models\User;
  4. use Yii;
  5. use yii\console\Controller;
  6. /**
  7. * Class RbacController.
  8. * @package app\commands
  9. */
  10. class RbacController extends Controller
  11. {
  12. public function actionInit()
  13. {
  14. $auth = Yii::$app->authManager;
  15. $createPost = $auth->createPermission('createPost');
  16. $createPost->description = 'Create a post';
  17. $updatePost = $auth->createPermission('updatePost');
  18. $updatePost->description = 'Update a post';
  19. $updatePost = $auth->createPermission('updatePost');
  20. $updatePost->description = 'Update a post';
  21. $deletePost = $auth->createPermission('deletePost');
  22. $deletePost->description = 'Delete a post';
  23. $readPost = $auth->createPermission('readPost');
  24. $readPost->description = 'Read a post';
  25. $authorRule = new \app\rbac\AuthorRule();
  26. // add permissions
  27. $auth->add($createPost);
  28. $auth->add($updatePost);
  29. $auth->add($deletePost);
  30. $auth->add($readPost);
  31. $auth->add($authorRule);
  32. // add the "updateOwnPost" permission and associate the rule with it.
  33. $updateOwnPost = $auth->createPermission('updateOwnPost');
  34. $updateOwnPost->description = 'Update own post';
  35. $updateOwnPost->ruleName = $authorRule->name;
  36. $auth->add($updateOwnPost);
  37. $auth->addChild($updateOwnPost, $updatePost);
  38. // create Author role
  39. $author = $auth->createRole('author');
  40. $auth->add($author);
  41. $auth->addChild($author, $createPost);
  42. $auth->addChild($author, $updateOwnPost);
  43. $auth->addChild($author, $readPost);
  44. // create Admin role
  45. $admin = $auth->createRole('admin');
  46. $auth->add($admin);
  47. $auth->addChild($admin, $updatePost);
  48. $auth->addChild($admin, $deletePost);
  49. $auth->addChild($admin, $author);
  50. // assign roles
  51. $auth->assign($admin, User::findByUsername('admin')->id);
  52. $auth->assign($author, User::findByUsername('demo')->id);
  53. echo "Done!\n";
  54. }
  55. }
  1. 在控制台中运行:
  1. yii rbac/init
  1. 创建controllers/RbacController.php
  1. <?php
  2. namespace app\controllers;
  3. use app\models\User;
  4. use stdClass;
  5. use Yii;
  6. use yii\filters\AccessControl;
  7. use yii\helpers\Html;
  8. use yii\web\Controller;
  9. /**
  10. * Class RbacController.
  11. */
  12. class RbacController extends Controller
  13. {
  14. public function behaviors()
  15. {
  16. return [
  17. 'access' => [
  18. 'class' => AccessControl::className(),
  19. 'rules' => [
  20. [
  21. 'allow' => true,
  22. 'actions' => ['delete'],
  23. 'roles' => ['deletePost'],
  24. ],
  25. [
  26. 'allow' => true,
  27. 'actions' => ['test'],
  28. ],
  29. ],
  30. ],
  31. ];
  32. }
  33. public function actionDelete()
  34. {
  35. return $this->renderContent(
  36. Html::tag('h1', 'Post deleted.')
  37. );
  38. }
  39. /**
  40. * @param $description
  41. * @param $rule
  42. * @param array $params
  43. *
  44. * @return string
  45. */
  46. protected function renderAccess($description, $rule, $params = [])
  47. {
  48. $access = Yii::$app->user->can($rule, $params);
  49. return $description.': '.($access ? 'yes' : 'no');
  50. }
  51. public function actionTest()
  52. {
  53. $post = new stdClass();
  54. $post->createdBy = User::findByUsername('demo')->id;
  55. return $this->renderContent(
  56. Html::tag('h1', 'Current permissions').
  57. Html::ul([
  58. $this->renderAccess('Use can create post',
  59. 'createPost'),
  60. $this->renderAccess('Use can read post',
  61. 'readPost'),
  62. $this->renderAccess('Use can update post',
  63. 'updatePost'),
  64. $this->renderAccess('Use can own update post',
  65. 'updateOwnPost', [
  66. 'post' => $post,
  67. ]),
  68. $this->renderAccess('Use can delete post',
  69. 'deletePost'),
  70. ])
  71. );
  72. }
  73. }
  1. 运行一次rbac/test检查access to all the created permissions of the RBAC hierachy:

第五章 安全 - 图8

  1. 然后尝试使用demo登录(密码是demo),再次运行rbac/test

第五章 安全 - 图9

  1. 然后尝试使用admin登录(密码是admin),再次运行rbac/test

第五章 安全 - 图10

  1. demo用户登录,运行rbac/delete

第五章 安全 - 图11

  1. admin用户登录,运行rbac/delete

第五章 安全 - 图12

工作原理…

Yii模仿NIST RBAC模型实现了一个一般的层次化的RBAC。它通过应用组件authManager提供了RBAC功能。

RBAC层级是一个有向无环图,也就是说,它由结点和有向连接边组成。有三种类型的结点:角色、权限和规则。

角色是权限(例如创建帖子和更新帖子)的集合。一个角色可以分配给一个或多个用户。为了检查用户是否有某个指定的权限,我们可以检查这个用户是否被赋予了拥有该权限的角色。

角色和权限都可以以等级化的方式组织。特别地,一个角色可以包含其它角色和权限,并且权限可以包含其它权限。Yii实现了一个偏序层级,它包含了特定的tree等级。当一个角色包含一个权限时,反过来说是不正确的。

为了测试权限,我们创建了两个动作。第一个动作是test,包含了创建权限和角色的检查器。第二个动作是delete,它被访问过滤器限制了访问。访问过滤的规则如下所示:

  1. [
  2. 'allow' => true,
  3. 'actions' => ['delete'],
  4. 'roles' => ['deletePost'],
  5. ],

这意味着,我们允许所有拥有deletePost权限的用户运行deletePost动作。Yii以检查deletePost权限开始。注意到访问规则元素被命名为roles,你可以指定一个RBAC等级节点,无论是角色、规则还是权限。检查updatePost是复杂的:

  1. Yii::$app->user->can('updatePost', ['post' => $post]);

我们使用第二个参数来传递一个帖子(在我们的例子中,我们使用stdClass来模拟它)。如果用户以demo登录,然后获得了updatePost的权限。如果你很幸运,你只需要go through updatePostupdateOwnPost和作者。

因为updateOwnPost有一个定义好的规则,它会在传参给checkAccess时运行。如果结果为真,访问将会得到授权。因为Yii不知道最短的方法是什么,它会尝试检查所有可能性直至成功,或者没有剩余的备选项。

更多…

下面是一些有用的技巧,能让你更方便的使用RBAC。

保持层级简单和高效

遵守如下建议来提升性能,并降低层级复杂性:

  • 避免给一个用户关联多个角色
  • 不要连接相同类型的结点:例如,避免连接两个task

命名RBAC结点

一个复杂的层级如果不使用一些命名习惯的话会很难理解。能帮助我们降低复杂性的惯例是:

  1. [group_][own_]entity_action

只有当当前用户是元素的拥有者时,才能修改这个元素的能力。这是,会使用own这个关键词。group只是一个命名空间。entity是我们工作的实体名称,action是我们执行的动作。

例如,如果我们需要创建一个规则,它决定了用户是否可以删除一个博客文章,我们把它命名为blog_post_delete。如果这个规则决定了用户是否可以编辑他自己的评论,我们将会把它命名为blog_own_comment_edit

参考

为了了解更多关于SQL注入和使用Yii处理数据库,参考如下链接:

加密和解密数据

Yii2框架包含了一个特殊的安全组件,它提供了一套方法来处理常见的安全相关的任务。\yii\base\Security类需要OpenSSLPHP扩展,而不是mcrypt

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 设置数据库连接,并创建一个名叫order的表,如下所示:
  1. DROP TABLE IF EXISTS `order`;
  2. CREATE TABLE IF NOT EXISTS `order` (
  3. `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  4. `client` VARCHAR(255) NOT NULL,
  5. `total` FLOAT NOT NULL,
  6. `encrypted_field` BLOB NOT NULL,
  7. PRIMARY KEY (`id`)
  8. );
  1. 使用Gii生成Order模型。

如何做…

  1. 添加一个额外的key参数到config/params.php
  1. <?php
  2. return [
  3. 'adminEmail' => 'admin@example.com',
  4. 'key' => 'mysecretkey'
  5. ];
  1. Order模型添加behaviorshelper属性:
  1. public $encrypted_field_temp;
  2. public function behaviors()
  3. {
  4. return [
  5. [
  6. 'class' => AttributeBehavior::className(),
  7. 'attributes' => [
  8. ActiveRecord::EVENT_BEFORE_INSERT => 'encrypted_field',
  9. ActiveRecord::EVENT_BEFORE_UPDATE => 'encrypted_field',
  10. ],
  11. 'value' => function ($event) {
  12. $event->sender->encrypted_field_temp = $event->sender->encrypted_field;
  13. return Yii::$app->security->encryptByKey(
  14. $event->sender->encrypted_field,
  15. Yii::$app->params['key']
  16. );
  17. },
  18. ],
  19. [
  20. 'class' => AttributeBehavior::className(),
  21. 'attributes' => [
  22. ActiveRecord::EVENT_AFTER_INSERT => 'encrypted_field',
  23. ActiveRecord::EVENT_AFTER_UPDATE => 'encrypted_field',
  24. ],
  25. 'value' => function ($event) {
  26. return $event->sender->encrypted_field_temp;
  27. },
  28. ],
  29. [
  30. 'class' => AttributeBehavior::className(),
  31. 'attributes' => [
  32. ActiveRecord::EVENT_AFTER_FIND => 'encrypted_field',
  33. ],
  34. 'value' => function ($event) {
  35. return Yii::$app->security->decryptByKey(
  36. $event->sender->encrypted_field,
  37. Yii::$app->params['key']
  38. );
  39. },
  40. ],
  41. ];
  42. }
  1. 添加controller/CryptoController.php
  1. <?php
  2. namespace app\controllers;
  3. use app\models\Order;
  4. use Yii;
  5. use yii\db\Query;
  6. use yii\helpers\ArrayHelper;
  7. use yii\helpers\Html;
  8. use yii\helpers\VarDumper;
  9. use yii\web\Controller;
  10. /**
  11. * Class CryptoController.
  12. * @package app\controllers
  13. */
  14. class CryptoController extends Controller
  15. {
  16. public function actionTest()
  17. {
  18. $newOrder = new Order();
  19. $newOrder->client = "Alex";
  20. $newOrder->total = 100;
  21. $newOrder->encrypted_field = 'very-secret-info';
  22. $newOrder->save();
  23. $findOrder = Order::findOne($newOrder->id);
  24. return $this->renderContent(Html::ul([
  25. 'New model: ' . VarDumper::dumpAsString($newOrder->attributes),
  26. 'Find model: ' . VarDumper::dumpAsString($findOrder->attributes)
  27. ]));
  28. }
  29. public function actionRaw()
  30. {
  31. $row = (new Query())->from('order')
  32. ->where(['client' => 'Alex'])
  33. ->one();
  34. return $this->renderContent(Html::ul(
  35. $row
  36. ));
  37. }
  38. }
  1. 运行crypto/test

第五章 安全 - 图13

  1. 为了查看原始数据,运行crypto/raw

第五章 安全 - 图14

工作原理…

首先,我们已经添加了AttributeBehavior,当特定事件发生时,它会自动处理我们的数据。我们特定的事件是ActiveRecord::EVENT_AFTER_INSERTActiveRecord::EVENT_AFTER_UPDATEActiveRecord::EVENT_AFTER_FIND

在插入和更新事件期间,我们使用了一个特殊的方法Yii::$app->security->encryptByKey();加密了我们的数据。在保存到数据库前,这个方法使用HKDF和一个随机盐来加密我们的数据。从数据库中获取数据以后,我们也可以使用ActiveRecord::EVENT_AFTER_FIND方法来解密我们的数据。在这个例子中,我们也使用了特殊的Yii2方法Yii::$app->security->encryptByKey();。这个方法接受两个参数:加密的数据和key。

更多…

除了数据加密和解密以外,一个安全的组件也提供了基于标准算法的key derivation、数据防破坏和密码校验。

使用密码

校验一个密码:

  1. if (Yii::$app->getSecurity()->validatePassword($password, $hash)) {
  2. // all good, logging user in
  3. } else {
  4. // wrong password
  5. }

参考

为了了解更多关于SQL注入和使用Yii处理数据库的知识,参考http://www.yiiframework.com/doc-2.0/guide-security-passwords.html