diff options
author | Nick Vatamaniuc <vatamane@gmail.com> | 2021-05-28 18:35:44 -0400 |
---|---|---|
committer | Nick Vatamaniuc <nickva@users.noreply.github.com> | 2021-06-01 17:06:58 -0400 |
commit | ecd266b0e87f44e1080cabdb4c28e4758f5a4406 (patch) | |
tree | cac5a235ce38d5c2c611ace2f882cb41a74ba797 | |
parent | 0bd94653d19dd02c6eaaa4c5f749459a0c9f2a7c (diff) | |
download | couchdb-ecd266b0e87f44e1080cabdb4c28e4758f5a4406.tar.gz |
Improve basic auth credentials handling in replicator
Previously, there were two ways to pass in basic auth credentials for
endpoints -- using URL's userinfo part and encoding the them in an
`"Authorization": "basic ..."` header. Neither one is ideal for these reasons:
* Passwords in userinfo doesn't allow using ":", "@" and other characters.
However, even after switching to always unquoting them like we did recently
[1], would break authentication for usernames or passwords previously
containing "+" or "%HH" patterns, as "+" might now be decoded to a " ".
* Base64 encoded headers need an extra step to encode them. Also, quite often
these encoded headers are confused as being "encrypted" and shared in a
clear channel.
To improve this, revert the recent commit to unquote URL userinfo parts to
restore backwards compatibility, and introduce a way to pass in basic auth
credentials in the "auth" object. The "auth" object was already added a while
back to allow authentication plugins to store their credentials in it. The
format is:
```
"source": {
"url": "https://host/db",
"auth": {
"basic": {
"username":"myuser",
"password":"mypassword"
}
}
}
```
{"auth" : "basic" : {...}} object is checked first, and if credentials are
provided, they will be used. If they are not then userinfo and basic auth
header will be parsed.
Internally, there was a good amount duplication related to parsing credentials
from userinfo and headers in replication ID generation logic and in the auth
session plugin. As a cleanup, consolidate that logic in the
`couch_replicator_utils` module.
[1] https://github.com/apache/couchdb/commit/f672b911db19981a81d7fc6ce8ac33b150234fd7
7 files changed, 353 insertions, 225 deletions
diff --git a/src/couch_replicator/src/couch_replicator.erl b/src/couch_replicator/src/couch_replicator.erl index b169dccb1..413628143 100644 --- a/src/couch_replicator/src/couch_replicator.erl +++ b/src/couch_replicator/src/couch_replicator.erl @@ -373,13 +373,13 @@ t_strip_local_db_creds() -> t_strip_http_basic_creds() -> ?_test(begin Url1 = <<"http://adm:pass@host/db">>, - ?assertEqual(<<"http://adm:*****@host/db/">>, strip_url_creds(Url1)), + ?assertEqual(<<"http://host/db/">>, strip_url_creds(Url1)), Url2 = <<"https://adm:pass@host/db">>, - ?assertEqual(<<"https://adm:*****@host/db/">>, strip_url_creds(Url2)), + ?assertEqual(<<"https://host/db/">>, strip_url_creds(Url2)), Url3 = <<"http://adm:pass@host:80/db">>, - ?assertEqual(<<"http://adm:*****@host:80/db/">>, strip_url_creds(Url3)), + ?assertEqual(<<"http://host:80/db/">>, strip_url_creds(Url3)), Url4 = <<"http://adm:pass@host/db?a=b&c=d">>, - ?assertEqual(<<"http://adm:*****@host/db?a=b&c=d">>, + ?assertEqual(<<"http://host/db?a=b&c=d">>, strip_url_creds(Url4)) end). @@ -387,7 +387,7 @@ t_strip_http_basic_creds() -> t_strip_http_props_creds() -> ?_test(begin Props1 = {[{<<"url">>, <<"http://adm:pass@host/db">>}]}, - ?assertEqual(<<"http://adm:*****@host/db/">>, strip_url_creds(Props1)), + ?assertEqual(<<"http://host/db/">>, strip_url_creds(Props1)), Props2 = {[ {<<"url">>, <<"http://host/db">>}, {<<"headers">>, {[{<<"Authorization">>, <<"Basic pa55">>}]}} ]}, diff --git a/src/couch_replicator/src/couch_replicator_auth_session.erl b/src/couch_replicator/src/couch_replicator_auth_session.erl index 8daa7bc70..4f70cd668 100644 --- a/src/couch_replicator/src/couch_replicator_auth_session.erl +++ b/src/couch_replicator/src/couch_replicator_auth_session.erl @@ -78,7 +78,6 @@ -type headers() :: [{string(), string()}]. -type code() :: non_neg_integer(). --type creds() :: {string() | undefined, string() | undefined}. -type time_sec() :: non_neg_integer(). -type age() :: time_sec() | undefined. @@ -238,59 +237,15 @@ init_state(#httpdb{} = HttpDb) -> -spec extract_creds(#httpdb{}) -> {ok, string(), string(), #httpdb{}} | {error, term()}. -extract_creds(#httpdb{url = Url, headers = Headers} = HttpDb) -> - {{HeadersUser, HeadersPass}, HeadersNoCreds} = - couch_replicator_utils:remove_basic_auth_from_headers(Headers), - case extract_creds_from_url(Url) of - {ok, UrlUser, UrlPass, UrlNoCreds} -> - case pick_creds({UrlUser, UrlPass}, {HeadersUser, HeadersPass}) of - {ok, User, Pass} -> - HttpDb1 = HttpDb#httpdb{ - url = UrlNoCreds, - headers = HeadersNoCreds - }, - {ok, User, Pass, HttpDb1}; - {error, Error} -> - {error, Error} - end; - {error, Error} -> - {error, Error} - end. - - -% Credentials could be specified in the url and/or in the headers. -% * If no credentials specified return error. -% * If specified in url but not in headers, pick url creds. -% * Otherwise pick headers creds. -% --spec pick_creds(creds(), creds()) -> - {ok, string(), string()} | {error, missing_credentials}. -pick_creds({undefined, _}, {undefined, _}) -> - {error, missing_credentials}; -pick_creds({UrlUser, UrlPass}, {undefined, _}) -> - {ok, UrlUser, UrlPass}; -pick_creds({_, _}, {HeadersUser, HeadersPass}) -> - {ok, HeadersUser, HeadersPass}. - - --spec extract_creds_from_url(string()) -> - {ok, string() | undefined, string() | undefined, string()} | - {error, term()}. -extract_creds_from_url(Url) -> - case ibrowse_lib:parse_url(Url) of - {error, Error} -> - {error, Error}; - #url{username = undefined, password = undefined} -> - {ok, undefined, undefined, Url}; - #url{protocol = Proto, username = User, password = Pass} -> - % Excise user and pass parts from the url. Try to keep the host, - % port and path as they were in the original. - Prefix = lists:concat([Proto, "://", User, ":", Pass, "@"]), - Suffix = lists:sublist(Url, length(Prefix) + 1, length(Url) + 1), - NoCreds = lists:concat([Proto, "://", Suffix]), - User1 = chttpd:unquote(User), - Pass1 = chttpd:unquote(Pass), - {ok, User1, Pass1, NoCreds} +extract_creds(#httpdb{} = HttpDb) -> + case couch_replicator_utils:get_basic_auth_creds(HttpDb) of + {undefined, undefined} -> + % Return error. Session plugin should ignore this replication + % endpoint as there are no valid creds which can be used + {error, missing_credentials}; + {User, Pass} when is_list(User), is_list(Pass) -> + HttpDb1 = couch_replicator_utils:remove_basic_auth_creds(HttpDb), + {ok, User, Pass, HttpDb1} end. @@ -569,116 +524,15 @@ get_session_url_test_() -> ]]. -extract_creds_success_test_() -> - DefaultHeaders = (#httpdb{})#httpdb.headers, - [?_assertEqual({ok, User, Pass, HttpDb2}, extract_creds(HttpDb1)) || - {HttpDb1, {User, Pass, HttpDb2}} <- [ - { - #httpdb{url = "http://u:p@x.y/db"}, - {"u", "p", #httpdb{url = "http://x.y/db"}} - }, - { - #httpdb{url = "http://u%40:p%40@x.y/db"}, - {"u@", "p@", #httpdb{url = "http://x.y/db"}} - }, - { - #httpdb{url = "http://u%40u:p%40p@x.y/db"}, - {"u@u", "p@p", #httpdb{url = "http://x.y/db"}} - }, - { - #httpdb{url = "http://u%40%401:p%40%401@x.y/db"}, - {"u@@1", "p@@1", #httpdb{url = "http://x.y/db"}} - }, - { - #httpdb{url = "http://u%40%2540:p%40%2540@x.y/db"}, - {"u@%40", "p@%40", #httpdb{url = "http://x.y/db"}} - }, - { - #httpdb{url = "http://u:p@h:80/db"}, - {"u", "p", #httpdb{url = "http://h:80/db"}} - }, - { - #httpdb{url = "http://u%3A:p%3A@h:80/db"}, - {"u:", "p:", #httpdb{url = "http://h:80/db"}} - }, - { - #httpdb{url = "https://u:p@h/db"}, - {"u", "p", #httpdb{url = "https://h/db"}} - }, - { - #httpdb{url = "https://u%2F:p%2F@h/db"}, - {"u/", "p/", #httpdb{url = "https://h/db"}} - }, - { - #httpdb{url = "http://u:p@127.0.0.1:5984/db"}, - {"u", "p", #httpdb{url = "http://127.0.0.1:5984/db"}} - }, - { - #httpdb{url = "http://u:p@[2001:db8:a1b:12f9::1]/db"}, - {"u", "p", #httpdb{url = "http://[2001:db8:a1b:12f9::1]/db"}} - }, - { - #httpdb{url = "http://u:p@[2001:db8:a1b:12f9::1]:81/db"}, - {"u", "p", #httpdb{url = "http://[2001:db8:a1b:12f9::1]:81/db"}} - }, - { - #httpdb{url = "http://u:p%3A%2F%5B%5D%40@[2001:db8:a1b:12f9::1]:81/db"}, - {"u", "p:/[]@", #httpdb{url = "http://[2001:db8:a1b:12f9::1]:81/db"}} - }, - { - #httpdb{url = "http://u:p@x.y/db/other?query=Z&query=w"}, - {"u", "p", #httpdb{url = "http://x.y/db/other?query=Z&query=w"}} - }, - { - #httpdb{url = "http://u:p%3F@x.y/db/other?query=Z&query=w"}, - {"u", "p?", #httpdb{url = "http://x.y/db/other?query=Z&query=w"}} - }, - { - #httpdb{ - url = "http://h/db", - headers = DefaultHeaders ++ [ - {"Authorization", "Basic " ++ b64creds("u", "p")} - ] - }, - {"u", "p", #httpdb{url = "http://h/db"}} - }, - { - #httpdb{ - url = "http://h/db", - headers = DefaultHeaders ++ [ - {"Authorization", "Basic " ++ b64creds("u", "p@")} - ] - }, - {"u", "p@", #httpdb{url = "http://h/db"}} - }, - { - #httpdb{ - url = "http://h/db", - headers = DefaultHeaders ++ [ - {"Authorization", "Basic " ++ b64creds("u", "p@%40")} - ] - }, - {"u", "p@%40", #httpdb{url = "http://h/db"}} - }, - { - #httpdb{ - url = "http://h/db", - headers = DefaultHeaders ++ [ - {"aUthoriZation", "bASIC " ++ b64creds("U", "p")} - ] - }, - {"U", "p", #httpdb{url = "http://h/db"}} - }, - { - #httpdb{ - url = "http://u1:p1@h/db", - headers = DefaultHeaders ++ [ - {"Authorization", "Basic " ++ b64creds("u2", "p2")} - ] - }, - {"u2", "p2", #httpdb{url = "http://h/db"}} - } - ]]. +extract_creds_success_test() -> + HttpDb = #httpdb{auth_props = [ + {<<"basic">>, {[ + {<<"username">>, <<"u2">>}, + {<<"password">>, <<"p2">>} + ]}} + ]}, + ?assertEqual({ok, "u2", "p2", #httpdb{}}, extract_creds(HttpDb)), + ?assertEqual({error, missing_credentials}, extract_creds(#httpdb{})). cookie_update_test_() -> @@ -795,7 +649,7 @@ t_process_ok_no_cookie() -> t_init_state_fails_on_401() -> ?_test(begin mock_http_401_response(), - {error, Error} = init_state(#httpdb{url = "http://u:p@h"}), + {error, Error} = init_state(httpdb("http://u:p@h")), SessionUrl = "http://h/_session", ?assertEqual({session_request_unauthorized, SessionUrl, "u"}, Error) end). @@ -805,32 +659,36 @@ t_init_state_401_with_require_valid_user() -> ?_test(begin mock_http_401_response_with_require_valid_user(), ?assertMatch({ok, #httpdb{}, #state{cookie = "Cookie"}}, - init_state(#httpdb{url = "http://u:p@h"})) + init_state(httpdb("http://u:p@h"))) end). t_init_state_404() -> ?_test(begin mock_http_404_response(), - ?assertEqual(ignore, init_state(#httpdb{url = "http://u:p@h"})) + ?assertEqual(ignore, init_state(httpdb("http://u:p@h"))) end). t_init_state_no_creds() -> ?_test(begin - ?_assertEqual(ignore, init_state(#httpdb{url = "http://h"})) + ?_assertEqual(ignore, init_state(httpdb("http://h"))) end). t_init_state_http_error() -> ?_test(begin mock_http_error_response(), - {error, Error} = init_state(#httpdb{url = "http://u:p@h"}), + {error, Error} = init_state(httpdb("http://u:p@h")), SessionUrl = "http://h/_session", ?assertEqual({session_request_failed, SessionUrl, "u", x}, Error) end). +httpdb(Url) -> + couch_replicator_utils:normalize_basic_auth(#httpdb{url = Url}). + + setup_all() -> meck:expect(couch_replicator_httpc_pool, get_worker, 1, {ok, worker}), meck:expect(couch_replicator_httpc_pool, release_worker_sync, 2, ok), @@ -885,14 +743,6 @@ mock_http_error_response() -> meck:expect(ibrowse, send_req_direct, 7, {error, x}). -extract_creds_error_test_() -> - [?_assertMatch({error, Error}, extract_creds(HttpDb)) || - {HttpDb, Error} <- [ - {#httpdb{url = "some_junk"}, invalid_uri}, - {#httpdb{url = "http://h/db"}, missing_credentials} - ]]. - - parse_max_age_test_() -> [?_assertEqual(R, parse_max_age(mochiweb_headers:make([{"Max-Age", A}]))) || {A, R} <- [ diff --git a/src/couch_replicator/src/couch_replicator_docs.erl b/src/couch_replicator/src/couch_replicator_docs.erl index 619063222..3087195bd 100644 --- a/src/couch_replicator/src/couch_replicator_docs.erl +++ b/src/couch_replicator/src/couch_replicator_docs.erl @@ -408,7 +408,7 @@ parse_rep_db({Props}, Proxy, Options) -> {BinHeaders} = get_value(<<"headers">>, Props, {[]}), Headers = lists:ukeysort(1, [{?b2l(K), ?b2l(V)} || {K, V} <- BinHeaders]), DefaultHeaders = (#httpdb{})#httpdb.headers, - #httpdb{ + HttpDb = #httpdb{ url = Url, auth_props = AuthProps, headers = lists:ukeymerge(1, Headers, DefaultHeaders), @@ -419,7 +419,8 @@ parse_rep_db({Props}, Proxy, Options) -> http_connections = get_value(http_connections, Options), retries = get_value(retries, Options), proxy_url = ProxyURL - }; + }, + couch_replicator_utils:normalize_basic_auth(HttpDb); parse_rep_db(<<"http://", _/binary>> = Url, Proxy, Options) -> parse_rep_db({[{<<"url">>, Url}]}, Proxy, Options); diff --git a/src/couch_replicator/src/couch_replicator_httpc.erl b/src/couch_replicator/src/couch_replicator_httpc.erl index 6012b52f4..a2af51898 100644 --- a/src/couch_replicator/src/couch_replicator_httpc.erl +++ b/src/couch_replicator/src/couch_replicator_httpc.erl @@ -112,7 +112,13 @@ send_ibrowse_req(#httpdb{headers = BaseHeaders} = HttpDb0, Params) -> end end, {ok, Worker} = couch_replicator_httpc_pool:get_worker(HttpDb#httpdb.httpc_pool), - IbrowseOptions = [ + BasicAuthOpts = case couch_replicator_utils:get_basic_auth_creds(HttpDb) of + {undefined, undefined} -> + []; + {User, Pass} when is_list(User), is_list(Pass) -> + [{basic_auth, {User, Pass}}] + end, + IbrowseOptions = BasicAuthOpts ++ [ {response_format, binary}, {inactivity_timeout, HttpDb#httpdb.timeout} | lists:ukeymerge(1, get_value(ibrowse_options, Params, []), HttpDb#httpdb.ibrowse_options) diff --git a/src/couch_replicator/src/couch_replicator_ids.erl b/src/couch_replicator/src/couch_replicator_ids.erl index 04e71c3ef..80ff0016a 100644 --- a/src/couch_replicator/src/couch_replicator_ids.erl +++ b/src/couch_replicator/src/couch_replicator_ids.erl @@ -134,21 +134,10 @@ get_rep_endpoint(#httpdb{url=Url, headers=Headers}) -> get_v4_endpoint(#httpdb{} = HttpDb) -> {remote, Url, Headers} = get_rep_endpoint(HttpDb), - {{UserFromHeaders, _}, HeadersWithoutBasicAuth} = - couch_replicator_utils:remove_basic_auth_from_headers(Headers), - {UserFromUrl, Host, NonDefaultPort, Path} = get_v4_url_info(Url), - User = pick_defined_value([UserFromUrl, UserFromHeaders]), + {User, _} = couch_replicator_utils:get_basic_auth_creds(HttpDb), + {Host, NonDefaultPort, Path} = get_v4_url_info(Url), OAuth = undefined, % Keep this to ensure checkpoints don't change - {remote, User, Host, NonDefaultPort, Path, HeadersWithoutBasicAuth, OAuth}. - - -pick_defined_value(Values) -> - case [V || V <- Values, V /= undefined] of - [] -> - undefined; - DefinedValues -> - hd(DefinedValues) - end. + {remote, User, Host, NonDefaultPort, Path, Headers, OAuth}. get_v4_url_info(Url) when is_binary(Url) -> @@ -158,16 +147,15 @@ get_v4_url_info(Url) -> {error, invalid_uri} -> % Tolerate errors here to avoid a bad user document % crashing the replicator - {undefined, Url, undefined, undefined}; + {Url, undefined, undefined}; #url{ protocol = Schema, - username = User, host = Host, port = Port, path = Path } -> NonDefaultPort = get_non_default_port(Schema, Port), - {User, Host, NonDefaultPort, Path} + {Host, NonDefaultPort, Path} end. @@ -197,71 +185,81 @@ replication_id_convert_test_() -> http_v4_endpoint_test_() -> [?_assertMatch({remote, User, Host, Port, Path, HeadersNoAuth, undefined}, - get_v4_endpoint(#httpdb{url = Url, headers = Headers})) || - {{User, Host, Port, Path, HeadersNoAuth}, {Url, Headers}} <- [ + begin + HttpDb = #httpdb{url = Url, headers = Headers, auth_props = Auth}, + HttpDb1 = couch_replicator_utils:normalize_basic_auth(HttpDb), + get_v4_endpoint(HttpDb1) + end) || + {{User, Host, Port, Path, HeadersNoAuth}, {Url, Headers, Auth}} <- [ { {undefined, "host", default, "/", []}, - {"http://host", []} + {"http://host", [], []} }, { {undefined, "host", default, "/", []}, - {"https://host", []} + {"https://host", [], []} }, { {undefined, "host", default, "/", []}, - {"http://host:5984", []} + {"http://host:5984", [], []} }, { {undefined, "host", 1, "/", []}, - {"http://host:1", []} + {"http://host:1", [], []} }, { {undefined, "host", 2, "/", []}, - {"https://host:2", []} + {"https://host:2", [], []} }, { {undefined, "host", default, "/", [{"h","v"}]}, - {"http://host", [{"h","v"}]} + {"http://host", [{"h","v"}], []} }, { {undefined, "host", default, "/a/b", []}, - {"http://host/a/b", []} + {"http://host/a/b", [], []} }, { {"user", "host", default, "/", []}, - {"http://user:pass@host", []} + {"http://user:pass@host", [], []} }, { {"user", "host", 3, "/", []}, - {"http://user:pass@host:3", []} + {"http://user:pass@host:3", [], []} }, { {"user", "host", default, "/", []}, - {"http://user:newpass@host", []} + {"http://user:newpass@host", [], []} }, { {"user", "host", default, "/", []}, - {"http://host", [basic_auth("user","pass")]} + {"http://host", [basic_auth("user","pass")], []} }, { {"user", "host", default, "/", []}, - {"http://host", [basic_auth("user","newpass")]} + {"http://host", [basic_auth("user","newpass")], []} }, { - {"user1", "host", default, "/", []}, - {"http://user1:pass1@host", [basic_auth("user2","pass2")]} + {"user3", "host", default, "/", []}, + {"http://user1:pass1@host", [basic_auth("user2","pass2")], + auth_props("user3", "pass3")} + }, + { + {"user2", "host", default, "/", [{"h", "v"}]}, + {"http://host", [{"h", "v"}, basic_auth("user","pass")], + auth_props("user2", "pass2")} }, { {"user", "host", default, "/", [{"h", "v"}]}, - {"http://host", [{"h", "v"}, basic_auth("user","pass")]} + {"http://host", [{"h", "v"}], auth_props("user", "pass")} }, { {undefined, "random_junk", undefined, undefined}, - {"random_junk", []} + {"random_junk", [], []} }, { {undefined, "host", default, "/", []}, - {"http://host", [{"Authorization", "Basic bad"}]} + {"http://host", [{"Authorization", "Basic bad"}], []} } ] ]. @@ -272,4 +270,10 @@ basic_auth(User, Pass) -> {"Authorization", "Basic " ++ B64Auth}. +auth_props(User, Pass) when is_list(User), is_list(Pass) -> + [{<<"basic">>, {[ + {<<"username">>, list_to_binary(User)}, + {<<"password">>, list_to_binary(Pass)} + ]}}]. + -endif. diff --git a/src/couch_replicator/src/couch_replicator_scheduler_job.erl b/src/couch_replicator/src/couch_replicator_scheduler_job.erl index 1d328d0a6..db8edfbef 100644 --- a/src/couch_replicator/src/couch_replicator_scheduler_job.erl +++ b/src/couch_replicator/src/couch_replicator_scheduler_job.erl @@ -1072,8 +1072,8 @@ scheduler_job_format_status_test() -> highest_seq_done = <<"5">> }, Format = format_status(opts_ignored, [pdict, State]), - ?assertEqual("http://u:*****@h1/d1/", proplists:get_value(source, Format)), - ?assertEqual("http://u:*****@h2/d2/", proplists:get_value(target, Format)), + ?assertEqual("http://h1/d1/", proplists:get_value(source, Format)), + ?assertEqual("http://h2/d2/", proplists:get_value(target, Format)), ?assertEqual({"base", "+ext"}, proplists:get_value(rep_id, Format)), ?assertEqual([{create_target, true}], proplists:get_value(options, Format)), ?assertEqual(<<"mydoc">>, proplists:get_value(doc_id, Format)), diff --git a/src/couch_replicator/src/couch_replicator_utils.erl b/src/couch_replicator/src/couch_replicator_utils.erl index 5f608dee7..dbadb3787 100644 --- a/src/couch_replicator/src/couch_replicator_utils.erl +++ b/src/couch_replicator/src/couch_replicator_utils.erl @@ -23,12 +23,15 @@ pp_rep_id/1, iso8601/1, filter_state/3, - remove_basic_auth_from_headers/1, normalize_rep/1, - ejson_state_info/1 + ejson_state_info/1, + get_basic_auth_creds/1, + remove_basic_auth_creds/1, + normalize_basic_auth/1 ]). +-include_lib("ibrowse/include/ibrowse.hrl"). -include_lib("couch/include/couch_db.hrl"). -include("couch_replicator.hrl"). -include_lib("couch_replicator/include/couch_replicator_api_wrap.hrl"). @@ -191,6 +194,98 @@ ejson_state_info(Info) -> {[{<<"error">>, ErrMsg}]}. +-spec get_basic_auth_creds(#httpdb{}) -> + {string(), string()} | {undefined, undefined}. +get_basic_auth_creds(#httpdb{auth_props = AuthProps}) -> + case couch_util:get_value(<<"basic">>, AuthProps) of + undefined -> + {undefined, undefined}; + {UserPass} when is_list(UserPass) -> + User = couch_util:get_value(<<"username">>, UserPass), + Pass = couch_util:get_value(<<"password">>, UserPass), + case {User, Pass} of + _ when is_binary(User), is_binary(Pass) -> + {binary_to_list(User), binary_to_list(Pass)}; + _Other -> + {undefined, undefined} + end; + _Other -> + {undefined, undefined} + end. + + +-spec remove_basic_auth_creds(#httpd{}) -> #httpdb{}. +remove_basic_auth_creds(#httpdb{auth_props = Props} = HttpDb) -> + Props1 = lists:keydelete(<<"basic">>, 1, Props), + HttpDb#httpdb{auth_props = Props1}. + + +-spec set_basic_auth_creds(string(), string(), #httpd{}) -> #httpdb{}. +set_basic_auth_creds(undefined, undefined, #httpdb{} = HttpDb) -> + HttpDb; +set_basic_auth_creds(User, Pass, #httpdb{} = HttpDb) + when is_list(User), is_list(Pass) -> + HttpDb1 = remove_basic_auth_creds(HttpDb), + Props = HttpDb1#httpdb.auth_props, + UserPass = {[ + {<<"username">>, list_to_binary(User)}, + {<<"password">>, list_to_binary(Pass)} + ]}, + Props1 = lists:keystore(<<"basic">>, 1, Props, {<<"basic">>, UserPass}), + HttpDb1#httpdb{auth_props = Props1}. + + +-spec extract_creds_from_url(string()) -> + {ok, {string() | undefined, string() | undefined}, string()} | + {error, term()}. +extract_creds_from_url(Url) -> + case ibrowse_lib:parse_url(Url) of + {error, Error} -> + {error, Error}; + #url{username = undefined, password = undefined} -> + {ok, {undefined, undefined}, Url}; + #url{protocol = Proto, username = User, password = Pass} -> + % Excise user and pass parts from the url. Try to keep the host, + % port and path as they were in the original. + Prefix = lists:concat([Proto, "://", User, ":", Pass, "@"]), + Suffix = lists:sublist(Url, length(Prefix) + 1, length(Url) + 1), + NoCreds = lists:concat([Proto, "://", Suffix]), + {ok, {User, Pass}, NoCreds} + end. + + +% Normalize basic auth credentials so they are set only in the auth props +% object. If multiple basic auth credentials are provided, the resulting +% credentials are picked in the following order. +% 1) {"auth": "basic": {"username":.., "password": ...} ...} +% 2) URL userinfo part +% 3) "Authentication" : "basic $base64" headers +% +-spec normalize_basic_auth(#httpdb{}) -> #httpdb{}. +normalize_basic_auth(#httpdb{} = HttpDb) -> + #httpdb{url = Url, headers = Headers} = HttpDb, + {HeaderCreds, HeadersNoCreds} = remove_basic_auth_from_headers(Headers), + {UrlCreds, UrlWithoutCreds} = case extract_creds_from_url(Url) of + {ok, Creds = {_, _}, UrlNoCreds} -> + {Creds, UrlNoCreds}; + {error, _Error} -> + % Don't crash replicator if user provided an invalid + % userinfo part + {undefined, undefined} + end, + AuthCreds = {_, _} = get_basic_auth_creds(HttpDb), + HttpDb1 = remove_basic_auth_creds(HttpDb#httpdb{ + url = UrlWithoutCreds, + headers = HeadersNoCreds + }), + {User, Pass} = case {AuthCreds, UrlCreds, HeaderCreds} of + {{U, P}, {_, _}, {_, _}} when is_list(U), is_list(P) -> {U, P}; + {{_, _}, {U, P}, {_, _}} when is_list(U), is_list(P) -> {U, P}; + {{_, _}, {_, _}, {U, P}} -> {U, P} + end, + set_basic_auth_creds(User, Pass, HttpDb1). + + -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). @@ -269,4 +364,176 @@ normalize_rep_test_() -> end) }. + +get_basic_auth_creds_test() -> + Check = fun(Props) -> + get_basic_auth_creds(#httpdb{auth_props = Props}) + end, + + ?assertEqual({undefined, undefined}, Check([])), + + ?assertEqual({undefined, undefined}, Check([null])), + + ?assertEqual({undefined, undefined}, Check([{<<"other">>, <<"x">>}])), + + ?assertEqual({undefined, undefined}, Check([{<<"basic">>, []}])), + + UserPass1 = {[{<<"username">>, <<"u">>}, {<<"password">>, <<"p">>}]}, + ?assertEqual({"u", "p"}, Check([{<<"basic">>, UserPass1}])), + + UserPass3 = {[{<<"username">>, <<"u">>}, {<<"password">>, null}]}, + ?assertEqual({undefined, undefined}, Check([{<<"basic">>, UserPass3}])). + + +remove_basic_auth_creds_test() -> + Check = fun(Props) -> + HttpDb = remove_basic_auth_creds(#httpdb{auth_props = Props}), + HttpDb#httpdb.auth_props + end, + + ?assertEqual([], Check([])), + + ?assertEqual([{<<"other">>, {[]}}], Check([{<<"other">>, {[]}}])), + + ?assertEqual([], Check([ + {<<"basic">>, {[ + {<<"username">>, <<"u">>}, + {<<"password">>, <<"p">>} + ]}} + ])), + + ?assertEqual([{<<"other">>, {[]}}], Check([ + {<<"basic">>, {[ + {<<"username">>, <<"u">>}, + {<<"password">>, <<"p">>} + ]}}, + {<<"other">>, {[]}} + ])). + + +set_basic_auth_creds_test() -> + Check = fun(User, Pass, Props) -> + HttpDb = set_basic_auth_creds(User, Pass, #httpdb{auth_props = Props}), + HttpDb#httpdb.auth_props + end, + + ?assertEqual([], Check(undefined, undefined, [])), + + ?assertEqual([{<<"other">>, {[]}}], Check(undefined, undefined, + [{<<"other">>, {[]}}])), + + ?assertEqual([ + {<<"basic">>, {[ + {<<"username">>, <<"u">>}, + {<<"password">>, <<"p">>} + ]}} + ], Check("u", "p", [])), + + ?assertEqual([ + {<<"other">>, {[]}}, + {<<"basic">>, {[ + {<<"username">>, <<"u">>}, + {<<"password">>, <<"p">>} + ]}} + ], Check("u", "p", [{<<"other">>, {[]}}])). + + +normalize_basic_creds_test_() -> + DefaultHeaders = (#httpdb{})#httpdb.headers, + [?_assertEqual(Expect, normalize_basic_auth(Input)) || {Input, Expect} <- [ + { + #httpdb{url = "http://u:p@x.y/db"}, + #httpdb{url = "http://x.y/db", auth_props = auth_props("u", "p")} + }, + { + #httpdb{url = "http://u:p@h:80/db"}, + #httpdb{url = "http://h:80/db", auth_props = auth_props("u", "p")} + }, + { + #httpdb{url = "https://u:p@h/db"}, + #httpdb{url = "https://h/db", auth_props = auth_props("u", "p")} + }, + { + #httpdb{url = "http://u:p@[2001:db8:a1b:12f9::1]/db"}, + #httpdb{url = "http://[2001:db8:a1b:12f9::1]/db", + auth_props = auth_props("u", "p")} + }, + { + #httpdb{ + url = "http://h/db", + headers = DefaultHeaders ++ [ + {"Authorization", "Basic " ++ b64creds("u", "p")} + ] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("u", "p")} + }, + { + #httpdb{ + url = "http://h/db", + headers = DefaultHeaders ++ [ + {"Authorization", "Basic " ++ b64creds("u", "p@")} + ] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("u", "p@")} + }, + { + #httpdb{ + url = "http://h/db", + headers = DefaultHeaders ++ [ + {"Authorization", "Basic " ++ b64creds("u", "p@%40")} + ] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("u", "p@%40")} + }, + { + #httpdb{ + url = "http://h/db", + headers = DefaultHeaders ++ [ + {"aUthoriZation", "bASIC " ++ b64creds("U", "p")} + ] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("U", "p")} + }, + { + #httpdb{ + url = "http://u1:p1@h/db", + headers = DefaultHeaders ++ [ + {"Authorization", "Basic " ++ b64creds("u2", "p2")} + ] + }, + #httpdb{url ="http://h/db", auth_props = auth_props("u1", "p1")} + }, + { + #httpdb{ + url = "http://u1:p1@h/db", + auth_props = [{<<"basic">>, {[ + {<<"username">>, <<"u2">>}, + {<<"password">>, <<"p2">>} + ]}}] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("u2", "p2")} + }, + { + #httpdb{ + url = "http://u1:p1@h/db", + auth_props = [{<<"basic">>, {[ + {<<"username">>, <<"u2">>}, + {<<"password">>, <<"p2">>} + ]}}], + headers = DefaultHeaders ++ [ + {"Authorization", "Basic " ++ b64creds("u3", "p3")} + ] + }, + #httpdb{url = "http://h/db", auth_props = auth_props("u2", "p2")} + } + ]]. + + +auth_props(User, Pass) when is_list(User), is_list(Pass) -> + [{<<"basic">>, {[ + {<<"username">>, list_to_binary(User)}, + {<<"password">>, list_to_binary(Pass)} + ]}}]. + + -endif. |