Rails 5 Test Prescriptions 第10章 Testing for Security

Web 安全是一个可怕的主题。所有的你的程序都依靠密码学,代码超出了你的控制。

尽管如此,你还是可以控制部分网页安全 --所有的logins和access checks和injection errors。

本章聚焦在user logins, roles, 和使用测试来确保基本的用户验证。 

 

  • User Authentication and Authorization 用户验证和授权✅
  • Adding Users and Roles✅
  • Restricting Access✅
  • More Access-Control Testing ✅
  • Using Roles✅
  • Protection Against Form Modification 防止表格修改✅
  • Mass Assignment Testing✅
  • Other Security Resources❌没看

 

 


 

 

User Authentication and Authorization 

 

安装gem 'devise'

根据提示:

设置config/environments/development.rb (粘贴过来就行)

加a root route

在视图上加flash提示。如果用手脚架建立了一个model,自动会有flash提示。

<p class="notice"><%= notice %></p>

 

<p class="alert"><%= alert %></p>

 

然后生成User model: rails g devise User , 因为用了RSpec和Factory_Bot所以有了👇

      create      spec/models/user_spec.rb 

      create        spec/factories/users.rb

      route  devise_for :users #打包了一个全部的登入登出的路径

 

User model里面默认有一些Devise模块:

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable

 还有一些被注释掉的是可以选择的。

这些符号传给devise method能够使用Devise的功能features和假定了一系列的数据列columns,具体的list可以在migration file文件中看到。如(部分):

 

    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

然后增加Devise的测试helper:

RSpec.configure do |config|
  config.include Devise::Test::ControllerHelpers, type: :controller
  config.include Devise::Test::ControllerHelpers, type: :view
  config.include Devise::Test::IntegrationHelpesr, type: :system
  config.include Devise::Test::IntegrationHelpesr, type: :request

  config.include Devise::Test::IntegrationHelpers, type: :feature

#应该可以不写具体类型 

end

 


 

 

Adding Users and Roles 

 

建立一个系统测试user_and_role_spec.rb

直接在测试中写一个方法,log_in_as,用于注册。

  def log_in_as(user)

 

    visit new_user_session_path
    fill_in("user_email", with: user.email)
    fill_in("user_password", with: user.password)
    click_on('Log in')
  end

然后写测试。

⚠️至少在你的测试中写这么一次模仿用户登陆的测试,这样更直观,但会降低速度。

⚠️以后可以使用Devise自带的方法。 

两个测试:
  let(:user){User.create(email:"test@example.com", password:"password")}
  it "allows a logged_in user to view the project index page" do
    log_in_as(user)
    visit(projects_path)
    expect(current_path).to eq(projects_path)
  end
#不登陆,不能访问projects页面,跳转回登陆页面。但本次失败❌,
  it "does not allow a user to see the project page if not logged in" do
    visit(projects_path)
    expect(current_path).to eq(new_user_session_path)
  end

#因为,没有加before_action :authenticate_user!这个验证钩子方法。

Prescription:

Always do security testing in pair: the blocked logic and the okay logic.

再测试通过,不过之前的测试好多失败了,需要修改,才能通过。

Prescription:

当一个单独的变化打破了多个测试, 考虑你的测试策略是否有瑕疵

 

因此再factories/users.rb中建立一个user。 

  factory :user do
    sequence(:email){|n| "user_#{n}@example.com"}
    password("password")
  end

 

Devise提供了Test helpers来登陆,以前是ControllerHelpers,现在是:

Devise::Test::IntegrationHelpers 可用于integration, request, system tests

然后就可以使用sign_in(), sign_out()方法了。 

before do

  sign_in(FactoryBot.create(:user))

end 

 


 

Restricting Access 

有各种约束的情况,需要进行🚫限制。本例:

 

约束进入权。用户只能使用自己看自己所在的project组中的project.

  describe "roles" do
    let(:user){create(:user)}
    let(:project) { create(:project, name: "Project Gutenberg")}
    it "allows a user who is part of a project to see that project" do
      project.roles.create(user: user)
      sign_in(user)
      visit(project_path(project))
      expect(current_path).to eq(project_path(project))
    end
    it "doesn't allow a user who is not part of a project to see the project" do
      sign_in(user)
      visit(project_path(project))
      expect(current_path).to_not eq(project_path(project))
    end
  end

这个测试会失败❌,因为project.roles不存在。

 

思考:

现在,需要设计一个用户和一个工程的结合。如何设计这个数据,是扩展一个新的data还是建立一个新的structure(新model关联)。一般在测试前,就应当决定了。

 

教材是建立一个新的关系结构多对多。建立一个role model来关联project和user

rails g model role user:refenences project:references role_name:string 

rake db:migrate

 

然后在model层建立双方的关联:

user.rb 

  has_many :roles, dependent: :destroy
  has_many :users, through: :roles

project.rb

  has_many :roles, dependent: :destroy
  has_many :projects, through: :roles

 

再次测试:第二个测试会失败,因为没有写相关的逻辑。

👇进入单元测试环境,测试的是model层的逻辑。从集成测试的失败点看到,这里也应该让非关联用完无法看到project.

有2个独立的职责来判断一个用户。1是决定是否一个用户能够看到一个project。2是如果核查失败,则返回。 

进入model/user_spec.rb文件:

  let(:project) {create(:project)}
  let(:user) {create(:user)}
  it "cannot view a project it is not a part of" do
    expect(user.can_view?(project)).to be_falsy
  end
  it "can view a project it is a part of" do
    Role.create(user: user, project: project) 
    expect(user.can_view?(project)).to be_truthy
  end


can_view?是自定义的方法,写在模块层的user.rb中:

  def can_view?(project)
    projects.include?(project)
  end

❌想法:(我觉得这个方法只用于测试,实在没必要在user.rb中写。而且in?方法也不常用,不如直接在测试中使用include?()方法。单元测试其实测的是你的多对多的关联是否建立好。)

✅思考:集成测试到模块的单元测试再到集成测试,这是一个通过测试来-设计交互-发现逻辑缺陷-model层补充底层数据的逻辑-控制器层补充(mvc)的逻辑-完成设计的过程。

 

集成测试还需要改,测试这个逻辑: 如果用户没有使用权,控制器会成功挡住page.

需要在projects_conroller.rb中修改show action.  

 

 

    unless current_user.can_view?(@project)

 

      redirect_to new_user_session_path
      return
    end
    respond_to do |format|
      format.html {}
      format.js {render json: @prject.as_json(root:true, include:tasks)}
    end

 测试通过。

⚠️之前的集成测试task也需要修改,增加Role.create()把user和project建立上关联。 

 

总结:

 

  1. 先思考需要做什么,然后写一个集成测试,
  2. 集成测试不通过的问题是什么?
  3. 然后写针对的单元测试。
  4. 再增加集成测试通过的逻辑。
  5. 在这个集成测试通过后,还要看增加的逻辑对之前的测试是否有负面影响,并改正。 

 


 

More Access-Control Testing 

 

分开责任和测试各自的控制器和模块相关的逻辑的好处是,让你在增加其他需求时自己会更清楚。

下面增加两个需求: 

1.添加一个主管用户可以看到所有project。 

2.公开的project可以被任何user看到,包括未注册的游客。

rails g migration add_public_fields

然后给Project增加publics属性,User增加admin 属性。 

在spec/models/user_spec.rb加入测试,然后修改user.rb中的can_view?方法。通过测试。

 

这里因为无需添加集成测试,因为集成测试的逻辑无需改变,增加的功能和用户的操作无关。 

pasting

 

Using Roles 

 

  • project index list,只有用户可以看到它权限内的projects
  • 新tasks form。也有限制(这个不看了) 

 

先加一个集成测试案例。

  describe "index page" do
    let!(:my_project) { create(:project, name: "My Project") }
    let!(:not_my_project) { create(:project, name: "Not My Project") }
    it "allows users to see only projects that are visible" do
      my_project.roles.create(user: user)
      sign_in(user)
      visit(projects_path)
      expect(page).to have_selector("#project_#{my_project.id}")
      expect(page).not_to have_selector("#project_#{not_my_project.id}")
    end
  end

 最后一个无法通过,因为控制器还是返回所有的projects。

然后写单元测试在user_spec.rb

  describe "visible projects" do
    let!(:project_1) { create(:project, name: "Project 1") }
    let!(:project_2) { create(:project, name: "Project 2") }
    it "allows a user to see their projects" do
      user.projects << project_1
      expect(user.visible_projects).to eq([project_1])
    end
    it "allows an admin to see all projects" do
      user.admin = true
      expect(user.visible_projects).to match_array(Project.all)
    end
    it "allows a user to see public projects" do
      user.projects << project_1
      project_2.update(public: true)
      expect(user.visible_projects).to match_array([project_1, project_2])
    end
    it "has no duplicates in project list" do
      user.projects << project_1
      project_1.update(public: true)
      expect(user.visible_projects).to match_array([project_1])
    end
  end

 

 match_array是一个RSpec中定义的匹配器。 

 

 用到了visible_projects,需要在模块User中定义。

  def visible_projects
    return Project.all if admin == true
    Project.where(id: project_ids).or(Project.where(public: true))
  end

然后单元测试通过,但控制器还要修改@projects = current_user.visible_projects 

最后,集成测试通过。

 

⚠️ Project.where(id: project_ids).or(Project.where(public: true))

不明白。明天看看查询。看看是否有替代的。


 

6月4日周一,回顾了几个sql知识点。

1. 👆昨天标记的project_ids, 可以得到关联的projects的id的数组形式。

has_many关联的方法,⚠️,😫,没有详细的看知道,这个就是collection_singular_ids方法

返回一个对象集合的id的数组:

 

@book_ids = @author.book_ids 


SELECT
"projects"."id" FROM "projects" INNER JOIN "roles" ON "projects"."id" = "roles"."project_id" WHERE "roles"."user_id" = ?  [["user_id", 2]] 

而: user.projects.map(&:id)

是链式方法调用。所以和数据库交互的是user.projects对应的sql语法,然后再使用map方法。

 SELECT "projects".* FROM "projects" INNER JOIN "roles" ON "projects"."id" = "roles"."project_id" WHERE "roles"."user_id" = ?  [["user_id", 2]] 

 

 

 

2. user.projects << project_1的数据库运行代码:

 

begin transaction

Role create  INSERT INTO "roles" ("user_id", "project_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 1], ["project_id", 14], ["created_at", "2018-06-04 01:38:43.790421"], ["updated_at", "2018-06-04 01:38:43.790421"]] 

 

 

commit transaction

 

Project Load  SELECT  "projects".* FROM "projects" INNER JOIN "roles" ON "projects"."id" = "roles"."project_id" WHERE "roles"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]

 

解释:

开始事物,然后发现user.没有关联这个project,所以创建一个role建立多对多关联。然后新增一条记录:

insert into "table name"(属性,属性) values(?, ?) [[key, value], [key, value]]

提交事物:

使用inner join 

 

user.visible_projects #此时含一条project数据

Project.where(id: project_ids).or(Project.all_public) 

SELECT "projects"."id" FROM "projects" INNER JOIN "roles" ON "projects"."id" = "roles"."project_id" WHERE "roles"."user_id" = ?  [["user_id", 1]]
Project Load (0.1ms)  SELECT  "projects".* FROM "projects" WHERE ("projects"."id" = ? OR "projects"."public" = ?) LIMIT ?  [["id", 14], ["public", 1], ["LIMIT", 11]] 
 =>

#<ActiveRecord::Relation [#<Project id: 14, name: "Project_1", due_date: nil, created_at: "2018-06-04 01:37:45", updated_at: "2018-06-04 01:37:45", public: false>]>

 

解释:

Project.where(id: user.project_ids).or(Project.where(public: true)) 

这条语法使用了关联语法:collection_singular_ids和查询方法:or(other)

or方法是模拟的数据库语法 where...or... 

 

user.projects    #此时有2条数据。

SELECT  "projects".* FROM "projects" INNER JOIN "roles" ON "projects"."id" = "roles"."project_id" WHERE "roles"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]] 

=>返回一个关联对象,这个对象是数组,数组里面又包含对象。

#<ActiveRecord::Associations::CollectionProxy [#<Project id: 14, name: "Project_1", due_date: nil, created_at: "2018-06-04 01:37:45", updated_at: "2018-06-04 01:37:45", public: false>,     #<Project id: 16, name: "Project_3", due_date: nil, created_at: "2018-06-04 02:12:50", updated_at: "2018-06-04 02:12:50", public: false>]> 

 

 


 

 

再次测试user_spec.rb发现

it "allows an user to view a public project"  没有通过测试,

这是英文project.public = true 更新了,但没有保存到数据库,而之后的方法会从数据库调用project数据。所以❌。

改正就是,project.save。最简单。

 

重构,因为把visble_projects和can_view?方法合并所以可以重构测试。

 

Prescription

增加用户验证,很可能会让早期的测试产生错误。尽早尝试解决。


 

Protection Against Form Modification 

(本章用到请求测试,相关博客:点击) 

 

思路:一个user创建了project ,然后才会创建tasks。他创建的tasks只能自己curd,别的user没有存取的权利。

目前的开发没有对此进行现在,正常的页面操作自然不会出现问题,但如果一个恶意的用户使用HTTP request来创建不属于他的project下的task, 就可以绕过user的验证去篡改数据库了。

因此需要对TasksCojtroller#create和其他更新或什么方法进行验证。

  

例子:

验证task必须由user自身的project创建才会成功。

这里的设计问题是在哪里进行存取的核查(access check), 和由此带来的问题哪里写test?

第一个问题:在TasksController中的方法内限制。

第二个问题: 

因为恶意request来自不是标准的用户交互(regular UI)的外部, 所以不能够使用Capybara写集成测试。 只能写请求测试

请求测试是当浏览器发送request后,服务器响应用户请求的过程。

request用到 HTTP 动 词:getpostdelete 和 patch。  

 

given: a user, a project than belongs to, a project the user doesn't belong to.

when:  the creation of the task

then:   是否task创建成功。

 

require 'rails_helper'
RSpec.describe "task controller requests" do
  let(:project) {create(:project, name:"Project Bluebook")}
  let(:user) { create(:user)}
  describe "creation" do
    before do
      sign_in(user)
    end
    it "can add a task to a project the user can see" do
      Role.create(user:user, project: project)
      post(tasks_path, params: {task: {name: "New Task", size: "3", project_id: project.id}})
      expect(request).to redirect_to(project_path(project))
    end
    it "cann't add a task to a project the user can not see" do
      post(tasks_path, params: {task: {name: "New Task", size: "3", project_id: project.id}})
      expect(request).to redirect_to(new_user_session_path)
    end
  end
end

解释:

post()方法: 属于ActionController::TestCase::Behavior, 模仿POST request (携带参数)

这是get()方法的例子: 

get :show,	#也可以是相对路径如 tasks_path或者 /tasks/new
  params: { id: 7 },
  session: { user_id: 1 },
  flash: { notice: 'This is flash message' }

然后修改想应的create action.加上判断语法:

    if !current_user.can_view?(@project)
      redirect_to new_user_session_path
      return
    end

 

 


 

 

Mass Assignment Testing 


对传入的request参数进行限制。permit()方法,以防止恶意塞入hash对儿数据。

  def task_params
    params[:task].permit(:project_id, :title, :size)
  end

 或者params.require([:task, :work]).permit(:project_id, :title, :size, :name...)

 require接受一个hash key 或一个array hash key

 

还有一种方法。只接受明确指定的参数。 

  def create
    @workflow = CreatesProject.new(
      name:params[:project][:name],
      task_string: params[:project][:tasks]
    )

。。。 

 

因为Capybara的集成测试 不能测试出这类潜在的问题,所以请求测试在这里发挥作用

 

posted @ 2018-06-02 09:29  Mr-chen  阅读(129)  评论(0编辑  收藏  举报