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 =

Reply via email to