回顾
在我们第五天的学习中,我们已经习惯于操作模板与动作:表单与分页对于我们而言已不在神秘。但是在构建登陆表单之后,我们也许希望演示一下如何限制非授权用户对特定功能的访问。这就是我们今天所要学习的内容,以及一些表单验证的内容。因为我们要使用自定义的类来扩展程序,所以我们会对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>
不要忘记测试修改。
明天见