Озадачившись однажды вопросом добавление регистрации/входа на сайт через сторонние сервисы начал искать, что уже есть готового, либо описания как это уже кто-то делал. Готовые сервисы были откину сразу, остался вариант реализовывать самому. И тут Google навел на подробную инструкцию. Ознакомившись и вдохновленный тем решением сделал свою модификацию, все работало, был просто счастлив.
Спустя некоторое время решил посмотреть что-же еще есть на том ресурсе интересного, но к своему разочарованию сайт был не доступен. Слава кэшу Яндекса, откуда была выдернута копия того материала. И чтобы он не пропал безвозвратно, решил сделать его перевод и выложить здесь.
И так приступим...
Эта глава будет посвящена известному гему Omniauth. Omniauth это новая система идентификации поверх Rack для мультипровайдерной внешней идентификации. Он будет использован для связи CommunityGuides (прим: в настоящий момент ресурс не доступен и похоже уже не вернется) с Facebook, Google, Twitter и Github. Данная глава покажет как интегрировать все это с существующей идентификацией через Devise.
Добавляем вход через Facebook
Omniauth — система идентификации поверх Rack для мультипровайдерной внешней идентификации.
Для начала мы зарегистрируем наше приложение на Facebook developers.facebook.com/setup. Укажите имя (будет отображаться пользователям) и URL (например www.communityguides.eu/). Facebook допускает перенаправление только на зарегистрированный сайт, для разработки вам нужно указать другой URL (например http://localhost:3000/). Не указывайте в URL localhost либо 127.0.0.1 это приведет к ошибке “invalid redirect_uri”, что довольно распространено. Добавьте гем ‘omniauth’ к вашему проекту выполните bundle install, создайте инициализатор с вашим APP_ID/APP_SECRET и перезапустите сервер.
Gemfile
gem 'omniauth', '0.1.6'
config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :facebook, 'APP_ID', 'APP_SECRET'
end
Теперь создадим новый контроллер и модель, которая расширит нашего пользователя различными сервисами и установит связь между ними.
Terminal
rails generate model service user_id:integer provider:string uid:string uname:string uemail:string
rails generate controller services
app/models/user.rb
class User < ActiveRecord::Base
devise :database_authenticatable, :oauthable, :registerable,
:recoverable, :rememberable, :trackable, :validatable,
:confirmable, :lockable
has_many :services, :dependent => :destroy
has_many :articles, :dependent => :destroy
has_many :comments, :dependent => :destroy
has_many :ratings, :dependent => :destroy
belongs_to :country
attr_accessible :email, :password, :password_confirmation, :remember_me, :fullname, :shortbio, :weburl
validates :weburl, :url => {:allow_blank => true}, :length => { :maximum => 50 }
validates :fullname, :length => { :maximum => 40 }
validates :shortbio, :length => { :maximum => 500 }
end
app/models/service.rb
class Service < ActiveRecord::Base
belongs_to :user
attr_accessible :provider, :uid, :uname, :uemail
end
config/routes.rb
...
match '/auth/facebook/callback' => 'services#create'
resources :services, :only => [:index, :create]
...
Мы определили новые маршруты для сервисов (пока только index и create) и добавили так называемый маршрут для обратного вызова. Что это? Мы делаем запрос на аутентификацию пользователя через http://localhost:3000/auth/facebook. Запрос направляется на Facabook и далее Facebook перенаправляет запрос на вашу страницу используя путь /auth/facebook/callback. Мы сопоставили данный путь нашему контроллеру Services, в частности методу create. Сейчас данный метод возвращает лишь полученный хэш.
app/controllers/services_controller.rb
class ServicesController < ApplicationController
def index
end
def create
render :text => request.env["omniauth.auth"].to_yaml
end
end
Давайте проверим это. Перейдем по адресу http://localhost:3000/auth/facebook после чего попадем на запрос на доступ к вашим данным на Facebook. Принимаем предложение и возвращаемся в наше приложение, которое отобразит полученные данные (смотрите исходный код страницы для нормального форматирования).
Исходный код страницы
---
user_info:
name: Markus Proske
urls:
Facebook: http://www.facebook.com/profile.php?id=....
Website:
nickname: profile.php?id=....
last_name: Proske
first_name: Markus
uid: "..."
credentials:
token: ...........
extra:
user_hash:
name: Markus Proske
timezone: 1
gender: male
id: "...."
last_name: Proske
updated_time: 2010-11-18T13:43:01+0000
verified: true
locale: en_US
link: http://www.facebook.com/profile.php?id=........
email: markus.proske@gmail.com
first_name: Markus
provider: facebook
Нас интересуют только поля id, provider name и email, расположенные в extra: user_hash. Для проверки заменим create метод следующим кодом:
app/controllers/services_controller.rb
...
def create
omniauth = request.env['omniauth.auth']
if omniauth
omniauth['extra']['user_hash']['email'] ? email = omniauth['extra']['user_hash']['email'] : email = ''
omniauth['extra']['user_hash']['name'] ? name = omniauth['extra']['user_hash']['name'] : name = ''
omniauth['extra']['user_hash']['id'] ? uid = omniauth['extra']['user_hash']['id'] : uid = ''
omniauth['provider'] ? provider = omniauth['provider'] : provider = ''
render :text => uid.to_s + " - " + name + " - " + email + " - " + provider
else
render :text => 'Error: Omniauth is empty'
end
end
...
Отлично, мы сумели аутентифицировать пользователя через Facebook! Еще осталось много чего нужно сделать, мы интегрируем это в нашу схему с Devise. Есть несколько моментов, на которые нужно обратить внимание:
- Пользователь входит используя Facebook: Facebook предоставляет почту пользователя. Проверим есть ли уже такой, если нет то создаем нового пользователя к предоставленным адресом и автоматически подтверждаем. Создаем новую запись в модели Serviсe для Facebook и присваиваем созданному пользователю.
- Пользователь регистрируется или входит через Facebook первый раз, но уже имеет локального пользователя: снова получаем адрес почтуот Facebook и смотрим в нашу базу. Если мы находим такой адрес, то создаем новую записть для Facebook и связываем с найденным пользователем.
- Пользователь повторно входит через Facebook: смотрим в базу и выполняем вход для него.
Omniauth предоставляет возможность добавить больше сервисов, как мы и сделаем. Наша аутентификация завязана на почтовый адрес, поэтому только провайдеры предоставляющие его могут быть использованы. Например Github возвращает адрес только в том случаем, если пользователь указал публичный адрес. Twitter напротив никогда не показывает почтовый адрес Тем не менее, Github аккаунт с адресом может быть использован как и Fb для входа/регистрации, а Github без адреса или Twitter аккаунты могут быть добавлены к существующему локальному пользователю, либо созданного через другого провайдера.
Каждый провайдер возвращает хэш содержащий различные параметры. К сожалению, это никак не стандартизовано и каждый может давать различные имена одинакомым атрибутам. Это значит, что мы должны различать сервисы в методе create. Так же заметим, что есть только один метод для обратного вызова. Поэтому что мы должны сделать с полученными данными (войти или зарегистрировать) зависит только от нас. Изменим наш маршрут снова для всех сервисов, добавим в него параметр, в который будет помещаться имя используемого: params[:service].
config/routes.rb
...
match '/auth/:service/callback' => 'services#create'
resources :services, :only => [:index, :create, :destroy]
...
Далее идем на страницы для Github и Twitter. Регистрируем снова на localhost (для Twitter-а вместо localhost нужно использовать 127.0.0.1). Получим новые маршруты http://localhost:3000/auth/github/callback/ и http://127.0.0.1:3000/auth/twitter/callback. После чего изменим инициализатор.
config/initializers/omniauth.rb
# Do not forget to restart your server after changing this file
Rails.application.config.middleware.use OmniAuth::Builder do
provider :facebook, 'APP_ID', 'APP_SECRET'
provider :twitter, 'CONSUMER_KEY', 'CONSUMER_SECRET'
provider :github, 'CLIENT ID', 'SECRET'
end
Созданный метод будет проверять наличие параметра из пути и Omniauth хэша. Далее, в зависимости от сервиса аутентификации, необходимые значение из хеша переносятся в наши переменные. По крайней мере, сервис провайдер и идентификатор пользователя для него должны быть определены, иначе остановка.
Часть первая: пользователь еще не вошел: Сначала проверим, есть ли пара провайдер-идентификатор в нашей модели Service, которая подразумевает что, данная пара ассоциирована с пользователем и может быть использована для его входа. Если это так, то делаем вход. Если нет, то проверяем существование почтового адреса. Используя этот адрес, мы может найти в имеющейся модели пользователя если он уже был с ним зарегистрирован. Когда такой пользователь найдется, этот сервис будет добавлен ему и в будущем он сможет использовать его для входа. В случае если это новый почтовый адрес, то вместо этого создаем нового пользователя, подтверждаем его и добавляем данный сервис аутентификации ему.
Часть вторая: если пользователь уже вошел: Мы просто добавляем данный сервис к его аккаунту если не был добавлен ранее.
Посмотрим внимательно ниже на метод Create. Он содержит весь необходимый код для обработки различных случаев описанных выше и предоставляет идентификацию для Facebook, Github и Twitter. Заметьте, что только 4 строки кода нужны для добавления нового провайдера. Еще нету интерфейса для этого, но можете проверить перейдя по ссылкам сами:
- Facebook: http://localhost:3000/auth/facebook
- Github: http://localhost:3000/auth/github
- Twitter: http://localhost:3000/auth/twitter
- Index отображает все сервисы привязанные к текущему пользователю: http://localhost:3000/services (мы создадим эти страницы позже вместе со страницами входа и регистрации)
- метод Delete удаляет сервис.
class ServicesController < ApplicationController
before_filter :authenticate_user!, :except => [:create]
def index
# get all authentication services assigned to the current user
@services = current_user.services.all
end
def destroy
# remove an authentication service linked to the current user
@service = current_user.services.find(params[:id])
@service.destroy
redirect_to services_path
end
def create
# get the service parameter from the Rails router
params[:service] ? service_route = params[:service] : service_route = 'no service (invalid callback)'
# get the full hash from omniauth
omniauth = request.env['omniauth.auth']
# continue only if hash and parameter exist
if omniauth and params[:service]
# map the returned hashes to our variables first - the hashes differ for every service
if service_route == 'facebook'
omniauth['extra']['user_hash']['email'] ? email = omniauth['extra']['user_hash']['email'] : email = ''
omniauth['extra']['user_hash']['name'] ? name = omniauth['extra']['user_hash']['name'] : name = ''
omniauth['extra']['user_hash']['id'] ? uid = omniauth['extra']['user_hash']['id'] : uid = ''
omniauth['provider'] ? provider = omniauth['provider'] : provider = ''
elsif service_route == 'github'
omniauth['user_info']['email'] ? email = omniauth['user_info']['email'] : email = ''
omniauth['user_info']['name'] ? name = omniauth['user_info']['name'] : name = ''
omniauth['extra']['user_hash']['id'] ? uid = omniauth['extra']['user_hash']['id'] : uid = ''
omniauth['provider'] ? provider = omniauth['provider'] : provider = ''
elsif service_route == 'twitter'
email = '' # Twitter API never returns the email address
omniauth['user_info']['name'] ? name = omniauth['user_info']['name'] : name = ''
omniauth['uid'] ? uid = omniauth['uid'] : uid = ''
omniauth['provider'] ? provider = omniauth['provider'] : provider = ''
else
# we have an unrecognized service, just output the hash that has been returned
render :text => omniauth.to_yaml
#render :text => uid.to_s + " - " + name + " - " + email + " - " + provider
return
end
# continue only if provider and uid exist
if uid != '' and provider != ''
# nobody can sign in twice, nobody can sign up while being signed in (this saves a lot of trouble)
if !user_signed_in?
# check if user has already signed in using this service provider and continue with sign in process if yes
auth = Service.find_by_provider_and_uid(provider, uid)
if auth
flash[:notice] = 'Signed in successfully via ' + provider.capitalize + '.'
sign_in_and_redirect(:user, auth.user)
else
# check if this user is already registered with this email address; get out if no email has been provided
if email != ''
# search for a user with this email address
existinguser = User.find_by_email(email)
if existinguser
# map this new login method via a service provider to an existing account if the email address is the same
existinguser.services.create(:provider => provider, :uid => uid, :uname => name, :uemail => email)
flash[:notice] = 'Sign in via ' + provider.capitalize + ' has been added to your account ' + existinguser.email + '. Signed in successfully!'
sign_in_and_redirect(:user, existinguser)
else
# let's create a new user: register this user and add this authentication method for this user
name = name[0, 39] if name.length > 39 # otherwise our user validation will hit us
# new user, set email, a random password and take the name from the authentication service
user = User.new :email => email, :password => SecureRandom.hex(10), :fullname => name
# add this authentication service to our new user
user.services.build(:provider => provider, :uid => uid, :uname => name, :uemail => email)
# do not send confirmation email, we directly save and confirm the new record
user.skip_confirmation!
user.save!
user.confirm!
# flash and sign in
flash[:myinfo] = 'Your account on CommunityGuides has been created via ' + provider.capitalize + '. In your profile you can change your personal information and add a local password.'
sign_in_and_redirect(:user, user)
end
else
flash[:error] = service_route.capitalize + ' can not be used to sign-up on CommunityGuides as no valid email address has been provided. Please use another authentication provider or use local sign-up. If you already have an account, please sign-in and add ' + service_route.capitalize + ' from your profile.'
redirect_to new_user_session_path
end
end
else
# the user is currently signed in
# check if this service is already linked to his/her account, if not, add it
auth = Service.find_by_provider_and_uid(provider, uid)
if !auth
current_user.services.create(:provider => provider, :uid => uid, :uname => name, :uemail => email)
flash[:notice] = 'Sign in via ' + provider.capitalize + ' has been added to your account.'
redirect_to services_path
else
flash[:notice] = service_route.capitalize + ' is already linked to your account.'
redirect_to services_path
end
end
else
flash[:error] = service_route.capitalize + ' returned invalid data for the user id.'
redirect_to new_user_session_path
end
else
flash[:error] = 'Error while authenticating via ' + service_route.capitalize + '.'
redirect_to new_user_session_path
end
end
Наш код полностью работоспособен и прямо сейчас можно использовать один локальный аккаунт и три сервиса для входа или регистрации. Несмотря на то что, вход и регистрация всегда проходят по одному пути /auth/service и обратный вызов всегда идет на /auth/service/callback.
Наш пример прекрасно работает, но есть недостаток, который может привести к нежелательным аккаунтам: возьмем пользователя с локальным аккаунтов (почта: one@user.com) и аккаунта в Facebook (почта: two@user.com) который уже привязан к локальному. Никаких проблем, адреса не совпадают. Если пользователь имеет Google аккаунт с почтой: three@user.com, то он может быть привязан без проблем пока сессия активна. С другой стороны, предположим, что пользователь никогда не связывал Google аккаунт и он еще не вошел: если он нажмет на “войти через Google” наш create метод выполнит поиск для three@user.com, ничего не найдет и создаст нового пользователя.
Пришло время добавить пару вьюшек, начнем с входа и регистрации:
<section id="deviseauth">
<h2>Sign in</h2>
<h3>Sign in with your CommunityGuides account -- OR -- use an authentication service</h3>
<div id="local" class="box">
<%= form_for(resource, :as => resource_name, :url => session_path(resource_name)) do |f| %>
<p><%= f.label :email %><br />
<%= f.text_field :email %></p>
<p><%= f.label :password %><br />
<%= f.password_field :password %></p>
<% if devise_mapping.rememberable? %>
<p><%= f.check_box :remember_me %> <%= f.label :remember_me %></p>
<% end %>
<p><%= f.submit "Sign in" %></p>
<% end %>
</div>
<div id="remote">
<div id="terms" class="box">
<%= link_to "Terms of Service", "#" %>
</div>
<div id="services" class="box">
<a href="/auth/facebook" class="services"><%= image_tag "facebook_64.png", :size => "64x64", :alt => "Facebook" %>Facebook</a>
<a href="/auth/google" class="services"><%= image_tag "google_64.png", :size => "64x64", :alt => "Google" %>Google</a>
<a href="/auth/github" class="services"><%= image_tag "github_64.png", :size => "64x64", :alt => "Github" %>Github</a>
<a href="/auth/twitter" class="services"><%= image_tag "twitter_64.png", :size => "64x64", :alt => "Twitter" %>Twitter</a>
</div>
</div>
<div id="devise_links">
<%= render :partial => "devise/shared/links" %>
</div>
</section>
<section id="deviseauth">
<h2>Sign up</h2>
<h3>Sign up on CommunityGuides manually -- OR -- or use one of your existing accounts</h3>
<div id="local2" class="box">
<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %>
<%= devise_error_messages! %>
<p><%= f.label :email %><br />
<%= f.text_field :email %></p>
<p><%= f.label :password %><br />
<%= f.password_field :password %></p>
<p><%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation %></p>
<p><%= recaptcha_tags %></p>
<p><%= f.submit "Sign up" %></p>
<% end %>
</div>
<div id="remote2">
<div id="terms" class="box">
<%= link_to "Terms of Service", "#" %>
</div>
<div id="services" class="box">
<a href="/auth/facebook" class="services2"><%= image_tag "facebook_64.png", :size => "64x64", :alt => "Facebook" %>Facebook</a>
<a href="/auth/google" class="services2"><%= image_tag "google_64.png", :size => "64x64", :alt => "Google" %>Google</a>
<a href="/auth/github" class="services2"><%= image_tag "github_64.png", :size => "64x64", :alt => "Github" %>Github*</a>
<div id="footnote_signup">* You can use Github only if you set a public email address</div>
</div>
</div>
<div id="devise_links">
<%= render :partial => "devise/shared/links" %>
</div>
</section>
Вы можете скачать изображения Github:Authbuttons. Сейчас наши пользователи могут входить или регистрироваться через удобный интерфейс. В дополнение, нам нужна страница с настройками, где пользователи смогут управлять аккаунтами связанными с локальным.
<section id="deviseauth">
<h2>Authentication Services - Setting</h2>
<div id="currservices">
<h3>The following <%= @services.count == 1 ? 'account is' : 'accounts are' %> connected with your local account at CommunityGuides:</h3>
<% @services.each do |service| %>
<div class="services_used">
<%= image_tag "#{service.provider}_64.png", :size => "64x64" %>
<div class = "user">
<div class="line1">Name: <%= service.uname %> (ID: <%= service.uid %>)</div>
<div class="line2">Email: <%= service.uemail != '' ? service.uemail : 'not set' %></div>
<div class="line3">
<% @services.count == 1 ? @msg = 'Removing the last account linked might lock you out of your account if you do not know the email/password sign-in of your local account!' : @msg = '' %>
<%= link_to "Remove this service", service, :confirm => 'Are you sure you want to remove this authentication service? ' + @msg, :method => :delete, :class => "remove" %>
</div>
</div>
</div>
<% end %>
</div>
<div id="availableservices">
<h3>You can connect more services to your account:</h3>
<div id="services">
<a href="/auth/facebook" class="services"><%= image_tag "facebook_64.png", :size => "64x64", :alt => "Facebook" %>Facebook</a>
<a href="/auth/google" class="services"><%= image_tag "google_64.png", :size => "64x64", :alt => "Google" %>Google</a>
<a href="/auth/github" class="services"><%= image_tag "github_64.png", :size => "64x64", :alt => "Github" %>Github</a>
<a href="/auth/twitter" class="services"><%= image_tag "twitter_64.png", :size => "64x64", :alt => "Twitter" %>Twitter</a>
</div>
<h4>If you signed-up for CommunityGuides via an authentication service a random password has been set for the local password. You can request a new password using the "Forgot your Password?" link on the sign-in page.</h4>
</div>
</section>
Добавляем Google
Наконец давайте добавим Google к списку наших сервис провайдеров. Google (и OpenID в частности) требуют постоянного хранилища. Вы можете использовать ActiveRecord или файловую систему как показано ниже. Если вы хотите разворачивать на Heroku, помните, что у вас нету доступа на запись в /tmp. Хотя, как отмечено в Heroku Docs, вы можете писать в ./tmp.
Две строчки конфигураций и четыре для присвоения значений из хеша — это все что нужно для добавления авторизации через Google в вашем коде. Это ли не великолепно? Достаточно Omniauth на сегодня, но если вы хотите использовать его в одном из ваших проектом, вы можете найти много ресурсов в Omniauth Wiki, также Райна Бэйтс сделал великолепные скринкасты по нему.
Вновь настроим Devise
Существует небольшой недостаток в профиле наших пользователей. Пльзователю нужно вводить текущий пароль для смены настроек. Если он зарегистрирован через один из сервисов, то он не имеет пароля, помните, мы устанавливали его в случайную строку. В Devise Wiki есть статья с тем как полностью убрать пароль. Но у себя мы хотим оставить пароль только для локальных пользователей. Для остальных пользователей разрешим менять свой профить без использования пароля. В дополнение, они смогут установить локальный пароль если захотят. Это достигается путем модификации метода update для контроллера регистрации:
...
def update
# no mass assignment for country_id, we do it manually
# check for existence of the country in case a malicious user manipulates the params (fails silently)
if params[resource_name][:country_id]
resource.country_id = params[resource_name][:country_id] if Country.find_by_id(params[resource_name][:country_id])
end
if current_user.haslocalpw
super
else
# this account has been created with a random pw / the user is signed in via an omniauth service
# if the user does not want to set a password we remove the params to prevent a validation error
if params[resource_name][:password].blank?
params[resource_name].delete(:password)
params[resource_name].delete(:password_confirmation) if params[resource_name][:password_confirmation].blank?
else
# if the user wants to set a password we set haslocalpw for the future
params[resource_name][:haslocalpw] = true
end
# this is copied over from the original devise controller, instead of update_with_password we use update_attributes
if resource.update_attributes(params[resource_name])
set_flash_message :notice, :updated
sign_in resource_name, resource
redirect_to after_update_path_for(resource)
else
clean_up_passwords(resource)
render_with_scope :edit
end
end
end
...
Код использует дополнительное поле в пользовательской модели, вы можете вернуть и добавить его в миграцию (t.boolean :haslocalpw, :null => false, :default => true), измените модель для разрешения массового присваивания для этого поля, измените вьюшку чтобы скрыть поле для ввода текущего пароля если haslocalpw ложно и изменим create метод нашего service контроллера для установки этого поля при создании пользователя:
app/controllers/services_controller.rb
...
user = User.new :email => email, :password => SecureRandom.hex(10), :fullname => name, :haslocalpw => false
...
PS: это первый мой большой перевод, поэтому просьба ошибки/кривые формулировки в личку. Большое спасибо.
Автор: fuCtor