ぴよログ

↓に移転したのでこっちは更新されません、多分。

Deviseで生成したユーザーの更新フォームに自前のフィールドを追加する

Rails4+Deviseの話。Deviseでユーザーを作るとデフォルトでユーザー情報更新フォームがついてくる。これはbootstrapでスタイル付けしてあるものだけど、基本はこんな感じになる。

このUserモデルに対して別のフィールドをいくつか追加した上で、同じフォームで更新可能にするにはどうしたらいいのか、というのが今日のテーマ。

例えば名前とか、表示名とか、住所とか、そういう情報がアプリケーションに依っては必要になってくると思う。

今回はnameというカラムをUserに持たせることにする。なお、add_column :users, :name, :stringみたいなマイグレーションは既に済んでいるとする。

DeviseのカスタムViewを用意する

% rails generate devise:views

このコマンドによってアプリケーションディレクトリ内にdevise用のビューが生成される。ちなみに先ほどのスクリーンショットは生成したビューを日本語化したもの。

これをやっておくことでDeviseのコントローラが表示に使うビューを自由に変更することができる。

registrations/edit.html.hamlを編集する

erbの人は随時読み替えてもらえばOK。app/views/devise/registrations/edit.html.hamlname用のフィールドを追加する。

例えばこんな感じで3行ほど追加する。

  %h2
    アカウント情報更新
  = form_for(resource, :as => resource_name, :url => registration_path(resource_name), :html => { :method => :put, role:"form" }) do |f|
    = devise_error_messages!
    -# この3行を追加 ##################################################
    .form-group
      = f.label :name, "名前"
      = f.text_field :name, :autofocus => true, class:"form-control"
    -# ##############################################################
    .form-group
      = f.label :email, "Eメール"
      = f.email_field :email, class:"form-control"
    - if devise_mapping.confirmable? && resource.pending_reconfirmation?
      .form-control
        Currently waiting confirmation for: #{resource.unconfirmed_email}
    .form-group
      = f.label :password, "パスワード"
      %i (パスワードを変更しない場合は空欄のままにしてください)
      = f.password_field :password, :autocomplete => "off", class:"form-control"
    .form-group
      = f.label :password_confirmation, "パスワード(再入力)"
      = f.password_field :password_confirmation, class:"form-control"
    .form-group
      = f.label :current_password, "現在のパスワード"
      %i (更新するために現在のパスワードが必要です。)
      = f.password_field :current_password, class:"form-control"
    .form-group
      = f.submit "更新", class:"btn btn-primary"
  %h3 退会
  %p
    退会してユーザー情報を削除しますか? #{button_to "退会する", registration_path(resource_name), :data => { :confirm => "よろしいですか?" }, :method => :delete, class:"btn btn-danger"}
  = link_to "戻る", :back

さあこれでフォームができたからもう動くだろうと思ってみると実際にはそうはいかない。Rails4から導入されているStrong Parametersの概念により、許可されていないnameを更新できないというエラーが発生してしまうからだ。

Devise用のコントローラを作る

nameの更新を許可するため、Deviseのコントローラを継承したものを用意する必要がある。このコントローラでは、更新のためのupdateアクションでのみnameパラメータを追加でpermitするという処理を行い、それ以外はベースクラスであるDevise::RegistrationsControllerでDevise従来の処理を行う。

# app/controllers/users/registrations_constroller.rb
class Users::RegistrationsController < Devise::RegistrationsController

  before_action :configure_permitted_parameters, only: [:update]

  private 
  
  def configure_permitted_parameters
    devise_parameter_sanitizer.for(:account_update) do |u|
      u.permit(:name,
        :email, :password, :password_confirmation, :current_password)
    end
  end  
 
end

パラメータの許可はdevise_parameter_sanitizerというDevise用のオブジェクトを経由して行われていて、この部分で:nameも許可対象として指定してあげれば良い。

そしてconfig/routes.rbを編集し、新しく作ったUsers::RegistrationsControllerを使うようにルートを書き換えればよい。

# before
devise_for :users

# after
devise_for :users, controllers: {
  registrations: 'users/registrations'
}

完成!

RailsでDeviseを使ったときのログイン周りの画面遷移

Deviseを使ってユーザー認証機能を作ったときに、もうちょっとよくするための方法メモ。Deviseは普通の実装するとログイン後は/にリダイレクトされる。それを別のURLにする方法はここに書いた。

Deviseでログイン後のURLを変える方法 - PILOG

では、ログインに必要なページのURLに直接アクセスがあったときはどうするか。流れとしてはこうなるのが望ましい。

  1. ログインが必要なページにアクセスがある
  2. ユーザーにログインフォームを見せる
  3. ログインが完了したら最初にアクセスしたURLへリダイレクト

ログインが必要かどうかはコントローラレベルで制御する。これはまあよくやるやつ。

# application_controller.rb
class ApplicationController < ActionController::Base
private
  def sign_in_required
    redirect_to new_user_session_url unless user_signed_in?
  end
end

# hoge_controller.rb
class HogeController < ApplicationController
  before_action :sign_in_required
  def index
    # ...
  end

  def ...
end 

全てのコントローラで共有するために、ApplicationControllerにsign_in_requiredというメソッドを定義する。これにはログインしていなければログインページヘリダイレクトする、と書かれている。このsign_in_requiredは各コントローラのbefore_actionで使い、指定したアクションを要ログインにすることができる。

ここまでの実装だと1と2までは実現できているが、最初に開こうとしたURLへのリダイレクトは実現されておらず、ユーザーとしては非常に面倒くさい。リダイレクト部分を実現するためには最初のURLを覚えておいて、ログイン後のURLを/ではなく別のURLに差し替えてあげる必要がある。

そこでApplicationControllerをさらに拡張する。

class ApplicationController < ActionController::Base
  before_action :store_location

  private

  def sign_in_required
    redirect_to new_user_session_url unless user_signed_in?
  end

  def store_location
    return unless request.get? 
    if (request.path != "/users/sign_in" &&
        request.path != "/users/sign_up" &&
        request.path != "/users/password/new" &&
        request.path != "/users/sign_out" &&
        !request.xhr?)
      session[:previous_url] = request.fullpath 
    end
  end

  def after_sign_in_path_for(resource)
    session[:previous_url] || root_path
  end  

end

store_locationでURLを覚えておき、after_sign_in_path_forで覚えておいたURLを返すということをしている。そしてstore_locationを全てのアクションの【前】に呼び出している。

詳しいことは↓に書かれているが、ここの例ではstore_locationafter_filterに指定、つまり各アクションの後に呼んでいる。今回の例ではアクション中にログインページにリダイレクトされてしまうため、覚えておくURLが適切なものではなくなってしまう。そこでbefore_actionを指定することでなんとか実現している。

How To: Redirect back to current page after sign in, sign out, sign up, update · plataformatec/devise Wiki

Deviseでログイン後のURLを変える方法

RailsでDeviseを使っているときの話。いくらでも情報がある話ですが、自分のためにメモります。

http://hostname/ にログインボタンを置いておいて、ログイン後はhttp://hostname/memberとかに移動したいケースがあると思いますが、これは比較的簡単に書けます。

# application_controller.rb

ApplicationController
  # ...
  def after_sign_in_path_for(resource)
    member_path # ログイン後に遷移したいパス
  end
end

応用例

Deviseのドキュメントからの引用です。

after_sign_in_path_forログイン前のパスを覚えておいて、ログイン後にはそちらのURLに戻るという遷移を実現できます。

ありそうなシチュエーションとしては、FacebookとかでシェアされたコンテンツのURLを踏んだ人がサインアップしたらそのコンテンツのURLに飛ぶみたいな場合ですね。

次のようなコードで実現できるようです。要はリクエストのたびにフルパスの情報をセッションに残しておいて、ログイン後のURLは保存しておいた値を使うというものです。

# application_controller.rb
after_filter :store_location

def store_location
  # store last url - this is needed for post-login redirect to whatever the user last visited.
  if (request.fullpath != "/users/sign_in" &&
      request.fullpath != "/users/sign_up" &&
      request.fullpath != "/users/password" &&
      !request.xhr?) # don't store ajax calls
    session[:previous_url] = request.fullpath 
  end
end

def after_sign_in_path_for(resource)
  session[:previous_url] || root_path
end

Railsでログインとは別に複数のサービスとの連携を行う方法

ログインはメールアドレスでさせておいてログイン後に各種SSOサービスとの連携を済ませる方法を考えてみます。

まず、お手軽にやりたいのでDeviseとOmniauthを使うのは確定です。omniauth-facebookomniauth-twitterなどを使うと簡単に連携できますよね。

ところが、よくあるDevise+Omniauthのサンプルを見ると大体ユーザーモデルにOAuthの結果を結びつけていることが多いです。ユーザー1人に対してサービス1種類が関連づけられるみたいな。

でも複数のサービスと接続したいということもありそうです。というか、実際多くのサービスでログインしたあとで他のサービスとの関連付けを行ったりできます。QiitaとかChatworkとか、Gunosyとかもそうだったかも。

モデルを分けます

ユーザーモデルにサービスと認証したフィールドを持たせるからいけないのであって、ユーザーは独立して存在し認証情報は別モデルとしてユーザーと関連づければ、複数のサービスとの連携も可能です。

複数のサービスを区別なく扱える認証モデルみたいなのを作って、ユーザーモデルと1対多の関係をもてばよさそう。

実はまだ試していないので、今から実装しつつ試してみます。

↑の方法でできました

サンプルアプリケーションはこちらにあります。

xoyip/multi-oauth

全体の流れ

  1. gemを追加
  2. omniauthの設定ファイルを作る
  3. Modelを作る
  4. Controllerを作る
  5. Viewを作る
  6. ブラウザで確認!

gemを追加する

Gemfileに必要なGemを追加します。

# Gemfile
gem 'devise'
gem 'omniauth'
gem 'omniauth-hatena'
gem 'omniauth-github'
gem 'omniauth-twitter'
gem 'figaro'
gem 'haml-rails’

ユーザー認証のためのDeviseとOAuth連携のためのomniauth各種は当然。ConsumerKey等を隠すためのfigaroとHamlを入れています。

今回はTwitterGithubはてなとの連携をしてみました。

omniauthの設定ファイルを作る

omniauth用の設定ファイルでConsumerKey等を指定してあげます。Keyはそれぞれ以下のリンクから発行可能。

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :hatena,
  ENV['HATENA_CONSUMER_KEY'],
  ENV['HATENA_CONSUMER_SECRET'],
  {
    scope: "write_public,read_public,write_private,read_private"
  }

  provider :twitter,
  ENV['TWITTER_CONSUMER_KEY'],
  ENV['TWITTER_CONSUMER_SECRET']

  provider :github,
  ENV['GITHUB_CONSUMER_KEY'],
  ENV['GITHUB_CONSUMER_SECRET']
end 

Figaroを使っているので、KeyやSecretはconfig/application.ymlに書きます。

# config/application.yml
development:
  HATENA_CONSUMER_KEY: YOUR_HATENA_KEY
  HATENA_CONSUMER_SECRET: YOUR_HATENA_SECRET
  TWITTER_CONSUMER_KEY: YOUR_TWITTER_KEY
  TWITTER_CONSUMER_SECRET: YOUR_TWITTER_SECRET
  GITHUB_CONSUMER_KEY: YOUR_GITHUB_KEY
  GITHUB_CONSUMER_SECRET: YOUR_GITHUB_SECRET 

Modelを作る

続いて、ユーザーモデルと認証モデルを作成します。

$ rails g devise:install
$ rails g devise User # ←コメントで指摘をいただいたので修正しました  
$ rails g model Auth uid:string provider:string user_id:integer

ユーザーモデルと認証モデルはこんな感じ。ユーザーモデルは複数のAuthモデルと関連することができます。

# user.rb
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable, :confirmable,
         :recoverable, :rememberable, :trackable, :validatable

  has_many :auths
end
# auth.rb
class Auth < ActiveRecord::Base
  belongs_to :user
end 

Controllerを作る

ログインなどのリンクを置くためのルートページ用コントローラと、認証を取り扱うコントローラを作っておきましょう。

$ rails g controller home index
$ rails g controller auth create destroy

HomeControllerでユーザーが触るページを作ります。ログイン済みの場合は現在連携中のサービスをArrayで持っておき、あとでViewで使用します。

# home_controller.rb
class HomeController < ApplicationController
  def index
    if user_signed_in?
      @providers = current_user.auths.pluck(:provider)
    end
  end
end

AuthControllerでは認証後のcallback先としてのcreateと、連携解除のためのdestroyメソッドを用意します。

# auth_controller.rb
class AuthController < ApplicationController
  before_filter :authenticate_user!
  def create
    auth = request.env["omniauth.auth"]
    uid = auth["uid"]
    provider = auth["provider"]
    unless Auth.find_by_uid_and_provider(uid,provider)
      Auth.create(uid:uid, provider:provider, user_id:current_user.id)
    end
    redirect_to root_url
  end

  def destroy
    provider = params[:provider]
    auth = Auth.find_by_provider_and_user_id(provider,current_user.id)
    auth.destroy
    redirect_to root_url
  end
end

createではauth_hashから認証オブジェクトを生成しユーザーと関連づけ、destroyでは認証オブジェクトを削除します。

routingの設定も必要なので、config/routes.rbは次のようにします。

# config/routes.rb
  root "home#index"
  devise_for :users
  get "/auth/:provider/callback" => "auth#create"
  delete "/auth/destroy/:provider" => 'auth#destroy', as: :destroy_connection 

Viewを作る

最後にViewを作ります。

ログインしていない場合はログインリンクとサインアップリンクを、ログイン済みの場合は各種サービスとの連携/連携解除リンクとログアウトボタンを表示します。

さきほどhome#indexで取っておいた@providerはここで使っています。

%h1 multi auth
- if user_signed_in?
  %ul
    %li= link_to "ログアウト", destroy_user_session_path, method: :delete
    - if @providers.include? "twitter"
      %li
        twitterと接続済み
        = link_to "接続解除", destroy_connection_path(:twitter), method: :delete, data:{confirm:"Sure?"}
    - else
      %li= link_to "twitterと接続", "/auth/twitter"
    - if @providers.include? "github"
      %li
        githubと接続済み
        = link_to "接続解除", destroy_connection_path(:github), method: :delete, data:{confirm:"Sure?"}
    - else
      %li= link_to "githubと接続", "/auth/github"
    - if @providers.include? "hatena"
      %li
        hatenaと接続済み
        = link_to "接続解除", destroy_connection_path(:hatena), method: :delete, data:{confirm:"Sure?"}
    - else
      %li= link_to "hatenaと接続", "/auth/hatena"
- else
  %ul
    %li= link_to "ログイン", new_user_session_path
    %li= link_to "サインアップ", new_registration_path(:user) 

ブラウザで確認!

こんな感じになりました。

multioauth

まとめ

今回作ったAuthモデルには認証によって得ることができる情報うちuidproviderの2つの情報しか残していませんでしたが、ここにアクセストークンなどをいい感じで置いておくことでサービスのAPIを叩いていろいろな連携を行うことができるようになります。

超参考リンク

id:hirayosさんのOmniAuthで認証機能を作る - RuntimeError