devise に CAPTCHA 認証を追加する方法

### 2014/12/11 追記

新しくなった reCAPTCHA が[良さそう](http://jp.techcrunch.com/2014/12/04/20141203googles-recaptcha-mostly-does-away-with-those-annoying-captchas/)だったので、それに対応した。

---

やりたいことは、Twitter や Facebook みたいに数回ログインに失敗したら、そのユーザーでログインするのに CAPTCHA 認証を通さないといけない機能。
devise とかいうクソ gem <sub>[1](#1)</sub> を使ってしまったせいで苦労したので、書き留めておく。

なお、使う CAPTCHA は [reCAPTCHA](https://developers.google.com/recaptcha/)。

## devise の Lockable の設定

```rb:config/initializers/devise.rb
Devise.setup do |config|
  # ...

  # ==> Configuration for :lockable
  config.lock_strategy = :failed_attempts
  config.unlock_strategy = :none
  config.maximum_attempts = 2 # お好きな値に
  
  #...
end
```

## CAPTCHA の表示

アカウントロックされているかどうかを知るだけでも工夫がいる。

ログインの検証を行ってるのは、`Devise::SessionsController#create` の `warden.authenticate!` だが、`warden.authenticate` に変えたところで、アカウントロックされているユーザーのモデルは取得できない。

最初は、warden の Strategy を拡張する方法や、custom failure を使う方法とか小難しいこと考えたけど、`Devise::Strategies#validate` で `fail!` に渡している認証失敗メッセージを確認することにした。

```rb:app/controllers/devise/sessions_controller.rb
class SessionsController < Devise::SessionsController
  helper_method :access_locked?

  # アカウントロックでエラーになったか調べる
  def access_locked?
    # Devise::Models::Lockable#unauthenticated_message の戻り値が入ってる
    warden.message == :locked
  end
end
```

あとは、View に表示させる。

```haml:app/views/devise/sessions/new.html.haml
-# CAPTCHA を表示させたいところに追加
- if access_locked?
  render "devise/sessions/recapture"
```

```haml:app/views/devise/sessions/_recapture.html.haml
%script{:type => "text/javascript", :src => "//www.google.com/recaptcha/api.js", :defer => true, :async => true}

.g-recaptcha{:"data-sitekey" => your_site_key}
 ```

まあ自分で書かなくても、[ruby-recaptcha](https://bitbucket.org/mml/ruby-recaptcha/) なんかの gem を使ってもいいと思います。

## CAPTCHA 認証とロック解除

warden の Strategy を拡張する。

```rb:lib/devise/strategy/captcha_authenticatable.rb
require 'cgi'
require 'net/http'
require 'uri'
require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    # Devise 標準の DatabaseAuthenticatable に、reCAPTCHA 認証の機能を追加。
    class CaptchaAuthenticatable < Authenticatable
      def authenticate!
        resource  = valid_password? && mapping.to.find_for_database_authentication(authentication_hash)
        encrypted = false

        if validate(resource){ encrypted = true; validate_resource(resource) }
          resource.after_database_authentication
          success!(resource)
        end

        mapping.to.new.password = password if !encrypted && Devise.paranoid
        fail(:not_found_in_database) unless resource
      end

      # ロックされたユーザーの CAPTCHA 認証と、パスワードの検証を行う。
      def validate_resource(resource)
        if resource.access_locked?
          return false unless validate_recaptcha
          resource.unlock_access! # CAPTCHA 認証が通ったらロック解除

          # パスワードまで合ってないとロック解除しないとか、
          # いろんな要件があると思うが、このメソッド内に記述すればよい
        end
        resource.valid_password?(password)
      end

      protected

      # CAPTCHA の検証を行います。
      def validate_recaptcha
        # CAPTCHA のパラメータがある場合のみ検証する
        return false unless params.has_key?("g-recaptcha-response")

        res = Net::HTTP.post_form(URI.parse("https://www.google.com/recaptcha/api/siteverify"),
                                  { :secret     => your_secret_key,
                                    :remoteip   => CGI.escape(request.remote_ip),
                                    :response   => CGI.escape(params["g-recaptcha-response"]) })
        results = JSON.parse(res.body)
        Rails.logger.info("reCAPTCHA: #{results}")
        results["success"]
      end
    end
  end
end

Warden::Strategies.add(:captcha_authenticatable, Devise::Strategies::CaptchaAuthenticatable)
```

前述した [ruby-recaptcha](https://bitbucket.org/mml/ruby-recaptcha/) なんかを使ってるなら、`validate_recaptcha` の代わりに、その gem で用意されたメソッドを使えばよい。

拡張した Strategy を使うように devise を設定する。

```rb:config/initializers/devise.rb
require 'devise/strategies/captcha_authenticatable'

Devise.setup do |config|
  # ...

  # ==> Warden configuration
  config.warden do |manager|
    manager.default_strategies(:scope => :user).unshift :captcha_authenticatable
  end

  #...
end
```

### 2013/12/26 追記

`lib/devise/hooks/lockable.rb` でリセット処理を行っているので、下記処理は不要だった。

<del>
ログイン成功した時に、ログイン失敗のカウントをリセットする。
</del>

```rb:app/model/user.rb
class User < ActiveRecord::Base

  # override Devise::Models::DatabaseAuthenticatable#after_database_authentication
  def after_database_authentication
    # lib/devise/hooks/lockable.rb の処理と重複していたのでコメントアウト
    #unlock_access! if failed_attempts > 0
  end
end
```


以上。

----
<a name="1">1.</a> 個人の主観が入っています。