もふもふ技術部

IT技術系mofmofメディア

Rails5 + Doorkeeper + DeviseでOAuthサーバーを実装する

最近ちょっとAmazon Echo Alexaのスキル開発にハマってまして、「【Amazon Echo入門#6】AlexaちゃんとTwitterアカウントを連携してみる」というエントリを書いたりしてます。

どうやらAlexaと既存のWEBサービスのユーザーアカウントを連携させるにはoauthのプロバイダを実装しないといけないっぽくて、Railsでやってみようと思った。

oauthのgemとRails5の問題?っぽいところでめっちゃハマったので、犠牲者を増やさないためにもここに記しておきます。

参考リンク

OAuthサーバ側

新しいRailsアプリケーションを生成する。名前はなんでも良いです。

$ rails new trash-day-server

Gemfile

gem 'devise'
gem 'doorkeeper'
gem 'omniauth'
gem 'oauth2'

Deviseのインストールやテーブルの生成。

$ bundle install
$ rails g devise:install
$ rails g devise user
$ rake db:migrate

Doorkeeperのインストールやテーブルの生成。

$ rails generate doorkeeper:install
$ rails generate doorkeeper:migration

デフォルトままでRails5だとmigrate時にエラーがでるのでmigrationファイルを編集する。

20171118031209_create_doorkeeper_tables.rb

class CreateDoorkeeperTables < ActiveRecord::Migration[4.2]
$ rake db:migrate

config/initializers/doorkeeper.rb

resource_owner_authenticator do
  current_user || warden.authenticate!(scope: :user)
end

クライアント側からのユーザー情報取得用の処理を実装しておく

app/controllers/api/v1/api_controller.rb

class Api::V1::ApiController < ApplicationController
  private
  def current_resource_owner
    User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
  end
end

app/controllers/api/v1/users_controller.rb

class Api::V1::UsersController < Api::V1::ApiController
  before_action :doorkeeper_authorize!
  respond_to :json

  def me
    respond_with current_resource_owner
  end
end

config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  use_doorkeeper
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html

  namespace :api do
    namespace :v1 do
      get '/me' => 'users#me'
    end
  end

  root to: 'home#show'
end
$ rails s

http://localhost:3000/oauth/applicationsにアクセスするとログイン画面が表示されるので、適当にサインアップし、再度同じURLにアクセスする。

アプリケーションを登録して、application_idとsecretを発行する。

Callback urls: http://localhost:3001/users/auth/doorkeeper/callback

application

コマンドでテストしてみる。

$ rails c

> require 'oauth2'
> client_id= 'b1560de64a5faf96d6f44e1ca32c0473d88260cc3b6472c501acfb1180821d41'
> client_secret= 'eb49bd18ab22f45341686c86e302d69cc58c371aaf0ceb24139a793a31a44355'
> site = 'http://localhost:3000'
> redirect_uri = 'http://localhost:3001/users/auth/doorkeeper/callback'

> client = OAuth2::Client.new(client_id, client_secret, :site => site)
> login_url =  client.auth_code.authorize_url(redirect_uri: redirect_uri)

成功すれば問題なし。サーバ側はこれでOK。

クライアント側

新しいRailsアプリケーションを生成する。名前はなんでも良い。

$ rails new trash-day-client
$ cd trash-day-client

Gemfile

gem 'devise'
gem 'omniauth'
gem 'omniauth-oauth2'
gem 'oauth2'
$ bundle install

Deviseのインストールとテーブルの生成。

$ rails g devise:install
$ rails g devise user
$ rake db:migrate

OAuthの使用する場合は追加のフィールドが必要。

$ rails g migration AddUidToUser
class AddUidToUser < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :uid, :string
    add_column :users, :provider, :string
  end
end
$ rake db:migrate

サーバ側で取得したapp_idとsecretをDeviseの設定に追記する。

config/initializers/devise.rb

require File.expand_path('lib/omniauth/strategies/doorkeeper', Rails.root)
Devise.setup do |config|
  config.omniauth(:doorkeeper, 'b1560de64a5faf96d6f44e1ca32c0473d88260cc3b6472c501acfb1180821d41', 'eb49bd18ab22f45341686c86e302d69cc58c371aaf0ceb24139a793a31a44355')
end

lib/omniauth/strategies/doorkeeper.rb

module OmniAuth
  module Strategies
    class Doorkeeper < OmniAuth::Strategies::OAuth2
      option :name, :doorkeeper
      option :client_options, site: 'http://localhost:3000', authorize_path: '/oauth/authorize'

      uid { raw_info['id'] }

      info do
        { email: raw_info['email'] }
      end

      def raw_info
        @raw_info ||= JSON.parse(access_token.get('api/v1/me').response.body)
      end

      def callback_url
         full_host + script_name + callback_path
      end
    end
  end
end

認証成功後のコールバック処理を実装する。

app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def doorkeeper
    @user = User.find_or_create_with_doorkeeper(request.env['omniauth.auth'])

    if @user.persisted?
      sign_in(@user)
      set_flash_message(:notice, :success, kind: 'doorkeeper') if is_navigational_format?
      redirect_to '/'
    else
      session['devise.doorkeeper_data'] = request.env['omniauth.auth']
      redirect_to root_url, alert: 'Doorkeeper ログインに失敗しました'
    end
  end
end

app/models/user.rb

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

  def self.find_or_create_with_doorkeeper(auth)
    user = self.find_by(provider: auth.provider, uid: auth.uid )
    return user unless user.nil?

    self.create(
      email: auth.info.email,
      provider: auth.provider,
      uid: auth.uid,
      password: Devise.friendly_token[0, 20]
    )
  end
end

ポート3001番で起動する。

$ rails s -p 3001

http://localhost:3001/users/sign_inを開くとログイン画面が開くので「Sign in with Doorkeeper」をクリック。

authorize

Authorizeをクリックして、ログインが成功することを確認。

success

ハマりどころ

Rails5とomniauth-oauth2の組み合わせで発生するっぽい?バグにハマりました。

どこをどう見てもサンプルと同じように実装しているのに、サーバからクライアント側にcallbackするタイミングでエラーになってしまいます。

クライアント側のエラーログ

E, [2017-11-18T13:15:56.492767 #32292] ERROR -- omniauth: (doorkeeper) Authentication failure! invalid_credentials: OAuth2::Error, invalid_grant: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
{"error":"invalid_grant","error_description":"The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."}
Processing by Users::OmniauthCallbacksController#failure as HTML
  Parameters: {"code"=>"72da2b48acfee81df7325c175a31b273fd3b1a368d4995d2b626e30e357339c2", "state"=>"56ec955d52f6935b846a80604c30ff2ce5722caeb7eb27ce"}
Redirected to http://localhost:3001/users/sign_in
Completed 302 Found in 8ms (ActiveRecord: 0.0ms)

doorkeeperのソースコードを読んでいったところ、redirect_uriをバリデーションしている箇所があるのですが、GETパラメータ付きのURLと付いていないURLで比較してvalidationしていたためredirect_uriが無効だよってエラーになってたみたい。

クライアント側のstrategies/doorkeeper.rbに以下を追記してオーバーライドすれば解消します。

def callback_url
  full_host + script_name + callback_path
end

このバグに4時間近く持ってかれたわ。