This is an automated email from the ASF dual-hosted git repository. jiahuili430 pushed a commit to branch fix-password-hasher in repository https://gitbox.apache.org/repos/asf/couchdb.git
commit 4ed6cc5f6dca4807f6a34880b1332eed86b23ce4 Author: Jiahui Li <[email protected]> AuthorDate: Sat Dec 6 19:12:32 2025 -0600 Avoid updating password hash when request with simple password scheme When using the `simple` password scheme, the number of iterations is `undefined`, so the user's password hash is updated every time when a new request is made using user's authentication credentials. Add a case statement to avoid this situation. --- src/couch/src/couch_password_hasher.erl | 11 +- .../test/eunit/couch_passwords_hasher_tests.erl | 196 +++++++++++++++++++++ 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/src/couch/src/couch_password_hasher.erl b/src/couch/src/couch_password_hasher.erl index 677d1c2f5..eb50c341c 100644 --- a/src/couch/src/couch_password_hasher.erl +++ b/src/couch/src/couch_password_hasher.erl @@ -28,6 +28,9 @@ -export([worker_loop/1]). +% For testing +-export([is_doc/1, upgrade_password_hash/6]). + -define(IN_PROGRESS_ETS, couch_password_hasher_in_progress). -record(state, { @@ -40,7 +43,7 @@ maybe_upgrade_password_hash(Req, UserName, Password, UserProps, AuthModule, AuthCtx) -> UpgradeEnabled = config:get_boolean("chttpd_auth", "upgrade_hash_on_auth", true), - IsDoc = is_doc(UserProps), + IsDoc = couch_password_hasher:is_doc(UserProps), NeedsUpgrade = needs_upgrade(UserProps), InProgress = in_progress(AuthModule, UserName), if @@ -122,7 +125,7 @@ needs_upgrade(UserProps) -> "iterations", 600000 ), case {TargetScheme, TargetIterations, TargetPRF} of - {CurrentScheme, CurrentIterations, _} when CurrentScheme == <<"simple">> -> + {CurrentScheme, _, _} when CurrentScheme == <<"simple">>, CurrentIterations =:= undefined -> false; {CurrentScheme, CurrentIterations, CurrentPRF} when CurrentScheme == <<"pbkdf2">> -> false; @@ -141,7 +144,9 @@ worker_loop(Parent) -> receive {upgrade_password_hash, Req, UserName, Password, UserProps, AuthModule, AuthCtx} -> couch_log:notice("upgrading stored password hash for '~s' (~p)", [UserName, AuthCtx]), - upgrade_password_hash(Req, UserName, Password, UserProps, AuthModule, AuthCtx), + couch_password_hasher:upgrade_password_hash( + Req, UserName, Password, UserProps, AuthModule, AuthCtx + ), erlang:send_after(5000, Parent, {done, AuthModule, UserName}); _Msg -> ignore diff --git a/src/couch/test/eunit/couch_passwords_hasher_tests.erl b/src/couch/test/eunit/couch_passwords_hasher_tests.erl new file mode 100644 index 000000000..acb105eec --- /dev/null +++ b/src/couch/test/eunit/couch_passwords_hasher_tests.erl @@ -0,0 +1,196 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(couch_passwords_hasher_tests). + +-include_lib("couch/include/couch_db.hrl"). +-include_lib("couch/include/couch_eunit.hrl"). + +-define(USER, "couch_passowrds_hash_test_admin"). +-define(PASS, "pass"). +-define(AUTH, {basic_auth, {?USER, ?PASS}}). +-define(CONTENT_JSON, {"Content-Type", "application/json"}). +-define(RANDOM_USER, "user-" ++ ?b2l(couch_uuids:random())). + +setup(Scheme) -> + update_hash(?USER, ?PASS), + Db = ?b2l(?tempdb()), + create_db(Db), + config:set("couch_httpd_auth", "authentication_db", Db, false), + config:set("couch_httpd_auth", "password_scheme", Scheme, false), + meck:new(couch_password_hasher, [passthrough]), + Db. + +teardown(_, Db) -> + delete_db(Db), + config:delete("admins", ?USER, false), + config:delete("couch_httpd_auth", "authentication_db", false), + config:delete("couch_httpd_auth", "password_scheme", false), + meck:unload(). + +couch_password_hasher_test_() -> + { + "couch_password_hasher tests", + { + setup, + fun() -> test_util:start_couch([chttpd]) end, + fun test_util:stop_couch/1, + [ + upgrade_password_hash_tests("simple"), + upgrade_password_hash_tests("pbkdf2") + ] + } + }. + +upgrade_password_hash_tests(Scheme) -> + { + "password scheme " ++ Scheme ++ " tests", + foreachx, + fun setup/1, + fun teardown/2, + [ + {Scheme, Test} + || Test <- + [ + fun create_user_by_admin_should_not_upgrade_password_hash/2, + fun request_by_user_should_not_upgrade_password_hash/2, + fun update_user_password_by_user_should_not_upgrade_password_hash/2 + ] + ] + }. + +create_user_by_admin_should_not_upgrade_password_hash(_, Db) -> + ?_test(begin + reset(), + create_user(Db, ?RANDOM_USER, ?PASS), + ?assertEqual(0, num_calls()) + end). + +request_by_user_should_not_upgrade_password_hash(_, Db) -> + ?_test(begin + User = ?RANDOM_USER, + create_user(Db, User, ?PASS), + {200, _} = req(get, url(Db, "org.couchdb.user:" ++ User)), + ?_assertEqual(0, num_calls()), + + update_hash(User, ?PASS), + reset(), + meck:expect(couch_password_hasher, is_doc, fun(_) -> true end), + Headers = [{basic_auth, {User, ?PASS}}], + {200, _} = req(get, url(), Headers, []), + ?_assertEqual(0, num_calls()), + config:delete("admins", User, false) + end). + +update_user_password_by_user_should_not_upgrade_password_hash(_, Db) -> + ?_test(begin + User = ?RANDOM_USER, + create_user(Db, User, ?PASS), + {200, #{<<"_rev">> := Rev}} = req(get, url(Db, "org.couchdb.user:" ++ User)), + ?_assertEqual(0, num_calls()), + + update_hash(User, ?PASS), + reset(), + meck:expect(couch_password_hasher, is_doc, fun(_) -> true end), + NewPass = "new_password", + update_password(Db, User, ?PASS, NewPass, ?b2l(Rev)), + ?_assertEqual(0, num_calls()), + + update_hash(User, NewPass), + OldAuth = [{basic_auth, {User, ?PASS}}], + {401, _} = req(get, url(), OldAuth, []), + ?_assertEqual(0, num_calls()), + NewAuth = [{basic_auth, {User, NewPass}}], + {200, _} = req(get, url(), NewAuth, []), + ?_assertEqual(0, num_calls()), + config:delete("admins", User, false) + end). + +%%%%%%%%%%%%%%%%%%%% Utility Functions %%%%%%%%%%%%%%%%%%%% +update_hash(User, Pass) -> + Hashed = couch_passwords:hash_admin_password(Pass), + config:set("admins", User, ?b2l(Hashed), false). + +url() -> + Addr = config:get("chttpd", "bind_address", "127.0.0.1"), + Port = mochiweb_socket_server:get(chttpd, port), + lists:concat(["http://", Addr, ":", Port]). + +url(Db) -> + url() ++ "/" ++ Db. + +url(Db, Path) -> + url(Db) ++ "/" ++ Path. + +create_db(Db) -> + case req(put, url(Db)) of + {201, #{}} -> ok; + Error -> error({failed_to_create_test_db, Db, Error}) + end. + +delete_db(Db) -> + case req(delete, url(Db)) of + {200, #{}} -> ok; + Error -> error({failed_to_delete_test_db, Db, Error}) + end. + +create_user(Db, UserName, Password) -> + ok = couch_auth_cache:ensure_users_db_exists(), + User = ?l2b(UserName), + Pass = ?l2b(Password), + Body = + {[ + {<<"name">>, User}, + {<<"password">>, Pass}, + {<<"roles">>, []}, + {<<"type">>, <<"user">>} + ]}, + case req(put, url(Db, "org.couchdb.user:" ++ UserName), jiffy:encode(Body)) of + {201, #{}} -> ok; + Error -> error({failed_to_create_user, UserName, Error}) + end. + +update_password(Db, UserName, Password, NewPassword, Rev) -> + User = ?l2b(UserName), + NewPass = ?l2b(NewPassword), + Body = + {[ + {<<"name">>, User}, + {<<"password">>, NewPass}, + {<<"roles">>, []}, + {<<"type">>, <<"user">>} + ]}, + Headers = [{basic_auth, {UserName, Password}}, {"If-Match", Rev}], + case req(put, url(Db, "org.couchdb.user:" ++ UserName), Headers, jiffy:encode(Body)) of + {201, #{}} -> ok; + Error -> error({failed_to_update_password, UserName, Error}) + end. + +req(Method, Url) -> + Headers = [?CONTENT_JSON, ?AUTH], + {ok, Code, _, Res} = test_request:request(Method, Url, Headers), + {Code, jiffy:decode(Res, [return_maps])}. + +req(Method, Url, Body) -> + Headers = [?CONTENT_JSON, ?AUTH], + {ok, Code, _, Res} = test_request:request(Method, Url, Headers, Body), + {Code, jiffy:decode(Res, [return_maps])}. + +req(Method, Url, Headers, Body) -> + {ok, Code, _, Res} = test_request:request(Method, Url, Headers, Body), + {Code, jiffy:decode(Res, [return_maps])}. + +reset() -> + meck:reset(couch_password_hasher). + +num_calls() -> + meck:num_calls(couch_password_hasher, upgrade_password_hash, ['_', '_', '_', '_', '_', '_']).
