Авторизация через Facebook, Google, Twitter и Github используя Omniauth

от автора

Озадачившись однажды вопросом добавление регистрации/входа на сайт через сторонние сервисы начал искать, что же есть может готового либо описания как это уж кто-то делал. Готовые сервисы были откину сразу, остался вариант реализовывать самому. И тут 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 строки кода нужны для добавления нового провайдера. Еще нету интерфейса для этого, но можете проверить перейдя по ссылкам сами:

app/controllers/services_controller.rb

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, ничего не найдет и создаст нового пользователя.
Пришло время добавить пару вьюшек, начнем с входа и регистрации:

app/views/devise/sessions/new.html.erb

<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> 

app/views/users/registrations/new.html.erb

<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. Сейчас наши пользователи могут входить или регистрироваться через удобный интерфейс. В дополнение, нам нужна страница с настройками, где пользователи смогут управлять аккаунтами связанными с локальным.

app/views/services/index.html.erb

<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 для контроллера регистрации:

app/controllers/users/registrations_controller.rb

... 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: это первый мой большой перевод, поэтому просьба ошибки/кривые формулировки в личку. Большое спасибо.

ссылка на оригинал статьи http://habrahabr.ru/post/166325/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *