8.2 登录

最后更新于:2022-04-01 22:29:34

# 8.2 登录 登录表单已经可以处理无效提交,下一步要正确处理有效提交,登入用户。本节通过临时会话让用户登录,浏览器关闭后会话自动失效。[8.4 节](#remember-me)会实现持久会话,即便浏览器关闭,依然处于登录状态。 实现会话的过程中要定义很多相关的函数,而且要在多个控制器和视图中使用。[4.2.5 节](chapter4.html#back-to-the-title-helper)说过,Ruby 支持使用“模块”把这些函数集中放在一处。Rails 生成器很人性化,生成会话控制器时([8.1.1 节](#sessions-controller))自动生成了一个会话辅助方法模块。而且,其中的辅助方法会自动引入 Rails 视图。如果在控制器的基类(`ApplicationController`)中引入辅助方法模块,还可以在控制器中使用,如[代码清单 8.11](#listing-sessions-helper-include) 所示。 ##### 代码清单 8.11:在 `ApplicationController` 中引入会话辅助方法模块 app/controllers/application_controller.rb ``` class ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper end ``` 做好这些基础工作后,现在可以开始编写代码登入用户了。 ## 8.2.1 `log_in` 方法 有 Rails 提供的 `session` 方法协助,登入用户很简单。(`session` 方法和 [8.1.1 节](#sessions-controller)生成的会话控制器没有关系。)我们可以把 `session` 视作一个哈希,可以按照下面的方式赋值: ``` session[:user_id] = user.id ``` 这么做会在用户的浏览器中创建一个临时 cookie,内容是加密后的用户 ID。在后续的请求中,可以使用 `session[:user_id]` 取回这个 ID。[8.4 节](#remember-me)使用的 `cookies` 方法创建的是持久 cookie,而 `session` 方法创建的是临时会话,浏览器关闭后立即失效。 我们想在多个不同的地方使用这个登录方式,所以在会话辅助方法模块中定义一个名为 `log_in` 的方法,如[代码清单 8.12](#listing-log-in-function) 所示。 ##### 代码清单 8.12:`log_in` 方法 app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用户 def log_in(user) session[:user_id] = user.id end end ``` `session` 方法创建的临时 cookie 会自动加密,所以[代码清单 8.12](#listing-log-in-function) 中的代码是安全的,攻击者无法使用会话中的信息以该用户的身份登录。不过,只有 `session` 方法创建的临时 cookie 是这样,`cookies` 方法创建的持久 cookie 则有可能会受到“会话劫持”(session hijacking)攻击。所以在 [8.4 节](#remember-me)我们会小心处理存入用户浏览器中的信息。 定义好 `log_in` 方法后,我们可以完成会话控制器中的 `create` 动作了——登入用户,然后重定向到用户的资料页面,如[代码清单 8.13](#listing-log-in-success) 所示。[[4](#fn-4)] ##### 代码清单 8.13:登入用户 app/controllers/sessions_controller.rb ``` class SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy end end ``` 注意简洁的重定向代码 ``` redirect_to user ``` 我们在 [7.4.1 节](chapter7.html#the-finished-signup-form)见过。Rails 会自动把地址转换成用户资料页的地址: ``` user_url(user) ``` 定义好 `create` 动作后,[代码清单 8.2](#listing-login-form) 中的登录表单就可以使用了。不过从应用的外观上看不出什么区别,除非直接查看浏览器中的会话,否则没有方法判断用户是否已经登录。[8.2.2 节](#current-user)会使用会话中的用户 ID 从数据库中取回当前用户,做些视觉上的变化。[8.2.3 节](#changing-the-layout-links)会修改网站布局中的链接,还会添加一个指向当前用户资料页面的链接。 ## 8.2.2 当前用户 把用户 ID 安全地存储在临时会话中之后,在后续的请求中可以将其读取出来。我们要定义一个名为 `current_user` 的方法,从数据库中取出用户 ID 对应的用户。`current_user` 方法的作用是编写类似下面的代码: ``` <%= current_user.name %> ``` 或是: ``` redirect_to current_user ``` 查找用户的方法之一是使用 `find` 方法,在用户资料页面就是这么做的([代码清单 7.5](chapter7.html#listing-user-show-action)): ``` User.find(session[:user_id]) ``` [6.1.4 节](chapter6.html#finding-user-objects)说过,如果用户 ID 不存在,`find` 方法会抛出异常。在用户的资料页面可以使用这种表现,因为必须有相应的用户才能显示他的信息。但 `session[:user_id]` 的值经常是 `nil`(表示用户未登录),所以我们要使用 `create` 动作中通过电子邮件地址查找用户的 `find_by` 方法,通过 `id` 查找用户: ``` User.find_by(id: session[:user_id]) ``` 如果 ID 无效,`find_by` 方法返回 `nil`,而不会抛出异常。 因此,我们可以按照下面的方式定义 `current_user` 方法: ``` def current_user User.find_by(id: session[:user_id]) end ``` 这样定义应该可以,不过如果页面中多次调用 `current_user`,就会多次查询数据库。所以,我们要使用一种 Ruby 习惯写法,把 `User.find_by` 的结果存储在实例变量中,只在第一次调用时查询数据库,后续再调用直接返回实例变量中存储的值:[[5](#fn-5)] ``` if @current_user.nil? @current_user = User.find_by(id: session[:user_id]) else @current_user end ``` 使用 [4.2.3 节](chapter4.html#objects-and-message-passing)中介绍的“或”操作符 `||`,可以把这段代码改写成: ``` @current_user = @current_user || User.find_by(id: session[:user_id]) ``` `User` 对象是真值,所以仅当 `@current_user` 没有赋值时才会执行 `find_by` 方法。 上述代码虽然可以使用,但并不符合 Ruby 的习惯。`@current_user` 赋值语句的正确写法是这样: ``` @current_user ||= User.find_by(id: session[:user_id]) ``` 这种写法用到了容易让人困惑的 `||=`(或等)操作符,参见[旁注 8.1](#aside-or-equals) 中的说明。 ##### 旁注 8.1:`||=` 操作符简介 `||=`(或等)赋值操作符在 Ruby 中常用,因此有追求的 Rails 开发者要学会使用。初学时可能会觉得 `||=` 很神秘,不过和其他操作符对比之后,你会发现也不难理解。 我们先来看一下常见的变量自增一赋值: ``` x = x + 1 ``` 很多编程语言都为这种操作提供了简化的操作符,在 Ruby 中(C、C++、Perl、Python、Java 等也可以),可以写成下面这样: ``` x += 1 ``` 其他操作符也有类似的简化形式: ``` $ rails console >> x = 1 => 1 >> x += 1 => 2 >> x *= 3 => 6 >> x -= 8 => -2 >> x /= 2 => -1 ``` 通过上面的例子可以得知,`x = x O y` 和 `x O=y` 是等效的,其中 `O` 表示操作符。 在 Ruby 中还经常会遇到这种情况,如果变量的值为 `nil` 则给它赋值,否则就不改变这个变量的值。我们可以使用 [4.2.3 节](chapter4.html#objects-and-message-passing)介绍的或操作符(`||`)编写下面的代码: ``` >> @foo => nil >> @foo = @foo || "bar" => "bar" >> @foo = @foo || "baz" => "bar" ``` 因为 `nil` 是“假值”,所以第一个赋值语句等同于 `nil || "bar"`,得到的结果是 `"bar"`。同样,第二个赋值操作等同于 `"bar" || "baz"`,得到的结果还是 `"bar"`。这是因为除了 `nil` 和 `false` 之外,其他值都是“真值”,而如果第一个表达式的值是真值,`||` 会终止执行。(或操作的执行顺序从左至右,只要出现真值就会终止语句的执行,这种方式叫“短路计算”(short-circuit evaluation)。) 和前面的控制台会话对比之后,我们发现 `@foo = @foo || "bar"` 符合 `x = x O y` 形式,其中 `||` 就是 `O`: ``` x = x + 1 -> x += 1 x = x * 3 -> x *= 3 x = x - 8 -> x -= 8 x = x / 2 -> x /= 2 @foo = @foo || "bar" -> @foo ||= "bar" ``` 因此,`@foo = @foo || "bar"` 和 `@foo ||= "bar"` 两种写法是等效的。在获取当前用户时,建议使用下面的写法: ``` @current_user ||= User.find_by(id: session[:user_id]) ``` 不难理解吧![[6](#fn-6)] 综上所述,`current_user` 方法更简洁的定义方式如[代码清单 8.14](#listing-current-user) 所示。 ##### 代码清单 8.14:在会话中查找当前用户 app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用户 def log_in(user) session[:user_id] = user.id end # 返回当前登录的用户(如果有的话) def current_user @current_user ||= User.find_by(id: session[:user_id]) end end ``` 定义好 `current_user` 之后,现在可以根据用户的登录状态修改应用的布局了。 ## 8.2.3 修改布局中的链接 实现登录功能后,我们要根据登录状态修改布局中的链接。具体而言,我们要添加退出链接、用户设置页面的链接、用户列表页面的链接和当前用户的资料页面链接,构思图如[图 8.7](#fig-login-success-mockup) 所示。[[7](#fn-7)]注意,退出链接和资料页面的链接在“Account”(账户)下拉菜单中。使用 Bootstrap 实现下拉菜单的方法参见[代码清单 8.16](#listing-layout-login-logout-links)。 ![login success mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333052b0130.png)图 8.7:成功登录后显示的资料页面构思图 此时,在现实开发中,我会考虑编写集成测试检测上面规划的行为。我在[旁注 3.3](chapter3.html#aside-when-to-test) 中说过,当你熟练掌握 Rails 的测试工具后,会倾向于先写测试。但这个测试涉及到一些新知识,所以最好在专门的一节中编写([8.2.4 节](#testing-layout-changes))。 修改网站布局中的链接时要在 ERb 中使用 `if-else` 语句,用户登录时显示一组链接,未登录时显示另一组链接: ``` <% if logged_in? %> # 登录用户看到的链接 <% else %> # 未登录用户看到的链接 <% end %> ``` 为了编写这种代码,我们需要定义 `logged_in?` 方法,返回布尔值。 用户登录后,当前用户存储在会话中,即 `current_user` 不是 `nil`。检测会话中有没有当前用户要使用“非”操作符([4.2.3 节](chapter4.html#objects-and-message-passing))。“非”操作符写做 `!`,经常读作“bang”。`logged_in?` 方法的定义如[代码清单 8.15](#listing-logged-in-p) 所示。 ##### 代码清单 8.15:`logged_in?` 辅助方法 app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用户 def log_in(user) session[:user_id] = user.id end # 返回当前登录的用户(如果有的话) def current_user @current_user ||= User.find_by(id: session[:user_id]) end # 如果用户已登录,返回 true,否则返回 false def logged_in? !current_user.nil? end end ``` 定义好 `logged_in?` 方法之后,可以修改用户登录后显示的链接了。我们要添加四个新链接,其中两个链接的地址先使用占位符,[第 9 章](chapter9.html#updating-showing-and-deleting-users)会换成真正的地址: ``` <%= link_to "Users", '#' %> <%= link_to "Settings", '#' %> ``` 退出链接使用[代码清单 8.1](#listing-sessions-resource) 中定义的退出页面地址: ``` <%= link_to "Log out", logout_path, method: "delete" %> ``` 注意,退出链接中指定了哈希参数,指明这个链接发送的是 HTTP `DELETE` 请求。[[8](#fn-8)]我们还要添加资料页面的链接: ``` <%= link_to "Profile", current_user %> ``` 这个链接可以写成: ``` <%= link_to "Profile", user_path(current_user) %> ``` 和之前一样,我们可以直接链接到用户对象,Rails 会自动把 `current_user` 转换成 `user_path(current_user)`。最后,如果用户未登录,我们要添加一个链接,使用[代码清单 8.1](#listing-sessions-resource) 中定义的登录地址,链接到登录页面: ``` <%= link_to "Log in", login_path %> ``` 把这些链接都放到头部局部视图中,得到的视图如[代码清单 8.16](#listing-layout-login-logout-links) 所示。 ##### 代码清单 8.16:修改布局中的链接 app/views/layouts/_header.html.erb ``` ``` 除了在布局中添加新链接之外,[代码清单 8.16](#listing-layout-login-logout-links) 还借助 Bootstrap 实现了下拉菜单。[[9](#fn-9)]注意这段代码中使用的几个 Bootstrap CSS 类:`dropdown`,`dropdown-menu` 等。为了让下拉菜单生效,我们要在 `application.js`(Asset Pipeline 的一部分)中引入 Bootstrap 提供的 JavaScript 库,如[代码清单 8.17](#listing-bootstrap-js) 所示。 ##### 代码清单 8.17:在 `application.js` 中引入 Bootstrap JavaScript 库 app/assets/javascripts/application.js ``` //= require jquery //= require jquery_ujs //= require bootstrap //= require turbolinks //= require_tree . ``` 现在,你应该访问登录页面,然后使用有效账户登录——这样足以测试前三节编写的代码表现是否正常。[[10](#fn-10)]添加[代码清单 8.16](#listing-layout-login-logout-links) 和[代码清单 8.17](#listing-bootstrap-js) 中的代码后,应该能看到下拉菜单和只有已登录用户才能看到的链接,如[图 8.8](#fig-profile-with-logout-link) 所示。如果关闭浏览器,还能确认应用确实忘了登录状态,必须再次登录才能看到上述改动。 ![profile with logout link 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333052d80c3.png)图 8.8:用户登录后看到了新添加的链接和下拉菜单 ## 8.2.4 测试布局中的变化 我们自己动手验证了成功登录后应用的表现正常,在继续之前,还要编写集成测试检查这些行为,以及捕获回归。我们要在[代码清单 8.7](#listing-flash-persistence-test)的基础上,再添加一些测试,检查下面的操作步骤: 1. 访问登录页面; 2. 通过 `post` 请求发送有效的登录信息; 3. 确认登录链接消失了; 4. 确认出现了退出链接; 5. 确认出现了资料页面链接。 为了检查这些变化,在测试中要登入已经注册的用户,也就是说数据库中必须有一个用户。Rails 默认使用“固件”实现这种需求。固件是一种组织数据的方式,这些数据会载入测试数据库。[6.2.5 节](chapter6.html#uniqueness-validation)删除了默认生成的固件([代码清单 6.30](chapter6.html#listing-empty-fixtures)),目的是让检查电子邮件地址的测试通过。现在,我们要在这个空文件中加入自定义的固件。 目前,我们只需要一个用户,它的名字和电子邮件地址应该是有效的。因为我们要登入这个用户,所以还要提供正确的密码,和提交给会话控制器中 `create` 动作的密码比较。参照[图 6.7](chapter6.html#fig-user-model-password-digest) 中的数据模型,可以看出,我们要在用户固件中定义 `password_digest` 属性。我们会定义 `digest` 方法计算这个属性的值。 [6.3.1 节](chapter6.html#a-hashed-password)说过,密码摘要使用 bcrypt 生成(通过 `has_secure_password` 方法),所以固件中的密码摘要也要使用这种方法生成。查看[安全密码的源码](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb)后,我们发现生成摘要的方法是: ``` BCrypt::Password.create(string, cost: cost) ``` 其中,`string` 是要计算哈希值的字符串;`cost` 是“耗时因子”,决定计算哈希值时消耗的资源。耗时因子的值越大,由哈希值破解出原密码的难度越大。这个值对生产环境的安全防护很重要,但在测试中我们希望 `digest` 方法的执行速度越快越好。安全密码的源码中还有这么一行代码: ``` cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost ``` 这行代码相当难懂,你无须完全理解,它的作用是严格实现前面的分析:在测试中耗时因子使用最小值,在生产环境则使用普通(最大)值。([8.4.5 节](chapter8.html#remember-me-checkbox)会深入介绍奇怪的 `?-:` 写法。) `digest` 方法可以放在几个不同的地方,但 [8.4.1 节](#remember-token-and-digest)会在用户模型中使用,所以建议放在 `user.rb` 中。因为计算摘要时不用获取用户对象,所以我们要把 `digest` 方法附在 `User` 类上,也就是定义为类方法([4.4.1 节](chapter4.html#constructors)简要介绍过)。结果如[代码清单 8.18](#listing-digest-method) 所示。 ##### 代码清单 8.18:定义固件中要使用的 `digest` 方法 app/models/user.rb ``` class User < ActiveRecord::Base before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 返回指定字符串的哈希摘要 def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end end ``` 定义好 `digest` 方法后,我们可以创建一个有效的用户固件了,如[代码清单 8.19](#listing-real-user-fixture) 所示。 ##### 代码清单 8.19:测试用户登录所需的固件 test/fixtures/users.yml ``` michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> ``` 特别注意一下,固件中可以使用嵌入式 Ruby。因此,我们可以使用 ``` <%= User.digest('password') %> ``` 生成测试用户正确的密码摘要。 我们虽然定义了 `has_secure_password` 所需的 `password_digest` 属性,但有时也需要使用密码的原始值。可是,在固件中无法实现,如果在[代码清单 8.19](#listing-real-user-fixture) 中添加 `password` 属性,Rails 会提示数据库中没有这个列(确实没有)。所以,我们约定固件中所有用户的密码都一样,即 `'password'`。 创建了一个有效用户固件后,在测试中可以使用下面的方式获取这个用户: ``` user = users(:michael) ``` 其中,`users` 对应固件文件 `users.yml` 的文件名,`:michael` 是[代码清单 8.19](#listing-real-user-fixture) 中定义的用户。 定义好用户固件之后,现在可以把本节开头列出的操作步骤转换成代码了,如[代码清单 8.20](#listing-user-login-test-valid-information) 所示。(注意,这段代码中的 `get` 和 `post` 两步严格来说没有关系,其实向控制器发起 `POST` 请求之前没必要向登录页面发起 `GET` 请求。我之所以加入这一步是为了明确表明操作步骤,以及确认渲染登录表单时没有错误。) ##### 代码清单 8.20:测试使用有效信息登录的情况 GREEN test/integration/users_login_test.rb ``` require 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "login with valid information" do get login_path post login_path, session: { email: @user.email, password: 'password' } assert_redirected_to @user follow_redirect! assert_template 'users/show' assert_select "a[href=?]", login_path, count: 0 assert_select "a[href=?]", logout_path assert_select "a[href=?]", user_path(@user) end end ``` 在这段代码中,我们使用 `assert_redirected_to @user` 检查重定向的地址是否正确;使用 `follow_redirect!` 访问重定向的目标地址。还确认页面中有零个登录链接,从而确认登录链接消失了: ``` assert_select "a[href=?]", login_path, count: 0 ``` `count: 0` 参数的目的是,告诉 `assert_select`,我们期望页面中有零个匹配指定模式的链接。([代码清单 5.25](chapter5.html#listing-layout-links-test)中使用的是 `count: 2`,指定必须有两个匹配模式的链接。) 因为应用代码已经能正常运行,所以这个测试应该可以通过: ##### 代码清单 8.21:**GREEN** ``` $ bundle exec rake test TEST=test/integration/users_login_test.rb \ > TESTOPTS="--name test_login_with_valid_information" ``` 上述命令说明了如何运行一个测试文件中的某个测试——使用如下参数,并指定测试的名字: ``` TESTOPTS="--name test_login_with_valid_information" ``` (测试的名字是使用下划线把“test”和测试说明连接在一起。) ## 8.2.5 注册后直接登录 虽然现在基本完成了认证功能,但是新注册的用户可能还是会困惑,为什么注册后没有登录呢。注册后立即要求用户登录是很奇怪的,所以我们要在注册的过程中自动登入用户。为了实现这一功能,我们只需在用户控制器的 `create` 动作中调用 `log_in` 方法,如[代码清单 8.22](#listing-login-upon-signup) 所示。[[11](#fn-11)] ##### 代码清单 8.22:注册后登入用户 app/controllers/users_controller.rb ``` class UsersController < ApplicationController def show @user = User.find(params[:id]) end def new @user = User.new end def create @user = User.new(user_params) if @user.save log_in @user flash[:success] = "Welcome to the Sample App!" redirect_to @user else render 'new' end end private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end end ``` 为了测试这个功能,我们可以在[代码清单 7.26](chapter7.html#listing-a-test-for-valid-submission) 中添加一行代码,检查用户是否已经登录。我们可以定义一个 `is_logged_in?` 辅助方法,功能和[代码清单 8.15](#listing-logged-in-p) 中的 `logged_in?` 方法一样,如果(测试环境的)会话中有用户的 ID 就返回 `true`,否则返回 `false`,如[代码清单 8.23](#listing-test-helper-sessions) 所示。(我们不能像[代码清单 8.15](#listing-logged-in-p) 那样使用 `current_user`,因为在测试中不能使用 `current_user` 方法,但是可以使用 `session` 方法。)我们定义的方法不是 `logged_in?`,而是 `is_logged_in?`——测试辅助方法和会话辅助方法名字不一样,以免混淆。[[12](#fn-12)] ##### 代码清单 8.23:在测试中定义检查登录状态的方法,返回布尔值 test/test_helper.rb ``` ENV['RAILS_ENV'] ||= 'test' . . . class ActiveSupport::TestCase fixtures :all # 如果用户已登录,返回 true def is_logged_in? !session[:user_id].nil? end end ``` 然后,我们可以使用[代码清单 8.24](#listing-login-after-signup-test) 中的测试检查注册后用户有没有登录。 ##### 代码清单 8.24:测试注册后有没有登入用户 GREEN test/integration/users_signup_test.rb ``` require 'test_helper' class UsersSignupTest < ActionDispatch::IntegrationTest . . . test "valid signup information" do get signup_path assert_difference 'User.count', 1 do post_via_redirect users_path, user: { name: "Example User", email: "user@example.com", password: "password", password_confirmation: "password" } end assert_template 'users/show' assert is_logged_in? end end ``` 现在,测试组件应该可以通过: ##### 代码清单 8.25:**GREEN** ``` $ bundle exec rake test ```
';