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 66ec097ea4e0ec364140f9cf7bf001cc19ca5520
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            |   9 +-
 .../test/eunit/couch_passwords_hasher_tests.erl    | 196 +++++++++++++++++++++
 2 files changed, 202 insertions(+), 3 deletions(-)

diff --git a/src/couch/src/couch_password_hasher.erl 
b/src/couch/src/couch_password_hasher.erl
index 677d1c2f5..6298b5ec1 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 = ?MODULE: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,7 @@ 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),
+            ?MODULE: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, ['_', '_', 
'_', '_', '_', '_']).

Reply via email to