第11章 管理
再加上一个基本的用户管理系统,当进入站点管理功能时要求用户先登录。只需要根据用户名和密码识别用户即可。只要通过用户识别,该用户就可以使用所有的管理功能。
添加用户
不能直接以明文形式保存密码,而是要首先对其进行SHA1 加密,然后保存一个160 位的散列码。当用户再次登录时,我们会对他输入的密码做同样的加密处理,并将加密的结果与数据库中保存的散列码进行比较。为了让系统更加安全,我们还对密码做了salt 处理:当生成散列值时将密码与一个伪随机字符串组合之后再生成散列码。
ruby script/generate scaffold user name:string hashed_password:string salt:string
rake db:migrate
class User < ActiveRecord::Base
validates_presence_of :name
validates_uniqueness_of :name
attr_accessor :password_confirmation
validates_confirmation_of :password
validate :password_non_blank
private
def password_non_blank
errors.add(:password, "Missing password" ) if hashed_password.blank?
end
end
神秘的validates_confirmation_of声明:
你肯定用过这样的表单:你要首先输入一遍密码,然后在另一个输入框中再输入一遍密码,以确保你所输入的正是你想输入的。Rails 可以自动地校验这两遍输入的密码是否相同。
最后,我们用一个校验钩子来检查密码已经被设上了值,但我们不会直接检查password 属性。为什么?因为这个属性并不真的存在——至少不存在于数据库里。所以我们需要检查它的替身——也就是经过散列加密的密码——存在。
depot_p/app/models/user.rb
private
def self.encrypted_password(password, salt)
string_to_hash = password + "wibble" + salt
Digest::SHA1.hexdigest(string_to_hash)
end
此外还必须引用digest/sha1库
将一个随机数与对象ID 组合起来,就得到了我们需要的salt 字符串—— salt 值是什么并不重要,只要它是无法预测的就行( 譬如说,用时间作为salt 值的熵*就不如用随机字符串来得高) 。最后,把这个salt 值放进模型对象的salt 属性中。
depot_p/app/models/user.rb
def create_new_salt
self.salt = self.object_id.to_s + rand.to_s
end
在代码里写self.salt=...,以强制调用salt= 方法——准确地说是“调用当前对象的salt= 方法”。如果没有self. 的话, Ruby 会认为我们正在给一个局部变量赋值,那我们的代码就没有效果了
每当明文密码被放进User 对象时,都会自动生成加密后的散列码( 后者将被存入数据库) 。为此,我们将把“明文密码”变成模型中的一个虚拟属性(virtualattribute)—— 它从应用程序的角度看上去像是个属性,但却不会被存入数据库。
attr_accessor :password (标准的访问方式)
attr_accessor会在幕后生成两个属性访问方法:一个名叫password 的读方法,以及一个名叫password=的写方法——写方法的名称以等号结尾,意味着它是可以被赋值的。我们不打算使用标准的访问方法,而是自己动手来实现访问方法,在写方法中生成一个新的salt 值,然后用它来生成密码散列值。
depot_p/app/models/user.rb
def password
@password
end
def password=(pwd)
@password = pwd
return if pwd.blank ?
create_new_salt
self.hashed_password = User.encrypted_password(self.password, self.salt)
end
def self.authenticate(name, password)
user = self.find_by_name(name)
if user
expected_password = encrypted_password(password, user.salt)
if user.hashed_password != expected_password
user = nil
end
end
user
end
方法的第一行调用了find_by_name 方法,但你哪儿都找不到这么一个代码。不过别担心, ActiveRecord 会注意到我们调用了一个未经定义的方法,并且该方法的名字以find_by开头、以一个字段名结尾,所以它会自动帮我们创建一个查找方法,并将其添加到模型类上。
管理用户
depot_p/app/controllers/users_controller.rb
def index
→ @users = User.find(:all, :order => :name)
def create
respond_to do |format|
if @user.save
→ flash[:notice] = "User #{@user.name} was successfully created."
→ format.html { redirect_to(:action=>'index') }
def update
respond_to do |format|
if @user.update_attributes(params[:user])
→ flash[:notice] = "User #{@user.name} was successfully updated."
→ format.html { redirect_to(:action=>'index') }
depot_p/app/views/users/index.html.erb
<h1>Listing users</h1>
<table>
<tr>
<th>Name</th>
</tr>
<% for user in @users %>
<tr>
<td><%=h user.name %></td>
<td><%= link_to 'Show', user %></td>
<td><%= link_to 'Edit', edit_user_path(user) %></td>
<td><%= link_to 'Destroy', user, :confirm => 'Are you sure? ', :method => :delete %></td>
</tr>
<% end %>
</table>
<br />
<%= link_to 'New user', new_user_path %>
depot_p/app/views/users/new.html.erb
<div class="depot-form">
<% form_for(@user) do |f| %>
<%= f.error_messages %>
<fieldset>
<legend>Enter User Details</legend>
<div>
<%= f.label :name %>:
<%= f.text_field :name, :size => 40 %>
</div>
<div>
<%= f.label :user_password, 'Password' %>:
<%= f.password_field :password, :size => 40 %>
</div>
<div>
<%= f.label :user_password_confirmation, 'Confirm' %>:
<%= f.password_field :password_confirmation, :size => 40 %>
</div>
<div>
<%= f.submit "Add User" , :class => "submit" %>
</div>
</fieldset>
<% end %>
</div>
现在我们已经能够把用户信息存入数据库了。在我们尝试之前,先来将用户样式表链接起来,我们还是通过修改用户视图的布局模板来实现。
depot_p/app/views/layouts/users.html.erb
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
<title>Users: <%= controller.action_name %></title>
→ <%= stylesheet_link_tag 'scaffold', 'depot' %>
</head>
登录
我们需要提供一张表单,以便管理员输入用户名和密码。
在管理员登录以后,我们须要在session中记录这一事实,直到他们登出为止。
我们要限制对应用程序中管理端的访问:只允许以管理员身份登录的用户访问。
我们先来定义一个admin 控制器,它包含三个方法—— login() 、logout() 和index()方法( 仅用来欢迎管理员) 。
ruby script/generate controller admin login logout index
depot_p/app/controllers/admin_controller.rb
def login
if request.post?
user = User.authenticate(params[:name], params[:password])
if user
session[:user_id] = user.id
redirect_to(:action => "index" )
else
flash.now[:notice] = "Invalid user/password combination"
end
end
end
depot_p/app/views/admin/login.html.erb
<div class="depot-form">
<% form_tag do %>
<fieldset>
<legend>Please Log In</legend>
<div>
<label for="name">Name:</label>
<%= text_field_tag :name, params[:name] %>
</div>
<div>
<label for="password">Password:</label>
<%= password_field_tag :password, params[:password] %>
</div>
<div>
<%= submit_tag "Login" %>
</div>
</fieldset>
<% end %>
</div>
它没有使用form_for 方法,而是使用了form_tag—— 后者只是生成一个普通的HTML<form> 标记而已。
我们可以把params 中的值直接与表单输入字段关联起来——不需要任何模型对象作为中介。在这里,我们直接使用了params 对象
depot_p/app/views/admin/index.html.erb
<h1>Welcome</h1>
It's <%= Time.now %>
We have <%= pluralize(@total_orders, "order" ) %>.
depot_p/app/controllers/admin_controller.rb
def index
@total_orders = Order.count
end
访问控制
对于没有以管理员身份登录的人,我们希望阻止他们访问管理端的页面。用Rails 提供的过滤器(filter) 工具实现这一功能并不困难。
Rails 的过滤器允许对action 方法调用进行拦截,在调用action 方法之前或之后加上我们自己的处理逻辑。
前置过滤器(before filter)来拦截所有对admin 控制器中action 的调用。拦截器将对session[:user_id]进行检查:如果这里有值,并且又能够与数据库中的用户对应上,就说明管理员已经登录了,调用就会继续进行;如果session[:user_id]没有值,拦截器就会发起一次重定向,将用户引导到登录页面。
这个方法应该放在哪里?当然,可以将它直接放在admin 控制器中,但由于某些原因( 很快会解释) ,我们要把它放在ApplicationController中——这是所有控制器的父类,它位于app/controllers/application.rb 文件中。同时请注意,我们需要限制对这个方法的访问,因为application.rb中的方法会成为所有控制器的实例方法,其中所有的public 方法都会以action 的形式暴露给最终用户。
class ApplicationController < ActionController::Base
→ before_filter :authorize, :except => :login
→ protected
→ def authorize
→ unless User.find_by_id(session[:user_id])
→ flash[:notice] = "Please log in"
→ redirect_to :controller => 'admin', :action => 'login'
→ end
→ end
→ end
需要注意,这样做有些过头了,管理员自己也无法访问store 了,这样做还不行。 我们再改一改,把需要身份认证的方法标注出来,这就是所谓的黑名单,它比较容易因遗漏而造成错误。更好的方法是把不需要认证的方法做成白名单,我们这里就采用这种方法。做法也很简单,只需将store 控制器中的authorize方法重写就可以了。
depot_q/app/controllers/store_controller.rb
class StoreController < ApplicationController
#...
protected
def authorize
end
end
现在请删除session文件( 因为你已经登录了) 。
rake db:sessions:clear
友好的登录系统
按照目前的代码,如果管理员尝试在未登录的状态下访问受限的页面,他就会被引到登录页面上;在完成登录之后,接着出现的是统一的状态页面——用户最初的请求已经被遗忘了。如果你愿意的话,也可以对应用程序稍作修改,在用户登录之后将其引到最初请求的页面。
首先,如果authorize()方法需要让用户去登录的话,应该同时将当前请求的URI记在session中。
def authorize
unless User.find_by_id(session[:user_id])
session[:original_uri] = request.request_uri
flash[:notice] = "Please log in"
redirect_to :controller => 'admin', :action => 'login'
end
end
一旦用户登录成功,我们就可以检查session中是否保存了一个请求URI:如果有的话,就将用户请求重定向到他原本请求的地址。
if user
session[:user_id] = user.id
uri = session[:original_uri]
session[:original_uri] = nil
redirect_to(uri || { :action => "index" })
增加边栏,以及更多的管理功能
我们再次编辑app/controllers/application.rb ,这次加上一个对布局的调用。
depot_q/app/controllers/application.rb
class ApplicationController < ActionController::Base
layout "store"
#...
现在访问http://localhost:3000/admin 的 话,会看到一个错误,那是视图尝试显示购物车造成的。现在,我们要做的是防止在与购物车无关的功能中将购物车显现出来,并在布局模板的边栏中增加不同管理功能的链接,并且保证只有session中存在:user_id 时才显示出来。
depot_q/app/views/layouts/store.html.erb
<div id="columns">
<div id="side">
→ <% if @cart %>
<% hidden_div_if(@cart.items.empty ? , :id => "cart" ) do %>
<%= render(:partial => "cart" , :object => @cart) %>
<% end %>
→ <% end %>
→ <% if session[:user_id] %>
→ <br />
→ <%= link_to 'Orders', :controller => 'orders' %><br />
→ <%= link_to 'Products', :controller => 'products' %><br />
→ <%= link_to 'Users', :controller => 'users' %><br />
→ <br />
→ <%= link_to 'Logout', :controller => 'admin', :action => 'logout' %>
→ <% end %>
现在访问http://localhost:3000/admin的话,就能看到熟悉的Pragmatic Bookshelf标题和边栏了。但如果访问http://localhost:3000/users就看不到。那是由于我们还有一件事情没做:由脚手架产生的表单会将应用中缺省的布局模板覆盖掉,我们需要停用它,很简单,删掉就行了 。
del app/views/layouts/products.html.erb
del app/views/layouts/users.html.erb
del app/views/layouts/orders.html.erb
没有管理员了……
点击用户“ dave” 旁边的destroy链接。没有任何问题,用户被删除了。但令我们吃惊的是,出现在眼前的却是登录页面。我们已经把唯一的管理员用户给删掉了,当进行随后的请求时,身份认证失败,因此应用程序拒绝让我们进入。现在的情形令人尴尬:我们必须首先登录才能使用管理功能:但数据库中已经没有任何管理员用户,所以我们无法登录。
ruby script/console
User.create(:name => 'dave', :password => 'secret',:password_confirmation => 'secret')
User.count
我们将把“删除”动作放在一个事务的范围内。如果用户被删除之后数据库中不存在任何别的用户,就将事务回滚,将最后一个被删除的用户恢复回去。
为此我们需要用到ActiveRecord 的钩子方法。在前面我们已经看过了一个钩子方法:当ActiveRecord 要校验模型对象的状态是否合法时,它就会调用该对象的validate 方法。实际上, ActiveRecord 总共定义了大概20 个钩子方法,分别在模型对象生命周期的不同时间点被调
用。在这里我们要使用after_destroy钩子方法,该方法会在SQL delete 语句执行之后被调用。如果它是公有的,就会被和它在同一个事务中的delete 方法调用,因此只要在该方法里抛出异常,整个事务就会被回滚。
depot_q/app/models/user.rb
def after_destroy
if User.count.zero?
raise "Can't delete last user"
end
end
这里的关键概念是:用异常来表示删除用户的过程中出现了错误。
在事务内部,异常会导致自动回滚;如果在删除用户之后users 表为空,抛出异常就可以撤销删除操作,恢复最后一个用户。
异常可以把错误信息带回给控制器。我们在控制器中使用begin/rescue 代码块来处理异常,并将错误信息放在flash 中报告给用户。如果你只想中断事务,并不想报告异常,那就抛出ActiveRecord::Rollback 异常,因为只有这个异常才不会传递给ActiveRecord::Base.transaction 。
depot_q/app/controllers/users_controller.rb
def destroy
@user = User.find(params[:id])
→ begin
→ flash[:notice] = "User #{@user.name} deleted"
→ @user.destroy
→ rescue Exception => e
→ flash[:notice] = e.message
→ end
用户登出
depot_q/app/controllers/admin_controller.rb
def logout
session[:user_id] = nil
flash[:notice] = "Logged out"
redirect_to(:action => "login" )
end
注意到,在StoreController 中有一点重复代码:除了empty_cart 之外,每个action 都要到session数据中去寻找用户的购物车。下面这行代码
@cart = find_cart
在控制器中出现了好几次。现在我们知道,可以用过滤器来解决这个问题。于是,我们对find_cart()方法进行了修改,让它直接把找到的结果放进@cart 实例变量中。
depot_q/app/controllers/store_controller.rb
def find_cart
@cart = (session[:cart] ||= Cart.new)
end
before_filter :find_cart, :except => :empty_cart