あすたぴのブログ

astap(あすたぴ)のブログ

Deviseに独自のstrategyを入れる

結論

deviseのデフォルトに沿わない場合は、deviseはいらない。

結局 ほぼwardenだけの話

config/initializers/devise.rb

require 'devise/strategies/media_authenticatable'
~~~
  config.warden do |manager|
    manager.default_strategies(scope: :user).unshift :user_authenticatable
    manager.strategies.add(:user_authenticatable,
                           Devise::Strategies::UserAuthenticatable)
  end

app/models/devise/strategies/user_authenticatable.rb

require 'devise/strategies/authenticatable'
module Devise
  module Strategies
    class MediaAuthenticatable < Authenticatable
      def authenticate!
      # 認証ロジックをかく
      end

      def valid?
      # 認証をする条件を書く
      end
    end
  end
end

これだけかな。

残念なところ

Devise.add_moduleの流儀に従って、モジュールを追加し、strategyだけを追加したくても、モデルが必須だったり(空実装でも定義だけ必要になる)、 DeviseControllerを継承したコントローラーを作ろうとして、routingをadd_moduleから設定しようとした場合、ルーティングを設定するメソッドの実装がActionDispatch::Routingを拡張して書く必要があったり。

まとめ

なんか、そこまでがんばり必要がなさすぎるな。と。 moduleは自分で拡張するものではないんだろうな。

wardenに直接追加するんだったら deviseとは?となる。 もやもやするライブラリだ。

Railsの認証Gem、Deviseとはなんなのか

目的

deivseについての理解を深めて、デフォルト動作ではない認証を作れるようにする。

背景

用意されすぎているライブラリは苦手。 挙動を変えたい場合に出来ること、出来ないことがわかりにくい。 理解したいってこと。

deviseとは

RailsEngineで作られている、Railsの認証ライブラリ。 サクっと認証を用意できる。ために、色々とデフォルト動作が決まっている。 手厚いサポート(機能)があるが、実際そんなにいらないことも多いはず。 ただ、有名すぎてこれを使っちゃうだろう。 wardenをみたあとでは、別にwardenでもいいよね。って気持ちはある。

だがデフォルトの動作を理解し自分の必要な部分だけを残し、ほかはいい感じにカスタマイズが出来るのであれが、deviseを使ったほうが楽だろうなというところ。 それが可能か検証する。

defaultで含まれているモジュール

  • Database Authenticatable
    • 一般的なやつ。passwordで認証をする。DBには暗号化されたパスワードを保存してる
  • Omniauthable
    • omniAuth gemとの連携
  • confirmable
    • 登録時に、確認メールを送る。ログイン時に確認済がどうかを判断する?
  • Recoverable
    • パスワードリセット
  • Registerable
    • 登録、編集、削除
  • Rememberable
    • ユーザーを覚えておくためのトークンを発行したりする。
    • remember me のやつ
  • Trackable
    • サインインしたときのユーザーのIPとかとっておく
  • Timeoutable
    • 時間内にアクティブではないユーザーのセッションを期限切れにする
  • Validatable
    • mailアドレスとパスワードで認証するってことかな?
  • Lockable
    • 指定回数、ログインに失敗したらアカウントをロックする

揃っている。 これらは、(User)モデルで使用するかどうかを指定できるため、簡単につけ外しができる。 ※Tableにカラムがあれば。カラムを消したりしたりすると簡単ではないが(めんどい)。

devise module

Devise.add_moduleメソッドでモジュールを登録できる。 optionは以下。

※以下のリストは正直全然わかってない。

  • model
    • moduleで使用するモデルへのぱす
  • controller
    • moduleで使うコントローラー
  • route
    • moduleの使う route helper
  • strategy
    • strategyを持っているか。 true or false
  • insert_at
    • moduleが含まれる位置

model以外はすべて bool 値でもよく、module名と同じになる。

Deviseの既存のモジュールは lib/devise/modules.rb で読み込まれている。

Devise.with_options model: true do |d|
  # Strategies first
  d.with_options strategy: true do |s|
    routes = [nil, :new, :destroy]
    s.add_module :database_authenticatable, controller: :sessions, route: { session: routes }
    s.add_module :rememberable, no_input: true
  end

  # Other authentications
  d.add_module :omniauthable, controller: :omniauth_callbacks,  route: :omniauth_callback

  # Misc after
  routes = [nil, :new, :edit]
  d.add_module :recoverable,  controller: :passwords,     route: { password: routes }
  d.add_module :registerable, controller: :registrations, route: { registration: (routes << :cancel) }
  d.add_module :validatable

  # The ones which can sign out after
  routes = [nil, :new]
  d.add_module :confirmable,  controller: :confirmations, route: { confirmation: routes }
  d.add_module :lockable,     controller: :unlocks,       route: { unlock: routes }
  d.add_module :timeoutable

  # Stats for last, so we make sure the user is really signed in
  d.add_module :trackable
end

これらは、(User)modelで devise メソッドで定義することで使用できるようになる。

devise :database_authenticatable, :trackable, :omniauthable, omniauth_providers: [:google_oauth2]

このdeviseメソッドはどこからきているのか。

lib/devise/orm/active_record.rb で、active_recordが読み込まれたタイミングのcallbackで、Devise::Models が extends されている。

require 'orm_adapter/adapters/active_record'

ActiveSupport.on_load(:active_record) do
  extend Devise::Models
end

deviseメソッドはなにをしているか。

include Devise::Models::Authenticatable

Models::Authenticatableクラスのinclude

deviseメソッドで指定されたモジュール群の、 include ClassMethods が定義されていた場合、 extends もする。

たとえば、 database_authenticatable の場合だとこんな感じで定義されていて、 Devise::Models.config を呼んで、find_for_database_authentication をモデルに定義する。

      module ClassMethods
        Devise::Models.config(self, :pepper, :stretches, :send_email_changed_notification, :send_password_change_notification)

        # We assume this method already gets the sanitized values from the
        # DatabaseAuthenticatable strategy. If you are using this method on
        # your own, be sure to sanitize the conditions hash to only include
        # the proper fields.
        def find_for_database_authentication(conditions)
          find_for_authentication(conditions)
        end
      end

このモデルに定義したモジュールたちがどう使われるのか。を見ていきたい。

lib/devise/rails/routes.rbdevise_for メソッドから見ていく。

このメソッドは config/routes.rb で以下のような形で呼ぶことになる。

devise_for :users, controllers: { sessions: 'users/sessions' }

devise_forの責任としては、指定したリソース(モデル)を使用した認証関連のルーティングの設定をすること。

devise_for :users とだけ定義した場合は、 User モデルを見て、ルーティングを設定する。 ルーティングの他は Devise::Mapping の生成をする。

mappingはscopeごとに生成され、以下のようなものになる。

mapping = Devise.mappings[:user]
mapping.name #=> :user
mapping.as   #=> "users"
mapping.to   #=> User
mapping.modules  #=> [:authenticatable]

認証ロジックを自作したい。

ここまで見てきたわかったことは、 deviseに処理を追加したい場合は、モジュールを作る。 モジュールには、スコープのモデルに追加したいメソッド、 スコープを使用してのルーティングの追加、 ルーティングに使用するコントローラーの追加、 スコープを認証する際のstrategyの定義ができる。

単純に考えると、モジュールを作るかー。となりそうなものだが、 database_authenticatable を継承したりしていい感じに出来ないの?って思ったりする。

そこらへんを見ていきたい。

モデルに対して、 database_authenticatable を deviseメソッドで指定すると、以下のルーティングが作成される。

    new_user_session GET      /sign_in(.:format)  devise/sessions#new
        user_session POST     /sign_in(.:format)  devise/sessions#create
destroy_user_session DELETE   /sign_out(.:format) devise/sessions#destroy

Deviseに用意されているControllerは DeviseController を継承している。 DeviseControllerApplicationController を継承している。

DeviseControllerは warden へアクセスするヘルパーメソッドが用意されている。

自作するために。

strategyモジュールを作って、モデルに読み込ませる。 認証をするControllerでDeviseControllerを継承し、 warden.authenticate , sign_in 等のヘルパーメソッドを呼ぶ。

こんな感じ。 というかこれだけの事をするのに devise が必要か?というといらない。

devise とはなんだったのか

wardenのヘルパー。

deviseの決めた挙動での認証システムの高速な実装。

strategy, routing等まで含めた認証実装の簡易化。

たとえば、社内システムでSSOを実装する。となった場合に、1度実装すれば簡単に使いまわすことが可能。 という感じだろうか。

まとめ

読んでいないソースが大半なので、確定ではないが、 deviseの標準を大きく外れるようであれば、無理にdeviseを使う必要もないとう判断になった。 wardenで十分ぽい。

RackとWardenについて

Rackについてはこちらをみた。

じぶんの解釈

Rackとはwebサーバーを作る際のお作法(インターフェース)。

インターフェースに従ってWebサーバーを作成することで、サーバーを交換可能。 ミドルウェアを共通化できる。 という感じだろうか

Warden

General Rack Authentication Framework

Rackの上で構築される認証のフレームワーク。 有名なdeviseはwardenのラッパーになる。

Rackミドルウェアとして作られている。 session情報は env['rack.session'] Wardenは env[warden] にオブジェクトを入れる。 このオブジェクトを用いて、認証を行うことができる。

env['warden'].authenticated?               # Ask the question if a request has been previously authenticated
env['warden'].authenticated?(:foo)         # Ask the question if a request is authenticated for the :foo scope
env['warden'].authenticate(:password)      # Try to authenticate via the :password strategy.  If it fails proceed anyway.
env['warden'].authenticate!(:password)     # Ensure authentication via the password strategy. If it fails, bail.

認証が成功されると、 user オブジェクトへアクセスが出来る。 これは nil 以外なら何でもよい。

env['warden'].authenticate(:password) この記述ではパスワードで認証を行っている。 この、どのように認証を行うか。を strategy と呼んでいて、これを拡張して独自で認証機構を実装することができる。

strategy

Rackアプリからwadenを利用していく

ためしにやっていく

Rackアプリ

config.ru というファイル名で以下を作成

require 'rack'


app = Proc.new do |env|
    ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

run app

rackup コマンドで config.ru を読み込んでサーバーを起動する。

vagrant-ubuntu-trusty-64% rackup
Puma starting in single mode...
* Version 3.8.2 (ruby 2.3.0-p0), codename: Sassy Salamander
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://localhost:9292
Use Ctrl-C to stop
vagrant-ubuntu-trusty-64% curl http://localhost:9292/
A barebones rack app.%

curlでリクエストを投げると、レスポンスが確認できる。

wardenの追加

require 'rack'
require 'warden'

app = Proc.new do |env|
    ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

failure_app = Proc.new do |env|
    ['401', {'Content-Type' => 'text/html'}, ['fail.']]
end

use Rack::Session::Cookie, :secret => "replace this with some secret key"
use Warden::Manager do |manager|
  manager.default_strategies :password, :basic
  manager.failure_app = failure_app
end

run app

wardenを使う際には、デフォルトのstrategyと認証が失敗した場合に呼ばれる、rack endpoint を指定する。

sessionシリアライズロジックと認証するユーザー

config.ru の run.app の前あたりに以下を入れる

Warden::Manager.serialize_into_session do |user|
  user.id
end

Warden::Manager.serialize_from_session do |id|
  User.get(id)
end

Userクラスの定義もその下らへんに書く。

class User
  attr_accessor :id, :password

  USER_MAPPING = {
    a: 'hogehoge',
    b: 'mogemoge',
  }

  def initialize(id, pass)
    self.id = id
    self.passworkd = pass
  end

  def self.get(id)
    new(id, USER_MAPPING[id.to_sym])
  end
end

これで認証のもとなるUserの準備ができたので、認証ロジックを作りたいとおもう。

認証をしてみる

strategyは複数設定が可能。 その中の1つでも成功するか、すべての戦略を通るか、戦略がfailするまで呼ばれる。

strategyWarden::Strategies::Base を継承して作られる。 実装が必要なのは、 valid?authenticate! の2つ。

valid?

valid? メソッドは strategy の実行条件(ガード)。 宣言シない場合、strategy は常に実行される。

上記の例では、 username, passwordのどちらか1つでもあれば戦略を実行します。

authenticate!

認証ロジック。 利用可能なメソッドがいくつかある。

  • request
  • session
  • params
  • env

  • halt!

    • strategyの実行を止める。後続のstrategyは実行されない
  • pass
    • strategyを実行しない
  • success!
    • 認証に成功。userオブジェクトをsessionに格納し、ログインする。halt!する
  • fail!
    • 認証失敗。halt!
  • redirect!
    • 別のURLへリダイレクトする。halt!
  • custom!

    • わからん
  • headers

    • headerを設定する
  • errors
    • errorオブジェクトへのアクセス

では、実際に strategy を書いてみる。 こんな感じのコードをまた run.app の前あたりにいれる。

Warden::Strategies.add(:password) do

  def valid?
    params['id'] || params['password']
  end

  def authenticate!
    if User.authenticate(params['id'], params['password'])
      success! User.get(params['id'])
    else
      fail!('failll')
    end
  end
end

User.authenticateを実装する

  def self.authenticate(id, pass)
    return false unless password = USER_MAPPING[id.to_sym]
    !!(password == pass)
  end

Rack endpointを修正する

app = Proc.new do |env|
  env['warden'].authenticate!
  ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

やってみる

vagrant-ubuntu-trusty-64% curl "http://localhost:9292/?id=a&password=hogehoge"
A barebones rack app.%

vagrant-ubuntu-trusty-64% curl "http://localhost:9292/?id=b&password=hogehoge"
fail.%

id=a, password=hogehogeは認証が成功し、id=b, password=hogehogeはfailしている。

scope

複数のユーザー(タイプ?)をログイン可能にする。

defautユーザーとAdminユーザーで認証ロジックを変えたい場合など、 スコープ(defalt, admin)でそれぞれ定義が可能。

callbacks

  • after_set_user
  • after_authentication
  • after_fetch
  • before_failure
  • after_failed_fetch
  • before_logout
  • on_request

これらのタイミングにcallbackを仕込める。

Warden::Manager.after_set_user do |user, auth, opts|
  unless user.active?
    auth.logout
    throw(:warden, :message => "User not active")
  end
end

set_userは認証に成功し、env[‘warden’].user にユーザーオブジェクトを入れる時のこと。 そのあとに上記のcallbackが呼ばれる。

まとめ

これだけ見ると非常にシンプル。 deviseを理解するために、wardenを見た。 次はdeviseを理解していくぞ。

CircleCi2.0が最高かもしれん

ローカルの環境をdockerで整えたので、次はCI環境を整備する。 dockerで構築したのだから、テストもdockerにしたい。 ちょうどCircleCi2.0がBetaテスト中で、さらにネイティブでdockerサポートが入っている。 これは試すしかないということでやってみた。

CricleCi2.0を使う

プロジェクトのホームディレクトリに .circleci ディレクトリを作成。 config.yml を作成する

config.yml の中身

version: 2
jobs:
  build:
    working_directory: ~/app
    docker:
      - image: hogehoge/mogemoge:latest
    steps:
      - checkout
      - run: echo "hello world"

これでCircleCiを通すだけの準備は完了する。 imageはDockerHubのpublicディレクトリであれば普通に取ってこれる。privateリポは別途設定が必要。 config.yml の設定は、try & error で直して、pushして、CircleCI流して〜ってやるとかなり消耗する。 複雑なテストをやろうとすると死ねる。

CircleCi2.0ではローカルでほぼ同様のことが可能になっている。 (docker executorのみ使える。従来のCircleCiの主流であったmachine executorは使えない)

circleci コマンド

install

vagrant-ubuntu-trusty-64% sudo curl -o /usr/local/bin/circleci https://circle-downloads.s3.amazonaws.com/releases/build_agent_wrapper/circleci && sudo chmod +x /usr/local/bin/circleci

usage はこんな感じ

vagrant-ubuntu-trusty-64% circleci
Receiving latest version of circleci...
The CLI tool to be used in CircleCI.

Usage:
  circleci [flags]
  circleci [command]

Available Commands:
  build       run a full build locally
  config      validate and update configuration files
  tests       collect and split files with tests
  version     output version info

Flags:
  -c, --config string   config file (default is .circleci/config.yml)
      --taskId string   TaskID
      --verbose         emit verbose logging output

config fileのvalidate

vagrant-ubuntu-trusty-64% circleci config validate

  config file is valid

buildのhelp

vagrant-ubuntu-trusty-64% circleci build --help

run a full build locally

Usage:
  circleci build [flags]

Flags:
      --branch string         Git branch
      --checkout-key string   Git Checkout key (default "~/.ssh/id_rsa")
      --config string         config file (default ".circleci/config.yml")
      --index int             node index of parallelism
      --job string            job to be executed (default "build")
      --parallelism int       parallelism level (default 1)
      --repo-url string       Git Url
      --revision string       Git Revision
      --skip-checkout         use local path as-is (default true)
  -v, --volume value          Volume bind-mounting (default [])

Global Flags:
      --taskId string   TaskID
      --verbose         emit verbose logging output

circleci build をふつうに叩いたらふつうに落ちた。 どうやら working_directroyが相対パスだとダメらしい。最初のymlで相対パスで設定させといてそれ?w

working_directory: /app に変更して通った。

最終的なかたち

version: 2
jobs:
  build:
    working_directory: /app
    docker:
      - image: docker image name
        environment:
          RACK_ENV: 'test'
      - image: mysql:5.7.17
        environment:
          MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
          MYSQL_ROOT_HOST: '%'
    steps:
      - checkout
      - run: bundle install
      - run: bundle exec rails db:create
      - run: bundle exec rails db:migrate
      - run: bundle exec rspec
      - deploy:
          name: run deploy
          command: |
            if [ "${CIRCLE_BRANCH}" == "master" ]; then
              apk add curl
              curl --user ${CIRCLE_API_TOKEN}: \
                --data build_parameters[CIRCLE_JOB]=deploy \
                --data revision=$CIRCLE_SHA1 \
                https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/tree/$CIRCLE_BRANCH \
                >> /dev/null
            fi
  deploy:
    working_directory: /app
    docker:
      - image: docker image name
        environment:
          RACK_ENV: 'production'
    steps:
      - checkout
      - run: bundle install
      - setup_remote_docker
      - deploy:
          name: deploy
          command: echo 'hello'

docker-composeのように、imageがリンクされるわけではないので 127.0.0.01 でアクセスが出来るようにする必要があった。MYSQL_ROOT_HOST: '%'

config/database.yml にはこんな感じにかく host: <%= ENV['DB_HOST'] || ENV['MYSQL_ROOT_HOST'] || '127.0.0.1' %>

以下のサポートを参照した。 https://discuss.circleci.com/t/rails-mysql-container-connection/10604

deploy

こちらを参考にさせていただきました。 http://h3poteto.hatenablog.com/entry/2017/03/31/231410

別ジョブで実行するという方式。 残念ながら別ジョブはCLIコマンドではテストが出来ないので、 pushして try & error になってしまった。 ハックだし、いずれより良い形で公式にサポートがされるだろう。 現時点では docker executorでdeployするimageをbuildするいい方法がないかもしれない。

deployタスクは1.0のときのように、デフォルトで branchで別けれるようにしてほしかった。

【追記】 公式ドキュメントに増えた?昨日はなかった気がするのにw

circleci.com

This approach is a temporary workaround for the current features available during Beta. Soon we’ll be adding a much more elegant way to manage multiple jobs.

より良い方法をそのうち公開するっていってるのでよかったね

まとめ

deploy抜きで考えるならば、テスト、構築ともに速くなって非常にいいと思った。 そもそもここでdeploy用のimageをbuildするという方式も違うのかもしれない。 testが通ったwebhookで別のところでやってもいいかもしれない。 それも考えてみよう。