====== 表单与验证 ======
// 原文:http://www.symfony-project.com/askeet/6 //
**回顾:**
在[[askeet_5|第五天]], 你已经习惯去操控模版和动作了,并且表格和分页器对你来说也不再是秘密。但是在实现了一个登录表单后,你可能期待我们告诉你怎样去限制一个未授权的用户访问特定的功能集合。这就是我们今天要做的,此外还有一些表单验证功能。由于我们将使用自定义的类来扩展我们的程序, 你应该熟悉在symfony书中[[http://www.symfony-project.com/content/book/page/custom_helper.html|自定义扩展]]那章的概念。
=====登录表单的验证=====
====验证文件====
登录表单含有一个昵称,一个密码域。但是如果用户输入了不正确的数据怎么办?为了应对这种情况, 在/frontend/modules/user/validate目录下创建一个login.yml文件(login是将要被去验证的动作的名称),并加入以下内容:
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的报头, 针对每个表单域的检查需求被列了出来,同时还有其出错时相应的错误信息。最后,由于昵称域需要特定的验证规则集合,我们在相应的报头下列出了详细的规则内容。在此例中,sfStringValidator是一个symfony内建的验证器。他的作用是检查一个字符串的格式(默认的symfony验证器在symfony book[[http://www.symfony-project.com/content/book/page/validate_form.html|怎样验证一个表单]]一章中有所讨论)。
====错误处理====
如果用户输入了错误的数据将会发生些什么?写在login.yml文件中的条件将不会被满足,symfony的控制器将会把申请传递给userAction类的handleErrorLogin()方法而不是form_tag参数中计划好的executeLogin()方法。如果这个方法不存在, 默认的行为就会显示loginError.php模版,这是因为默认的handleError()方法返回:
public function handleError()
{
return sfView::ERROR;
}
这将要写一个全新的模版。 但是我们却喜欢再次显示登录表单,并把错误信息显示在出了问题的表单域附近。因此,在这个例子中,让我们修改一下登录错误行为,让它去显示loginSuccess.php模版:
public function handleErrorLogin()
{
return sfView::SUCCESS;
}
#note[**注意**:关于动作名字的命名规则,和他的返回值还有模版文件的名字在symfony book[[http://www.symfony-project.com/content/book/page/view.html|视图]]一章有详细讨论。]#
====模版错误助手====
一旦loginsuccess模版被再次调用,也就是显示错误的时候了。我们将使用Validation助手组的form_rror()助手来实现这个目的。改写模版中的两个form-row div为:
如果一个错误被定义在login.yml作为参数给出的表单域中,form_error()助手将输出这个错误消息。是时间去测试表单验证了。尝试着输入一个少于5个字符的昵称,或是漏掉一两个表单域。错误消息将会魔术般地被显示在相关的表单与之上:
#sym[{{login_form_error.gif|}}]#
现在密码是必须的,但是在数据库中却没有密码段。这没关系,一旦你输入任何密码,登录就会成功。 这不是一个非常安全的过程,不是吗?
====风格化错误====
如果你测试了表单并得到了一个错误,你可能注意到了你的错误消息并没有像上面图片中的风格。这是因为我们已经定义了.form_error类风格,这是form_error产生的错误的默认类风格:
.form_error
{
padding-left: 85px;
color: #d8732f;
}
=====验证用户=====
====自定义验证器====
你还记着昨天在login动作中检查一个输入昵称的存在吗?嗯,那个看起来像个表单验证。这段代码应该被取出来放到一个自定义的验证器中。你认为这是复杂的?但他确实不是。编辑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
我们只是添加了一个myLoginValidator类作为昵称表单域的新的验证器。这个验证器目前还不存在。但是我们知道它将需要一个密码数据去完全验证用户。因此密码数据将被用标签password作为参数来传递。
====密码存储====
请等一下,在我们的数据模型和测试数据中,并没有密码字段。这时我们应该定义一个。但是你知道在数据库中存储一个有着明显文本的密码是一个非常不安全的想法。所以, 我们将存储一个密码的[[http://en.wikipedia.org/wiki/SHA_hash_functions|sha1哈希码]]并用一个随机的密码匙来哈希它。如果你不熟悉'salt'过程,请查看[[http://en.wikipedia.org/wiki/Password_cracking|密码骇客实践]]。
因此,打开schema.xml文件并给User表加入以下列:
使用propel-build-model命令重建Propel模型。你应该也把这两个字段加入数据库中。或者是用手工方式,或是是用symfony的propel-build-sql命令产生出来的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));
}
这个函数模拟了一个密码的直接存储,同时它还存储了一个salt随机密码匙(一个哈希后的三十二个字符的随机字符串)和哈希后的密码(一个40个字符的字符串)
====在测试数据中添加密码值====
还记着第三天的测试数据文件吗?是时候给测试的用户添加一个密码和电邮了。打开并编辑askeet/data/fixtures/test_data.yml文件如下:
User:
...
fabien:
nickname: fabpot
first_name: Fabien
last_name: Potencier
password: symfony
email: fp@example.com
francois:
nickname: francoisz
first_name: François
last_name: Zaninotto
password: adventcal
email: fz@example.com
由于setPassword()函数已经在User类中定义了。当sfPropelData对象在被调用时,会正确的添加在schema中定义好的新的sha1_password和salt字段:
$ php batch/load_data.php#note[**注意**:sfPropelData对象可以应付那些没有绑定在“实际”数据库字段上的方法(并且现在我们超过了你传统的SQL处理)。如果你有奇怪这是怎么运行的,可以看一下symfony book的[[http://www.symfony-project.com/content/book/page/populate.html|数据库生]]成一章]# #note[**注意**: 由于我们阻止了“Anonymous Coward”的登录,所以不需要给他设立密码。此外,我们非常感谢你没尝试把这里的密码用在我们的银行账户上, 因为密码是绝密的!]# ====自定义验证器==== 现在是时间编写自定义的myLoginValidator了。你可以把它建立在任意一个可被module访问的lib/目录下(他们是,askeet/lib/, askeet/apps/frontend/lib/, askeet/apps/frontend/modules/user/lib/)。现在,myLoginValidator被用来作为一个应用程序范围内的验证器,所以myLoginValidator.class.php将被建立在askeet/apps/frontend/lib/目录下:
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下的数据)合并到参数**容器(Holder)**对象中。
接下来,execute()方法被....执行。$password_param是在login.yml中password报头下的一个表单域名。他被当作一个表单域名来读取申请变量中得值。因此$password包含了用户输入的密码值。$value是当前表单域的值——myLoginValidator是为了昵称这个表单域被调用的。因此,$login中包含了用户输入的昵称。在最后,myLoginValidator拥有了真正验证一个用户的所有必备数据。
接下来的代码是从login动作剥离出来的。但是另外,密码有效性的测试(在这之前总是真值)被实现了:用户输入密码的哈希值(使用存在数据库中的salt来哈希)被用来和用户的哈希密码来比较。
如果登录和密码是正确的,验证器将返回真值并且表单的目标动作(executeLogin())将被执行。如果不是,他将返回false并且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/目录下添加一个secrity.yml文件,如下(现在先不要这么做):
all:
is_secure: on
credentials: subscriber
这个模块中的动作只有当用户被验证过拥有一个subscriber资质之后才能被执行。
在askeet项目中,登录被用来发布新问题,声明关于一个问题的兴趣和发表评论。所有其他的动作对于未登录用户是开放的。
因此去限制question/add动作(还需要去编写),添加下面的security.yml文件在askeet/apps/frontend/modules/question/config/目录下:
add:
is_secure: on
credentials: subscriber
all:
is_secure: off
=====来点重构怎么样?=====
今天几乎要结束了,但是我们想玩一下我们做喜欢的游戏:把代码移到一个不太可能的地方。
验证密码时的头四行代码被用来授权用户的访问和存储他的id以备新的申请。你可以把它看成一个myUser类的方法(这是一个**对话(session)**类,而不是相对于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中;把下面的代码:
getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?>
改为:
getNickname().' profile', 'user/profile') ?>
不要忘了测试这些修改。和以前同样的登录过程应该仍然工作,但是现在有了更好的代码。
=====明天见=====
明天,将是时间去做一点视图配置的工作了,自定义CSS和一致性的组件,再修改一下页头。
不要忘了,你仍然可以下载今天的全部代码从[[http://svn.askeet.com/tags/release_day_6/|askeet SVN代码库]],标签为release_day_6。如果你觉得要提问或是回答问题对askeet,欢迎浏览[[http://www.symfony-project.com/forum/index.php/f/8/|askeet论坛]]。不要忘了第21天的程序仍然取决于你的建议。