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

в 5:49, , рубрики: Facebook, github, Google, oauth2, ruby, ruby on rails

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

Автор: fuCtor

Источник

Поделиться

* - обязательные к заполнению поля