2010/11/30

CouchDB: Ruby CouchモジュールをDigest認証対応にする

CouchDBのGetting startedには Ruby用のCouchモジュールが掲載されています。

Basic認証に対応させたり、SSLクライアント認証対応にしたりしてきましたが、今回はDigest認証に対応させる事にしました。

これはCouchDBの標準機能ではありません。フロントエンドにApacheなどでProxy Serverを配置し、そのProxy ServerがDigest認証を行なう事を想定しています。

なおDigest認証の仕様は RFC2069 - An Extension to HTTP : Digest Access Authenticationで定義されています。

使い方のサンプル

Couch::Server#newメソッドの第三引数のハッシュに'digest_auth'プロパティを設定します。 他はBasic認証と同じです。

サンプルコード

couch =  Couch::Server.new("couch.example.org","443", {
                           'digest_auth' => 'true',
		           'user' => 'admin',
		           'password' => 'xxxxxxxxxxxx',
		         ...})

他は同じように使えますが、ループの中で繰り返し実行するような場合には#get, #post, #put, #deleteなどの各メソッドで再認証を求められる可能性を考慮する必要があります。

open(file).each_line do |line|
  row = 
  json = Hash.new
  ...
  res = ""
  while not res.kind_of?(Net::HTTPSuccess)
    begin
      res = couch.post(uri, json.to_json)
    rescue
      p $!
    end
  end
end

ライブラリを少し変更すると自動的に再認証することもできますが、最大回数を設定するなどの配慮が必要になるでしょう。

Digest情報のキャッシュ

今回利用したライブラリのサンプルでは再利用の方法について、サンプルはないようでした。

RFC2069では、再利用できる情報としてusername, password, nonce, nonce count and opaque valuesが挙げられています。

これらの情報を使いまわすようにしていますが、手元のApacheを使った環境では5分毎に再認証(rc=401)を求められます。

この挙動への対応として、ライブラリ側でのコントロールをしない方法を選択しました。 オリジナルのライブラリで行なっていたレスポンスコードに応じた例外の送出は行なわれません。

レスポンスオブジェクトはそのままクライアントに返すので、クライアント側では再認証が発生した場合に再度メソッドを呼び出すなど判断をしてください。

net-http-digest_authモジュールの導入

標準ライブラリではDigest認証に対応しません。今回は net-http-digest_authを利用しました。

このライブラリのロードはdigest_authプロパティを設定した場合に発生するので、クライアント側でrubygemsを呼び出すなどの準備が必要です。

couchdb.rbと同じディレクトリにnetディレクトリを配置した場合の設定例

main.rbからcouchdb.rbを呼び出す、次のようなディレクトリ構造を想定しています。

bin/main.rb
lib/
lib/couchdb.rb
lib/net/http/digest_auth.rb

main.rbでは次のようにライブラリをロードします。

netディレクトリを手動で配置した場合の設定例

$:.unshift File.join([File.dirname($0),"..","lib"])
require 'couchdb'
gemsを使う場合の設定例

今回もmain.rbからcouchdb.rbを呼び出す、次のようなディレクトリ構造を想定しています。

$ cd lib
$ gem install -i gems net-http-digest_auth
bin/main.rb
lib/couchdb.rb
lib/gems/gems/net-http-digest_auth-1.0/lib/net/http/digest_auth.rb

標準以外の場所にgemsディレクトリがある場合の対応は以下のようになります。 (-iオプションを使わずに)標準的な場所にインストールした場合には、当然、ENV['GEM_HOME']の行は不要です。

gemsを使う場合の設定例

ENV['GEM_HOME'] = File.join([File.dirname($0),"..","lib","gems"])
require 'rubygems'
$:.unshift File.join([File.dirname($0),"..","lib"])
require 'couchdb'

変更を加えたCouchモジュール

モジュールの全体は次の通りです。

変更したCouchモジュール: lib/couchdb.rb

# -*- coding: utf-8 -*-

require 'net/https'

#
# This module comes from the couchdb wiki;
# http://wiki.apache.org/couchdb/Getting_started_with_Ruby
#
# Modifyed by Yasuhiro ABE - yasu@yasundial.org
#
module Couch

  class Server
    def initialize(host, port, options = nil)
      @host = host
      @port = port
      @options = options
      @options = Hash.new if options.nil? or not options.kind_of?(Hash)
      @www_auth = nil
      @auth = nil
      if options.has_key?('digest_auth')
        require 'net/http/digest_auth'
        @digest_auth = Net::HTTP::DigestAuth.new
      end
    end

    def delete(uri)
      setup_digest_auth(uri,'DELETE')
      request(Net::HTTP::Delete.new(uri))
    end

    def get(uri)
      setup_digest_auth(uri,'GET')
      request(Net::HTTP::Get.new(uri))
    end

    def put(uri, json)
      setup_digest_auth(uri,'PUT')
      req = Net::HTTP::Put.new(uri)
      req["content-type"] = "application/json"
      req.body = json
      request(req)
    end

    def post(uri, json)
      setup_digest_auth(uri,'POST')
      req = Net::HTTP::Post.new(uri)
      req["content-type"] = "application/json"
      req.body = json
      request(req)
    end

    def check_ssl(client)
      if @options.has_key?('cacert')
        client.use_ssl = true
        client.ca_file = @options['cacert']
        client.verify_mode  = @options['ssl_verify_mode'] if @options.has_key?('ssl_verify_mode')
        client.verify_mode  = OpenSSL::SSL::VERIFY_PEER if not @options.has_key?('ssl_verify_mode')
        client.verify_depth = @options['ssl_verify_depth'] if @options.has_key?('ssl_verify_depth')
        client.verify_depth = 5 if not @options.has_key?('ssl_verify_depth')
        client.cert         = @options['ssl_client_cert'] if @options.has_key?('ssl_client_cert')
        client.key          = @options['ssl_client_key'] if @options.has_key?('ssl_client_key')
      end
    end

    def request(req)
      req.basic_auth @options['user'], @options['password'] if @options.has_key?('user') and @options.has_key?('password') and not @options.has_key?('digest_auth')
      req["X-Auth-CouchDB-UserName"] = @options['proxy_auth_user'] if @options.has_key?('proxy_auth_user')
      req["X-Auth-CouchDB-Roles"] = @options['proxy_auth_roles'] if @options.has_key?('proxy_auth_roles')
      req["X-Auth-CouchDB-Token"] = @options['proxy_auth_token'] if @options.has_key?('proxy_auth_token')
      
      client = Net::HTTP.new(@host, @port)
      check_ssl(client)
      
      if @options.has_key?('digest_auth')
        req["Authorization"] = @auth
      end
      
      res = client.start { |http| http.request(req) }
      @www_auth = nil if res.kind_of?(Net::HTTPUnauthorized) and @options.has_key?('digest_auth')
      res
    end
    
    private

    def setup_digest_auth(uri, method)
      return if not @options.has_key?('digest_auth')
      if @www_auth == nil
        req = Net::HTTP::Get.new(uri)
        client = Net::HTTP.new(@host, @port)
        check_ssl(client)
        res = client.start { |http| http.request(req) }
        ## res must be the Net::HTTPUnauthorized
        raise res if not res.kind_of?(Net::HTTPUnauthorized)
        @www_auth = res['www-authenticate']
      end
      url = TinyURI.new(@options['user'], @options['password'], uri)
      @auth = @digest_auth.auth_header(url, @www_auth, method)
    end
  end

  private
  # net/http/digest_auth using this class to pass information.
  class TinyURI  # :nodoc:all
    attr_accessor :request_uri, :user, :password
    def initialize(user, pass, path)
      @user = user 
      @password = pass
      @request_uri = path
    end
  end
end

2010/11/30 22:00追記
認証に失敗した場合に@www_authを初期化するコードが抜けていたので追記しています。

モジュールの使い方: README.rd

手元で書いたREADME.rbの内容をそのまま転載します。

ruby用Couchモジュールについて

http://wiki.apache.org/couchdb/Getting_started_with_Ruby に掲載されているモジュールをベースにしています。

追加した機能は次の通りです。

  • Proxy認証 (couch_httpd_auth:proxy_authentification_handler用)
  • SSLクライアント認証 (stunnelを想定したセキュア接続用)
  • Basic認証 (couch_httpd_auth:default_authentication_handler, apache等proxy serverでの認証用)
  • Digest認証 (apache等proxy serverでの認証用)

基本的な使い方はCouch::Server.new(host,port,options)でインスタンス生成時のoptions引数にハッシュを与えます。

プロパティ認証用プロパティ

optionsにプロパティが設定されていない場合には何もしません。 値がセットされている場合に、各ヘッダにその値を指定します。

  • proxy_auth_user (X-Auth-CouchDB-UserNameヘッダの値にセット)
  • proxy_auth_roles (X-Auth-CouchDB-Rolesヘッダの値にセット)
  • proxy_auth_token (X-Auth-CouchDB-Tokenヘッダの値にセット)

現状ではこれらの値の設定をサポートするメソッドは提供されません。

Basic and Digest認証用プロパティ

認証を有効にするためには、user, password両方とも設定する必要があります。

  • user (任意の文字列)
  • password (任意の文字列)
Digest認証用プロパティ

次の"digest_auth"が設定され、@options.has_key?('digest_auth')がtrueを返す場合に有効です。

  • digest_auth (設定されている場合に有効)

この機能のテストでは{ "digest_auth" => "true" } のようにダミーの文字列を設定しています。

Digest認証を使うクライアントで必要なライブラリのロード

Digest認証を有効にした場合には、net/http/digest_auth モジュールをロードしようとします。

デフォルトでは有効になっていないので、モジュールを適切な方法で配置してください。

方法の1つは次のようにcouchdb.rbと同じディレクトリにnetディレクトリを配置して、相対パスでこのlibディレクトリを$:変数に含める方法です。


  bin/main.rb
  lib/
  lib/couchdb.rb
  lib/net/http/digest_auth.rb

main.rbには次のように記述します。

  $:.unshift File.join([File.dirname($0),"..","lib"])
  require 'couchdb'

方法の2つめはgemsを使い、require 'rubygems'を加える方法です。 デフォルトの場所以外にgemsでインストールした場合には次のように、その場所をポイントする必要があります。

$ gem install -i gems net-http-digest_auth

次のようなディレクトリ構造だとします。


  bin/main.rb
  lib/couchdb.rb
  lib/gems/gems/net-http-digest_auth-1.0/lib/net/http/digest_auth.rb

この場合は、main.rbの先頭は次のようになるでしょう。

  ENV['GEM_HOME'] = File.join([File.dirname($0),"..","lib","gems"])
  require 'rubygems'
  $:.unshift File.join([File.dirname($0),"..","lib"])
  require 'couchdb'

前者の方法はシンプルですがライブラリのメンテナンスを考えるならgemsの利用も検討するべきです。

それにドメイン毎の事情を考慮して、他の方法を検討することはとても素晴しいアプローチです。

SSLクライアント認証用プロパティ

stunnelを使って検証しています。

cacertが設定されている場合に、自動的にNet::HTTP#use_sslを有効にします。 それぞれに設定する値の詳細は、net/httpsのNet::HTTPクラスライブラリを参照してください。

  • cacert (デフォルト値なし。pemファイルへのパス文字列)
  • ssl_verify_mode (default: OpenSSL::SSL::VERIFY_PEER。another value: OpenSSL::SSL::VERIFY_NONE)
  • ssl_verify_depth (default: 5)
  • ssl_client_cert (default値なし。OpenSSL::X509::Certificate オブジェクト)
  • ssl_client_key (default値なし。OpenSSL::PKey::RSA オブジェクト、もしくは、OpenSSL::PKey::DSA オブジェクト)
SSLクライアント認証とBasic認証を組み合せる場合のコーディング例

n少なくとも次のような方法で各オブジェクトを準備し、Couch::Serverクラスのインスタンスを生成する事が必要です。 Basic認証が不要な場合は、user, passwordプロパティを設定しないでください。

詳細はnet/httpsのNet::HTTPクラスライブラリの各メソッドから、OpenSSL:SSLクラスライブラリと併せて参照してください。

  ssl_client_cert = OpenSSL::X509::Certificate.new(File.new(File.expand_path('stunnel.client.cert.pem', File.dirname($0))))
  ssl_client_key = OpenSSL::PKey::RSA.new(File.new(File.expand_path('stunnel.client.key.pem', File.dirname($0))), 'xxxxxxx')

  couch = Couch::Server.new("couch.example.org","5984", {
    'user' => 'admin',
    'password' => 'xxxxxxxxxx',
    'cacert' => File.expand_path('cacert.pem', File.dirname($0)),
    'ssl_client_cert' => ssl_client_cert,
    'ssl_client_key'  => ssl_client_key
  })
SSLサーバ認証について

SSLサーバ認証は、SSLクライアント認証のサブセットで、少なくともcacertプロパティにはサーバ側の証明書が検証できるPEMファイルを設定することが必要です。

その他のプロパティは任意です。

0 件のコメント: