Alexander Timoshilov

Ruby on Rails developer

Аутентификация через Twitter, Facebook, ВКонтакте, используя Devise и OmniAuth в Rails 4

| Comments

Существует довольно много OAuth решений, но я хочу поделиться тем, что использую лично, так как оно позволяет грамотно связать несколько провайдеров(twitter, fb и тд) с одним пользователем. Если вы пользуетесь готовыми решениями с интернета, вы обнаружите, что каждый раз, когда пользователь логинится с другого OAuth провайдера, создается новый аккаунт, что приводит в замешательство. Так же некоторые провайдеры не делятся email адресом пользователя, поэтому нам придется добавить дополнительный шаг в регистрацию, подробнее об этом здесь.

Начальная реализация

Gemfile

gem 'devise'
gem 'omniauth'
gem 'omniauth-twitter'
gem 'omniauth-facebook'
gem 'omniauth-vkontakte'

Генерируем миграции и модели

rails generate devise:install
rails generate devise user
rails g migration add_name_to_users name:string
rails g model identity user:references provider:string uid:string

# Отредактируйте db/migrate/[timestamp]_add_devise_to_users.rb для установки Devise модулей, которые вы будете использовать.
# Я обычно использую "confirmable" модуль, для регистраций через email.

app/models/identity.rb

class Identity < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :uid, :provider
  validates_uniqueness_of :uid, :scope => :provider

  def self.find_for_oauth(auth)
    find_or_create_by(uid: auth.uid, provider: auth.provider)
  end
end

app/config/initializers/devise.rb

Devise.setup do |config|
...
  config.omniauth :facebook, "KEY", "SECRET"
  config.omniauth :twitter, "KEY", "SECRET"
  config.omniauth :vkontakte, "KEY", "SECRET"
...
end

config/environments/[environment].rb

...
  # General Settings
  config.app_domain = 'somedomain.com'

  # Email
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.perform_deliveries = true
  config.action_mailer.default_url_options = { host: config.app_domain }
  config.action_mailer.smtp_settings = {
    address: 'smtp.gmail.com', 
    port: '587',
    enable_starttls_auto: true,
    user_name: 'someuser',
    password: 'somepass',
    authentication: :plain,
    domain: 'somedomain.com'
  }
...

config/routes.rb

...
  devise_for :users, :controllers => { omniauth_callbacks: 'omniauth_callbacks' }
...

app/controllers/omniauth_callbacks_controller.rb

Стоит отметить, что единственный безопасный критерий соответствия пользователя с OAuth провайдером - это подтвержденный email адрес, но это приведет к созданию нескольких аккаунтов, если пользователь имеет разные email адреса для разных OAuth провайдеров. К примеру, пользователь зарегистрировался через facebook, затем позже попытался залогиниться через вконтакте, с которым связан другой email адрес, в таком случае система создаст новый аккаунт, потому что не найдет совпадений с уже существующим пользователем.

Поэтому, что бы связать аккаунт с несколькими OAuth провайдерами, current_user сессия должна уже быть установлена при возврате OAuth коллбэка, и передана через User.find_for_oauth. Это может прозвучать сложно, однако что бы прикрепить еще один провайдер, скажем фейсбук, это redirect_to user_omniauth_authorize_path(:facebook), когда пользователь уже залогинен.

class OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def self.provides_callback_for(provider)
    class_eval %Q{
      def #{provider}
        @user = User.find_for_oauth(env["omniauth.auth"], current_user)

        if @user.persisted?
          sign_in_and_redirect @user, event: :authentication
          set_flash_message(:notice, :success, kind: "#{provider}".capitalize) if is_navigational_format?
        else
          session["devise.#{provider}_data"] = env["omniauth.auth"]
          redirect_to new_user_registration_url
        end
      end
    }
  end

  [:twitter, :facebook, :vkontakte].each do |provider|
    provides_callback_for provider
  end

  def after_sign_in_path_for(resource)
    if resource.email_verified?
      super resource
    else
      finish_signup_path(resource)
    end
  end
end

app/models/user.rb

class User < ActiveRecord::Base
  TEMP_EMAIL_PREFIX = 'change@me'
  TEMP_EMAIL_REGEX = /\Achange@me/

  # Include default devise modules. Others available are:
  # :lockable, :timeoutable
  devise :database_authenticatable, :registerable, :confirmable,
    :recoverable, :rememberable, :trackable, :validatable, :omniauthable

  validates_format_of :email, :without => TEMP_EMAIL_REGEX, on: :update

  def self.find_for_oauth(auth, signed_in_resource = nil)

    # Получить identity пользователя, если он уже существует
    identity = Identity.find_for_oauth(auth)

    # Если signed_in_resource предоставлен оно всегда переписывает существующего пользователя
    # что бы identity не была закрыта случайно созданным аккаунтом.
    # Заметьте, что это может оставить зомби-аккаунты (без прикрепленной identity)
    # которые позже могут быть удалены
    user = signed_in_resource ? signed_in_resource : identity.user

    # Создать пользователя, если нужно
    if user.nil?

      # Получить email пользователя, если его предоставляет провайдер
      # Если email не был предоставлен мы даем пользователю временный и просим
      # пользователя установить и подтвердить новый в следующим шаге через UsersController.finish_signup
      email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
      email = auth.info.email if email_is_verified
      user = User.where(:email => email).first if email

      # Создать пользователя, если это новая запись
      if user.nil?
        user = User.new(
          name: auth.extra.raw_info.name,
          #username: auth.info.nickname || auth.uid,
          email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
          password: Devise.friendly_token[0,20]
        )
        user.skip_confirmation!
        user.save!
      end
    end

    # Связать identity с пользователем, если необходимо
    if identity.user != user
      identity.user = user
      identity.save!
    end
    user
  end

  def email_verified?
    self.email && self.email !~ TEMP_EMAIL_REGEX
  end
end

Завершение регистрации

Большинство OAuth провайдеров предоставляют нам всю необходимую информацию, но если пользователь зарегистрировался через твитер, или по какой-то иной причине OAuth провайдер не предоставил нам email, или вы просто хотите получить какую-то дополнительную информацию от пользователя, тогда нам надо добавить дополнительный шаг.

config/routes.rb

...
  match '/users/:id/finish_signup' => 'users#finish_signup', via: [:get, :patch], :as => :finish_signup
...

app/controllers/users_controller.rb

Если вы используете Devise confirmable модуль, для подтверждения email, тогда вы возможно захотите пропустить шаг валидации email, что бы не лишать пользователя всей радости регистрации через OAuth. Если вы все же хотите заставить пользователя подтвердить своей email адрес, тогда просто закомментируйте строчку current_user.skip_reconfirmation!

class UsersController < ApplicationController
  before_action :set_user, only: [:show, :edit, :update, :destroy]

  ...

  # GET /users/:id.:format
  def show
    # authorize! :read, @user
  end

  # GET /users/:id/edit
  def edit
    # authorize! :update, @user
  end

  # PATCH/PUT /users/:id.:format
  def update
    # authorize! :update, @user
    respond_to do |format|
      if @user.update(user_params)
        sign_in(@user == current_user ? @user : current_user, :bypass => true)
        format.html { redirect_to @user, notice: 'Your profile was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: 'edit' }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # GET/PATCH /users/:id/finish_signup
  def finish_signup
    # authorize! :update, @user 
    if request.patch? && params[:user] #&& params[:user][:email]
      if @user.update(user_params)
        @user.skip_reconfirmation!
        sign_in(@user, :bypass => true)
        redirect_to @user, notice: 'Your profile was successfully updated.'
      else
        @show_errors = true
      end
    end
  end

  # DELETE /users/:id.:format
  def destroy
    # authorize! :delete, @user
    @user.destroy
    respond_to do |format|
      format.html { redirect_to root_url }
      format.json { head :no_content }
    end
  end
  
  private
    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      accessible = [ :name, :email ] # extend with your own params
      accessible << [ :password, :password_confirmation ] unless params[:user][:password].blank?
      params.require(:user).permit(accessible)
    end
end

app/views/users/finish_signup.html.erb

В этой версии форма просит только email адрес пользователя, вы так же можете добавить и другие необходимые поля, и даже запросить пользователя указать пароль, в таком случае в будущем он сможет логиниться через email+пароль. Заметье, что шаблон ниже использует разметку бутстрапа.

<div id="add-email" class="container">
  <h1>Add Email</h1>
  <%= form_for(current_user, :as => 'user', :url => finish_signup_path(current_user), :html => { role: 'form'}) do |f| %>
    <% if @show_errors && current_user.errors.any? %>
      <div id="error_explanation">
        <% current_user.errors.full_messages.each do |msg| %>
          <%= msg %><br>
        <% end %>
      </div>
    <% end %>
    <div class="form-group">
      <%= f.label :email %>
      <div class="controls">
        <%= f.text_field :email, :autofocus => true, :value => '', class: 'form-control input-lg', placeholder: 'Example: email@me.com' %>
        <p class="help-block">Please confirm your email address. No spam.</p>
      </div>
    </div>
    <div class="actions">
      <%= f.submit 'Continue', :class => 'btn btn-primary' %>
    </div>
  <% end %>
</div>

app/controllers/application_controller.rb

Следующий метод опциональный, но он полезен, если вы хотите убедиться, что пользователь предоставил всю необходимую информацию.

Вы можете использовать его как before_filter, следующим образом: before_filter :ensure_signup_complete, only: [:new, :create, :update, :destroy]

class ApplicationController < ActionController::Base

  ...

  def ensure_signup_complete
    # Убеждаемся, что цикл не бесконечный
    return if action_name == 'finish_signup'

    # Редирект на адрес 'finish_signup' если пользователь
    # не подтвердил свою почту
    if current_user && !current_user.email_verified?
      redirect_to finish_signup_path(current_user)
    end
  end
end

На этом все. Если я что-нибудь упустил, пожалуйста сообщите мне и я быстро поправлю.

Данная статья является переводом. Оригинал

Comments