This is an automated email from the ASF dual-hosted git repository. rnewson pushed a commit to branch lockout in repository https://gitbox.apache.org/repos/asf/couchdb.git
commit 9655b13651bbd7ac8f00fc0d6c3973908629fc56 Author: Robert Newson <[email protected]> AuthorDate: Wed Apr 17 16:33:10 2024 +0100 wip --- src/couch/src/couch_httpd_auth.erl | 24 +++++++++++++----- src/couch/src/couch_lockout.erl | 50 +++++++++++++++++++++++++++++++++++++ src/couch/src/couch_primary_sup.erl | 9 +++++++ src/ets_lru/src/ets_lru.erl | 21 ++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl index 58fd4320c..cfb0d3f5d 100644 --- a/src/couch/src/couch_httpd_auth.erl +++ b/src/couch/src/couch_httpd_auth.erl @@ -28,7 +28,7 @@ -export([cookie_auth_header/2]). -export([handle_session_req/1, handle_session_req/2]). --export([authenticate/4, verify_totp/2]). +-export([verify_totp/2]). -export([ensure_cookie_auth_secret/0, make_cookie_time/0]). -export([maybe_value/3]). @@ -40,6 +40,8 @@ -compile({no_auto_import, [integer_to_binary/1, integer_to_binary/2]}). +-define(LOCKOUT_MSG, <<"Account is temporarily locked due to multiple authentication failures">>). + party_mode_handler(Req) -> case chttpd_util:get_chttpd_config_boolean( @@ -111,7 +113,7 @@ default_authentication_handler(Req, AuthModule) -> reject_if_totp(UserProps), UserName = ?l2b(User), Password = ?l2b(Pass), - case authenticate(AuthModule, UserName, Password, UserProps) of + case authenticate(Req, AuthModule, UserName, Password, UserProps) of true -> couch_password_hasher:maybe_upgrade_password_hash( AuthModule, UserName, Password, UserProps @@ -500,7 +502,7 @@ handle_session_req(#httpd{method = 'POST', mochi_req = MochiReq} = Req, AuthModu nil -> {ok, [], nil}; Result -> Result end, - case authenticate(AuthModule, UserName, Password, UserProps) of + case authenticate(Req, AuthModule, UserName, Password, UserProps) of true -> verify_totp(UserProps, Form), couch_password_hasher:maybe_upgrade_password_hash( @@ -619,19 +621,29 @@ extract_username(Form) -> maybe_value(_Key, undefined, _Fun) -> []; maybe_value(Key, Else, Fun) -> [{Key, Fun(Else)}]. -authenticate(AuthModule, UserName, Password, UserProps) -> +authenticate(Req, AuthModule, UserName, Password, UserProps) -> + case couch_lockout:is_locked_out(Req, UserName) of + true -> + throw({forbidden, ?LOCKOUT_MSG}); + false -> + ok + end, UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<>>), case couch_passwords_cache:authenticate(AuthModule, UserName, Password, UserSalt) of not_found -> case authenticate_int(Password, UserSalt, UserProps) of false -> + couch_lockout:lockout(Req, UserName), false; true -> couch_passwords_cache:insert(AuthModule, UserName, Password, UserSalt), true end; - Result when is_boolean(Result) -> - Result + false -> + couch_lockout:lockout(Req, UserName), + false; + true -> + true end. authenticate_int(Pass, UserSalt, UserProps) -> diff --git a/src/couch/src/couch_lockout.erl b/src/couch/src/couch_lockout.erl new file mode 100644 index 000000000..49bcfebd0 --- /dev/null +++ b/src/couch/src/couch_lockout.erl @@ -0,0 +1,50 @@ +% 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_lockout). +-compile(tuple_calls). +-include_lib("couch/include/couch_db.hrl"). + +-define(LRU, couch_lockout_lru). + +-export([is_locked_out/2, lockout/2]). + +is_locked_out(#httpd{} = Req, UserName) -> + case config:get_boolean("couch_lockout", "enable", true) of + true -> + is_locked_out_int(Req, UserName); + false -> + false + end. + +is_locked_out_int(#httpd{} = Req, UserName) -> + LockoutThreshold = config:get_integer("couch_lockout", "threshold", 5), + case ets_lru:lookup_d(?LRU, {peer(Req), UserName}) of + {ok, FailCount} when FailCount >= LockoutThreshold -> + true; + {ok, _} -> + false; + not_found -> + false + end. + +lockout(#httpd{} = Req, UserName) -> + case config:get_boolean("couch_lockout", "enable", true) of + true -> + ets_lru:update_counter(?LRU, {peer(Req), UserName}, 1), + ok; + false -> + ok + end. + +peer(#httpd{} = Req) -> + (Req#httpd.mochi_req):get(peer). diff --git a/src/couch/src/couch_primary_sup.erl b/src/couch/src/couch_primary_sup.erl index d7e24fa61..9de96dd12 100644 --- a/src/couch/src/couch_primary_sup.erl +++ b/src/couch/src/couch_primary_sup.erl @@ -33,6 +33,15 @@ init([]) -> {max_idle, config:get_integer("couch_passwords_cache", "max_idle", 600_000)} ] ]}, + permanent, 5000, worker, [ets_lru]}, + {couch_lockout_lru, + {ets_lru, start_link, [ + couch_lockout_lru, + [ + {max_objects, config:get_integer("couch_lockout", "max_objects", 10_000)}, + {max_idle, config:get_integer("couch_lockout", "max_lifetime", 300_000)} + ] + ]}, permanent, 5000, worker, [ets_lru]} ] ++ couch_servers(), {ok, {{one_for_one, 10, 3600}, Children}}. diff --git a/src/ets_lru/src/ets_lru.erl b/src/ets_lru/src/ets_lru.erl index e0af787ee..080c8f7e2 100644 --- a/src/ets_lru/src/ets_lru.erl +++ b/src/ets_lru/src/ets_lru.erl @@ -18,6 +18,7 @@ stop/1, insert/3, + update_counter/3, lookup/2, match/3, match_object/3, @@ -75,6 +76,9 @@ lookup(LRU, Key) -> insert(LRU, Key, Val) -> gen_server:call(LRU, {insert, Key, Val}). +update_counter(LRU, Key, Incr) when is_integer(Incr) -> + gen_server:call(LRU, {update_counter, Key, Incr}). + remove(LRU, Key) -> gen_server:call(LRU, {remove, Key}). @@ -177,6 +181,23 @@ handle_call({insert, Key, Val}, _From, St) -> true = ets:insert(St#st.ctimes, {NewATime, Key}) end, {reply, ok, St, 0}; +handle_call({update_counter, Key, Incr}, _From, St) -> + NewATime = strict_monotonic_time(St#st.time_unit), + Pattern = #entry{key = Key, atime = '$1', val = '$2', _ = '_'}, + case ets:match(St#st.objects, Pattern) of + [[ATime, Val]] -> + true = ets:update_element(St#st.objects, Key, [ + {#entry.val, Val + Incr}, {#entry.atime, NewATime} + ]), + true = ets:delete(St#st.atimes, ATime), + true = ets:insert(St#st.atimes, {NewATime, Key}); + [] -> + Entry = #entry{key = Key, val = Incr, atime = NewATime, ctime = NewATime}, + true = ets:insert(St#st.objects, Entry), + true = ets:insert(St#st.atimes, {NewATime, Key}), + true = ets:insert(St#st.ctimes, {NewATime, Key}) + end, + {reply, ok, St, 0}; handle_call({remove, Key}, _From, St) -> Pattern = #entry{key = Key, atime = '$1', ctime = '$2', _ = '_'}, Reply =
