AuthlogicとOmniAuthを使って複数のアカウントと連携する

[OmniAuth](https://github.com/intridea/omniauth) を使った認証について、 [devise](https://github.com/plataformatec/devise) と組み合わせてログインする記事は割と見かけるのだけれども [Authlogic](https://github.com/binarylogic/authlogic) との組み合わせの記事はあまり見かけなくて、実際にやろうとした時にちょっとばたばたしたので自分用にメモ。

## 1ユーザに複数の認証アカウント

なんで devise でなくて authlogic 使いたかったかっていうと、1ユーザが複数のアカウントでログインできる状態にしたかったから。

そのためには

```rb:app/models/user.rb
class User < ActiveRecord::Model
  has_one :facebook_account
  has_one :twitter_account
end
```

というように、なっていてほしい。
でもって、ログイン処理の対象になるのは FacebookAccount や TwitterAccount でなくて User モデルであってほしい。

最初 devise でそういうことをやろうとしたんだけど、なんだかうまくいかなくて authlogic に乗り換えたわけです。

authlogic にも Facebook とか OpenID とかの Add on があるみたいなんですが、そちらはどんな実装になっているのかコードを読み切れなかったので、使っていません。
認証に関しては、対応サービスも多い OmniAuth 使うことにしました。

## authlogic の準備

### authlogic インストール

```rb:Gemfile
gem 'authlogic'
```

```
$ bundle
```

### User

名前はもちろん、Userでなくてなんでも良い。

```
$ rails g model User persistence_token:string
```

`persistence_token` は authlogic が使うカラムなので、必ず作っておく必要がある。

```rb:app/models/user.rb
class User < ActiveRecord::Base
  acts_as_authentic
end
```

で、生成されたモデルの中で `acts_as_authentic` を呼ぶ。

### UserSession

これも、名前はなんでも構わない。

```rb:app/models/user_session.rb
class UserSession < Authlogic::Session::Base
end
```

`Authlogic::Session::Base` を継承したクラスがログインセッションを扱うモデルになる。

ログインする時は `UserSession.create` だし、ログイン中のセッションを探す時は `UserSession.find` だし。まるで `ActiveRecord::Base` を継承したモデルのような操作感。
`UserSession.new` するとインスタンスが返ってきて、それを `form_for` に渡せるのも便利。

で、これがとても大事なことなんだけれども。
`UserSession.new` や `UserSession.create` は、 `UserSession.create(params[:user_session])` みたいな感じでユーザIDとパスワードを受け取ってログインするんだけれど。
それ以外にも、ログイン対象のモデル(今回なら `User`)のインスタンスを直接受け取ることもできる。
モデルのインスタンスを受け取ってログインできるの、超絶便利！

## omniauth-facebook で認証する

### omniauth-facebook

```rb:Gemfile
gem 'omniauth-facebook'
```

で `bundle` する。

### Facebook アプリの設定

```rb:config/initializers/omniauth_builder.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :facebook, ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_APP_SECRET']
end
```

### FacebookAccount

```
$ rails g model FacebookAccount user:references uid:string access_token:string name:string image:text
```

migrate で `user` と `uid` それぞれ unique index 指定しておくこと。

で、認証後にユーザを特定するためのメソッドを用意する。

```rb:app/models/facebook_account.rb
class FacebookAccount < ActiveRecord::Base
  belongs_to :user

  def self.authenticate(auth)
    account = self.where(uid: auth.uid).take
    account ||= self.new(uid: auth.uid)
    account.access_token = auth.credentials.token
    account.name = auth.info.name
    account.image = auth.info.image

    unless account.user
      account.user = User.create
    end

    account.save!
    account
  end
end
```

uid が見つかればそのアカウントを使うし、見つからなければ新しく作る。
ログイン中のユーザに対してアカウント追加したい場合はこれだとうまくいかないんだけど、今はまだ考えない。

受け取るのは [OmniAuth::AuthHash](https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema) インスタンス。
ついでなので、ユーザの情報を更新もしておく。

## いよいよログイン

### コールバックURL

Omniauth は、認証開始の URL は自動で作られてしまうのだけれども、コールバックは自分で設定する必要がある。

```rb:config/routes.rb
  %w(get post).each do |method|
    send(method, 'auth/:provider/callback' => 'user_sessions#create')
  end
```

### ついでにログアウトも追加しておく

```rb:config/routes.rb
  get 'logout' => 'user_sessions#destroy'
```

### UserSessionsController

```rb:app/controllers/user_sessions_controller.rb
class UserSessionsController < ApplicationController
  def create
    account = account_model.authenticate(env['omniauth.auth'])
    @user_session = UserSession.new(account.user)
    if @user_session.save
      flash[:notice] = 'Authenticate success'
    else
      flash[:error] = 'Authenticate failed.'
    end
    redirect_to root_path
  end

  def destroy
    current_user_session.destroy
    redirect_to root_path
  end

  private
  def account_model
    # params[:provider] に意図していない文字列が入ってないかチェックしたりとか
    Object.const_get("#{params[:provider].capitalize}Account")
  end
end
```

## ログイン中の情報

### ログイン中のユーザ

```rb:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception

  helper_method :current_user_session, :current_user

  private

  def current_user_session
    return @current_user_session if defined?(@current_user_session)
    @current_user_session = UserSession.find
  end

  def current_user
    return @current_user if defined?(@current_user)
    @current_user = current_user_session && current_user_session.user
  end
end
```

これで `current_user` で、ログイン中ユーザが参照できる。

### ログイン中かどうか

current_user を使っても良いんだけど。

```rb:app/controllers/application_controller.rb
  def login?
    !!current_user
  end
```

あると使うし、便利かなって。

### ログイン必須

```rb:app/controllers/application_controller.rb
  def require_login
    unless login?
      raise # 何かちょうど良いエラーでも
    end
  end
```

後はログイン必須にしたいコントローラで。

```rb
class XxxController < ApplicationController.rb
  before_action :require_login
  # ...
```

## ほかサービスのアカウントでも認証できるようにする

例えば、ここから Twitter アカウントでも認証させたくなったら。
FacebookAccount と同じ要領で追加すれば良いだけなので以下略。

あ、これじゃあタイトル詐欺だ、すみません。

`Facebook.authenticate` で、ユーザがログイン中の時のことを考慮しないといけないとか。
後、アカウント単位で連携を解除できるようにしたいとか。
そういうところは、ちょこっと気をつけないといけないと思うので、頑張ってください。