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 e219ee352916d68e8fe4c2de65a7c5d87888c009 Author: Robert Newson <[email protected]> AuthorDate: Thu Oct 19 23:34:29 2023 +0100 upgrade hashes on successful authentication --- src/chttpd/src/chttpd_auth_cache.erl | 2 +- src/couch/src/couch_auth_cache.erl | 2 +- src/couch/src/couch_httpd_auth.erl | 11 ++++- src/couch/src/couch_password_hasher.erl | 88 +++++++++++++++++++++++++++++++-- src/couch/src/couch_users_db.erl | 28 ++++++++--- 5 files changed, 119 insertions(+), 12 deletions(-) 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 1b4fe1ebb..555b1cc3e 100644 --- a/src/couch/src/couch_httpd_auth.erl +++ b/src/couch/src/couch_httpd_auth.erl @@ -113,10 +113,19 @@ default_authentication_handler(Req, AuthModule) -> Password = ?l2b(Pass), case authenticate(AuthModule, UserName, Password, UserProps) of true -> + Roles = couch_util:get_value(<<"roles">>, UserProps, []), + case lists:member(<<"_admin">>, Roles) of + true -> + ok; + false -> + couch_password_hasher:maybe_upgrade_password_hash( + AuthModule, UserName, Password, UserProps + ) + end, Req0 = Req#httpd{ user_ctx = #user_ctx{ name = UserName, - roles = couch_util:get_value(<<"roles">>, UserProps, []) + roles = Roles } }, case chttpd_util:get_chttpd_auth_config("secret") of diff --git a/src/couch/src/couch_password_hasher.erl b/src/couch/src/couch_password_hasher.erl index b34bc52d9..e51c69279 100644 --- a/src/couch/src/couch_password_hasher.erl +++ b/src/couch/src/couch_password_hasher.erl @@ -21,17 +21,37 @@ 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, + worker_mon +}). %%%=================================================================== %%% Public functions %%%=================================================================== +maybe_upgrade_password_hash(AuthModule, UserName, Password, UserProps) -> + NeedsUpgrade = needs_upgrade(UserProps), + InProgress = in_progress(AuthModule, UserName), + if + 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 +65,36 @@ start_link() -> init(_Args) -> hash_admin_passwords(true), - {ok, #state{}}. + ?IN_PROGRESS_ETS = ets:new(?IN_PROGRESS_ETS, [named_table, {read_concurrency, true}]), + {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({'DOWN', MonRef, _, _, normal}, #state{worker_mon = MonRef} = State) -> + ets:delete_all_objects(?IN_PROGRESS_ETS), + {noreply, start_worker_loop(State)}; +handle_info(Msg, State) -> + {stop, {invalid_info, Msg}, State}. + code_change(_OldVsn, #state{} = State, _Extra) -> {ok, State}. @@ -71,3 +110,46 @@ hash_admin_passwords_int(Persist) -> end, couch_passwords:get_unhashed_admins() ). + +needs_upgrade(UserProps) -> + CurrentScheme = couch_util:get_value(<<"password_scheme">>, UserProps, <<"simple">>), + TargetScheme = ?l2b(chttpd_util:get_chttpd_auth_config("password_scheme", "pbkdf2")), + CurrentPRF = couch_util:get_value(<<"pbkdf2_prf">>, UserProps, <<"sha">>), + TargetPRF = ?l2b(chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256")), + CurrentIterations = couch_util:to_integer( + couch_util:get_value(<<"iterations">>, UserProps, "10") + ), + TargetIterations = chttpd_util:get_chttpd_auth_config_integer( + "iterations", 10 + ), + if + CurrentScheme == TargetScheme andalso TargetScheme == <<"simple">> andalso + CurrentIterations == TargetIterations -> + false; + CurrentScheme == TargetScheme andalso TargetScheme == <<"pbkdf2">> andalso + CurrentPRF == TargetPRF andalso CurrentIterations == TargetIterations -> + false; + true -> + true + end. + +in_progress(AuthModule, UserName) -> + ets:member(?IN_PROGRESS_ETS, {AuthModule, UserName}). + +start_worker_loop(State) -> + {WorkerPid, WorkerMon} = spawn_monitor(?MODULE, worker_loop, [self()]), + State#state{worker_pid = WorkerPid, worker_mon = WorkerMon}. + +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..a32591d99 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 = + if + PreserveSalt -> + % use existing salt, if present. + couch_util:get_value(?SALT, Body, couch_uuids:random()); + true -> + 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})
