This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 90d8063128dd5fc66fd0d0821647491fbc6d6cc2
Author: Robert Newson <rnew...@apache.org>
AuthorDate: Fri Oct 27 17:30:17 2023 +0100

    upgrade password on next session or basic auth
---
 rel/overlay/etc/default.ini             |  1 +
 src/chttpd/src/chttpd_auth_cache.erl    |  2 +-
 src/couch/src/couch_auth_cache.erl      |  2 +-
 src/couch/src/couch_httpd_auth.erl      |  6 +++
 src/couch/src/couch_password_hasher.erl | 87 +++++++++++++++++++++++++++++++--
 src/couch/src/couch_users_db.erl        | 28 ++++++++---
 6 files changed, 115 insertions(+), 11 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 90656b7a5..7a4417856 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -326,6 +326,7 @@ bind_address = 127.0.0.1
 ;max_iterations = 1000000000
 ;password_scheme = pbkdf2
 ;pbkdf2_prf = sha256 ; must be one of sha | sha224 | sha256 | sha384 | sha512
+;upgrade_hash_on_auth = true; whether to upgrade password hashes on successful 
authentication.
 
 ; List of Erlang RegExp or tuples of RegExp and an optional error message.
 ; Where a new password must match all RegExp.
diff --git a/src/chttpd/src/chttpd_auth_cache.erl 
b/src/chttpd/src/chttpd_auth_cache.erl
index 36157ed38..c664cd3a9 100644
--- a/src/chttpd/src/chttpd_auth_cache.erl
+++ b/src/chttpd/src/chttpd_auth_cache.erl
@@ -61,7 +61,7 @@ get_user_creds(_Req, UserName) when is_binary(UserName) ->
 
 update_user_creds(_Req, UserDoc, _Ctx) ->
     {_, Ref} = spawn_monitor(fun() ->
-        case fabric:update_doc(dbname(), UserDoc, []) of
+        case fabric:update_doc(dbname(), UserDoc, [?ADMIN_CTX]) of
             {ok, _} ->
                 exit(ok);
             Else ->
diff --git a/src/couch/src/couch_auth_cache.erl 
b/src/couch/src/couch_auth_cache.erl
index efa8e4765..08675c941 100644
--- a/src/couch/src/couch_auth_cache.erl
+++ b/src/couch/src/couch_auth_cache.erl
@@ -54,7 +54,7 @@ get_user_creds(_Req, UserName) ->
 update_user_creds(_Req, UserDoc, _AuthCtx) ->
     ok = ensure_users_db_exists(),
     couch_util:with_db(users_db(), fun(UserDb) ->
-        {ok, _NewRev} = couch_db:update_doc(UserDb, UserDoc, []),
+        {ok, _NewRev} = couch_db:update_doc(UserDb, UserDoc, [?ADMIN_CTX]),
         ok
     end).
 
diff --git a/src/couch/src/couch_httpd_auth.erl 
b/src/couch/src/couch_httpd_auth.erl
index c0a823a12..58fd4320c 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -113,6 +113,9 @@ default_authentication_handler(Req, AuthModule) ->
                     Password = ?l2b(Pass),
                     case authenticate(AuthModule, UserName, Password, 
UserProps) of
                         true ->
+                            couch_password_hasher:maybe_upgrade_password_hash(
+                                AuthModule, UserName, Password, UserProps
+                            ),
                             Req#httpd{
                                 user_ctx = #user_ctx{
                                     name = UserName,
@@ -500,6 +503,9 @@ handle_session_req(#httpd{method = 'POST', mochi_req = 
MochiReq} = Req, AuthModu
     case authenticate(AuthModule, UserName, Password, UserProps) of
         true ->
             verify_totp(UserProps, Form),
+            couch_password_hasher:maybe_upgrade_password_hash(
+                AuthModule, UserName, Password, UserProps
+            ),
             % setup the session cookie
             Secret = ?l2b(ensure_cookie_auth_secret()),
             UserSalt = couch_util:get_value(<<"salt">>, UserProps),
diff --git a/src/couch/src/couch_password_hasher.erl 
b/src/couch/src/couch_password_hasher.erl
index b34bc52d9..197fcd4fc 100644
--- a/src/couch/src/couch_password_hasher.erl
+++ b/src/couch/src/couch_password_hasher.erl
@@ -21,17 +21,38 @@
     init/1,
     handle_call/3,
     handle_cast/2,
+    handle_info/2,
     code_change/3
 ]).
 
--export([hash_admin_passwords/1]).
+-export([maybe_upgrade_password_hash/4, hash_admin_passwords/1]).
 
--record(state, {}).
+-export([worker_loop/1]).
+
+-define(IN_PROGRESS_ETS, couch_password_hasher_in_progress).
+
+-record(state, {
+    worker_pid
+}).
 
 %%%===================================================================
 %%% Public functions
 %%%===================================================================
 
+maybe_upgrade_password_hash(AuthModule, UserName, Password, UserProps) ->
+    UpgradeEnabled = config:get_boolean("chttpd_auth", "upgrade_hash_on_auth", 
true),
+    IsAdmin = is_admin(UserProps),
+    NeedsUpgrade = needs_upgrade(UserProps),
+    InProgress = in_progress(AuthModule, UserName),
+    if
+        UpgradeEnabled andalso not IsAdmin andalso NeedsUpgrade andalso not 
InProgress ->
+            gen_server:cast(
+                ?MODULE, {upgrade_password_hash, AuthModule, UserName, 
Password, UserProps}
+            );
+        true ->
+            ok
+    end.
+
 -spec hash_admin_passwords(Persist :: boolean()) -> Reply :: term().
 hash_admin_passwords(Persist) ->
     gen_server:cast(?MODULE, {hash_admin_passwords, Persist}).
@@ -45,17 +66,34 @@ start_link() ->
 
 init(_Args) ->
     hash_admin_passwords(true),
-    {ok, #state{}}.
+    ?IN_PROGRESS_ETS = ets:new(?IN_PROGRESS_ETS, [named_table, 
{read_concurrency, true}]),
+    spawn_link(?MODULE, worker_loop, [self()]),
+    {ok, start_worker_loop(#state{})}.
 
 handle_call(Msg, _From, #state{} = State) ->
     {stop, {invalid_call, Msg}, {invalid_call, Msg}, State}.
 
+handle_cast({upgrade_password_hash, AuthModule, UserName, Password, 
UserProps}, State) ->
+    case ets:insert_new(?IN_PROGRESS_ETS, {{AuthModule, UserName}}) of
+        true ->
+            State#state.worker_pid !
+                {upgrade_password_hash, AuthModule, UserName, Password, 
UserProps};
+        false ->
+            ok
+    end,
+    {noreply, State};
 handle_cast({hash_admin_passwords, Persist}, State) ->
     hash_admin_passwords_int(Persist),
     {noreply, State};
 handle_cast(Msg, State) ->
     {stop, {invalid_cast, Msg}, State}.
 
+handle_info({done, AuthModule, UserName}, State) ->
+    ets:delete(?IN_PROGRESS_ETS, {AuthModule, UserName}),
+    {noreply, State};
+handle_info(Msg, State) ->
+    {stop, {invalid_info, Msg}, State}.
+
 code_change(_OldVsn, #state{} = State, _Extra) ->
     {ok, State}.
 
@@ -71,3 +109,46 @@ hash_admin_passwords_int(Persist) ->
         end,
         couch_passwords:get_unhashed_admins()
     ).
+
+is_admin(UserProps) ->
+    Roles = couch_util:get_value(<<"roles">>, UserProps, []),
+    lists:member(<<"_admin">>, Roles).
+
+needs_upgrade(UserProps) ->
+    CurrentScheme = couch_util:get_value(<<"password_scheme">>, UserProps),
+    TargetScheme = ?l2b(chttpd_util:get_chttpd_auth_config("password_scheme", 
"pbkdf2")),
+    CurrentPRF = couch_util:get_value(<<"pbkdf2_prf">>, UserProps),
+    TargetPRF = ?l2b(chttpd_util:get_chttpd_auth_config("pbkdf2_prf", 
"sha256")),
+    CurrentIterations = couch_util:get_value(<<"iterations">>, UserProps),
+    TargetIterations = chttpd_util:get_chttpd_auth_config_integer(
+        "iterations", 50000
+    ),
+    case {TargetScheme, TargetIterations, TargetPRF} of
+        {CurrentScheme, CurrentIterations, _} when CurrentScheme == 
<<"simple">> ->
+            false;
+        {CurrentScheme, CurrentIterations, CurrentPRF} when CurrentScheme == 
<<"pbkdf2">> ->
+            false;
+        {_, _, _} ->
+            true
+    end.
+
+in_progress(AuthModule, UserName) ->
+    ets:member(?IN_PROGRESS_ETS, {AuthModule, UserName}).
+
+start_worker_loop(State) ->
+    WorkerPid = spawn_link(?MODULE, worker_loop, [self()]),
+    State#state{worker_pid = WorkerPid}.
+
+worker_loop(Parent) ->
+    receive
+        {upgrade_password_hash, AuthModule, UserName, Password, UserProps} ->
+            couch_log:notice("upgrading stored password hash for '~s'", 
[UserName]),
+            upgrade_password_hash(AuthModule, Password, UserProps),
+            erlang:send_after(5000, Parent, {done, AuthModule, UserName})
+    end,
+    worker_loop(Parent).
+
+upgrade_password_hash(AuthModule, Password, UserProps0) ->
+    UserProps1 = [{<<"password">>, Password}, {<<"preserve_salt">>, true} | 
UserProps0],
+    NewUserDoc = couch_doc:from_json_obj({UserProps1}),
+    AuthModule:update_user_creds(nil, NewUserDoc, ?ADMIN_CTX).
diff --git a/src/couch/src/couch_users_db.erl b/src/couch/src/couch_users_db.erl
index f8d56882a..b4d1da938 100644
--- a/src/couch/src/couch_users_db.erl
+++ b/src/couch/src/couch_users_db.erl
@@ -24,6 +24,7 @@
 -define(PASSWORD_SHA, <<"password_sha">>).
 -define(PBKDF2, <<"pbkdf2">>).
 -define(PBKDF2_PRF, <<"pbkdf2_prf">>).
+-define(PRESERVE_SALT, <<"preserve_salt">>).
 -define(ITERATIONS, <<"iterations">>).
 -define(SALT, <<"salt">>).
 -define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})).
@@ -64,6 +65,21 @@ before_doc_update(Doc, Db, _UpdateType) ->
 save_doc(#doc{body = {Body}} = Doc) ->
     %% Support both schemes to smooth migration from legacy scheme
     Scheme = chttpd_util:get_chttpd_auth_config("password_scheme", "pbkdf2"),
+
+    % We preserve the salt value if requested (for a hashing upgrade, 
typically)
+    % in order to avoid conflicts if multiple nodes try to upgrade at the same 
time
+    % and to avoid invalidating existing session cookies (since the password 
did not
+    % change).
+    PreserveSalt = couch_util:get_value(?PRESERVE_SALT, Body, false),
+    Salt =
+        case PreserveSalt of
+            true ->
+                % use existing salt, if present.
+                couch_util:get_value(?SALT, Body, couch_uuids:random());
+            false ->
+                couch_uuids:random()
+        end,
+
     case {couch_util:get_value(?PASSWORD, Body), Scheme} of
         % server admins don't have a user-db password entry
         {null, _} ->
@@ -73,20 +89,19 @@ save_doc(#doc{body = {Body}} = Doc) ->
         % deprecated
         {ClearPassword, "simple"} ->
             ok = validate_password(ClearPassword),
-            Salt = couch_uuids:random(),
             PasswordSha = couch_passwords:simple(ClearPassword, Salt),
             Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?SIMPLE),
             Body1 = ?replace(Body0, ?SALT, Salt),
             Body2 = ?replace(Body1, ?PASSWORD_SHA, PasswordSha),
-            Body3 = proplists:delete(?PASSWORD, Body2),
-            Doc#doc{body = {Body3}};
+            Body3 = proplists:delete(?PRESERVE_SALT, Body2),
+            Body4 = proplists:delete(?PASSWORD, Body3),
+            Doc#doc{body = {Body4}};
         {ClearPassword, "pbkdf2"} ->
             ok = validate_password(ClearPassword),
             PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
             Iterations = chttpd_util:get_chttpd_auth_config_integer(
                 "iterations", 10
             ),
-            Salt = couch_uuids:random(),
             DerivedKey = couch_passwords:pbkdf2(
                 list_to_existing_atom(PRF), ClearPassword, Salt, Iterations
             ),
@@ -95,8 +110,9 @@ save_doc(#doc{body = {Body}} = Doc) ->
             Body2 = ?replace(Body1, ?ITERATIONS, Iterations),
             Body3 = ?replace(Body2, ?DERIVED_KEY, DerivedKey),
             Body4 = ?replace(Body3, ?SALT, Salt),
-            Body5 = proplists:delete(?PASSWORD, Body4),
-            Doc#doc{body = {Body5}};
+            Body5 = proplists:delete(?PRESERVE_SALT, Body4),
+            Body6 = proplists:delete(?PASSWORD, Body5),
+            Doc#doc{body = {Body6}};
         {_ClearPassword, Scheme} ->
             couch_log:error("[couch_httpd_auth] password_scheme value of '~p' 
is invalid.", [Scheme]),
             throw({forbidden, ?PASSWORD_SERVER_ERROR})

Reply via email to