認証周りを整理

自分で訳しといてあれですが、http://kuenishi.appspot.com/couchdb_definitive_guide_l10n_jp/21_security.html で言及されていないことがあります。それはユーザーデータベースの存在。動作までは確認していませんが、ソースコード(Erlang/JSのテスト)を確認したのでまとめます。

認証モジュールは入れ替え可能。

CouchDBの認証モジュールはErlangで記述さえすれば入れ替え可能です。etc/couchdb/default.ini に次のように設定します。

authentication_handlers = {couch_httpd_oauth, oauth_authentication_handler}, {couch_httpd_auth, default_authentication_handler}

これは couch_httpd_oauth というモジュールの oauth_authentication_hander という関数と、couch_htpd_auth の .... を使います、という設定です。

で、何が設定できるの?

grep -R authentication_hander * | grep export しました。

couch_httpd_auth.erl:-export([default_authentication_handler/1,special_test_authentication_handler/1]).
couch_httpd_auth.erl:-export([cookie_authentication_handler/1]).
couch_httpd_auth.erl:-export([null_authentication_handler/1]).
couch_httpd_oauth.erl:-export([oauth_authentication_handler/1, handle_oauth_req/1, consumer_lookup/2]).

5つ見つかっています。それぞれソースを読みます。oauthは今回はパス!

{couch_httpd_auth, default_authentication_handler}

% couch_httpd_auth 63行目付近
default_authentication_handler(Req) ->
    case basic_username_pw(Req) of
    {User, Pass} ->
        case couch_server:is_admin(User, Pass) of
        true ->
            Req#httpd{user_ctx=#user_ctx{name=?l2b(User), roles=[<<"_admin">>]}};
        false ->
            throw({unauthorized, <<"Name or password is incorrect.">>})
        end;

ベーシック認証でユーザー名とパスワードが渡ってきたら、couch_server:is_admin で調べるようです。成功すれば、現在のリクエストにuser_ctxにユーザー名と _adminというロールをつけて返すので、その後の処理(show/list 等)でユーザー名とロール"_admin"が使えるわけ。失敗したらunauthorized。

    nil ->
        case couch_server:has_admins() of
        true ->
            Req;
        false ->
            case couch_config:get("couch_httpd_auth", "require_valid_user", "false") of
                "true" -> Req;
                % If no admins, and no user required, then everyone is admin!
                % Yay, admin party!
                _ -> Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}
            end
        end
    end.

Basic認証がこない(Authorizationヘッダーがこない)場合は、has_adminsでadminの設定がされているかどうかを調べて、設定されていれば、リクエストには何もしない(認証されていない)でそのまま返し、設定されていない場合で require_valid_user が true になっていればやはりリクエストには何もしない(認証されていない)でそのまま返す。require_valid_userがfalseの場合は、認証はしないけれど、_adminユーザーであると見なしてリクエストを返す、ということになります。

デフォルトでは、皆すべて管理ユーザーです。

確かに、このことを意味していますね。

さらに、認証ソースを確かめるために、couch_server:is_admin/2 だとかその辺をみます。

% couch_server.erl 103行目付近
is_admin(User, ClearPwd) ->
    case couch_config:get("admins", User) of
    "-hashed-" ++ HashedPwdAndSalt ->
        [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","),
        couch_util:to_hex(crypto:sha(ClearPwd ++ Salt)) == HashedPwd;
    _Else ->
        false
    end.

ということで、認証ソースは、configファイルで確定ですね。

まとめ:default_authentication_handlerはBasic認証でlocal.ini にユーザー名とパスワードの組を書いておく。

{couch_httpd_auth, special_test_authentication_handler}

名前からしてテスト用だろ、って話はありますが一応確認。

special_test_authentication_handler(Req) ->
    case header_value(Req, "WWW-Authenticate") of
    "X-Couch-Test-Auth " ++ NamePass ->
        % NamePass is a colon separated string: "joe schmoe:a password".
        [Name, Pass] = re:split(NamePass, ":", [{return, list}]),
        case {Name, Pass} of
        {"Jan Lehnardt", "apple"} -> ok;
        {"Christopher Lenz", "dog food"} -> ok;
        {"Noah Slater", "biggiesmalls endian"} -> ok;
        {"Chris Anderson", "mp3"} -> ok;
        {"Damien Katz", "pecan pie"} -> ok;
        {_, _} ->
            throw({unauthorized, <<"Name or password is incorrect.">>})
        end,
        Req#httpd{user_ctx=#user_ctx{name=?l2b(Name)}};

はい、この認証モジュールを有効にした場合は、コミッターの名前とパスワードをWWW-Authenticateヘッダーに?*1 X-Couch-Test-Auth に続く文字列でいれておきます。

まとめ: {couch_httpd_auth, special_test_authentication_handler} はテスト用

{couch_httpd_auth, cookie_authentication_handler}

本命です。これの情報が実はあまりない。ソースを読むと、、

% 89行目
cookie_authentication_handler(Req) ->
    DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
    case cookie_auth_user(Req, ?l2b(DbName)) of
    % Fall back to default authentication handler
    nil -> default_authentication_handler(Req);
    Req2 -> Req2
    end.

設定ファイルに記述した authentication_db のデータベースを使って認証します。ということらしい。認証に失敗したら、default_authentication_handlerに切り替える模様。じゃあ、うまくいく場合は...?

% 209行目
cookie_auth_user(#httpd{mochi_req=MochiReq}=Req, DbName) ->
    case MochiReq:get_cookie_value("AuthSession") of
    undefined -> nil;
    [] -> nil;
    Cookie -> 
        case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
        {ok, Db} ->
            try
                AuthSession = couch_util:decodeBase64Url(Cookie),

AuthSessionというクッキーを取り出して、あればDBを開きます。ちなみに、このクッキーを生成するには /_session APIを使います。

                [User, TimeStr | HashParts] = string:tokens(?b2l(AuthSession), ":"),
                % Verify expiry and hash
                {NowMS, NowS, _} = erlang:now(),
                CurrentTime = NowMS * 1000000 + NowS,

セッションからユーザー、時刻、ハッシュ部分を取り出していますね。あと現在時刻の計算。

                case couch_config:get("couch_httpd_auth", "secret", nil) of
                nil -> nil;
                SecretStr ->

設定ファイルに secret を設定しないと問答無用でnilが返され認証に失敗(default_authenticaiton_handlerへフォールバック)するようです。

                    Secret = ?l2b(SecretStr),
                    case get_user(Db, ?l2b(User)) of
                    nil -> nil;
                    Result ->

シークレットを取り出しつつ、データベースからユーザー名をキーにしてドキュメント取り出す。取り出した結果はResultに束縛されているドキュメントになりますね。

                        UserSalt = proplists:get_value(<<"salt">>, Result, <<"">>),
                        FullSecret = <<Secret/binary, UserSalt/binary>>,
                        ExpectedHash = crypto:sha_mac(FullSecret, User ++ ":" ++ TimeStr),
                        Hash = ?l2b(string:join(HashParts, ":")),
                        Timeout = to_int(couch_config:get("couch_httpd_auth", "timeout", 600)),
                        ?LOG_DEBUG("timeout ~p", [Timeout]),

Resultからユーザーのsaltを取り出して、完全なSecretを作り出す。で期待するハッシュを計算する一方で、クッキーからもハッシュを取り出す。ついでに、タイムアウト値も計算する。

                        case (catch erlang:list_to_integer(TimeStr, 16)) of
                            TimeStamp when CurrentTime < TimeStamp + Timeout 

タイムアウトしていなくて、

                            andalso ExpectedHash == Hash ->
                                TimeLeft = TimeStamp + Timeout - CurrentTime,
                                ?LOG_DEBUG("Successful cookie auth as: ~p", [User]),
                                Req#httpd{user_ctx=#user_ctx{
                                    name=?l2b(User),
                                    roles=proplists:get_value(<<"roles">>, Result, [])
                                }, auth={FullSecret, TimeLeft < Timeout*0.9}};
                            _Else ->
                                nil

期待するハッシュとクッキーから得られたハッシュが一致するならば、リクエストオブジェクトにユーザー名と、ユーザーDBに定義されたロールをくっつけて返す!

まとめ: {couch_httpd_auth, cookie_authentication_handler} 設定ファイルに記述したauthentication_dbを認証用のデータベースとして使うことができます!

つまり、CouchDB自体としては _admin というロールしか定義していないけれど、cookie_authentication と authentication_db を使えばアプリケーションでロールを自由に定義できる、ってことですね。ちなみに、authentication_db のデフォルトの設定は users です。このソースを見る限りは、このデータベースに対して次のようにユーザーをドキュメントとして登録しておけばOK、だと思います。まだ確認してません、明日やります。

{
        _id: "ここは任意",
        salt: "ここにsaltを",
        password_sha: SHA1("パスワード" + "salt値")のhexフォーマット,
        username: ユーザー名,
        type: "user",
        roles: ["role1", "role2", ...] // _admin もOKだと思う
}

{couch_httpd_auth, null_authentication_handler}

null_authentication_handler(Req) ->
    Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}.

全員 _admin 君です。

補足

今回大発見のCookie Authは per instance です。CouchDBインスタンス全体で共通のユーザーデータベースを使うことになります。ということで、CouchDB上でユーザードメインの異なる複数のサービスを運用するには向きません。ちなみにこんなコメントアウトがありました(笑。

% Cookie auth handler using per-db user db
% cookie_authentication_handler(#httpd{path_parts=Path}=Req) ->
%     case Path of
%     [DbName|_] ->
%         case cookie_auth_user(Req, DbName) of
%         nil -> default_authentication_handler(Req);
%         Req2 -> Req2
%         end;
%     _Else ->
%         % Fall back to default authentication handler
%         default_authentication_handler(Req)
%     end.

まぁ気持ちはわかります。それにしてもErlangは読みやすくていいですね。すらすら書けるようになりたい。

*1:これ、レスポンスヘッダー用のヘッダ名じゃないの?