пятница, 12 октября 2012 г.

Ruby on Rails Tutorial 9

Глава 9 Войти, выйти

Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 8), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя; в Главе 11 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 12 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).
Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 10, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 10) удалять пользователей из базы данных.
Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:
$ git checkout -b sign-in-out

9.1 Сессии

Сессия это полупостоянное соединение между двумя компьютерами, такими как клиентский компьютер с запущенным веб-браузером и сервер с запущенными на нем Rails. Есть несколько моделей поведения сессий, принятых в сети: “забывание” сессии при закрытии браузера, опциональное использование “запомнить меня” флажка для постоянных сессий, и запоминание сессий до явных признаков выхода пользователя из системы.1 Мы выберем последнюю из этих опций: когда пользователь войдет, мы запомним его статус вошедшего “навсегда”2 и очистим сесию только после явного выхода пользователя из системы.
Удобно моделировать сессии как RESTful ресурс: у нас будет signin страница для new сессий, вход будет создавать (create) сессию, и выход будет уничтожать (destroy) ее. Поэтому нам понадобится контроллер Sessions с new, create, и destroy действиями. В отличие от контроллера Users, который использует сервер базы данных (с помощью модели User) для сохранения данных, контроллер Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть работы, по созданию вход/выход системы, происходит от построения этой, основанной на куках, аутентификационной машинерии. В этом, а также в последующих разделах, мы выполним подготовку к ее запуску, построив контроллер Sessions, формы входа, и связанные действия контроллера. (Большая часть этой работы параллельна регистрации пользователя из Главы 8.) Затем мы завершим вход пользователей необходимым куки-манипулирующим кодом в Разделе 9.3.

9.1.1 Sessions контроллер

Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create (Раздел 9.2 и Раздел 9.3) и выход обрабатывается отправкой запроса DELETE к действию destroy (Раздел 9.4). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 6.2.) Так как мы знаем, что нам нужно new действие, мы можем создать его при генерации контроллера Sessions (аналогично контроллеру Users в Листинге 5.23):3
$ rails generate controller Sessions new
$ rm -rf spec/views
$ rm -rf spec/helpers
Теперь, как и с регистрационной формой в Разделе 8.1, мы добавим пару тестов для new действия и соответствующего представления в новый файл Sessions контроллер specs (Листинг 9.1). (Эта схема должна показаться вам знакомой.)
Листинг 9.1. Тесты для new сессия действия и представления.
spec/controllers/sessions_controller_spec.rb
require 'spec_helper'

describe SessionsController do
  render_views

  describe "GET 'new'" do

    it "should be successful" do
      get :new
      response.should be_success
    end

    it "should have the right title" do
      get :new
      response.should have_selector("title", :content => "Sign in")
    end
  end
end
Чтобы получить прохождение этих тестов, в первую очередь необходимо добавить маршрут для new действия; также, пока мы здесь, мы создадим все действия, необходимые в этой главе. Мы в основном следуем примеру из Листинга 6.26, но в данном случае мы определим только конкретно нужные нам действия, т.е., new, create, и destroy, и также добавим именованные маршруты для входа и выхода (Листинг 9.2).
Листинг 9.2. Добавление ресурса для получения стандартных RESTful действий для сессий.
config/routes.rb
SampleApp::Application.routes.draw do
  resources :users
  resources :sessions, :only => [:new, :create, :destroy]

  match '/signup',  :to => 'users#new'
  match '/signin',  :to => 'sessions#new'
  match '/signout', :to => 'sessions#destroy'
  .
  .
  .
end
Как вы можете видеть, метод resources может принимать хэш опций, который в данном случае имеет ключ :only и значение, эквивалентное массиву действий, на которые реагирует контроллер Sessions. Ресурсы, определенные в Листинге 9.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 6.2), как видно в Таблице 9.1.
HTTP запросURLИменованный маршрутДействиеЦель (назначение)
GET/signinsignin_pathnewстраница для новой сессии (вход)
POST/sessionssessions_pathcreateсоздание новой сессии
DELETE/signoutsignout_pathdestroyудаление сессии (выход)
Таблица 9.1: RESTful маршруты, обеспеченные правилами сессий в Листинге 9.2.
Мы можем получить прохождение второго теста из Листинга 9.1, добавив надлежащую переменную экземпляра заголовка в new действие, как показано в Листинге 9.3 (который также определяет create и destroy действия для использования в будущем).
Листинг 9.3. Добавление заголовка для страницы входа.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
    @title = "Sign in"
  end

  def create
  end

  def destroy
  end
end
С этим тесты из Листинга 9.1 должны пройти, и мы готовы к созданию формы входа.

9.1.2 Форма для входа

Форма входа (или, что эквивалентно, форма новой сессии) похожа на форму регистрации, за исключением двух полей (адрес электронной почты и пароль) вместо четырех. Макет представлен в Рис. 9.1.
signin_mockup
Рисунок 9.1: Макет формы входа. (полный размер)
Напомним из Листинга 8.2, что регистрационная форма использует хелпер form_for, принимающий в качестве аргумента переменную экземпляра @user:
<%= form_for(@user) do |f| %>
  .
  .
  .
<% end %>
Основное отличие от формы новой сессии заключается в отсутствии модели Session, и, следовательно отсутствии аналога переменной @user. Это означает, что при построении формы новой сессии мы должны дать form_for немного больше информации; в частности, в то время как
form_for(@user)
позволяет Rails сделать вывод о том, что action формы должны POST в URL /users, в случае сессий, мы должны указать и имя ресурса, и соответствующий URL:
form_for(:session, :url => sessions_path)
Так как мы аутентифицируем пользователей посредством email адресов и паролей, нам нужно поле для каждого из них внутри формы; результат представлен в Листинге 9.4.
Листинг 9.4. Код для формы входа.
app/views/sessions/new.html.erb
<h1>Sign in</h1>

<%= form_for(:session, :url => sessions_path) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.text_field :email %>
  </div>
  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password %>
  </div>
  <div class="actions">
    <%= f.submit "Sign in" %>
  </div>
<% end %>

<p>New user? <%= link_to "Sign up now!", signup_path %></p>
Форма, созданная кодом из Листинга 9.4, представлена на Рис. 9.2.
signin_form
Рисунок 9.2: Форма входа (/sessions/new). (полный размер)
Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), давайте все же взглянем на него (Листинг 9.5).
Листинг 9.5. HTML формы входа, произведенный Листингом 9.4.
<form action="/sessions" method="post">
  <div class="field">
    <label for="session_email">Email</label><br />
    <input id="session_email" name="session[email]" size="30" type="text" />

  </div>
  <div class="field">
    <label for="session_password">Password</label><br />
    <input id="session_password" name="session[password]" size="30"
           type="password" />
  </div>
  <div class="actions">
    <input id="session_submit" name="commit" type="submit" value="Sign in" />
  </div>
</form>
Сравнивая Листинг 9.5 с Листингом 8.5, вы, возможно, догадались, что отправка этой формы приведет к хэшу params, где params[:session][:email] и params[:session][:password] соответствуют email и password полям. Обработка этой отправки — и, в частности, аутентификация пользователей, основанная на отправленных email и password — является целью следующих двух разделов.

9.2 Сбой входа

Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. Мы начнем с рассмотрения того, что происходит при отправке формы, а затем организуем появление полезных сообщений об ошибке, в случае неудачного входа на сайт (как показано в Рис. 9.3.) Наконец, мы заложим основу для успешного входа (Раздел 9.3) путем оценки предоставляемой при входе информации на предмет валидности комбинации email/password.
signin_failure_mockup
Рисунок 9.3: Макет сбоя входа. (полный размер)

9.2.1 Обзор отправки формы

Давайте начнем с определения минималистичного create действия для контроллера Sessions (Листинг 9.6), которое не делает ничего, кроме воспроизведения new представления. Отправка /sessions/new формы с чистыми полями дает результат, показаный на Рис. 9.4.
Листинг 9.6. Предварительная версия Sessions create действия.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    render 'new'
  end
  .
  .
  .
end
initial_failed_signin_rails_3
Рисунок 9.4: Начальный сбой входа, с create как в Листинге 9.6(полный размер)
Тщательная проверка отладочной информации в Рис. 9.4 показывает, что, как намекалось в конце Раздел 9.1.2, отправка формы приводит к хэшу params, содержащему email и password под ключом :session:
--- !map:ActiveSupport::HashWithIndifferentAccess
commit: Sign in
session: !ActiveSupport::HashWithIndifferentAccess
  password: ""
  email: ""
authenticity_token: BlO65PA1oS5vqrv591dt9B22HGSWW0HbBtoHKbBKYDQ=
action: create
controller: sessions
Как и в случае регистрации пользователя (Рис. 8.6) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.5. В частности, params содержит вложенный хэш формы
{ :session => { :password => "", :email => "" } }
Это означает, что
params[:session]
само является хэшем:
{ :password => "", :email => "" }
Как результат,
params[:session][:email]
является предоставленным адресом электронной почты и
params[:session][:password]
является предоставленным паролем.
Иными словами, внутри create действия хэш params имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.authenticate из Раздела 7.2.4 (Листинг 7.12). Напомним, что authenticate возвращает nil для невалидной аутентификации, нашу стратегию для входа пользователей можно резюмировать следующим образом:
def create
  user = User.authenticate(params[:session][:email],
                           params[:session][:password])
  if user.nil?
    # Create an error message and re-render the signin form.
  else
    # Sign the user in and redirect to the user's show page.
  end
end

9.2.2 Ошибка входа (тест и код)

Для того, чтобы обрабатывать неудачную попытку входа, сначала нужно определить, что она неудачная. Тесты будут следовать примеру аналогичных тестов для регистрации пользователей (Листинг 8.6), как показано в Листинге 9.7.
Листинг 9.7. Тесты для неудачной попытки входа.
spec/controllers/sessions_controller_spec.rb
require 'spec_helper'

describe SessionsController do
  render_views
  .
  .
  .
  describe "POST 'create'" do

    describe "invalid signin" do

      before(:each) do
        @attr = { :email => "email@example.com", :password => "invalid" }
      end

      it "should re-render the new page" do
        post :create, :session => @attr
        response.should render_template('new')
      end

      it "should have the right title" do
        post :create, :session => @attr
        response.should have_selector("title", :content => "Sign in")
      end

      it "should have a flash.now message" do
        post :create, :session => @attr
        flash.now[:error].should =~ /invalid/i
      end
    end
  end
end
Код приложения, необходимый для прохождения этих тестов показан в Листинге 9.8. Как и было обещано в Разделе 9.2.1, мы извлекаем предоставленные адрес электронной почты и пароль из хэша params , а затем передаем их методу User.authenticate . Если пользователь не прошел проверку подлинности (то есть, если это nil), устанавливаем заголовок и повторно выводим форму входа.4 Мы обработаем другую часть выражения if-else в Разделе 9.3; на данный момент ограничившись комментарием описывающим наши планы.
Листинг 9.8. Код для неудачной попытки входа на сайт.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.authenticate(params[:session][:email],
                             params[:session][:password])
    if user.nil?
      flash.now[:error] = "Invalid email/password combination."
      @title = "Sign in"
      render 'new'
    else
      # Sign the user in and redirect to the user's show page.
    end
  end
  .
  .
  .
end
Напомним из Раздела 8.4.2, что мы отображали ошибки регистрации, используя сообщения об ошибках модели User. Так как сессии не являются моделью Active Record, эта стратегия не сработает в данном случае, так что вместо этого мы поместим сообщение во флэш (или, скорее, во flash.now; см. Блок 9.1). Благодаря отображению флэш сообщений в макете сайта (Листинг 8.16), flash[:error] сообщения отображаются автоматически; благодаря Blueprint CSS, они автоматически приобретают приятный стиль (Рис. 9.5).
failed_signin
Рисунок 9.5: Неудачная попытка входа (с флэш сообщением). (полный размер)

9.3 Успешный вход

Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Намек на то, куда мы двигаемся — страница профиля пользователя, с измененными навигационными ссылками — представлена в макете на Рис. 9.6.5 Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.
signin_success_mockup
Рисунок 9.6: Макет профиля пользователя после успешного входа (с обновленными навигационными ссылками).  (полный размер)

9.3.1 Завершение create действия

Заполнить область, занятую в настоящее время комментарием (Листинг 9.8) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in, а затем перенаправляем его на страницу профиля (Листинг 9.9). Мы видим теперь, почему это обманчивая легкость: увы, sign_in в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.
Листинг 9.9. Завершенное действие create контроллера Sessions (пока не рабочее).
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.authenticate(params[:session][:email],
                             params[:session][:password])
    if user.nil?
      flash.now[:error] = "Invalid email/password combination."
      @title = "Sign in"
      render 'new'
    else
      sign_in user
      redirect_to user
    end
  end
  .
  .
  .
end
Хотя у нас нет sign_in функции, мы все же можем написать тесты (Листинг 9.10). (Мы заполним тело первого теста в Разделе 9.3.3.)
Листинг 9.10. Ожидающие тесты для входа пользователя (будут завершены в Разделе 9.3.3).
spec/controllers/sessions_controller_spec.rb
describe SessionsController do
  .
  .
  .
  describe "POST 'create'" do
    .
    .
    .
    describe "with valid email and password" do

      before(:each) do
        @user = Factory(:user)
        @attr = { :email => @user.email, :password => @user.password }
      end

      it "should sign the user in" do
        post :create, :session => @attr
        # Fill in with tests for a signed-in user.
      end

      it "should redirect to the user show page" do
        post :create, :session => @attr
        response.should redirect_to(user_path(@user))
      end
    end
  end
end
Тесты пока не проходят, но это хорошее начало.

9.3.2 Запомнить меня

Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя “навсегда” и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5 что Ruby предоставляет модуль для упаковки функций вместе и включения их в нескольких местах, и это план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper. Кроме того, помощники, автоматически включаются в Rails представления, так что все что мы должны сделать, чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application controller (Листинг 9.11).
Листинг 9.11. Включение модуля Sessions хелпер в Application controller.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery
  include SessionsHelper
end
По умолчанию, все помощники доступны в views, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны включить его в явном виде.
Теперь мы готовы к первому элементу входа, самой sign_in функции. Наш метод аутентификации заключается в помещении remember token как cookie в браузер пользователя (Блок 9.2), а затем использовании token для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 9.3.3). В результате, Листинг 9.12, отправляет в стек две вещи: хэш cookies и current_user.6 Let’s start popping them off.(??)
Листинг 9.12. Завершенная (но пока еще не работающая) sign_in функция.
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    cookies.permanent.signed[:remember_token] = [user.id, user.salt]
    self.current_user = user
  end
end
Листинг 9.12 вводит утилиту cookies, поставляемую Rails. Мы можем использовать cookies как если бы она была хэшем; каждый элемент в cookie это сам хэш из двух элементов, value и дополнительное expires дата (срок)(# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения cookie со значением, равным пользовательскому id, которое истекает через 20 лет:
cookies[:remember_token] = { :value   => user.id,
                             :expires => 20.years.from_now.utc }
(Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 9.3.) Тогда мы могли бы извлекать пользователя кодом
User.find_by_id(cookies[:remember_token])
Конечно, cookies на самом деле не хэш, поскольку назначение cookies фактически сохраняет часть текста в браузере (как видно в Рис. 9.7), но часть красоты Rails заключается в том, что он позволяет забыть об этих подробностях и сконцентрироваться на написании приложений.
user_remember_token_cookie_rails_3
Рисунок 9.7: Безопасный remember token. (полный размер)
К сожалению, использование идентификатора пользователя таким образом, является небезопасным по той же причине, что рассматривалась в Блоке 9.2: злоумышленник может имитировать cookie с данным id, тем самым получив доступ к любому пользователю в системе. Традиционное решение до Rails 3 заключалась в создании безопасного remember token, связанного с моделью User для использования вместо пользовательского id (см., например, Rails 2.3 версию Rails Tutorial).Этот паттерн стал настолько распространенным, что Rails 3 теперь реализует его для нас, используя cookies.permanent.signed:
cookies.permanent.signed[:remember_token] = [user.id, user.salt]
Присваиваемое значение справа, это массив, состоящий из уникального идентификатора (например, пользовательский id) и безопасного значения используемого для создания цифровой подписи для предотвращения вида атак, описанного в Разделе 7.2. В частности, так как мы взяли на себя труд по созданию безопасной соли в Разделе 7.2.3, мы можем повторно использовать это значение здесь, чтобы подписать remember token. Under the hood(??), использование permanent является причиной, по которой Rails устанавливает истечение срока действия в 20.years.from_now, и signed делает cookie безопасными, так что id пользователя никогда не будет показан в браузере. (Мы увидим, как получить пользователя, используя remember token в Разделе 9.3.3.)
Код выше показывает важность использования new_record? в Листинге 7.10 для сохранения соли только после создания пользователя. В противном случае, соль менялась бы каждый раз, при сохранении пользователя, сделав невозможным извлечение пользовательской сессии в Разделе 9.3.3.

9.3.3 Текущий пользователь

В этом разделе мы узнаем, как получить и установить сессии для текущего пользователя. Давайте снова посмотрим на sign_in чтобы увидеть, где мы находимся:
module SessionsHelper

  def sign_in(user)
    cookies.permanent.signed[:remember_token] = [user.id, user.salt]
    self.current_user = user
  end
end
Сейчас в центре нашего внимания вторая строка:7
self.current_user = user
Цель этой строки заключается в создании current_user, доступного и в контроллерах и в представлениях, что позволит создавать конструкции, подобные этой:
<%= current_user.name %>
и
redirect_to current_user
Основная цель данного раздела состоит в определении current_user.
Для описания поведения оставшейся машинерии входа, мы сначала заполним тест для входа пользователя (Листинг 9.13).
Листинг 9.13. Заполнение теста для входа пользователя.
spec/controllers/sessions_controller_spec.rb
describe SessionsController do
  .
  .
  .
  describe "POST 'create'" do
    .
    .
    .
    describe "with valid email and password" do

      before(:each) do
        @user = Factory(:user)
        @attr = { :email => @user.email, :password => @user.password }
      end

      it "should sign the user in" do
        post :create, :session => @attr
        controller.current_user.should == @user
        controller.should be_signed_in
      end

      it "should redirect to the user show page" do
        post :create, :session => @attr
        response.should redirect_to(user_path(@user))
      end
    end
  end
end
Новый тест использует controller переменную (которая доступна внутри Rails тестов), чтобы проверить, что current_user переменная установлена для вошедшего пользователя, и что пользователь вошел:
it "should sign the user in" do
  post :create, :session => @attr
  controller.current_user.should == @user
  controller.should be_signed_in
end
Вторая строка может немного сбивать с толку в этой точке, но вы можете догадаться, опираясь на конвенцию RSpec для булевых методов, что
controller.should be_signed_in
это эквивалент
controller.signed_in?.should be_true
Это намек, на то, что мы определим signed_in? метод, который возвращает true если пользователь вошел и false в противном случае. Кроме того, signed_in? метод будет прикреплен к контроллеру, а не к пользователю, поэтому мы пишем controller.signed_in? вместо current_user.signed_in?. (Если пользователь не вошел в систему, как бы мы вызвали signed_in? на это?)
Чтобы начать писать код для current_user, обратите внимание, что строка
self.current_user = user
это назначение. Ruby имеет специальный синтаксис для определения таких назначаемых функций, как показано в Листинге 9.14.
Листинг 9.14. Определение присвоения current_user.
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end
end
Это может выглядеть сбивающим с толку, но это просто определение метода current_user= специально разработанного для обработки назначения current_user. Его единственный аргумент находящийся справа, в данном случае пользователь, который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user, эффективно хранящую пользователя для дальнейшего использования.
В обычном Ruby, мы могли бы определить второй метод, current_user, предназначенный для возвращения значения @current_user (Листинг 9.15).
Листинг 9.15. Заманчивое, но бесполезное определение current_user.
module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end

  def current_user
    @current_user     # Бесполезно! Не используйте эту строку.
  end
end
Если бы мы сделали это, мы бы фактически повторили функциональность attr_accessor, впервые показанного в Разделе 4.4.5 и использовавшегося для создания виртуального атрибута password в Разделе 7.1.1.8 Проблема в том, что он совершенно не в состоянии решить наши проблемы: с кодом в Листинге 9.15, статус вошедшего пользователя будет забыт: как только пользователь перейдет на другую страницу — poof! — сессия закончится и пользователь автоматически выйдет!
Чтобы избежать этой проблемы, мы можем найти сессию пользователя соответствующую cookie созданному кодом в Листинге 9.12, как показано в Листинге 9.16.
Листинг 9.16. Поиск текущего пользователя по remember_token.
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  def current_user
    @current_user ||= user_from_remember_token
  end

  private

    def user_from_remember_token
      User.authenticate_with_salt(*remember_token)
    end

    def remember_token
      cookies.signed[:remember_token] || [nil, nil]
    end
end
Этот код использует несколько более продвинутые возможности Ruby, поэтому давайте улучим момент для ознакомления с ними.
Во-первых, Листинг 9.16 использует общепринятый, но изначально обескураживающий ||= (“или равно”) оператор присваиваивания (Блок 9.4). Его эффект заключается в установке переменной экземпляра @current_user пользователю, соответствующему remember token, но только если @current_user не определен.9 Иными словами, конструкция
@current_user ||= user_from_remember_token
вызывает метод user_from_remember_token при первом вызове которого вызывается current_user, но при последующих вызовах возвращается @current_user без вызова user_from_remember_token.10
Листинг 9.16 также использует * оператор, что позволяет нам использовать два элемента массива в качестве аргумента метода, ожидающего две переменные, как мы видим в этой консольной сессии:
$ rails console
>> def foo(bar, baz)
?>   bar + baz
?> end
=> nil
>> foo(1, 2)
=> 3
>> foo(*[1, 2])
=> 3
Причиной, по которой это необходимо в Листинге 9.16 является то, что cookies.signed[:remember_me] возвращает массив из двух элементов — пользовательский id и соль  — но (в соответствии с общепринятой конвенцией Ruby) мы хотим, чтобы метод authenticate_with_salt принимал два аргумента, это может быть осуществлено следующим образом:
User.authenticate_with_salt(id, salt)
(Фундаментальных причин, по которым authenticate_with_salt не мог бы принять массив в качестве аргумента не существует, но это был бы идиоматически некорректный Ruby.)
Наконец, во вспомогательном методе remember_token, определенном в Листинге 9.16, мы используем || оператор для возвращения массива с nil значениями, если cookies.signed[:remember_me] само является nil:
cookies.signed[:remember_token] || [nil, nil]
Причиной использования этого кода является то, что поддержка cookies входа в Rails тестах еще незрелая, и nil значение для cookie может вызвать ложные поломки в тестах. Возвращая [nil, nil] вместо этого, мы решаем проблему.11
Последним шагом для получения рабочего кода в Листинге 9.16 является определение authenticate_with_salt метода класса. Этот метод, аналогичный оригинальному authenticate методу, определенному в Листинге 7.12, показан в Листинге 9.17.
Листинг 9.17. Добавление authenticate_with_salt метода к модели User.
app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .

  def self.authenticate(email, submitted_password)
    user = find_by_email(email)
    return nil  if user.nil?
    return user if user.has_password?(submitted_password)
  end

  def self.authenticate_with_salt(id, cookie_salt)
    user = find_by_id(id)
    (user && user.salt == cookie_salt) ? user : nil
  end
  .
  .
  .
end
Здесь authenticate_with_salt вначале ищет пользователя по уникальному id, а затем проверяет что соль, хранящаяся в cookie является правильной для этого пользователя.
Стоит отметить, что эта реализация authenticate_with_salt идентична по функционированию следующему коду, который более тесно параллелен authenticate методу:
def self.authenticate_with_salt(id, cookie_salt)
  user = find_by_id(id)
  return nil  if user.nil?
  return user if user.salt == cookie_salt
end
В обоих случаях метод возвращает пользователя, если user не nil, а соль пользователя совпадает с солью cookie’s, и возвращает nil в противном случае. С другой стороны, код, подобный
(user && user.salt == cookie_salt) ? user : nil
общепринят в идиоматически корректном Ruby, так что я подумал, что это хорошая идея, чтобы ввести его. Этот код использует странный, но полезный тернарный оператор для сжатия if-else конструкции в одну строку (Box 9.5).
В данный момент, тест входа почти проходит; осталось лишь определить необходимый булев метод signed_in? К счастью, это легко сделать с помощью “не” оператора !: пользователь является вошедшим, если current_user не является nil (Листинг 9.18).
Листинг 9.18. Вспомогательный метод signed_in?.
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  def signed_in?
    !current_user.nil?
  end

  private
  .
  .
  .
end
Хотя он уже с пользой используется в тестах, мы найдем методу signed_in? лучшее применение в Разделе 9.4.3, а затем в Главе 10.
С этим все тесты должны пройти.

9.4 Выход

Как обсуждалось в Разделе 9.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту необходимую возможность выхода. Как только мы закончим с этим, мы добавим интеграционные тесты и заставим нашу аутентификационную машинерию пройти их.

9.4.1 Уничтожение сессий

До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new для страницы входа и create для его завершения. Мы продолжим эту тему используя действие destroy для удаления сессий, т.е., для выхода.
Для того, чтобы протестировать действие выхода, нам, в первую очередь, необходим способ для входа в рамках теста. Самый простой способ сделать это, состоит в использовании объекта controller, который мы видели в Разделе 9.3.3 и использование sign_in помощника для входа данного пользователя. Для того чтобы использовать полученную функцию test_sign_in во всех наших тестах, мы должны поместить ее в файл spec helper, как показано в Листинге 9.19.12
Листинг 9.19. Функция test_sign_in для симуляции входа пользователя внутри тестов.
spec/spec_helper.rb
.
.
.
RSpec.configure do |config|
  .
  .
  .
  def test_sign_in(user)
    controller.sign_in(user)
  end
end
После запуска test_sign_in, current_user не будет nil, таким образом, signed_in? будет true.
С этим spec хелпером в руке, тест на выход прост: войти в систему как (сфабрикованный) пользователь, а затем обратиться к destroy действию и убедиться, что пользователь вышел (Листинг 9.20).
Листинг 9.20. Тест для уничтожения сессии (выхода пользователя).
spec/controllers/sessions_controller_spec.rb
describe SessionsController do
  .
  .
  .
  describe "DELETE 'destroy'" do

    it "should sign a user out" do
      test_sign_in(Factory(:user))
      delete :destroy
      controller.should_not be_signed_in
      response.should redirect_to(root_path)
    end
  end
end
Единственным новым элементом здесь является delete метод, который выдает HTTP запрос DELETE (по аналогии с get и post методами, виденными в предыдущих тестах), в соответствии с требованиями конвенции REST (Таблица 9.1).
Как и с входом пользователя, который опирается на функцию sign_in, выход пользователя просто откладывает тяжелую работу в функцию sign_out (Листинг 9.21).
Листинг 9.21. Уничтожение сессии (выход пользователя).
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    sign_out
    redirect_to root_path
  end
end
Как и другие элементы аутентификации, мы поместим sign_out в модуль Sessions хелпера (Листинг 9.22).
Листинг 9.22. Метод sign_out в модуле Sessions хелпера.
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    cookies.permanent.signed[:remember_token] = [user.id, user.salt]
    self.current_user = user
  end
  .
  .
  .
  def sign_out
    cookies.delete(:remember_token)
    self.current_user = nil
  end

  private
    .
    .
    .
end
Как вы можете видеть, sign_out эффективно отменяет метод sign_in, удаляя remember token и устанавливая текущего пользователя равным nil.13

9.4.2 Вход после регистрации

В принципе, мы закончили с аутентификацией, но в настоящее время конструкцией приложения не предусмотрены ссылки на Вход и Выход действия. Кроме того, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию.
Мы исправим вторую проблему первой, начав с тестирования того, что новый пользователь автоматически входит в систему (Листинг 9.23).
Листинг 9.23. Тестирование того, что вновь зарегистрированные пользователи также являются вошедшими.
spec/controllers/users_controller_spec.rb
require 'spec_helper'

describe UsersController do
  render_views
  .
  .
  .
  describe "POST 'create'" do
    .
    .
    .
    describe "success" do
      .
      .
      .
      it "should sign the user in" do
        post :create, :user => @attr
        controller.should be_signed_in
      end
      .
      .
      .
    end
  end
end
С методом sign_in из Раздела 9.3, получить прохождения этого теста, фактически впустив пользователя в систему, легко: просто добавим sign_in @user сразу после сохранения пользователя в базе данных (Листинг 9.24).
Листинг 9.24. Вход пользователя сразу после регистрации.
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(params[:user])
    if @user.save
      sign_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      @title = "Sign up"
      render 'new'
    end
  end

9.4.3 Изменение ссылок шаблона

Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы изменим ссылки в шаблоне, основываясь на статусе пользователя. В частности, как показано на макете в Рис. 9.6 мы организуем изменение ссылок, при входе и выходе пользователей из системы, и мы также добавим ссылку на профиль пользователя в страницу, показывающую пользователя для вошедших пользователей.
Начнем с двух интеграционных тестов: один будет проверять, что ссылка "Sign in" будет появляться для не вошедших пользователей, а другой будет проверять, что ссылка "Sign out" будет появляться для вошедших пользователей; в обоих случаях будет проверяться, что ссылка ведет к надлежащему URL. Мы поместим эти тесты в тест ссылок макета, который мы создали в Разделе 5.2.1; результат представлен в Листинге 9.25.
Листинг 9.25. Тесты для ссылок Войти/Выйти шаблона сайта.
spec/requests/layout_links_spec.rb
describe "Layout links" do
  .
  .
  .
  describe "when not signed in" do
    it "should have a signin link" do
      visit root_path
      response.should have_selector("a", :href => signin_path,
                                         :content => "Sign in")
    end
  end

  describe "when signed in" do

    before(:each) do
      @user = Factory(:user)
      visit signin_path
      fill_in :email,    :with => @user.email
      fill_in :password, :with => @user.password
      click_button
    end

    it "should have a signout link" do
      visit root_path
      response.should have_selector("a", :href => signout_path,
                                         :content => "Sign out")
    end

    it "should have a profile link"
  end
end
Здесь блок before(:each) входит, посетив страницу входа и отправив валидную пару email/пароль.14 Мы делаем это вместо использования test_sign_in функции из Листинга 9.19 так как test_sign_in не работает внутри итеграционных тестов по ряду причин. (См. упражнение в Разделе 9.6, предлагающее сделать функцию integration_sign_in для использования в интеграционных тестах.)
Код приложения использует если-то ветвящиеся структуры внутри Embedded Ruby, используя метод signed_in?, определенный в Листинге 9.18:
<% if signed_in? %>
<li><%= link_to "Sign out", signout_path, :method => :delete %></li>
<% else %>
<li><%= link_to "Sign in", signin_path %></li>
<% end %>
Обратите внимание, что ссылка Выхода передает хэш аргумент, указывающий, что она должна быть отправлена с HTTP запросом DELETE.15 Полный партиал header с добавленным фрагментом представлен в Листинге 9.26.
Листинг 9.26. Изменение ссылок макета для вошедших пользователей.
app/views/layouts/_header.html.erb
<header>
  <%= link_to logo, root_path %>
  <nav class="round">
    <ul>
      <li><%= link_to "Home", root_path %></li>
      <li><%= link_to "Help", help_path %></li>
      <% if signed_in? %>
      <li><%= link_to "Sign out", signout_path, :method => :delete %></li>
      <% else %>
      <li><%= link_to "Sign in", signin_path %></li>
      <% end %>
    </ul>
  </nav>
</header>
В Листинге 9.26 мы использовали logo хелпер из упражнения Главы 5 (Раздел 5.5); в случае, если вы не проработали это упражнение, ответ представлен в Листинге 9.27.
Листинг 9.27. Хелпер для логотипа сайта.
app/helpers/application_helper.rb
module ApplicationHelper
  .
  .
  .
  def logo
    image_tag("logo.png", :alt => "Sample App", :class => "round")
  end
end
Наконец, давайте добавим ссылку на профиль пользователя. И тест (Листинг 9.28) и код приложения (Листинг 9.29) очень просты. Обратите внимание, что URL ссылки на профиль это всего лишь current_user,16 что является нашим первым применением этого полезного метода. (Первым, но не последним.)
Листинг 9.28. Тест для ссылки на профиль пользователя.
spec/requests/layout_links_spec.rb
describe "Layout links" do
  .
  .
  .
  describe "when signed in" do
    .
    .
    .
    it "should have a profile link" do
      visit root_path
      response.should have_selector("a", :href => user_path(@user),
                                         :content => "Profile")
    end
  end
end
Листинг 9.29. Добавление ссылки на профиль пользователя.
app/views/layouts/_header.html.erb
<header>
  <%= link_to logo, root_path %>
  <nav class="round">
    <ul>
      <li><%= link_to "Home", root_path %></li>
      <% if signed_in? %>
      <li><%= link_to "Profile", current_user %></li>
      <% end %>
      <li><%= link_to "Help", help_path %></li>
      <% if signed_in? %>
      <li><%= link_to "Sign out", signout_path, :method => :delete %></li>
      <% else %>
      <li><%= link_to "Sign in", signin_path %></li>
      <% end %>
    </ul>
  </nav>
</header>
С кодом из этого раздела. вошедший пользователь теперь видит и ссылку на Выход, и ссылку на профиль, как и ожидалось (Рис. 9.8).
profile_with_signout_link
Рисунок 9.8: Вошедший пользователь со ссылками на Выход и профиль. (полный размер)

9.4.4 Интеграционный тест для входа и выхода

В качестве кульминации нашей тяжелой работы над аутентификацией, мы закончим интеграционными тестами для входа и выхода (поместив их в файл users_spec.rb в соответствии с конвенцией). RSpec интеграционное тестирование достаточно выразительно само по себе, но все же Листинг 9.30 требует небольшого разъяснения; мне особенно нравится использование click_link "Sign out", которое не только симулирует клик по ссылке Выход, но также выдает ошибку, в случае если такая ссылка не существует — тем самым тестируя URL, именованный маршрут, текст ссылки, и изменение ссылок шаблона, и все в одной строке. Если это не интеграционный тест, то я не знаю, что еще может им быть.
Листинг 9.30. Интеграционный тест для входа и выхода.
spec/requests/users_spec.rb
require 'spec_helper'

describe "Users" do

  describe "signup" do
    .
    .
    .
  end

  describe "sign in/out" do

    describe "failure" do
      it "should not sign a user in" do
        visit signin_path
        fill_in :email,    :with => ""
        fill_in :password, :with => ""
        click_button
        response.should have_selector("div.flash.error", :content => "Invalid")
      end
    end

    describe "success" do
      it "should sign a user in and out" do
        user = Factory(:user)
        visit signin_path
        fill_in :email,    :with => user.email
        fill_in :password, :with => user.password
        click_button
        controller.should be_signed_in
        click_link "Sign out"
        controller.should_not be_signed_in
      end
    end
  end
end

9.5 Заключение

Мы очень многое узнали в этой главе, трансформируя наше многообещающее но не сформированное приложение в сайт, обладающий полным набором функций для регистрации и входа/выхода пользователей. Все что нам необходимо для завершения аутентификационной функциональности, это ограничить доступ к страницам по статусу и идентификации пользователей. Мы выполним эту задачу, по пути дав пользователям возможность редактировать их информацию, а также дав администраторам возможность удалять пользователей из системы.
Прежде чем двигаться далее, объедините изменения с мастер веткой:
$ git add .
$ git commit -m "Done with sign in"
$ git checkout master
$ git merge sign-in-out

9.6 Упражнения

Второе и третье упражнения труднее, чем обычно. Их решение требует поиска во внешних источниках (например, чтения Rails API и поиска в Google), и они могут быть пропущены без особых потерь.
  1. Несколько интеграционных тестов используют одинаковый код для входа пользователя. Замените этот код на функцию integration_sign_in из Листинга 9.31 и проверьте, что тесты проходят.
  2. >Используйте session вместо cookies так, чтобы пользователи автоматически выходили при закрытии браузера.17 Подсказка: Ищите в Google “Rails session” (Rails сессии).
  3. (продвинутое) Некоторые сайты используют безопасный HTTP (HTTPS) для своих страниц входа. Поищите в сети, как использовать HTTPS в Rails, а затем обезопасьте new и create действия контроллера Sessions. Сверхзадача: Написать тесты для HTTPS функциональности. (Примечание: Я советую выполнять это упражнение только в development, что не потребует получения SSL сертификата или настройки SSL шифровальной машинерии. Как ни странно, развертывание сайтов с включенным SSL гораздо сложнее.)
Листинг 9.31. Функция для входа пользователей внутри интеграционных тестов.
spec/spec_helper.rb
.
.
.
RSpec.configure do |config|
  .
  .
  .
  def test_sign_in(user)
    controller.sign_in(user)
  end

  def integration_sign_in(user)
    visit signin_path
    fill_in :email,    :with => user.email
    fill_in :password, :with => user.password
    click_button
  end
end
  1. Другой распространенной моделью является завершение сессии после истечения определенного количества времени. Это особенно уместно на сайтах, содержащих конфиденциальную информацию, такую как банковские и финансово-торговые операции. 
  2. В Разделе 9.3.2 мы увидим, насколько продолжительно это самое “навсегда”. 
  3. Если бы мы также добавили create и destroy действия, generate скрипт создал бы представления для этих действий, которые нам не нужны. Конечно, мы могли бы удалить представления, но я предпочел не допустить их создания скриптом generate, а вместо этого определить действия вручную. 
  4. В случае, если вы задаетесь вопросом, почему мы используем user вместо @user в Листинге 9.8, это связано с тем, что эта user переменная никогда более не понадобится в других представлениях, следовательно, нет оснований использовать здесь переменную экземпляра. (Хотя использование @user по прежнему возможно.) 
  5. Изображение из http://www.flickr.com/photos/hermanusbackpackers/3343254977/
  6. На некоторых системах, вам, возможно, придется использовать self.current_user = user для прохождения предстоящих тестов. 
  7. Из-за включения модуля Sessions хелпера в Application controller, self переменная здесь является самим контроллером. 
  8. Фактически, эти двое полностью эквивалентны; attr_accessor это просто удобный способ создать именно такие getter/setter методы автоматически. 
  9. Как правило, это означает присвоение переменных, которые изначально nil, но не ложные (false) значения также будут переписаны оператором ||=
  10. Этот метод оптимизации, позволяющий избежать повторных вызовов функции известен как мемоизация
  11. Это вызывает чувство, как будто хвост виляет собакой, но это цена, которую мы платим за использование передовых технологий. 
  12. Если вы используете Spork, она будет расположена внутри блока Spork.prefork
  13. Вы можете узнать о вещах, подобных cookies.delete, почитав введение в cookies в Rails API. (Так как ссылки на Rails API имеют тенденцию быстро устаревать, используйте ваш Google для поиска актуальной версии.) 
  14. Обратите внимание, что мы можем использовать символы вместо строк для этикеток, например, fill_in :email вместо fill_in "Email". Мы использовали последнюю в Листинге 8.22, но теперь вас не должно удивлять, что Rails позволяет использовать в этом месте символы. 
  15. Веб браузеры на самом деле не могут выдавать запрос DELETE; Rails подделывает его с помошью JavaScript. 
  16. Вспомните из Раздела 7.3.3 что мы можем сделать ссылку непосредственно на объект user и позволить Rails вывести соответствующий URL. 
  17. Несколько странно: мы использовали cookies для реализации сессий, и session реализуются с cookies! 

Комментариев нет:

Отправить комментарий