使用Cucumber+Rspec玩转BDD(4)——用户登录并“记住我”
2009年3月13日 星期五
### 温故知新 ###
在上一篇文章中,我们参照文章内容完成了用户登录功能的开发工作。此时,注册用户可以顺利登录站点,查看用户资料等等;但这一状态也只限于当前的浏览器窗口,如果浏览器关闭了,用户重新打开浏览器下次访问的时候,还是需要来到登录页面进行重新登录。若不是做交易支付型站点,为了追求好一点的用户体验,我们可以给用户预留一个可选项;用户在登录的时候可以勾选“记住我”,一段时间内用户将不必重新登录。要实现用户的这种持久登录状态,我们应该怎么做呢?不妨来了解我们接下来的活儿。
为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master
### 新建工作分支 ###
$ git checkout -b remember_me
在有效时间内,要保持用户的在线状态。那么第一个问题会是,我们得知道用户是否已经登录呢?按照我们之前的预期,比如用户的资料应该是受保护的,只有当用户登录以后才可以查看;那就有必要在程序上做一些访问控制。
### 实现简单的访问控制 ###
使用 Rails的 before_filter 钩子方法可以非常方便地实现我们的目的。
$ gedit app/controllers/users_controller.rb
在 UserController 类中的任何方法之前加上如下一段代码:
before_filter :login_required, :only => [:show]
这样在查看用户资料的时候会检查用户是否已经登录,如果未登录会提供一张登录用的表单,如果已经登录了就会呈现用户资料。
我们继续秉承测试先行这一理念,从编写“用户登录且勾选记住我”的故事开始。
### 用户登录之“记住我” ###
$ gedit features/user_login.feature
在文件尾部续添如下文本:
场景: 用户已激活帐号且使用有效身份登录并勾选记住我
假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
当 我以<xuliicom@gmail.com/password>这个身份登录并勾选<记住我>
那么 我应该看到<登录成功>的提示信息
而且 我应该成功登录网站
当 我关闭网页下次再来访问的时候
那么 我应该依然保持登录状态
运行测试,
$ ruby script/cucumber -l zh-CN features/user_login.feature
测试失败,根据提示信息来看,我们需要添加一些故事情节运行所需的测试脚本。
### 添加用户驱动故事运行的测试脚本 ###
$ gedit features/step_definitions/user_steps.rb
续添如下脚本:
When /^我关闭网页下次再来访问的时候$/ do
当 %{session已经被清除}
而且 %{我来到用户登录页面}
end
When /^session已经被清除$/ do
request.reset_session
request.session[:user_id].should be_nil
end
Then /^我应该依然保持登录状态$/ do
# 很遗憾,在测试代码中,cookies里边放符号索引会返回nil对象
# 用字符串来索引没问题
# 更多信息可以查阅 http://dev.rubyonrails.org/ticket/5924
cookies['remember_token'].should_not be_blank
request.session[:user_id].should_not be_nil
end
修改 “When /^我以<(.+)\/(.+)>这个身份登录$/ do ... end” 这段代码如下:
When /^我以<(.+)\/(.+)>这个身份登录(并勾选<记住我>)?$/ do |username_or_email, password, remember|
当 %{我来到用户登录页面}
而且 %{我在输入框<用户名或邮箱>中输入<#{username_or_email}>}
而且 %{我在输入框<密码>中输入<#{password}>}
而且 %{我勾选<记住我>} if remember
而且 %{我按下<登录>按钮}
end
为 “当 我勾选<记住我>” 添加对应的运行脚本,
When /^我勾选<(.+)>$/ do |field|
check(field)
end
保存 user_steps.rb。运行测试,
$ ruby script/cucumber -l zh-CN features/user_login.feature
### 观测试了解工作内容 ###
测试结果返回 “Could not find field: "记住我" (Webrat::NotFoundError)”的相关信息,提示没有找到关于“记住我”的这个表单域,不用多想,这个“记住我”的多选框应该出现在用户登录页面。
$ gedit app/views/sessions/new.html.erb
修改后的登录页面代码如下,
<% form_tag sessions_path do %>
<p>
<%= label_tag 'username_or_email', '用户名或邮箱' %><br />
<%= text_field_tag 'username_or_email' %>
</p>
<p>
<%= label_tag 'password', '密码' %><br />
<%= password_field_tag 'password' %>
</p>
<p>
<%= check_box_tag 'remember_me', 1, true %>
<%= label_tag 'remember_me', '记住我' %>
</p>
<p>
<%= submit_tag '登录' %>
</p>
<% end %>
保存 app/views/sessions/new.html.erb。运行测试,
$ ruby script/cucumber -l zh-CN features/user_login.feature
问题出在 user_steps.rb 文件的第86行,我们来看看这行代码的内容,
测试代码里边写明了,如果用户关闭网页再次访问的时候,cookies[:remember_token]的值应该不为空,这样就可以实现记住我的功能。测试结果告诉我们该值为空,为了达到我们想要的效果,我们来做些实际的编码工作。
之前在用户登录页面的模板文件中,我们已经添加了供用户可选“记住我”的选项,下面添加一些处理业务流程的代码。
$ gedit app/controllers/sessions_controller.rb
修改 create 方法,在 sign_user_in(@user)之前添加如下一句代码,
remember(@user) if remember?
这样用户在勾选“记住我”选项之后,系统会自动设置记住用户的相关细节,不过这些具体细节还需要我们通过编码来完成。
继续在 SessionsController 中编写刚才那段代码中用到的两个方法,
private
def remember?
params[:remember_me] && params[:remember_me] == "1"
end
def remember(user)
user.remember_me!
cookies[:remember_token] = {
:value => user.remember_token,
:expires => user.remember_token_expires_at
}
end
我们将这两个方法设为私有仅供程序内部使用,上面的 remember 方法已经涉及到数据存取;我们还需要修改 User 模型类以衔接上述的业务逻辑。
首先增添上述代码用到的两个数据字段,即 remember_token 和 remember_token_expires_at;之所以添加这两个字段,是因为服务端需要记录客户端自动登录且唯一的cookie标识,并用该标识来验证客户端的请求是否有效,如果有效就可以打破HTTP协议无状态的限制建立持久的会话连接,如果客户端的cookie headers是伪造或失效的,那么很遗憾地非法请求将不得逞,并导向用户登录页面,提示该用户登录。下面我们来添加数据迁移文件,
$ ruby script/generate migration RememberMe
$ gedit db/migrate/*_remember_me.rb
class RememberMe < ActiveRecord::Migration
def self.up
add_column :users, :remember_token, :string
add_column :users, :remember_token_expires_at, :datetime
end
def self.down
remove_column :users, :remember_token_expires_at
remove_column :users, :remember_token
end
end
保存 db/migrate/*_remember_me.rb。然后执行迁移,
$ rake db:migrate
$ rake db:test:prepare
接着修改 UserModel,
$ gedit app/models/user.rb
添加如下代码,
# remember_token 是否失效
def remember?
remember_token_expires_at && Time.now < remember_token_expires_at
end
# 记住多长时间
def remember_me!
remember_me_until 2.weeks.from_now
end
# 保存“记住我”的相关设置
def remember_me_until(time)
self.remember_token_expires_at = time
self.remember_token = encrypt(time)
save(false)
end
保存 UserModel ,最后在 ApplicationController 类中添加一个读取并校验 cookie 的方法。用户访问的时候检查cookie,如果该cookie有效就可以直接登录了。
$ gedit app/controllers/application.rb
在 user_from_session 方法之后添加如下方法,
def user_from_cookie
if cookies[:remember_token]
user = User.find_by_remember_token(cookies[:remember_token])
user && user.remember? ? user : nil
end
end
然后修改 current_user 方法,可以接受用户使用 cookie 的方式登录。
def current_user
@_current_user ||= (user_from_session || user_from_cookie)
end
保存 ApplicationController,运行测试看看;
$ ruby script/cucumber -l zh-CN features/user_login.feature
测试通过!:)
### 小结 ###
这篇文章介绍的内容不是很多,功能也不算复杂。在用户已经能够登录站点的基础上,我们给登录用户加上了“记住我”的功能,这是一种持久登录状态。说到持久一词,就不得不提到HTTP协议是无状态的,无状态在这里意指在一般的B/S连接中,Server 无法识别特定的 Browser,因为一台 Web Server 响应的 Browsers 不计其数,Server 没办法知道当前所响应的Browser是谁,也不记得这Browser之前是否请求过。不过有了 cookie,就可以打破HTTP协议无状态的这一限制。cookie是一种在客户端存储数据并以此来跟踪和识别用户的机制。Server 响应 Browser 的请求时会发送一个带 set-cookie 的 http headers,Browser 会在本地记住这一cookies 数据;当 Browser 再次请求时,就会将这一 cookies 发送给 Server ,Server 在响应 Browser 请求的同时接受并读取此 cookies,以此来达到跟踪和识别用户的目的。如果服务端没有特别地设置cookies,客户端针对这一站点的cookies将随浏览器进程的关闭而失效。在之前我们使用session来做用户登录即是如此,因为Rails将会话数据(session data)存在客户端的cookies里边,cookies的有效期是随浏览器的关闭而失效的,所以当用户关闭浏览器后重新打开再次访问就需要登录;后来我们在程序里显示地配置了cookies的有效期为两周,当用户第一次登录后,cookies会在用户浏览器中保存两周,那么用户在这两周内就不需要重新登录了。
### 相关阅读 ###
cookie机制的纯JavaScript实现:http://chinaonrails.com/topic/view/1449.html
php系统和ror系统的用户登录授权问题:http://chinaonrails.com/topic/view/1711.html
了解关于cookie和Rails交互的更多信息:http://chinaonrails.com/q/cookie
### 下节预告 ###
接下来的一章里会向读者朋友们演示登录用户如何安全退出,敬请期待!
### 提交工作成果到GIT仓库 ###
$ git status
$ git add .
$ git commit -m "A user can be login with remember me."
$ git checkout master
$ git merge remember_me
$ git branch -d remember_me
$ git tag v4
(注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)