安全与表单验证

 回顾

在我们第五天的学习中,我们已经习惯于操作模板与动作:表单与分页对于我们而言已不在神秘。但是在构建登陆表单之后,我们也许希望演示一下如何限制非授权用户对特定功能的访问。这就是我们今天所要学习的内容,以及一些表单验证的内容。因为我们要使用自定义的类来扩展程序,所以我们会对Symfony一书的自定义扩展一节的内容有更深的理解。

登陆表单验证

验证规则

登陆表单有一个nickname与password域。但是如果用户提交了不正确的数据时会发生什么情况呢?为了能够处理这种情况,在/frontend/modules/user/validate目录下(login是要验证的动作名)创建一个login.yml文件,并且添加下面的内容:

methods:
  post: [nickname, password]

names:
  nickname:
    required:     true
    required_msg: your nickname is required
    validators:   nicknameValidator

  password:
    required:     true
    required_msg: your password is required

nicknameValidator:
    class:        sfStringValidator
    param:
      min:        5
      min_error:  nickname must be 5 or more characters

首先,在methods头下,定义了表单方法要进行验证的域列表(在这里我们只定义了POST方法,因为GET方法用于显示登陆表单而不需要验证)。然后,在names头下,列出了要检测的每一个表单域的需求,同时列出了相应的错误信息。实际上,因为nickname域声明为具有一个特殊的验证规则集合,所以在相应的头部下进行详细描述。在这个例子中,sfStringValidator是一个Symfony内建的验证器,用来检测一个字符串的格式(默认的Symfony验证器在Symfony一书的如何验证表单一节进行详细描述)。

错误处理

那么当用户输入错误的数据时会发生什么呢?如果并不满足login.yml文件中所写的条件,那么Symfony控制器就会将这个请求传递给userActions类的handleErrorLogin()方法,而不是form_tag参数中所设计的executeLogin()方法。如果这个方法不存在,默认行为则会显示loginError.php模板。这是因为默认的handleError()方法返回:

public function handleError()
{
  return sfView::ERROR;
}

这是要编写的一个全新的模板。但是我们更希望重新显示登陆表单,并且将错误信息显示在相关的表单域附近。所以我们要修改显示的登陆错误行为,在我们这个例子中,loginSuccess.php模板为:

public function handleErrorLogin()
{
  return sfView::SUCCESS;
}

模板错误帮助器

一旦再次调用loginSuccess.php模板,则会显示错误。我们将会使用验证帮助器组的form_error()帮助器。将模板的两个form-row div层改为下面的内容:

<?php use_helper('Validation') ?>
 
<div class="form-row">
  <?php echo form_error('nickname') ?>
  <label for="nickname">nickname:</label>
  <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
</div>
 
<div class="form-row">
  <?php echo form_error('password') ?>
  <label for="password">password:</label>
  <?php echo input_password_tag('password') ?>
</div>

如果发生错误,form_error()帮助器就会输出在login.yml文件中定义的错误。现在我们可以来测试一下表单验证了,我们可以输入一个小于5个字符的nickname,或是留空两个表单域来进行相应的测试。

现在密码是必须的,但是在数据中并没有密码。这并没有关系,只要我们输入密码,登陆就会成功。这并不是一个安全的过程,不是吗?

格式化错误

如果我们测试表单并且发生了错误,我们也许会注意到我们的错误信息格式并不是如上图所示的样子。这是因为我们定义了.form_error类格式,这是由form_error()帮助器所产生的表单错误的默认类:

.form_error
{
  padding-left: 85px;
  color: #d8732f;
}

授权用户

自定义验证器

我们是否还记得昨天在登陆动作中对所输入的用户是否存在的检测?是的,那看起来就是一个表单验证。这段代码应从这个动作中移出,并且包含在一个自定义验证器中。我们会认为这很复杂?事实上一点都不。编辑login.yml验证文件如下:

...
names:
  nickname:
    required:      true
    required_msg:  your nickname is required
    validators:    [nicknameValidator, userValidator]
...
userValidator:
    class:         myLoginValidator
    param:
      password:    password
      login_error: this account does not exist or you entered a wrong password

我们只是为nickname域添加了一个新的验证器,myLoginValidator。这个验证器还不存在,但是我们知道对于完全授权的用户是需要密码的,所以他使用标签password作为参数传递。

密码存储

但是我们需要停留一下。在我们的数据模型以及测试数据中,并没有密码集合。现在是确定一个的时候了。但是我们知道从安全的角度来说,将密码以明文的形式存储在文本以及数据中是一个糟糕的主意。所以我们会使用随机值对必码进行哈希处理,并且存储密码的sha1哈希值。

所以我们要打开schema.xml文件,并且在User表中添加下面的列:

<column name="email" type="varchar" size="100" />
<column name="sha1_password" type="varchar" size="40" />
<column name="salt" type="varchar" size="32" />

使用symfony propel-build-model命令重新构建Propel模块。我们同时也应将这两列添加到数据库中,可以手动或者是使用symfony propel-build-sql命令后生成的lib.model.schema.sql。现在打开askeet/lib/model/User.php文件,并且添加下面的setPassword()方法:

public function setPassword($password)
{
  $salt = md5(rand(100000, 999999).$this->getNickname().$this->getEmail());
  $this->setSalt($salt);
  $this->setSha1Password(sha1($salt.$password));
}

这个函数模拟一个直接的密码存储,但是所不同的是他存储的是随机键(一个32位的哈希化的随机字符串)与哈希化的密码(一个40位的字符串)。

在测试数据中添加密码

还记得第三天的测试数据文件吗?现在需要向测试用户中添加一个密码与一个email。打开并修改askeet/data/fixtures/test_data.yml文件如下:

User:
  ...
  fabien:
    nickname:   fabpot
    first_name: Fabien
    last_name:  Potencier
    password:   symfony
    email:      [email protected]

  francois:
    nickname:   francoisz
    first_name: François
    last_name:  Zaninotto
    password:   adventcal
    email:      [email protected]

因为我们已经为user类定义了setPassword()方法,所以当我们调用下面的命令时sfPropelData对象将会正确的处理sha1_password与salt列:

$ php batch/load_data.php

自定义验证器

现在需要编写我们自已的自定义myLoginValidator了。我们可以在模块可以访问的任何lib/目录下创建(也就是说在askeet/lib/,或是askeet/apps/frontend/lib/,或是askeet/apps/frontend/modules/user/lib/下)。就目前而言,我们认为这是一个程序相关的验证器,所以我们会在askeet/apps/frontend/lib/目录下创建myLoginValidator.class.php文件。

<?php
 
class myLoginValidator extends sfValidator
{    
  public function initialize($context, $parameters = null)
  {
    // initialize parent
    parent::initialize($context);
 
    // set defaults
    $this->setParameter('login_error', 'Invalid input');
 
    $this->getParameterHolder()->add($parameters);
 
    return true;
  }
 
  public function execute(&$value, &$error)
  {
    $password_param = $this->getParameter('password');
    $password = $this->getContext()->getRequest()->getParameter($password_param);
 
    $login = $value;
 
    // anonymous is not a real user
    if ($login == 'anonymous')
    {
      $error = $this->getParameter('login_error');
      return false;
    }
 
    $c = new Criteria();
    $c->add(UserPeer::NICKNAME, $login);
    $user = UserPeer::doSelectOne($c);
 
    // nickname exists?
    if ($user)
    {
      // password is OK?
      if (sha1($user->getSalt().$password) == $user->getSha1Password())
      {
        $this->getContext()->getUser()->setAuthenticated(true);
        $this->getContext()->getUser()->addCredential('subscriber');
 
        $this->getContext()->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getContext()->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');
 
        return true;
      }
    }
 
    $error = $this->getParameter('login_error');
    return false;
  }
}

当验证器被调用时--在登陆表单提交之后--initialize()方法会被首先调用。他会被始化login_error信息的默认值('Invalid input'),并且将参数(login.yml文件中param:头部下的部分)组合到一个参数保持对象中。

然后execute()方法会被执行。$password_param是在login.yml文件中password头下面提供的。他用作一个由请求参数中获取值的域名字。所以$password中包含用户所输入的密码。$value为当前域的值--而myLoginValidator类是为被nickname域所调用的。所以$login包含用户所输入的用户名。最后,这个验证器包含验证一个用户所必须的所有数据了。

下面的代码会由登陆动作中移除。但是,密码的验证测试还没有实现:用户输入密码的哈希值与用户的哈希密码进行对比。

如果登陆名与密码是正确的,验证器就会返回真,而表单的目标动作(executeLogin())就会执行。否则,他会返回假,而handleErrorLogin()会被执行。

由动作中移除代码

现在所有的验证代码都位于验证器中了,我们需要将其从登陆动作中移除。确实,当使用POST方法调用动作时,这就意味着验证器验证这个请求,所以用户是正确的。这就意味着在这个例子中动作所需要做的唯一的事情就是重定向到referer页面:

public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // display the form
    $this->getRequest()->getParameterHolder()->set('referer', $this->getRequest()->getReferer());
 
    return sfView::SUCCESS;
  }
  else
  {
    // handle the form submission
    // redirect to last page
    return $this->redirect($this->getRequestParameter('referer', '@homepage'));
  }
}

现在我们可以使用测试用户进行登陆来测试修改(在清除缓存之后,因为我们创建了一个新需要自动装入的验证器)。

限制访问

如果我们需要限制到一个动作的访问,我们只需要在模块config/目录下添加一个security.yml文件,如下所示(但是现在先不要做):

all:
  is_secure:   on
  credentials: subscriber

这要只有当用户被授权时,这个模块的动作才会被执行。

在askeet中,只有当发表一个新问题,声明对一个问题的兴趣或者是评价时才需要登陆。而所有其他的动作都会对非登陆用户开放。

所以要限制对question/add动作的访问,在askeet/apps/frontend/modules/question/config/目录下添加下面的security.yml文件:

add:
  is_secure:   on
  credentials: subscriber

all:
  is_secure:   off

重构

当验证密码为用户分配权限时执行了四行代码。我们可以将其看作myUser类的一个方法(会话类,而不是与User列相关的User类)。这很容易做到。将下面的代码添加到askeet/apps/frontend/lib/myUser.php类中:

public function signIn($user)
{
  $this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
  $this->setAuthenticated(true);
 
  $this->addCredential('subscriber');
  $this->setAttribute('nickname', $user->getNickname(), 'subscriber');
}
 
public function signOut()
{
  $this->getAttributeHolder()->removeNamespace('subscriber');
 
  $this->setAuthenticated(false);
  $this->clearCredentials();
}

现在将myLoginValidator类中由$this->getContext()->getUser()启动的四行代码改为:

$this->getContext()->getUser()->signIn($user);

同时将user/logout动作代码改为:

public function executeLogout()
{
  $this->getUser()->signOut();
 
  $this->redirect('@homepage');
}

subscriber_id与nickname会话属性同时也可以通过一个getter方法来进行抽象。仍然是在myUser类中,添加下面三个方法:

public function getSubscriberId()
{
  return $this->getAttribute('subscriber_id', '', 'subscriber');
}
 
public function getSubscriber()
{
  return UserPeer::retrieveByPk($this->getSubscriberId());
}
 
public function getNickname()
{
  return $this->getAttribute('nickname', '', 'subscriber');
}

我们可以在layout.php文件中使用这些新方法,将下面的代码行:

<li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>

替换为

<li><?php echo link_to($sf_user->getNickname().' profile', 'user/profile') ?></li>

不要忘记测试修改。

明天见

你可能感兴趣的:(安全与表单验证)