Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 7), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя. В Главе 10 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 11 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).
Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 9, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 9) удалять пользователей из базы данных.
После реализации ядра аутентификационного механизма мы немного отвлечемся от основной темы для того чтобы познакомиться с Cucumber - популярной системой предназначенной для разработки через поведение (Раздел 8.3). В частности, мы перепишем пару интеграционных RSpec тестов на Cucumber для сравнения этих двух методик.
Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:
$ git checkout -b sign-in-out
Удобно моделировать сессии как RESTful ресурс: у нас будет страница входа для новых сессий, вход будет создавать сессию и выход будет уничтожать ее. В отличие от ресурса Users который использует базу данных (через модель User) для сохранения данных, ресурс Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть сложностей в разработке системы входа связана с построением этого, опирающегося на куки, аутентификационного механизма. В этом и последующих разделах мы будем заниматься подготовительной работой - создадим контроллер Sessions , форму входа и соответствующие действия контроллера. Затем мы завершим вход пользователей написав необходимый для манипуляций с куки код в Разделе 8.2.
Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create (Раздел 8.1 и Раздел 8.2) и выход обрабатывается отправкой запроса DELETE к действию destroy (Раздел 8.2.6). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 7.1.) Для начала мы сгенерируем контроллер Sessions и интеграционный тест для механизма аутентификации:
$ rails generate controller Sessions --no-test-framework $ rails generate integration_test authentication_pages
Следуя модели из Раздела 7.2 для страницы регистрации, мы создадим форму входа для создания новых сессий (рис 8.1).
(рис 8.1) Набросок формы входа.
Страница входа живет по URL предоставленному signin_path (уже определенному) и, как обычно, мы начнем с минималистичного теста как это показано в Листинге 8.1. (Сравните его с аналогичным кодом для страницы регистрации из Листинга 7.6.)
require 'spec_helper'
describe "Authentication" do
subject { page }
describe "signin page" do
before { visit signin_path }
it { should have_content('Sign in') }
it { should have_title('Sign in') }
end
end
Изначально тест провальный, как и требуется:
$ bundle exec rspec spec/
Для того чтобы получить прохождение тестов из Листинга 8.1, в первую очередь нам необходимо определить маршруты для ресурса Sessions, совместно с кастомным именованным маршрутом для страницы входа (который мы направим к действию new контроллера Session). Как и с ресурсом Users, мы можем использовать метод resources для определения стандартных RESTful маршрутов:
resources :sessions, only: [:new, :create, :destroy]
Поскольку нам нет надобности показывать или редактировать сессии, мы ограничимся действиями new, create и destroy с помощью опции :only принимаемой resources. Конечный результат, включающий именованные маршруты для входа и выхода, представлен в Листинге 8.2.
SampleApp::Application.routes.draw do resources :users resources :sessions, only: [:new, :create, :destroy] root 'static_pages#home' match '/signup', to: 'users#new', via: 'get' match '/signin', to: 'sessions#new', via: 'get' match '/signout', to: 'sessions#destroy', via: 'delete' . . . end
Обратите внимание на использование via: ’delete’ для маршрута выхода, указывающее на то, что он должен быть вызван с помощью HTTP запроса DELETE.
Ресурсы, определенные в Листинге 8.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 7.1), как видно в Таблице 8.1. Обратите внимание на то что маршруты для входа и выхода являются кастомными, но маршрут для создания сессии остался дефолтным (т.е., [resource name]_path).
| HTTP запрос | URL | Именованный маршрут | Действие | Цель (назначение) |
|---|---|---|---|---|
| GET | /signin | signin_path |
new |
страница для новой сессии (вход) |
| POST | /sessions | sessions_path |
create |
создание новой сессии |
| DELETE | /signout | signout_path |
destroy |
удаление сессии (выход) |
Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду rake routes:
$ rake routes
Prefix Verb URL Pattern Controller#Action
users GET /users(.:format) users#index
POST /users(.:format) users#create
new_user GET /users/new(.:format) users#new
edit_user GET /users/:id/edit(.:format) users#edit
user GET /users/:id(.:format) users#show
PATCH /users/:id(.:format) users#update
PUT /users/:id(.:format) users#update
DELETE /users/:id(.:format) users#destroy
sessions POST /sessions(.:format) sessions#create
new_session GET /sessions/new(.:format) sessions#new
session DELETE /sessions/:id(.:format) sessions#destroy
root GET / static_pages#home
signup GET /signup(.:format) users#new
signin GET /signin(.:format) sessions#new
signout DELETE /signout(.:format) sessions#destroy
help GET /help(.:format) static_pages#help
about GET /about(.:format) static_pages#about
contact GET /contact(.:format) static_pages#contact
Следующим шагом необходимым для прохождения тестов из Листинга 8.1 является добавление new действия к контроллеру Sessions, как это показано в Листинге 8.3 (который также определяет create и destroy действия для использования в будущем).
class SessionsController < ApplicationController def new end def create end def destroy end end
Последний шаг это определение начальной версии страницы входа. Обратите внимание, что, поскольку это страница для новой сесии, она живет в файле app/views/sessions/new.html.erb, который мы должны создать. Содержимое, которое в настоящий момент определяет только заголовок страницы и заголовок первого уровня, представлено в Листинге 8.4.
<% provide(:title, "Sign in") %> <h1>Sign in</h1>
Теперь тесты из Листинга 8.1 должны пройти и мы готовы к созданию самой формы входа.
$ bundle exec rspec spec/
Сравнивая рис 8.1 с рис 7.11, мы видим, что форма входа (или, что эквивалентно, форма новой сессии) выглядит аналогично форме регистрации, за исключением того что в ней два поля (email и пароль) вместо четырех. Как и с формой регистрации, мы можем протестировать форму входа используя Capybara для заполнения формы данными и последующего клика по кнопке.
В процессе написания тестов мы вынуждены обращать внимание на аспекты дизайна нашего приложения, что является одним из приятных побочных эффектов разработки через тестирование. Мы начнем с провального входа, как это показано на наброске в рис 8.2.
(рис 8.2) Набросок провального входа.
Как видно на рис 8.2, при предоставлении невалидной информации мы хотим вновь отрендерить страницу входа и вывести на экран сообщение об ошибке. Мы будем рендерить ошибку как флэш сообщение, что мы можем протестировать следующим образом:
it { should have_selector('div.alert.alert-error') }
Здесь используется предоставляемый Capybara метод have_selector который мы видели ранее в решениях для двух упражнений, Листинге 5.38 и Листинге 7.32. Метод have_selector проверяет наличие конкретного селектора (т.е. HTML тега, однако в Capybara 2.0 это работает только для видимых элементов). В данном случае мы ищем
div.alert.alert-error
который проверяет тег div. В частности, вспомнив что в CSS точка обозначает "класс" (Раздел 5.1.2), вы возможно догадались что это тест на наличие тега div с классами "alert" и "alert-error", вроде этого:
<div class="alert alert-error">Invalid...</div>
Комбинация тестов заголовка и флэша приводит нас к коду в Листинге 8.5. Как мы увидим, эти тесты упускают одну важную деталь, которой мы займемся в Разделе 8.1.5.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
end
end
Теперь, написав тесты для провального входа, мы возвращаемся к успешному входу. Изменениями которые мы хотим протестировать являются рендеринг страницы профиля пользователя (что предопределено заголовком страницы, который должен быть именем пользователя), совместно с тремя запланированными изменениями в навигации сайта:
(Мы отложим тесты для ссылки "Settings" до Раздела 9.1 и для ссылки "Users" до Раздела 9.3.) Набросок этих изменений представлен на рис 8.3.
(рис 8.3) Набросок профиля пользователя после успешного входа.
Код тестов для успешного входа представлен в Листинге 8.6.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
.
.
.
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Здесь мы использовали метод have_link. Он принимает в качестве аргументов текст ссылки и необязательный параметр :href, таким образом
it { should have_link('Profile', href: user_path(user)) }
убеждается в том что якорный тег a имеет правильный атрибут href (URL) — в данном случае, ссылку на страницу профиля пользователя. Обратите также внимание на то что мы позаботились upcase email адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.
После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 7.17 что форма регистрации использует вспомогательный метод form_for, принимающий в качестве аргумента переменную экземпляра @user:
<%= form_for(@user) do |f| %> . . . <% end %>
Основное отличие между этим и формой для входа заключается в том что у нас нет модели Session, и, следовательно, нет аналога для переменной @user. Это означает, что при конструировании формы для новой сессии нам необходимо предоставить методу form_for чуть больше информации; в частности, тогда как
form_for(@user)
позволяет Rails сделать вывод о том, что действием формы должно быть POST к URL /users, в случае с сессиями мы должны явно указать имя ресурса и соответствующий URL:
form_for(:session, url: sessions_path)
(Вторым возможным способом является использование form_tag вместо form_for; это было бы даже более идеоматически корректным решением с точки зрения Rails, но оно бы имело мало общего с формой регистрации, а на этом этапе я хочу подчеркнуть параллельность структуры. Создание рабочей формы с помощью form_tag оставлено в качестве упражнения (Раздел 8.5).)
Имея на руках правильный используя форму регистрации (Листинг 7.17) в качестве модели, как это показано в Листинге 8.7.
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>
<div class="row">
<div class="span6 offset3">
<%= form_for(:session, url: sessions_path) do |f| %>
<%= f.label :email %>
<%= f.text_field :email %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
Обратите внимание на то, что мы для удобства добавили ссылку на страницу входа. С кодом в Листинге 8.7, форма для входа выглядит как на рис 8.7" type="example">Листинге 8.7, форма для входа выглядит как на (рис 8.4) Форма для входа (/signin).
Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), пока все же давайте взглянем на него (Листинге 8.8).
<form accept-charset="UTF-8" action="/sessions" method="post">
<div>
<label for="session_email">Email</label>
<input id="session_email" name="session[email]" type="text" />
</div>
<div>
<label for="session_password">Password</label>
<input id="session_password" name="session[password]"
type="password" />
</div>
<input class="btn btn-large btn-primary" name="commit" type="submit"
value="Sign in" />
</form>
Сравнивая Листинге 8.8 с Листингом 7.20, вы, возможно, догадались, что отправка этой формы приведет к хэшу params, где params[:session][:email] и params[:session][:password] соответствуют email и password полям.
Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из рис 8.5" type="example">Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Давайте начнем с определения минималистичного действия .
class SessionsController < ApplicationController
.
.
.
def create
render 'new'
end
.
.
.
end
(рис 8.5) Начальный провальный вход с create из Листинга 8.9.
Тщательное изучение отладочной информации на рис 8.5 показывает, что, как намекалось в конце Раздела 8.1.3, отправка формы приводит к хэшу params содержащему email и password под ключом :session:
--- session: email: '' password: '' commit: Sign in action: create controller: sessions
Как и в случае регистрации пользователя (рис 7.15) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.6. В частности, params содержит вложенный хэш формы
{ session: { password: "", email: "" } }
Это означает что
params[:session]
само является хэшем:
{ password: "", email: "" }
Как результат,
params[:session][:email]
является предоставленным адресом электронной почты и
params[:session][:password]
является предоставленным паролем.
Иными словами, внутри create действия хэш params имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.find_by_email предоставленный Active Record (Раздел 6.1.4) и метод authenticate предоставляемый has_secure_password (Раздел 6.3.3). Вспомните что authenticate возвращает false для невалидной аутентификации, наша стратегия для входа пользователя может быть резюмирована следующим образом:
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
# Create an error message and re-render the signin form.
end
end
Здесь первая строка вытягивает пользователя из базы данных с помощью предоставленного адреса электронной почты. (Вспомните из Раздела 6.2.5 что email адреса сохраняются в нижнем регистре, поэтому здесь мы используем метод downcase для обеспечения соответствия когда предоставленный адрес валиден.) Следующая строка может немного смутить, но она довольна распространена в идеоматическом Rails программировании:
user user.authenticate(params[:session][:password])
Здесь используется (логическое и) для определения валидности полученного пользователя. Принимая в расчет что любой объект кроме nil и самой false является true в булевом контексте (Раздел 4.2.3), возможные результаты выглядят как Таблица 8.2. Мы видим в Таблице 8.2 что выражение if является true только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.
| Пользователь | Пароль | a b |
|---|---|---|
| не существует | что-нибудь | nil [anything] == false |
| валидный пользователь | неправильный пароль | true false == false |
| валидный пользователь | правильный пароль | true true == true |
Напомним из Раздела 7.3.3, что мы отображали ошибки регистрации используя сообщения об ошибках модели User. Эти ошибки связаны с конкретным объектом Active Record, но эта стратегия здесь не сработает, поскольку сессии не являются моделью Active Record. Вместо этого, мы поместим сообщение во флеш так чтобы оно отображалось при провальном входе. Первая, немного некорректная попытка представлена в Листинге 8.10.
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
flash[:error] = 'Invalid email/password combination' # Not quite right!
render 'new'
end
end
def destroy
end
end
Поскольку сообщение об ошибке отображается в шаблоне сайта (Листинге 7.27), сообщение ).
(рис 8.6) Флэш сообщение для провального входа.
К сожалению, как было отмечено в тексте и в комментарии к Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено (рис 8.10" type="example">Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено ((рис 8.7) Пример ненужного постоянства флэш сообщения.
Это постоянство флэша является багом нашего приложения и прежде чем приступить к его исправлению, было бы неплохо написать тест отлавливающий эту ошибку. В частности, тест провального входа в данный момент проходит:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"
Но тесты ни в коем случае не должны проходить при наличии известной ошибки приложения, так что мы должны добавить провальный тест для ее отлова. К счастью, работа с проблемами вроде неисчезающего флэша, это одна из тех областей, где интеграционные тесты блестяще справляются с поставленной задачей; они позволяют нам сказать именно то, что мы имеем в виду:
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
После отправки невалидных данных, этот тест переходит по Home ссылке, а затем требует отсутствия флэш сообщения об ошибке. Обновленный код с модифицированным тестом флэша показан в Листинге 8.11.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
end
.
.
.
end
end
Новый тест не проходит, как и требуется:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"
Для того чтобы получить прохождение этого провального теста, мы заменим flash на flash.now, который специально создан для отображения флэш сообщения на отрендеренных страницах; в отличие от содержимого flash, его содержимое исчезает сразу после дополнительного запроса. Исправленный код приложения представлен в Листинге 8.12.
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
end
end
Теперь набор тестов для пользователей предоставивших невалидные данные для входа должен быть зеленым:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "with invalid information"
Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.
Заполнить область, занятую в настоящее время комментарием (Листинг 8.12) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in, а затем перенаправляем его на страницу профиля (Листинг 8.13). Мы видим теперь, почему это обманчивая легкость: увы, sign_in в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.
class SessionsController < ApplicationController
.
.
.
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
sign_in user
redirect_to user
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
.
.
.
end
Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя "навсегда" и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5, что Ruby предоставляет модули для упаковки функций вместе и включения их в нескольких местах и это наш план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper. Кроме того, помощники автоматически включаются в Rails представления, так что все что мы должны сделать для того чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application контроллер (Листинг 8.14).
class ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper end
По умолчанию, все помощники доступны во views, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны явно включить его.
Поскольку HTTP является протоколом, не сохраняющим своего состояния, веб-приложения, требующие входа пользователей, должны реализовывать способ, позволяющий отслеживать прогресс каждого пользователя от страницы к странице. Один из методов для поддержания статуса вошедшего пользователя, является использование традиционных Rails сессий (с помощью специальной session функции) для хранения remember token, равного пользовательскому id:
session[:remember_token] = user.id
Этот session объект делает идентификатор пользователя доступным от страницы к странице, сохраняя его в cookie, которые истекают при закрытии браузера. На каждой странице приложения можно просто вызвать
User.find(session[:remember_token])
для получения пользователя. Из-за способа, которым Rails обрабатывает сессии, этот процесс является безопасным, если злоумышленник попытается подменить идентификатор пользователя, Rails обнаружит несоответствие, основываясь на специальном session id, генерируемом для каждой сессии.
Для выбранного нами способа, который подразумевает постоянные сессии — то есть статус вошедшего пользователя, сохраняющийся даже после того, как браузер закрыт — нам необходимо использовать постоянный идентификатор для вошедшего пользователя. Для того чтобы достигнуть этого, мы будем генерировать уникальный, безопасный remember token для каждого пользователя и мы будем хранить его в качестве постоянной куки отличающейся от обычной тем, что она не истекает при закрытии браузера.
Remember token должен быть связан с пользователем и должен сохраняться для последующего использования, поэтому мы добавим его в качестве атрибута модели User, как это показано на рис 8.8 .
(рис 8.8) Модель User с добавленным атрибутом remember_token.
Мы начнем с небольшого дополнения к спекам модели User (Листинг 8.15).
require 'spec_helper'
describe User do
.
.
.
it { should respond_to(:password_confirmation) }
it { should respond_to(:remember_token) }
it { should respond_to(:authenticate) }
.
.
.
end
Мы можем получить прохождение этого теста сгенерировав remember token в командной строке:
$ rails generate migration add_remember_token_to_users
Затем мы заполняем получившуюся миграциюю кодом из Листинга 8.16. Это дает нам код показанный в Листинге 8.16. Обратите внимание, что, посколку мы планируем искать пользователей в базе данных по remember token, мы должны добавить индекс (Блок 6.2) к столбцу remember_token
class AddRememberTokenToUsers < ActiveRecord::Migration
def change
add_column :users, :remember_token, :string
add_index :users, :remember_token
end
end
Затем мы, как обычно, обновляем тестовую и рабочую базы данных:
$ bundle exec rake db:migrate $ bundle exec rake test:prepare
В этой точке спеки модели User должны проходить:
$ bundle exec rspec spec/models/user_spec.rb
Теперь мы должны выбрать, что именно использовать в качестве remember token. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод
Мы планируем хранить сам base64 токен в браузере, а его зашифрованную версию - в базе данных приложения. После чего мы сможем осуществлять логин пользователей вытягивая токен из куки, шифруя его, а затем ища ему соответствие в зашифрованных токенах хранимых в базе данных. Причина по которой мы храним только зашифрованные токены заключается в том, что, даже если вся наша база данных будет скомпрометирована, атакер все равно не сможет использовать токены для входа. Для того чтобы сделать наш токен еще более безопасными, мы планируем менять его каждый раз когда пользователь создает новую сессию, а это означает что любые похищенные сессии—когда атакер использует украденные куки для входа от лица определенного пользователя—истекут при следующем входе пользователя. (Похищение сессий получило широкую огласку с помощью приложения Firesheep, которое показывало что токены на множестве знаменитых сайтов были видимы при подключении к публичным Wi-Fi сетям. Решение заключается в использовании SSL повсеместно на сайте, как это было описано в Разделе 7.4.4.)
Хотя в реальном приложении мы будем немедленно 'входить' вновь созданного пользователя (тем самым создавая новый токен в качестве побочного эффекта), мы не будем полагаться на такое поведение; более надежная практика заключается в обеспечении каждого пользователя валидным токеном с самого начала. Для того чтобы достигнуть этого мы будем создавать начальный токен с помощью функции обратного вызова - техники впервые представленной в Разделе 6.2.5 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк
Для того чтобы протестировать remember token, мы вначале сохраним тестового пользователя, а затем проверим, что атрибут пользовательского remember_token не является пустым. Что позволит нам при необходимости изменять случайную строку, если это нам когда-либо потребуется. Результат представлен в Листинге 8.17.
require 'spec_helper'
describe User do
before do
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
subject { @user }
.
.
.
describe "remember token" do
before { @user.save }
its(:remember_token) { should_not be_blank }
end
end
Листинг 8.17 вводит метод its, который похож на it но относит следующий за ним тест к данному атрибуту, а не к субъекту теста. Другими словами,
its(:remember_token) { should_not be_blank }
является эквивалентом
it { expect(@user.remember_token).not_to be_blank }
Код приложения вводит несколько новых элементов в модель User (app/models/user.rb). Во-первых, мы добавили метод обратного вызова для создания remember token непосредственно перед созданием нового пользователя в базе данных:
before_create :create_remember_token
Этот код, называемый method reference понужает Rails искать метод с названием create_remember_token и выполнять его перед сохранением пользователя. (В Листинге 6.20 мы явно передавали блок в before_save, но техника ссылки на метод более предпочтительна в общем случае.) Во-вторых, сам метод используется тольку внутри модели User, так что нам нет необходимости выставлять его на показ сторонним пользователям. Как мы видели в Разделе 7.3.2, Ruby предлагает использовать для этих целей ключевое слово private:
private
def create_remember_token
# Create the token.
end
Все методы, определенные в классе после private автоматически становятся скрытыми, таким образом
$ rails console >> User.first.create_remember_token
вызовет исключение NoMethodError.
Наконец, метод create_remember_token необходимо присвоить одному из атрибутов пользователей и в этом контексте необходимо использовать ключевое слово self перед remember_token:
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
Из-за способа которым Ruby обрабатывает назначения внутри объектов, без self назначение создаст локальную переменную с именем remember_token, а это совсем не то что нам нужно. Использование self обеспечивает установку назначением пользовательского remember_token таким образом, что он будет записан в базу данных вместе с другими атрибутами при сохранении пользователя. (Теперь вы знаете почему остальные before_save коллбэки из Листинга 6.20 используют self.email вместо просто email.)
Обратите внимание: мы шифровали токен с помощью SHA1 - хэширующего алгоритма который намного быстрее чем алгоритм Bcrypt используемый нами для шифрования паролей пользователей в Разделе 6.3.1, что важно, поскольку (как мы увидим в Разделе 8.2.2) для вошедших пользователей он будет выполняться на каждой странице. SHA1 является менее безопасным чем Bcrypt, но в данном случае его более чем достаточно так как шифруемый токен уже является 16-значной случайной строкой; SHA1 hexdigest такой строки по сути является невзламываемым. (Вызов to_s нужен для того чтобы мы имели возможность работать с nil токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)
Методы private) поскольку в Разделе 8.2.3 мы будем их использовать за пределами модели User.
Собрав все это воедино мы приходим модели User показанной в Листинге 8.18.
class User < ActiveRecord::Base
before_save { self.email = email.downcase }
before_create :create_remember_token
.
.
.
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
end
Кстати, дополнительный уровень отступа на create_remember_token сделан для того, чтобы визуально отделить методы определенные после private. (Практика показала что это мудрая практика.)
Поскольку шифрованная строка SecureRandom.urlsafe_base64 определенно не пустая, тесты для модели User теперь должны пройти:
$ bundle exec rspec spec/models/user_spec.rb
Теперь мы готовы к написанию первого элемента входа - самой sign_in функции. Как было отмечено выше, выбранный нами метод аутентификации заключается в помещении (вновь созданного) remember token в качестве куки в браузер пользователя и последующем использовании токена для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 8.2.3). Результирующий Листинг 8.19 вводит новый для нас метод current_user который мы будем реализовывать в Разделе 8.2.3.
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Здесь мы следуем избранной тактике: во-первых, создаем новый токен; во-вторых, помещаем зашифрованный токен в куки браузера; в-третьих, сохраняем зашифрованный токен в базе данных; в-четвертых, устанавливаем текущего пользователя равным данному пользователю (Раздел 8.2.3). Как мы увидим в Разделе 8.2.3, установка текущего пользователя равным user в данный момент не нужна из-за незамедлительного редиректа в create действии (Листинг 8.13), но все же это хорошая идея - на тот случай если мы когда-нибудь захотим использовать sign_in без редиректа.
В Листинге 8.19 обратите внимание на использование update_attribute для сохранения токена. Как вкратце упоминалось в Разделе 6.1.5), этот метод позволяет обновлять один атрибут в обход валидаций — в данном случае это необходимо так как у нас нет пароля пользователя. Листинг 8.19 также вводит утилиту cookies которая позволяет нам манипулировать куками браузера как если бы они были хэшем; каждый элемент в куки представляет из себя хэш из двух элементов: value и (необязательный) expires дата (# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения куки со значением, равным пользовательскому токену, которая истекает через 20 лет:
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
(Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 8.1.)
______________________________________________________________
Блок 8.1.Куки истекают через 20.years.from_now
Вы можете вспомнить из Раздела 4.4.2, что Ruby позволяет добавлять методы к любому, даже встроенному классу. В том разделе мы добавляли palindrome? метод к String классу (и в результате обнаружили, что "deified" является палиндромом), и мы также видели, как Rails добавляет blank? метод к классу Object (таким образом, "".blank?, " ".blank?, и nil.blank? все являются true). Код куки в Листинге 8.19 (который внутренне устанавливает срок действия cookie в 20.years.from_now) дает еще один пример из этой практики, посредством одного из Rails’ временных хелперов, которые являются методами добавленными к Fixnum (базовый класс для чисел):
$ rails console >> 1.year.from_now => Sun, 13 Mar 2011 03:38:55 UTC +00:00 >> 10.weeks.ago => Sat, 02 Jan 2010 03:39:14 UTC +00:00
Rails добавляет и другие помощники:
>> 1.kilobyte => 1024 >> 5.megabytes => 5242880
Они полезны для валидации загрузки, что позволяет легко ограничить, например, загрузку изображений размером в 5.megabytes.
Хотя она должна использоваться с осторожностью, возможность добавлять методы к встроенным классам позволяет создавать черезвычайно естественные добавления к обычному Ruby. Действительно, большая часть элегантности Rails в конечном счете, является производной от податливости лежащего в его основе языка Ruby.
______________________________________________________________
Паттерн установки куки истекающей через 20 лет стал настолько общепринятым, что Rails добавил специальный метод permanent для его реализации, так что мы можем просто написать
cookies.permanent[:remember_token] = remember_token
Под капотом, применение permanent приводит к автоматической установке даты истечения куки через 20 лет (20.years.from_now).
После того как куки установлены, на последующих представлениях страниц мы можем извлекать пользователя с кодом вроде
User.find_by(remember_token: remember_token)
(Как мы увидим в Листинге 8.22, на самом деле мы вначале должны захэшировать токен.) Конечно, cookies это на самом деле не хэш, поскольку назначение cookies действительно сохраняет кусочек текста в браузере, но частью красоты Rails является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.
Обсудив способ хранения пользовательского remember token в куки для последующего использования, теперь нам необходимо узнать как извлекать пользователя при последующем просмотре страниц. Давайте еще раз взглянем на функцию sign_in для того чтобы понять где мы находимся:
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Единственный участок кода который в данный момент не работает это:
self.current_user = user
Как было отмечено сразу после Листинга 8.19, этот код никогда не будет использоваться в данном приложении из-за немедленного редиректа в Листинге 8.13, но для метода sign_in было бы опасным полагаться на это.
Целью current_user, доступного и в контроллерах и в представлениях является возможность создания конструкции подобные этой:
<%= current_user.name %<
и
redirect_to current_user
Использование self в назначении является необходимым по тем же причинам что были отмечены в обсуждении приведшем к Листингу 8.18: без self Ruby будет просто создавать локальную переменную с названием current_user.
Для того, чтобы начать писать код для current_user, обратите внимание, что строка
self.current_user = user
это назначение, которое мы должны определить. В Ruby есть специальный синтаксис для определения таких назначаемых функций, показанный в Листинге 8.20.
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
end
Это может выглядеть сбивающим с толку — большинство языков не позволит вам использовать знак равенства в определении метода, но это просто определение метода current_user= специально разработанного для обработки назначения current_user. Другими словами, код
self.current_user = ...
автоматически конвертируется в
current_user=(...)
тем самым вызывая метод current_user=. Его единственный аргумент это то, что находится справа от назначения, в данном случае - пользователь который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user, эффективно хранящую пользователя для дальнейшего использования.
В обычном Ruby, мы могли бы определить второй метод, current_user, предназначенный для возвращения значения @current_user, как это показано в Листинге 8.21.
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
def current_user
@current_user # Useless! Don't use this line.
end
end
Если бы мы сделали это, мы бы фактически повторили функциональность attr_accessor это просто удобный способ создавать такие getter/setter методы автоматически.@current_user это nil. Таким образом, когда пользователь обратится к еще одной странице, даже находясь в том же приложении, Rails установит @current_user равным nil и код в Листинге 8.21 не сделает то чего вы от него ожидали.
Для того чтобы избежать этой проблемы мы можем искать пользователя соответствующего remember token созданному кодом в Листинге 8.19, как это показано в Листинге 8.22. Обратите внимание: поскольку токен хранимый в базе данных зашифрован, нам нужно зашифровать токен полученный из куки прежде чем использовать его для поиска пользователя в базе данных. Мы достигним этого с помощью метода User.encrypt определенного в Листинге 8.18.
module SessionsHelper
.
.
.
def current_user=(user)
@current_user = user
end
def current_user
remember_token = User.encrypt(cookies[:remember_token])
@current_user ||= User.find_by(remember_token: remember_token)
end
end
Листинг 8.22 использует общепринятый, но изначально обескураживающий ||= ("или равно") оператор присваиваивания (Блок 8.2). Его эффект заключается в установке переменной экземпляра @current_user пользователю, соответствующему remember token, но только если nil, но обратите внимание - ложные (false) значения также будут переписаны оператором ||=
@current_user ||= User.find_by(remember_token: remember_token)
вызывает метод current_user используется чаще чем один раз для запроса отдельно взятого пользователя; в любом случае, find_by будет вызван по крайней мере один раз при каждом посещении страницы на этом сайте.
______________________________________________________________
Блок 8.2.Что за *$@! этот ваш ||= ?
Конструкция ||= - очень Рубишная — то есть, она очень характерна для языка Ruby — и, следовательно, важно ее знать, если вы планируете много программировать на Ruby. Хотя на первый взгляд она может показаться таинственной, или равно легко понять по аналогии.
Начнем с общепринятой идиомы для изменения определенной в настоящее время переменной. Многие компьютерные программы включают приращение переменной, как в
x = x + 1
Большинство языков обеспечивают синтаксическое сокращение для этой операции; в Ruby (и в C, C++, Perl, Python, Java, и т.д.), это выглядит следующим образом:
x += 1
Аналогичные конструкции существуют и для других операторов:
$ rails console >> x = 1 => 1 >> x += 1 => 2 >> x *= 3 => 6 >> x -= 7 => -1
В каждом случае, паттерном является то, что x = x O y и x O= y эквивалентны для любого оператора O.
Другим распространенным Ruby паттерном является назначение переменной, если она nil но в противном случае оставляя ее в покое. Вспоминая or оператор || из Раздела 4.2.3, мы можем записать это следующим образом:
>> @user => nil >> @user = @user || "the user" => "the user" >> @user = @user || "another user" => "the user"
Поскольку nil ложно в булевом контексте, первое присвоение это nil || "the user", что оценивается как "the user"; аналогично, второе присвоение является "the user" || "another user", которое также оценивается как "the user" — так как строки true в булевом контексте, серия || выражений прекращается после оценки первого выражения. (Эта практика оценки выражений || слева направо и остановки на первом истинном значении, известна как оценка короткого замыкания (short-circuit evaluation).)
Сравнивая в консольной сессии различные операторы, мы видим, что @user = @user || value следует x = x O y паттерну с || вместо O, что позволяет предположить следующую эквивалентную конструкцию:
>> @user ||= "the user" => "the user"
Вуаля!
______________________________________________________________
Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на рис 8.3, мы организуем изменение ссылок при входе и выходе пользователей из системы, а также мы добавим ссылки на список всех пользователей, на страницу настроек пользователя (будет закончена в Главе 9) и одну для профиля текущего пользователя. При этом, мы получим прохождение тестов из Листинга 8.6, а это означает, что наш набор тестов станет польностью зеленым впервые с начала этой главы.
Смена ссылок в шаблоне сайта подразумевает использование если-иначе ветвящихся структур внутри Embedded Ruby:
<% if signed_in? %> # Ссылки для вошедших пользователей <% else %> # Ссылки для не вошедших пользователей <% end %>
Такой код требует наличия булевого метода signed_in?, который мы сейчас и реализуем.
Пользователь является вошедшим если в сессии существует текущий пользователь, т.e., если current_user не является nil. Это требует использования оператора "not", написанного с помощью восклицательного знака ! и обычно читаемого как "bang". В данном контексте пользователь является вошедшим если current_user является не nil, как это показано в Листинге 8.23.
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
def signed_in?
!current_user.nil?
end
.
.
.
end
Имея на руках метод signed_in?, мы готовы закончить ссылки шаблона. Это будут четыре новых ссылки, две из которых пока останутся заглушками (мы их доработаем в Главе 9):
<%= link_to "Users", '#' %> <%= link_to "Settings", '#' %>
Ссылка на выход, между прочим, использует путь выхода, определенный в Листинге 8.2:
<%= link_to "Sign out", signout_path, method: "delete" %>
(Обратите внимание на то, что ссылка на выход передает хэш аргументов указывающий на то, что она должна отправить HTTP запрос DELETE.
<%= link_to "Profile", current_user %>
Здесь мы могли бы написать
<%= link_to "Profile", user_path(current_user) %>
но Rails позволяет нам ссылаться непосредственно на пользователя и в этом контексте current_user будет автоматически конвертирован в user_path(current_user).
В процессе размещения новых ссылок в шаблон, мы воспользуемся случаем и создадим выпадающее меню с помощью Bootstrap, вы можете более подробно почитать об этом на странице компонентов Bootstrap. Полный результат представлен в Листинге 8.24. В частности, обратите внимание на CSS id и классы связанные с выпадающим меню Bootstrap.
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="navbar-inner">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<ul class="nav pull-right">
<li><%= link_to "Home", root_path %></li>
<li><%= link_to "Help", help_path %></li>
<% if signed_in? %>
<li><%= link_to "Users", '#' %></li>
<li id="fat-menu" class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Settings", '#' %></li>
<li class="divider"></li>
<li>
<%= link_to "Sign out", signout_path, method: "delete" %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Sign in", signin_path %></li>
<% end %>
</ul>
</nav>
</div>
</div>
</header>
Выпадающее меню требует применения JavaScript библиотеки Bootstrap, которую мы можем включить с помощью Рельсового файлопровода, отредактировав файл application.js, как это показано в Листинге 8.25.
//= require jquery //= require jquery_ujs //= require bootstrap //= require turbolinks //= require_tree .
Здесь используется библиотека Sprockets для включения Bootstrap JavaScript, которая в свою очередь доступна благодаря гему bootstrap-sass из Раздела 5.1.2.
С кодом в Листинге 8.24 все тесты должны пройти:
$ bundle exec rspec spec/
Вошедший пользователь теперь видит новые ссылки и выпадающее меню определенное Листингом 8.24, as shown in рис 8.24" type="example">Листингом 8.24, as shown in (рис 8.9) Вошедший пользователь с новыми ссылками и выпадающим меню.
В этой точке вам следует проверить, что вы можете войти, закрыть браузер и быть по-прежнему вошедшими при повторном посещении примера приложения. Если хотите, вы можете даже проверить куки браузера для того чтобы посмотреть на результат непосредственно (рис 8.10).
(рис 8.10) Куки remember token в локальном браузере.
В принципе, хотя мы закончили с аутентификацией, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию. Реализация этого - последний штрих который мы добавим прежде чем позволим пользователям входить на наш сайт. Мы начнем с добавления строки к тестам аутентификации (Листинг 8.26). Это включает "after saving the user" describe блок из Листинга 7.32 (Раздел 7.6), который вы должны добавить в тест, если вы не сделали этого в соответствующем упражнении
require 'spec_helper'
describe "User pages" do
.
.
.
describe "with valid information" do
.
.
.
describe "after saving the user" do
before { click_button submit }
let(:user) { User.find_by(email: 'user@example.com') }
it { should have_link('Sign out') }
it { should have_title(user.name) }
it { should have_selector('div.alert.alert-success', text: 'Welcome') }
end
end
end
end
Здесь мы протестировали появление ссылки на выход для того чтобы убедиться что пользователь успешно вошел после регистрации.
С методом sign_in из Раздела 8.2, получение прохождения этого теста фактически впустив пользователя в систему легко: просто добавим sign_in @user сразу после сохранения пользователя в базе данных (Листинг 8.27).
class UsersController < ApplicationController
.
.
.
def create
@user = User.new(user_params)
if @user.save
sign_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new'
end
end
.
.
.
end
Как обсуждалось в Разделе 8.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту, необходимую нам и пользователям, возможность выхода.
До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new для страницы входа и create для его завершения. Мы продолжим эту тему используя действие destroy для удаления сессий, т.е., для выхода. Для того чтобы протестировать это, мы кликнем по ссылке "Sign out" а затем попробуем найти вновь появившуюся ссылку на вход (Листинг 8.28).
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
.
.
.
describe "with valid information" do
.
.
.
describe "followed by signout" do
before { click_link "Sign out" }
it { should have_link('Sign in') }
end
end
end
end
Как и со входом пользователя, основанном на функции sign_in, выход пользователя просто перекладывает работу на функцию sign_out (Листинг 8.29).
class SessionsController < ApplicationController
.
.
.
def destroy
sign_out
redirect_to root_url
end
end
Как и другие элементы аутентификации, мы поместим nil в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out без редиректа.)
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
.
.
.
def sign_out
current_user.update_attribute(:remember_token,
User.encrypt(User.new_remember_token))
cookies.delete(:remember_token)
self.current_user = nil
end
end
Это завершает триумвират регистрация/вход/выход и набор тестов должен пройти:
$ bundle exec rspec spec/
Стоит отметить, что наш набор тестов покрывает большую часть механизма аутентификации, но не все же не полностью. Например, мы не тестируем то как долго живет "remember me" куки и даже не тестируем устанавливается ли она вообще. Это возможно сделать, но практика показывает, что непосредственное тестирование значения куки является хрупким и имеет тенденцию зависеть от деталей реализации которые иногда меняются от одного релиза Rails к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь на функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.
Закончив основу системы аутентификации примера приложения, мы собираемся воспользоваться подвернувшейся возможностью для того чтобы показать как написать тесты входа с помощью Cucumber - популярного инструмента для разработки через поведение, который пользуется значительной популярностью в сообществе Ruby. Этот раздел необязателен и может быть пропущен без потери целостности повествования.
Cucumber позволяет определять текстовые истории описывающие поведение приложения. Множество Rails программистов находят Cucumber особенно полезным при работе над клиентскими проектами; поскольку они могут быть прочитаны даже технически не подкованными пользователями, тесты на Cucumber могут быть прочитаны (а иногда даже написаны) клиентом. Конечно же, применение тестового фреймворка, который не является чистым Ruby, имеет и оборотную сторону, и я считаю что текстовые истории зачастую могут быть излишне многословными. Тем не менее, Cucumber занял прочные позиции в Ruby-инструментарии тестирования и мне особенно нравится его акцент на поведении верхнего уровня, а не на деталях реализации.
Поскольку акценты в этой книге смещены в сторону RSpec и Capybara, последующая презентация совершенно не претендует на полноту и исчерпывающее раскрытие темы. Ее цель - просто дать вам возможность ощутить вкус Cucumber-а (несомненно свежий и сочный) — если он поразит ваше воображение, существуют целые книги на эту тему готовые удовлетворить ваш аппетит. (Я особенно рекомендую The RSpec Book (David Chelimsky) и Rails 3 in Action (Ryan Bigg и Yehuda Katz), и The Cucumber Book (Matt Wynne и Aslak Hellesoy).)
Для того чтобы установить Cucumber, во-первых, добавьте гем cucumber-rails и служебный гем database_cleaner в группу :test в Gemfile (Листинг 8.31).
. . . group :test do . . . gem 'cucumber-rails', '1.4.0', :require => false gem 'database_cleaner', github: 'bmabey/database_cleaner' end . . .
Затем установите как обычно:
$ bundle install
Для того чтобы настроить приложение для использования Cucumber, мы затем генерируем несколько необходимых, поддерживающих его работу файлов и директорий:
$ rails generate cucumber:install
Это создает директорию features/ где будут жить файлы связанные с Cucumber.
Огурцовые фичи это описания ожидаемого поведения с помощью plain-text языка называемого Gherkin. Gherkin тесты читаются во многом как хорошо написанные примеры RSpec, но, поскольку они написаны простым текстом, они более доступны для тех, кому комфортнее читать английский, а не код Руби.
Наши Огурцовые фичи будут реализовывать небольшое количество примеров входа в Листинге 8.5 и Листинге 8.6. Для того чтобы начать, мы создадим файл signing_in.feature в директории features/.
Огурцовые фичи начинаются с короткого описания функционала:
Feature: Signing in
Затем они добавляют индивидуальные сценарии. Например, для того, чтобы протестировать провальный вход, мы можем написать следующий сценарий:
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Аналогично, для того, чтобы протестировать успешный вход, мы можем добавить следующее:
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Собрав все это вместе мы приходим к файлу Огурцовой фичи показанному в Листинге 8.32.
Feature: Signing in
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Для запуска фич мы используем исполняемую команду cucumber:
$ bundle exec cucumber features/
Сравните это с
$ bundle exec rspec spec/
В данном контексте стоит отметить, что, как и RSpec, Cucumber может быть вызван с помощью Rake-задачи:
$ bundle exec rake cucumber
(По непонятным для меня причинам, это иногда пишут как rake cucumber:ok.)
Все что мы пока сделали, это лишь написали немного простого текста, так что не думаю что для вас стало сюрпризом что Огурцовые сценарии пока не проходят. Для того чтобы получить зеленый набор тестов, нам необходима добавить файл step, который свяжет строки простого текста с Руби-кодом. Файл отправляется в директорию features/step_definitions; мы назовем его authentication_steps.rb.
Строки Feature и Scenario нужны в основном для документации, но каждой последующей строке нужен соответствующий Ruby. Например, строка
Given a user visits the signin page
в файле фич будет обработана соответствующим определением шага
Given /^a user visits the signin page$/ do visit signin_path end
В фиче, Given это просто строка, но в файле с шагами Given является методом который принимает регулярное выражение и блок. Регулярное выражение соответствует тексту строки в сценарии, а содержимое блока является чистым Руби кодом, необходимым для реализации шага. В данном случае, "a user visits the signin page" реализуется посредством
visit signin_path
Если это выглядит знакомым, все правильно: это просто Capybara, которая включена по умолчанию в файлы с Огурцовыми шагами. Следующие две строки тоже должны выглядеть знакомо; шаги сценария
When they submit invalid signin information Then they should see an error message
в файле фич обрабатываются следующими шагами:
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Первый шаг также использует Capybara, при этом второй использует объект Capybara page вместе с RSpec. Очевидно, вся работа с тестами, которую мы проделали с RSpec и Capybara также полезна с Cucumber.
Остальные шаги обрабатываются аналогично. Конечный файл определения шагов преставлен в Листинге 8.33. Попробуйте добавлять шаги по одному, запуская
$ bundle exec cucumber features/
каждый раз до тех пор пока все тесты не пройдут.
Given /^a user visits the signin page$/ do
visit signin_path
end
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Given /^the user has an account$/ do
@user = User.create(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
When /^the user submits valid signin information$/ do
fill_in "Email", with: @user.email
fill_in "Password", with: @user.password
click_button "Sign in"
end
Then /^they should see their profile page$/ do
expect(page).to have_title(@user.name)
end
Then /^they should see a signout link$/ do
expect(page).to have_link('Sign out', href: signout_path)
end
С кодом в Листинге 8.33, Огурцовые тесты должны пройти:
$ bundle exec cucumber features/
Написав несколько простых Огурцовых сценариев, стоит сравнить результат с эквивалентным примером на RSpec. Для начала, взглянем на Огурцовую фичу в Листинг 8.32 и соответствующее определение шагов в Листинге 8.33. Затем взглянем на RSpec request specs (интеграционные тесты):
describe "Authentication" do
subject { page }
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Вы можете видеть, что дело может быть сделано как с помощью Огурца, так и с помощью интеграционных тестов. Огурцовые фичи легкочитаемы, но они полностью отделены от кода который их реализует, а это палка о двух концах. Я считаю что Огурец легок в чтении, но сложен в написании, в то время как интеграционные тесты (для программистов) немного более сложны в чтении и намного более просты в написании.
Один из замечательных эффектов Огурцового разделения является то, что он работает на более высоком уровне абстракции. Например, мы пишем
Then they should see an error message
для того, чтобы выразить ожидание увидеть сообщение об ошибке, и
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
для того чтобы реализовать тест. Что особенно удобно в этом, так это то, что только второй элемент (шаг) зависит от реализации, таким образом, если мы изменим, например, класс CSS используемый для сообщений об ошибках, файл фич останется неизменным.
В этом случае можно запечалиться переписывая
should have_selector('div.alert.alert-error')
в куче мест, в то время как вы на самом деле хотели лишь указать на то, что на странице должно присутствовать сообщение об ошибке. Эта практика тесно связывает тест с реализацией и мы должны были бы изменить это повсеместно при изменении реализации. В контексте чистого RSpec для этого есть решение, которое заключается в использовании кастомных проверок, позволяющих нам писать вместо этого следующее:
should have_error_message('Invalid')
Мы можем определить такие проверки в том же вспомогательном файле, в который мы поместили тестовый хелпер full_title в Разделе 5.3.4. Сам код выглядит примерно следующим образом:
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
Мы можем также определить вспомогательные функции для общепринятых операций:
def valid_signin(user) fill_in "Email", with: user.email fill_in "Password", with: user.password click_button "Sign in" end
Получившийся в результате вспомогательный код показан в Листинге 8.34 (который включает в себя результаты Листинга 5.41 и Листинга 5.42 из Раздела 5.6). Я нахожу этот подход более гибким, нежели Огурцовые определения шагов, в особенности когда проверки или помощники долженствования натурально принимают аргумент, такой как valid_signin(user). Определения шагов может повторить эту функциональность с помощью проверок регулярных выражений, но я считаю такой подход гораздо более громоздким (# в оригинале - (cu)cumbersome).
include ApplicationHelper
def valid_signin(user)
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Sign in"
end
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
С кодом из Листинга 8.34, мы можем написать
it { should have_error_message('Invalid') }
и
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before { valid_signin(user) }
.
.
.
В наших тестах есть множество примеров тесной связи между тестами и реализацией сайта. Прохождение по текущему набору тестов и разрыв связей между тестами и деталями реализации с помощью создания кастомных проверок и методов остается в качестве упражнения (Раздел 8.5).
Мы очень многое узнали в этой главе, трансформируя наше многообещающее, но не сформированное приложение в сайт, обладающий полным набором функций для регистрации и входа/выхода пользователей. Все что нам необходимо для завершения аутентификационной функциональности, это ограничить доступ к страницам по статусу и идентификации пользователей. Мы выполним эту задачу, по пути дав пользователям возможность редактировать их информацию, а также дав администраторам возможность удалять пользователей из системы.
Прежде чем двигаться далее, объедините изменения с мастер веткой:
$ git add . $ git commit -m "Finish sign in" $ git checkout master $ git merge sign-in-out
Затем отправьте изменения на удаленный репозиторий GitHub и продакшен сервер Heroku
$ git push $ git push heroku $ heroku run rake db:migrate
form_tag вместо form_for. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см.RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэша params.Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 7), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя. В Главе 10 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 11 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).
Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 9, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 9) удалять пользователей из базы данных.
После реализации ядра аутентификационного механизма мы немного отвлечемся от основной темы для того чтобы познакомиться с Cucumber - популярной системой предназначенной для разработки через поведение (Раздел 8.3). В частности, мы перепишем пару интеграционных RSpec тестов на Cucumber для сравнения этих двух методик.
Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:
$ git checkout -b sign-in-out
Удобно моделировать сессии как RESTful ресурс: у нас будет страница входа для новых сессий, вход будет создавать сессию и выход будет уничтожать ее. В отличие от ресурса Users который использует базу данных (через модель User) для сохранения данных, ресурс Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть сложностей в разработке системы входа связана с построением этого, опирающегося на куки, аутентификационного механизма. В этом и последующих разделах мы будем заниматься подготовительной работой - создадим контроллер Sessions , форму входа и соответствующие действия контроллера. Затем мы завершим вход пользователей написав необходимый для манипуляций с куки код в Разделе 8.2.
Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create (Раздел 8.1 и Раздел 8.2) и выход обрабатывается отправкой запроса DELETE к действию destroy (Раздел 8.2.6). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 7.1.) Для начала мы сгенерируем контроллер Sessions и интеграционный тест для механизма аутентификации:
$ rails generate controller Sessions --no-test-framework $ rails generate integration_test authentication_pages
Следуя модели из Раздела 7.2 для страницы регистрации, мы создадим форму входа для создания новых сессий (рис 8.1).
(рис 8.1) Набросок формы входа.
Страница входа живет по URL предоставленному signin_path (уже определенному) и, как обычно, мы начнем с минималистичного теста как это показано в Листинге 8.1. (Сравните его с аналогичным кодом для страницы регистрации из Листинга 7.6.)
require 'spec_helper'
describe "Authentication" do
subject { page }
describe "signin page" do
before { visit signin_path }
it { should have_content('Sign in') }
it { should have_title('Sign in') }
end
end
Изначально тест провальный, как и требуется:
$ bundle exec rspec spec/
Для того чтобы получить прохождение тестов из Листинга 8.1, в первую очередь нам необходимо определить маршруты для ресурса Sessions, совместно с кастомным именованным маршрутом для страницы входа (который мы направим к действию new контроллера Session). Как и с ресурсом Users, мы можем использовать метод resources для определения стандартных RESTful маршрутов:
resources :sessions, only: [:new, :create, :destroy]
Поскольку нам нет надобности показывать или редактировать сессии, мы ограничимся действиями new, create и destroy с помощью опции :only принимаемой resources. Конечный результат, включающий именованные маршруты для входа и выхода, представлен в Листинге 8.2.
SampleApp::Application.routes.draw do resources :users resources :sessions, only: [:new, :create, :destroy] root 'static_pages#home' match '/signup', to: 'users#new', via: 'get' match '/signin', to: 'sessions#new', via: 'get' match '/signout', to: 'sessions#destroy', via: 'delete' . . . end
Обратите внимание на использование via: ’delete’ для маршрута выхода, указывающее на то, что он должен быть вызван с помощью HTTP запроса DELETE.
Ресурсы, определенные в Листинге 8.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 7.1), как видно в Таблице 8.1. Обратите внимание на то что маршруты для входа и выхода являются кастомными, но маршрут для создания сессии остался дефолтным (т.е., [resource name]_path).
| HTTP запрос | URL | Именованный маршрут | Действие | Цель (назначение) |
|---|---|---|---|---|
| GET | /signin | signin_path |
new |
страница для новой сессии (вход) |
| POST | /sessions | sessions_path |
create |
создание новой сессии |
| DELETE | /signout | signout_path |
destroy |
удаление сессии (выход) |
Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду rake routes:
$ rake routes
Prefix Verb URL Pattern Controller#Action
users GET /users(.:format) users#index
POST /users(.:format) users#create
new_user GET /users/new(.:format) users#new
edit_user GET /users/:id/edit(.:format) users#edit
user GET /users/:id(.:format) users#show
PATCH /users/:id(.:format) users#update
PUT /users/:id(.:format) users#update
DELETE /users/:id(.:format) users#destroy
sessions POST /sessions(.:format) sessions#create
new_session GET /sessions/new(.:format) sessions#new
session DELETE /sessions/:id(.:format) sessions#destroy
root GET / static_pages#home
signup GET /signup(.:format) users#new
signin GET /signin(.:format) sessions#new
signout DELETE /signout(.:format) sessions#destroy
help GET /help(.:format) static_pages#help
about GET /about(.:format) static_pages#about
contact GET /contact(.:format) static_pages#contact
Следующим шагом необходимым для прохождения тестов из Листинга 8.1 является добавление new действия к контроллеру Sessions, как это показано в Листинге 8.3 (который также определяет create и destroy действия для использования в будущем).
class SessionsController < ApplicationController def new end def create end def destroy end end
Последний шаг это определение начальной версии страницы входа. Обратите внимание, что, поскольку это страница для новой сесии, она живет в файле app/views/sessions/new.html.erb, который мы должны создать. Содержимое, которое в настоящий момент определяет только заголовок страницы и заголовок первого уровня, представлено в Листинге 8.4.
<% provide(:title, "Sign in") %> <h1>Sign in</h1>
Теперь тесты из Листинга 8.1 должны пройти и мы готовы к созданию самой формы входа.
$ bundle exec rspec spec/
Сравнивая рис 8.1 с рис 7.11, мы видим, что форма входа (или, что эквивалентно, форма новой сессии) выглядит аналогично форме регистрации, за исключением того что в ней два поля (email и пароль) вместо четырех. Как и с формой регистрации, мы можем протестировать форму входа используя Capybara для заполнения формы данными и последующего клика по кнопке.
В процессе написания тестов мы вынуждены обращать внимание на аспекты дизайна нашего приложения, что является одним из приятных побочных эффектов разработки через тестирование. Мы начнем с провального входа, как это показано на наброске в рис 8.2.
(рис 8.2) Набросок провального входа.
Как видно на рис 8.2, при предоставлении невалидной информации мы хотим вновь отрендерить страницу входа и вывести на экран сообщение об ошибке. Мы будем рендерить ошибку как флэш сообщение, что мы можем протестировать следующим образом:
it { should have_selector('div.alert.alert-error') }
Здесь используется предоставляемый Capybara метод have_selector который мы видели ранее в решениях для двух упражнений, Листинге 5.38 и Листинге 7.32. Метод have_selector проверяет наличие конкретного селектора (т.е. HTML тега, однако в Capybara 2.0 это работает только для видимых элементов). В данном случае мы ищем
div.alert.alert-error
который проверяет тег div. В частности, вспомнив что в CSS точка обозначает "класс" (Раздел 5.1.2), вы возможно догадались что это тест на наличие тега div с классами "alert" и "alert-error", вроде этого:
<div class="alert alert-error">Invalid...</div>
Комбинация тестов заголовка и флэша приводит нас к коду в Листинге 8.5. Как мы увидим, эти тесты упускают одну важную деталь, которой мы займемся в Разделе 8.1.5.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
end
end
Теперь, написав тесты для провального входа, мы возвращаемся к успешному входу. Изменениями которые мы хотим протестировать являются рендеринг страницы профиля пользователя (что предопределено заголовком страницы, который должен быть именем пользователя), совместно с тремя запланированными изменениями в навигации сайта:
(Мы отложим тесты для ссылки "Settings" до Раздела 9.1 и для ссылки "Users" до Раздела 9.3.) Набросок этих изменений представлен на рис 8.3.
(рис 8.3) Набросок профиля пользователя после успешного входа.
Код тестов для успешного входа представлен в Листинге 8.6.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
.
.
.
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Здесь мы использовали метод have_link. Он принимает в качестве аргументов текст ссылки и необязательный параметр :href, таким образом
it { should have_link('Profile', href: user_path(user)) }
убеждается в том что якорный тег a имеет правильный атрибут href (URL) — в данном случае, ссылку на страницу профиля пользователя. Обратите также внимание на то что мы позаботились upcase email адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.
После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 7.17 что форма регистрации использует вспомогательный метод form_for, принимающий в качестве аргумента переменную экземпляра @user:
<%= form_for(@user) do |f| %> . . . <% end %>
Основное отличие между этим и формой для входа заключается в том что у нас нет модели Session, и, следовательно, нет аналога для переменной @user. Это означает, что при конструировании формы для новой сессии нам необходимо предоставить методу form_for чуть больше информации; в частности, тогда как
form_for(@user)
позволяет Rails сделать вывод о том, что действием формы должно быть POST к URL /users, в случае с сессиями мы должны явно указать имя ресурса и соответствующий URL:
form_for(:session, url: sessions_path)
(Вторым возможным способом является использование form_tag вместо form_for; это было бы даже более идеоматически корректным решением с точки зрения Rails, но оно бы имело мало общего с формой регистрации, а на этом этапе я хочу подчеркнуть параллельность структуры. Создание рабочей формы с помощью form_tag оставлено в качестве упражнения (Раздел 8.5).)
Имея на руках правильный используя форму регистрации (Листинг 7.17) в качестве модели, как это показано в Листинге 8.7.
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>
<div class="row">
<div class="span6 offset3">
<%= form_for(:session, url: sessions_path) do |f| %>
<%= f.label :email %>
<%= f.text_field :email %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
Обратите внимание на то, что мы для удобства добавили ссылку на страницу входа. С кодом в Листинге 8.7, форма для входа выглядит как на рис 8.7" type="example">Листинге 8.7, форма для входа выглядит как на (рис 8.4) Форма для входа (/signin).
Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), пока все же давайте взглянем на него (Листинге 8.8).
<form accept-charset="UTF-8" action="/sessions" method="post">
<div>
<label for="session_email">Email</label>
<input id="session_email" name="session[email]" type="text" />
</div>
<div>
<label for="session_password">Password</label>
<input id="session_password" name="session[password]"
type="password" />
</div>
<input class="btn btn-large btn-primary" name="commit" type="submit"
value="Sign in" />
</form>
Сравнивая Листинге 8.8 с Листингом 7.20, вы, возможно, догадались, что отправка этой формы приведет к хэшу params, где params[:session][:email] и params[:session][:password] соответствуют email и password полям.
Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из рис 8.5" type="example">Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Давайте начнем с определения минималистичного действия .
class SessionsController < ApplicationController
.
.
.
def create
render 'new'
end
.
.
.
end
(рис 8.5) Начальный провальный вход с create из Листинга 8.9.
Тщательное изучение отладочной информации на рис 8.5 показывает, что, как намекалось в конце Раздела 8.1.3, отправка формы приводит к хэшу params содержащему email и password под ключом :session:
--- session: email: '' password: '' commit: Sign in action: create controller: sessions
Как и в случае регистрации пользователя (рис 7.15) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.6. В частности, params содержит вложенный хэш формы
{ session: { password: "", email: "" } }
Это означает что
params[:session]
само является хэшем:
{ password: "", email: "" }
Как результат,
params[:session][:email]
является предоставленным адресом электронной почты и
params[:session][:password]
является предоставленным паролем.
Иными словами, внутри create действия хэш params имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.find_by_email предоставленный Active Record (Раздел 6.1.4) и метод authenticate предоставляемый has_secure_password (Раздел 6.3.3). Вспомните что authenticate возвращает false для невалидной аутентификации, наша стратегия для входа пользователя может быть резюмирована следующим образом:
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
# Create an error message and re-render the signin form.
end
end
Здесь первая строка вытягивает пользователя из базы данных с помощью предоставленного адреса электронной почты. (Вспомните из Раздела 6.2.5 что email адреса сохраняются в нижнем регистре, поэтому здесь мы используем метод downcase для обеспечения соответствия когда предоставленный адрес валиден.) Следующая строка может немного смутить, но она довольна распространена в идеоматическом Rails программировании:
user user.authenticate(params[:session][:password])
Здесь используется (логическое и) для определения валидности полученного пользователя. Принимая в расчет что любой объект кроме nil и самой false является true в булевом контексте (Раздел 4.2.3), возможные результаты выглядят как Таблица 8.2. Мы видим в Таблице 8.2 что выражение if является true только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.
| Пользователь | Пароль | a b |
|---|---|---|
| не существует | что-нибудь | nil [anything] == false |
| валидный пользователь | неправильный пароль | true false == false |
| валидный пользователь | правильный пароль | true true == true |
Напомним из Раздела 7.3.3, что мы отображали ошибки регистрации используя сообщения об ошибках модели User. Эти ошибки связаны с конкретным объектом Active Record, но эта стратегия здесь не сработает, поскольку сессии не являются моделью Active Record. Вместо этого, мы поместим сообщение во флеш так чтобы оно отображалось при провальном входе. Первая, немного некорректная попытка представлена в Листинге 8.10.
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
flash[:error] = 'Invalid email/password combination' # Not quite right!
render 'new'
end
end
def destroy
end
end
Поскольку сообщение об ошибке отображается в шаблоне сайта (Листинге 7.27), сообщение ).
(рис 8.6) Флэш сообщение для провального входа.
К сожалению, как было отмечено в тексте и в комментарии к Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено (рис 8.10" type="example">Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено ((рис 8.7) Пример ненужного постоянства флэш сообщения.
Это постоянство флэша является багом нашего приложения и прежде чем приступить к его исправлению, было бы неплохо написать тест отлавливающий эту ошибку. В частности, тест провального входа в данный момент проходит:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"
Но тесты ни в коем случае не должны проходить при наличии известной ошибки приложения, так что мы должны добавить провальный тест для ее отлова. К счастью, работа с проблемами вроде неисчезающего флэша, это одна из тех областей, где интеграционные тесты блестяще справляются с поставленной задачей; они позволяют нам сказать именно то, что мы имеем в виду:
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
После отправки невалидных данных, этот тест переходит по Home ссылке, а затем требует отсутствия флэш сообщения об ошибке. Обновленный код с модифицированным тестом флэша показан в Листинге 8.11.
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
end
.
.
.
end
end
Новый тест не проходит, как и требуется:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"
Для того чтобы получить прохождение этого провального теста, мы заменим flash на flash.now, который специально создан для отображения флэш сообщения на отрендеренных страницах; в отличие от содержимого flash, его содержимое исчезает сразу после дополнительного запроса. Исправленный код приложения представлен в Листинге 8.12.
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
# Sign the user in and redirect to the user's show page.
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
end
end
Теперь набор тестов для пользователей предоставивших невалидные данные для входа должен быть зеленым:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "with invalid information"
Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.
Заполнить область, занятую в настоящее время комментарием (Листинг 8.12) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in, а затем перенаправляем его на страницу профиля (Листинг 8.13). Мы видим теперь, почему это обманчивая легкость: увы, sign_in в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.
class SessionsController < ApplicationController
.
.
.
def create
user = User.find_by(email: params[:session][:email].downcase)
if user user.authenticate(params[:session][:password])
sign_in user
redirect_to user
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
.
.
.
end
Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя "навсегда" и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5, что Ruby предоставляет модули для упаковки функций вместе и включения их в нескольких местах и это наш план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper. Кроме того, помощники автоматически включаются в Rails представления, так что все что мы должны сделать для того чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application контроллер (Листинг 8.14).
class ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper end
По умолчанию, все помощники доступны во views, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны явно включить его.
Поскольку HTTP является протоколом, не сохраняющим своего состояния, веб-приложения, требующие входа пользователей, должны реализовывать способ, позволяющий отслеживать прогресс каждого пользователя от страницы к странице. Один из методов для поддержания статуса вошедшего пользователя, является использование традиционных Rails сессий (с помощью специальной session функции) для хранения remember token, равного пользовательскому id:
session[:remember_token] = user.id
Этот session объект делает идентификатор пользователя доступным от страницы к странице, сохраняя его в cookie, которые истекают при закрытии браузера. На каждой странице приложения можно просто вызвать
User.find(session[:remember_token])
для получения пользователя. Из-за способа, которым Rails обрабатывает сессии, этот процесс является безопасным, если злоумышленник попытается подменить идентификатор пользователя, Rails обнаружит несоответствие, основываясь на специальном session id, генерируемом для каждой сессии.
Для выбранного нами способа, который подразумевает постоянные сессии — то есть статус вошедшего пользователя, сохраняющийся даже после того, как браузер закрыт — нам необходимо использовать постоянный идентификатор для вошедшего пользователя. Для того чтобы достигнуть этого, мы будем генерировать уникальный, безопасный remember token для каждого пользователя и мы будем хранить его в качестве постоянной куки отличающейся от обычной тем, что она не истекает при закрытии браузера.
Remember token должен быть связан с пользователем и должен сохраняться для последующего использования, поэтому мы добавим его в качестве атрибута модели User, как это показано на рис 8.8 .
(рис 8.8) Модель User с добавленным атрибутом remember_token.
Мы начнем с небольшого дополнения к спекам модели User (Листинг 8.15).
require 'spec_helper'
describe User do
.
.
.
it { should respond_to(:password_confirmation) }
it { should respond_to(:remember_token) }
it { should respond_to(:authenticate) }
.
.
.
end
Мы можем получить прохождение этого теста сгенерировав remember token в командной строке:
$ rails generate migration add_remember_token_to_users
Затем мы заполняем получившуюся миграциюю кодом из Листинга 8.16. Это дает нам код показанный в Листинге 8.16. Обратите внимание, что, посколку мы планируем искать пользователей в базе данных по remember token, мы должны добавить индекс (Блок 6.2) к столбцу remember_token
class AddRememberTokenToUsers < ActiveRecord::Migration
def change
add_column :users, :remember_token, :string
add_index :users, :remember_token
end
end
Затем мы, как обычно, обновляем тестовую и рабочую базы данных:
$ bundle exec rake db:migrate $ bundle exec rake test:prepare
В этой точке спеки модели User должны проходить:
$ bundle exec rspec spec/models/user_spec.rb
Теперь мы должны выбрать, что именно использовать в качестве remember token. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод
Мы планируем хранить сам base64 токен в браузере, а его зашифрованную версию - в базе данных приложения. После чего мы сможем осуществлять логин пользователей вытягивая токен из куки, шифруя его, а затем ища ему соответствие в зашифрованных токенах хранимых в базе данных. Причина по которой мы храним только зашифрованные токены заключается в том, что, даже если вся наша база данных будет скомпрометирована, атакер все равно не сможет использовать токены для входа. Для того чтобы сделать наш токен еще более безопасными, мы планируем менять его каждый раз когда пользователь создает новую сессию, а это означает что любые похищенные сессии—когда атакер использует украденные куки для входа от лица определенного пользователя—истекут при следующем входе пользователя. (Похищение сессий получило широкую огласку с помощью приложения Firesheep, которое показывало что токены на множестве знаменитых сайтов были видимы при подключении к публичным Wi-Fi сетям. Решение заключается в использовании SSL повсеместно на сайте, как это было описано в Разделе 7.4.4.)
Хотя в реальном приложении мы будем немедленно 'входить' вновь созданного пользователя (тем самым создавая новый токен в качестве побочного эффекта), мы не будем полагаться на такое поведение; более надежная практика заключается в обеспечении каждого пользователя валидным токеном с самого начала. Для того чтобы достигнуть этого мы будем создавать начальный токен с помощью функции обратного вызова - техники впервые представленной в Разделе 6.2.5 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк
Для того чтобы протестировать remember token, мы вначале сохраним тестового пользователя, а затем проверим, что атрибут пользовательского remember_token не является пустым. Что позволит нам при необходимости изменять случайную строку, если это нам когда-либо потребуется. Результат представлен в Листинге 8.17.
require 'spec_helper'
describe User do
before do
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
subject { @user }
.
.
.
describe "remember token" do
before { @user.save }
its(:remember_token) { should_not be_blank }
end
end
Листинг 8.17 вводит метод its, который похож на it но относит следующий за ним тест к данному атрибуту, а не к субъекту теста. Другими словами,
its(:remember_token) { should_not be_blank }
является эквивалентом
it { expect(@user.remember_token).not_to be_blank }
Код приложения вводит несколько новых элементов в модель User (app/models/user.rb). Во-первых, мы добавили метод обратного вызова для создания remember token непосредственно перед созданием нового пользователя в базе данных:
before_create :create_remember_token
Этот код, называемый method reference понужает Rails искать метод с названием create_remember_token и выполнять его перед сохранением пользователя. (В Листинге 6.20 мы явно передавали блок в before_save, но техника ссылки на метод более предпочтительна в общем случае.) Во-вторых, сам метод используется тольку внутри модели User, так что нам нет необходимости выставлять его на показ сторонним пользователям. Как мы видели в Разделе 7.3.2, Ruby предлагает использовать для этих целей ключевое слово private:
private
def create_remember_token
# Create the token.
end
Все методы, определенные в классе после private автоматически становятся скрытыми, таким образом
$ rails console >> User.first.create_remember_token
вызовет исключение NoMethodError.
Наконец, метод create_remember_token необходимо присвоить одному из атрибутов пользователей и в этом контексте необходимо использовать ключевое слово self перед remember_token:
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
Из-за способа которым Ruby обрабатывает назначения внутри объектов, без self назначение создаст локальную переменную с именем remember_token, а это совсем не то что нам нужно. Использование self обеспечивает установку назначением пользовательского remember_token таким образом, что он будет записан в базу данных вместе с другими атрибутами при сохранении пользователя. (Теперь вы знаете почему остальные before_save коллбэки из Листинга 6.20 используют self.email вместо просто email.)
Обратите внимание: мы шифровали токен с помощью SHA1 - хэширующего алгоритма который намного быстрее чем алгоритм Bcrypt используемый нами для шифрования паролей пользователей в Разделе 6.3.1, что важно, поскольку (как мы увидим в Разделе 8.2.2) для вошедших пользователей он будет выполняться на каждой странице. SHA1 является менее безопасным чем Bcrypt, но в данном случае его более чем достаточно так как шифруемый токен уже является 16-значной случайной строкой; SHA1 hexdigest такой строки по сути является невзламываемым. (Вызов to_s нужен для того чтобы мы имели возможность работать с nil токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)
Методы private) поскольку в Разделе 8.2.3 мы будем их использовать за пределами модели User.
Собрав все это воедино мы приходим модели User показанной в Листинге 8.18.
class User < ActiveRecord::Base
before_save { self.email = email.downcase }
before_create :create_remember_token
.
.
.
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
end
Кстати, дополнительный уровень отступа на create_remember_token сделан для того, чтобы визуально отделить методы определенные после private. (Практика показала что это мудрая практика.)
Поскольку шифрованная строка SecureRandom.urlsafe_base64 определенно не пустая, тесты для модели User теперь должны пройти:
$ bundle exec rspec spec/models/user_spec.rb
Теперь мы готовы к написанию первого элемента входа - самой sign_in функции. Как было отмечено выше, выбранный нами метод аутентификации заключается в помещении (вновь созданного) remember token в качестве куки в браузер пользователя и последующем использовании токена для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 8.2.3). Результирующий Листинг 8.19 вводит новый для нас метод current_user который мы будем реализовывать в Разделе 8.2.3.
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Здесь мы следуем избранной тактике: во-первых, создаем новый токен; во-вторых, помещаем зашифрованный токен в куки браузера; в-третьих, сохраняем зашифрованный токен в базе данных; в-четвертых, устанавливаем текущего пользователя равным данному пользователю (Раздел 8.2.3). Как мы увидим в Разделе 8.2.3, установка текущего пользователя равным user в данный момент не нужна из-за незамедлительного редиректа в create действии (Листинг 8.13), но все же это хорошая идея - на тот случай если мы когда-нибудь захотим использовать sign_in без редиректа.
В Листинге 8.19 обратите внимание на использование update_attribute для сохранения токена. Как вкратце упоминалось в Разделе 6.1.5), этот метод позволяет обновлять один атрибут в обход валидаций — в данном случае это необходимо так как у нас нет пароля пользователя. Листинг 8.19 также вводит утилиту cookies которая позволяет нам манипулировать куками браузера как если бы они были хэшем; каждый элемент в куки представляет из себя хэш из двух элементов: value и (необязательный) expires дата (# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения куки со значением, равным пользовательскому токену, которая истекает через 20 лет:
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
(Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 8.1.)
______________________________________________________________
Блок 8.1.Куки истекают через 20.years.from_now
Вы можете вспомнить из Раздела 4.4.2, что Ruby позволяет добавлять методы к любому, даже встроенному классу. В том разделе мы добавляли palindrome? метод к String классу (и в результате обнаружили, что "deified" является палиндромом), и мы также видели, как Rails добавляет blank? метод к классу Object (таким образом, "".blank?, " ".blank?, и nil.blank? все являются true). Код куки в Листинге 8.19 (который внутренне устанавливает срок действия cookie в 20.years.from_now) дает еще один пример из этой практики, посредством одного из Rails’ временных хелперов, которые являются методами добавленными к Fixnum (базовый класс для чисел):
$ rails console >> 1.year.from_now => Sun, 13 Mar 2011 03:38:55 UTC +00:00 >> 10.weeks.ago => Sat, 02 Jan 2010 03:39:14 UTC +00:00
Rails добавляет и другие помощники:
>> 1.kilobyte => 1024 >> 5.megabytes => 5242880
Они полезны для валидации загрузки, что позволяет легко ограничить, например, загрузку изображений размером в 5.megabytes.
Хотя она должна использоваться с осторожностью, возможность добавлять методы к встроенным классам позволяет создавать черезвычайно естественные добавления к обычному Ruby. Действительно, большая часть элегантности Rails в конечном счете, является производной от податливости лежащего в его основе языка Ruby.
______________________________________________________________
Паттерн установки куки истекающей через 20 лет стал настолько общепринятым, что Rails добавил специальный метод permanent для его реализации, так что мы можем просто написать
cookies.permanent[:remember_token] = remember_token
Под капотом, применение permanent приводит к автоматической установке даты истечения куки через 20 лет (20.years.from_now).
После того как куки установлены, на последующих представлениях страниц мы можем извлекать пользователя с кодом вроде
User.find_by(remember_token: remember_token)
(Как мы увидим в Листинге 8.22, на самом деле мы вначале должны захэшировать токен.) Конечно, cookies это на самом деле не хэш, поскольку назначение cookies действительно сохраняет кусочек текста в браузере, но частью красоты Rails является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.
Обсудив способ хранения пользовательского remember token в куки для последующего использования, теперь нам необходимо узнать как извлекать пользователя при последующем просмотре страниц. Давайте еще раз взглянем на функцию sign_in для того чтобы понять где мы находимся:
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Единственный участок кода который в данный момент не работает это:
self.current_user = user
Как было отмечено сразу после Листинга 8.19, этот код никогда не будет использоваться в данном приложении из-за немедленного редиректа в Листинге 8.13, но для метода sign_in было бы опасным полагаться на это.
Целью current_user, доступного и в контроллерах и в представлениях является возможность создания конструкции подобные этой:
<%= current_user.name %<
и
redirect_to current_user
Использование self в назначении является необходимым по тем же причинам что были отмечены в обсуждении приведшем к Листингу 8.18: без self Ruby будет просто создавать локальную переменную с названием current_user.
Для того, чтобы начать писать код для current_user, обратите внимание, что строка
self.current_user = user
это назначение, которое мы должны определить. В Ruby есть специальный синтаксис для определения таких назначаемых функций, показанный в Листинге 8.20.
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
end
Это может выглядеть сбивающим с толку — большинство языков не позволит вам использовать знак равенства в определении метода, но это просто определение метода current_user= специально разработанного для обработки назначения current_user. Другими словами, код
self.current_user = ...
автоматически конвертируется в
current_user=(...)
тем самым вызывая метод current_user=. Его единственный аргумент это то, что находится справа от назначения, в данном случае - пользователь который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user, эффективно хранящую пользователя для дальнейшего использования.
В обычном Ruby, мы могли бы определить второй метод, current_user, предназначенный для возвращения значения @current_user, как это показано в Листинге 8.21.
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
def current_user
@current_user # Useless! Don't use this line.
end
end
Если бы мы сделали это, мы бы фактически повторили функциональность attr_accessor это просто удобный способ создавать такие getter/setter методы автоматически.@current_user это nil. Таким образом, когда пользователь обратится к еще одной странице, даже находясь в том же приложении, Rails установит @current_user равным nil и код в Листинге 8.21 не сделает то чего вы от него ожидали.
Для того чтобы избежать этой проблемы мы можем искать пользователя соответствующего remember token созданному кодом в Листинге 8.19, как это показано в Листинге 8.22. Обратите внимание: поскольку токен хранимый в базе данных зашифрован, нам нужно зашифровать токен полученный из куки прежде чем использовать его для поиска пользователя в базе данных. Мы достигним этого с помощью метода User.encrypt определенного в Листинге 8.18.
module SessionsHelper
.
.
.
def current_user=(user)
@current_user = user
end
def current_user
remember_token = User.encrypt(cookies[:remember_token])
@current_user ||= User.find_by(remember_token: remember_token)
end
end
Листинг 8.22 использует общепринятый, но изначально обескураживающий ||= ("или равно") оператор присваиваивания (Блок 8.2). Его эффект заключается в установке переменной экземпляра @current_user пользователю, соответствующему remember token, но только если nil, но обратите внимание - ложные (false) значения также будут переписаны оператором ||=
@current_user ||= User.find_by(remember_token: remember_token)
вызывает метод current_user используется чаще чем один раз для запроса отдельно взятого пользователя; в любом случае, find_by будет вызван по крайней мере один раз при каждом посещении страницы на этом сайте.
______________________________________________________________
Блок 8.2.Что за *$@! этот ваш ||= ?
Конструкция ||= - очень Рубишная — то есть, она очень характерна для языка Ruby — и, следовательно, важно ее знать, если вы планируете много программировать на Ruby. Хотя на первый взгляд она может показаться таинственной, или равно легко понять по аналогии.
Начнем с общепринятой идиомы для изменения определенной в настоящее время переменной. Многие компьютерные программы включают приращение переменной, как в
x = x + 1
Большинство языков обеспечивают синтаксическое сокращение для этой операции; в Ruby (и в C, C++, Perl, Python, Java, и т.д.), это выглядит следующим образом:
x += 1
Аналогичные конструкции существуют и для других операторов:
$ rails console >> x = 1 => 1 >> x += 1 => 2 >> x *= 3 => 6 >> x -= 7 => -1
В каждом случае, паттерном является то, что x = x O y и x O= y эквивалентны для любого оператора O.
Другим распространенным Ruby паттерном является назначение переменной, если она nil но в противном случае оставляя ее в покое. Вспоминая or оператор || из Раздела 4.2.3, мы можем записать это следующим образом:
>> @user => nil >> @user = @user || "the user" => "the user" >> @user = @user || "another user" => "the user"
Поскольку nil ложно в булевом контексте, первое присвоение это nil || "the user", что оценивается как "the user"; аналогично, второе присвоение является "the user" || "another user", которое также оценивается как "the user" — так как строки true в булевом контексте, серия || выражений прекращается после оценки первого выражения. (Эта практика оценки выражений || слева направо и остановки на первом истинном значении, известна как оценка короткого замыкания (short-circuit evaluation).)
Сравнивая в консольной сессии различные операторы, мы видим, что @user = @user || value следует x = x O y паттерну с || вместо O, что позволяет предположить следующую эквивалентную конструкцию:
>> @user ||= "the user" => "the user"
Вуаля!
______________________________________________________________
Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на рис 8.3, мы организуем изменение ссылок при входе и выходе пользователей из системы, а также мы добавим ссылки на список всех пользователей, на страницу настроек пользователя (будет закончена в Главе 9) и одну для профиля текущего пользователя. При этом, мы получим прохождение тестов из Листинга 8.6, а это означает, что наш набор тестов станет польностью зеленым впервые с начала этой главы.
Смена ссылок в шаблоне сайта подразумевает использование если-иначе ветвящихся структур внутри Embedded Ruby:
<% if signed_in? %> # Ссылки для вошедших пользователей <% else %> # Ссылки для не вошедших пользователей <% end %>
Такой код требует наличия булевого метода signed_in?, который мы сейчас и реализуем.
Пользователь является вошедшим если в сессии существует текущий пользователь, т.e., если current_user не является nil. Это требует использования оператора "not", написанного с помощью восклицательного знака ! и обычно читаемого как "bang". В данном контексте пользователь является вошедшим если current_user является не nil, как это показано в Листинге 8.23.
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
def signed_in?
!current_user.nil?
end
.
.
.
end
Имея на руках метод signed_in?, мы готовы закончить ссылки шаблона. Это будут четыре новых ссылки, две из которых пока останутся заглушками (мы их доработаем в Главе 9):
<%= link_to "Users", '#' %> <%= link_to "Settings", '#' %>
Ссылка на выход, между прочим, использует путь выхода, определенный в Листинге 8.2:
<%= link_to "Sign out", signout_path, method: "delete" %>
(Обратите внимание на то, что ссылка на выход передает хэш аргументов указывающий на то, что она должна отправить HTTP запрос DELETE.
<%= link_to "Profile", current_user %>
Здесь мы могли бы написать
<%= link_to "Profile", user_path(current_user) %>
но Rails позволяет нам ссылаться непосредственно на пользователя и в этом контексте current_user будет автоматически конвертирован в user_path(current_user).
В процессе размещения новых ссылок в шаблон, мы воспользуемся случаем и создадим выпадающее меню с помощью Bootstrap, вы можете более подробно почитать об этом на странице компонентов Bootstrap. Полный результат представлен в Листинге 8.24. В частности, обратите внимание на CSS id и классы связанные с выпадающим меню Bootstrap.
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="navbar-inner">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<ul class="nav pull-right">
<li><%= link_to "Home", root_path %></li>
<li><%= link_to "Help", help_path %></li>
<% if signed_in? %>
<li><%= link_to "Users", '#' %></li>
<li id="fat-menu" class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Settings", '#' %></li>
<li class="divider"></li>
<li>
<%= link_to "Sign out", signout_path, method: "delete" %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Sign in", signin_path %></li>
<% end %>
</ul>
</nav>
</div>
</div>
</header>
Выпадающее меню требует применения JavaScript библиотеки Bootstrap, которую мы можем включить с помощью Рельсового файлопровода, отредактировав файл application.js, как это показано в Листинге 8.25.
//= require jquery //= require jquery_ujs //= require bootstrap //= require turbolinks //= require_tree .
Здесь используется библиотека Sprockets для включения Bootstrap JavaScript, которая в свою очередь доступна благодаря гему bootstrap-sass из Раздела 5.1.2.
С кодом в Листинге 8.24 все тесты должны пройти:
$ bundle exec rspec spec/
Вошедший пользователь теперь видит новые ссылки и выпадающее меню определенное Листингом 8.24, as shown in рис 8.24" type="example">Листингом 8.24, as shown in (рис 8.9) Вошедший пользователь с новыми ссылками и выпадающим меню.
В этой точке вам следует проверить, что вы можете войти, закрыть браузер и быть по-прежнему вошедшими при повторном посещении примера приложения. Если хотите, вы можете даже проверить куки браузера для того чтобы посмотреть на результат непосредственно (рис 8.10).
(рис 8.10) Куки remember token в локальном браузере.
В принципе, хотя мы закончили с аутентификацией, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию. Реализация этого - последний штрих который мы добавим прежде чем позволим пользователям входить на наш сайт. Мы начнем с добавления строки к тестам аутентификации (Листинг 8.26). Это включает "after saving the user" describe блок из Листинга 7.32 (Раздел 7.6), который вы должны добавить в тест, если вы не сделали этого в соответствующем упражнении
require 'spec_helper'
describe "User pages" do
.
.
.
describe "with valid information" do
.
.
.
describe "after saving the user" do
before { click_button submit }
let(:user) { User.find_by(email: 'user@example.com') }
it { should have_link('Sign out') }
it { should have_title(user.name) }
it { should have_selector('div.alert.alert-success', text: 'Welcome') }
end
end
end
end
Здесь мы протестировали появление ссылки на выход для того чтобы убедиться что пользователь успешно вошел после регистрации.
С методом sign_in из Раздела 8.2, получение прохождения этого теста фактически впустив пользователя в систему легко: просто добавим sign_in @user сразу после сохранения пользователя в базе данных (Листинг 8.27).
class UsersController < ApplicationController
.
.
.
def create
@user = User.new(user_params)
if @user.save
sign_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new'
end
end
.
.
.
end
Как обсуждалось в Разделе 8.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту, необходимую нам и пользователям, возможность выхода.
До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new для страницы входа и create для его завершения. Мы продолжим эту тему используя действие destroy для удаления сессий, т.е., для выхода. Для того чтобы протестировать это, мы кликнем по ссылке "Sign out" а затем попробуем найти вновь появившуюся ссылку на вход (Листинг 8.28).
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
.
.
.
describe "with valid information" do
.
.
.
describe "followed by signout" do
before { click_link "Sign out" }
it { should have_link('Sign in') }
end
end
end
end
Как и со входом пользователя, основанном на функции sign_in, выход пользователя просто перекладывает работу на функцию sign_out (Листинг 8.29).
class SessionsController < ApplicationController
.
.
.
def destroy
sign_out
redirect_to root_url
end
end
Как и другие элементы аутентификации, мы поместим nil в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out без редиректа.)
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
.
.
.
def sign_out
current_user.update_attribute(:remember_token,
User.encrypt(User.new_remember_token))
cookies.delete(:remember_token)
self.current_user = nil
end
end
Это завершает триумвират регистрация/вход/выход и набор тестов должен пройти:
$ bundle exec rspec spec/
Стоит отметить, что наш набор тестов покрывает большую часть механизма аутентификации, но не все же не полностью. Например, мы не тестируем то как долго живет "remember me" куки и даже не тестируем устанавливается ли она вообще. Это возможно сделать, но практика показывает, что непосредственное тестирование значения куки является хрупким и имеет тенденцию зависеть от деталей реализации которые иногда меняются от одного релиза Rails к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь на функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.
Закончив основу системы аутентификации примера приложения, мы собираемся воспользоваться подвернувшейся возможностью для того чтобы показать как написать тесты входа с помощью Cucumber - популярного инструмента для разработки через поведение, который пользуется значительной популярностью в сообществе Ruby. Этот раздел необязателен и может быть пропущен без потери целостности повествования.
Cucumber позволяет определять текстовые истории описывающие поведение приложения. Множество Rails программистов находят Cucumber особенно полезным при работе над клиентскими проектами; поскольку они могут быть прочитаны даже технически не подкованными пользователями, тесты на Cucumber могут быть прочитаны (а иногда даже написаны) клиентом. Конечно же, применение тестового фреймворка, который не является чистым Ruby, имеет и оборотную сторону, и я считаю что текстовые истории зачастую могут быть излишне многословными. Тем не менее, Cucumber занял прочные позиции в Ruby-инструментарии тестирования и мне особенно нравится его акцент на поведении верхнего уровня, а не на деталях реализации.
Поскольку акценты в этой книге смещены в сторону RSpec и Capybara, последующая презентация совершенно не претендует на полноту и исчерпывающее раскрытие темы. Ее цель - просто дать вам возможность ощутить вкус Cucumber-а (несомненно свежий и сочный) — если он поразит ваше воображение, существуют целые книги на эту тему готовые удовлетворить ваш аппетит. (Я особенно рекомендую The RSpec Book (David Chelimsky) и Rails 3 in Action (Ryan Bigg и Yehuda Katz), и The Cucumber Book (Matt Wynne и Aslak Hellesoy).)
Для того чтобы установить Cucumber, во-первых, добавьте гем cucumber-rails и служебный гем database_cleaner в группу :test в Gemfile (Листинг 8.31).
. . . group :test do . . . gem 'cucumber-rails', '1.4.0', :require => false gem 'database_cleaner', github: 'bmabey/database_cleaner' end . . .
Затем установите как обычно:
$ bundle install
Для того чтобы настроить приложение для использования Cucumber, мы затем генерируем несколько необходимых, поддерживающих его работу файлов и директорий:
$ rails generate cucumber:install
Это создает директорию features/ где будут жить файлы связанные с Cucumber.
Огурцовые фичи это описания ожидаемого поведения с помощью plain-text языка называемого Gherkin. Gherkin тесты читаются во многом как хорошо написанные примеры RSpec, но, поскольку они написаны простым текстом, они более доступны для тех, кому комфортнее читать английский, а не код Руби.
Наши Огурцовые фичи будут реализовывать небольшое количество примеров входа в Листинге 8.5 и Листинге 8.6. Для того чтобы начать, мы создадим файл signing_in.feature в директории features/.
Огурцовые фичи начинаются с короткого описания функционала:
Feature: Signing in
Затем они добавляют индивидуальные сценарии. Например, для того, чтобы протестировать провальный вход, мы можем написать следующий сценарий:
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Аналогично, для того, чтобы протестировать успешный вход, мы можем добавить следующее:
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Собрав все это вместе мы приходим к файлу Огурцовой фичи показанному в Листинге 8.32.
Feature: Signing in
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Для запуска фич мы используем исполняемую команду cucumber:
$ bundle exec cucumber features/
Сравните это с
$ bundle exec rspec spec/
В данном контексте стоит отметить, что, как и RSpec, Cucumber может быть вызван с помощью Rake-задачи:
$ bundle exec rake cucumber
(По непонятным для меня причинам, это иногда пишут как rake cucumber:ok.)
Все что мы пока сделали, это лишь написали немного простого текста, так что не думаю что для вас стало сюрпризом что Огурцовые сценарии пока не проходят. Для того чтобы получить зеленый набор тестов, нам необходима добавить файл step, который свяжет строки простого текста с Руби-кодом. Файл отправляется в директорию features/step_definitions; мы назовем его authentication_steps.rb.
Строки Feature и Scenario нужны в основном для документации, но каждой последующей строке нужен соответствующий Ruby. Например, строка
Given a user visits the signin page
в файле фич будет обработана соответствующим определением шага
Given /^a user visits the signin page$/ do visit signin_path end
В фиче, Given это просто строка, но в файле с шагами Given является методом который принимает регулярное выражение и блок. Регулярное выражение соответствует тексту строки в сценарии, а содержимое блока является чистым Руби кодом, необходимым для реализации шага. В данном случае, "a user visits the signin page" реализуется посредством
visit signin_path
Если это выглядит знакомым, все правильно: это просто Capybara, которая включена по умолчанию в файлы с Огурцовыми шагами. Следующие две строки тоже должны выглядеть знакомо; шаги сценария
When they submit invalid signin information Then they should see an error message
в файле фич обрабатываются следующими шагами:
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Первый шаг также использует Capybara, при этом второй использует объект Capybara page вместе с RSpec. Очевидно, вся работа с тестами, которую мы проделали с RSpec и Capybara также полезна с Cucumber.
Остальные шаги обрабатываются аналогично. Конечный файл определения шагов преставлен в Листинге 8.33. Попробуйте добавлять шаги по одному, запуская
$ bundle exec cucumber features/
каждый раз до тех пор пока все тесты не пройдут.
Given /^a user visits the signin page$/ do
visit signin_path
end
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Given /^the user has an account$/ do
@user = User.create(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
When /^the user submits valid signin information$/ do
fill_in "Email", with: @user.email
fill_in "Password", with: @user.password
click_button "Sign in"
end
Then /^they should see their profile page$/ do
expect(page).to have_title(@user.name)
end
Then /^they should see a signout link$/ do
expect(page).to have_link('Sign out', href: signout_path)
end
С кодом в Листинге 8.33, Огурцовые тесты должны пройти:
$ bundle exec cucumber features/
Написав несколько простых Огурцовых сценариев, стоит сравнить результат с эквивалентным примером на RSpec. Для начала, взглянем на Огурцовую фичу в Листинг 8.32 и соответствующее определение шагов в Листинге 8.33. Затем взглянем на RSpec request specs (интеграционные тесты):
describe "Authentication" do
subject { page }
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Вы можете видеть, что дело может быть сделано как с помощью Огурца, так и с помощью интеграционных тестов. Огурцовые фичи легкочитаемы, но они полностью отделены от кода который их реализует, а это палка о двух концах. Я считаю что Огурец легок в чтении, но сложен в написании, в то время как интеграционные тесты (для программистов) немного более сложны в чтении и намного более просты в написании.
Один из замечательных эффектов Огурцового разделения является то, что он работает на более высоком уровне абстракции. Например, мы пишем
Then they should see an error message
для того, чтобы выразить ожидание увидеть сообщение об ошибке, и
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
для того чтобы реализовать тест. Что особенно удобно в этом, так это то, что только второй элемент (шаг) зависит от реализации, таким образом, если мы изменим, например, класс CSS используемый для сообщений об ошибках, файл фич останется неизменным.
В этом случае можно запечалиться переписывая
should have_selector('div.alert.alert-error')
в куче мест, в то время как вы на самом деле хотели лишь указать на то, что на странице должно присутствовать сообщение об ошибке. Эта практика тесно связывает тест с реализацией и мы должны были бы изменить это повсеместно при изменении реализации. В контексте чистого RSpec для этого есть решение, которое заключается в использовании кастомных проверок, позволяющих нам писать вместо этого следующее:
should have_error_message('Invalid')
Мы можем определить такие проверки в том же вспомогательном файле, в который мы поместили тестовый хелпер full_title в Разделе 5.3.4. Сам код выглядит примерно следующим образом:
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
Мы можем также определить вспомогательные функции для общепринятых операций:
def valid_signin(user) fill_in "Email", with: user.email fill_in "Password", with: user.password click_button "Sign in" end
Получившийся в результате вспомогательный код показан в Листинге 8.34 (который включает в себя результаты Листинга 5.41 и Листинга 5.42 из Раздела 5.6). Я нахожу этот подход более гибким, нежели Огурцовые определения шагов, в особенности когда проверки или помощники долженствования натурально принимают аргумент, такой как valid_signin(user). Определения шагов может повторить эту функциональность с помощью проверок регулярных выражений, но я считаю такой подход гораздо более громоздким (# в оригинале - (cu)cumbersome).
include ApplicationHelper
def valid_signin(user)
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Sign in"
end
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
С кодом из Листинга 8.34, мы можем написать
it { should have_error_message('Invalid') }
и
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before { valid_signin(user) }
.
.
.
В наших тестах есть множество примеров тесной связи между тестами и реализацией сайта. Прохождение по текущему набору тестов и разрыв связей между тестами и деталями реализации с помощью создания кастомных проверок и методов остается в качестве упражнения (Раздел 8.5).
Мы очень многое узнали в этой главе, трансформируя наше многообещающее, но не сформированное приложение в сайт, обладающий полным набором функций для регистрации и входа/выхода пользователей. Все что нам необходимо для завершения аутентификационной функциональности, это ограничить доступ к страницам по статусу и идентификации пользователей. Мы выполним эту задачу, по пути дав пользователям возможность редактировать их информацию, а также дав администраторам возможность удалять пользователей из системы.
Прежде чем двигаться далее, объедините изменения с мастер веткой:
$ git add . $ git commit -m "Finish sign in" $ git checkout master $ git merge sign-in-out
Затем отправьте изменения на удаленный репозиторий GitHub и продакшен сервер Heroku
$ git push $ git push heroku $ heroku run rake db:migrate
form_tag вместо form_for. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см.RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэша params.Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.