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 afc18bbac3f49a8b214d31b76ce1db11250072c4 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})