You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by be...@apache.org on 2021/05/05 08:31:52 UTC

[couchdb] 02/04: Add password validation

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

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

commit ba8b1674f04ce08f9166dbb5820a7f8d3f7943c2
Author: Christopher Astfalk <ch...@icloud.com>
AuthorDate: Mon Mar 29 22:06:20 2021 +0200

    Add password validation
---
 rel/overlay/etc/default.ini         |  4 ++
 src/fabric/src/fabric2_users_db.erl | 80 +++++++++++++++++++++++++++++-
 test/elixir/test/users_db_test.exs  | 98 ++++++++++++++++++++++++++++++++-----
 3 files changed, 168 insertions(+), 14 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index e267b4a..3d15eb4 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -183,6 +183,10 @@ iterations = 10 ; iterations for password hashing
 ; min_iterations = 1
 ; max_iterations = 1000000000
 ; password_scheme = pbkdf2
+; List of Erlang RegExp or tuples of RegExp and an optional error message.
+; Where a new password must match all RegExp.
+; Example: [{".{10,}", "Password min length is 10 characters."}, "\\d+"]
+; password_regexp = []
 ; proxy_use_secret = false
 ; comma-separated list of public fields, 404 if empty
 ; public_fields =
diff --git a/src/fabric/src/fabric2_users_db.erl b/src/fabric/src/fabric2_users_db.erl
index fdc787a..3714d34 100644
--- a/src/fabric/src/fabric2_users_db.erl
+++ b/src/fabric/src/fabric2_users_db.erl
@@ -31,6 +31,8 @@
 -define(ITERATIONS, <<"iterations">>).
 -define(SALT, <<"salt">>).
 -define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})).
+-define(REQUIREMENT_ERROR, "Password does not conform to requirements.").
+-define(PASSWORD_SERVER_ERROR, "Server cannot hash passwords at this time.").
 
 -define(
     DDOCS_ADMIN_ONLY,
@@ -77,6 +79,7 @@ save_doc(#doc{body={Body}} = Doc) ->
     {undefined, _} ->
         Doc;
     {ClearPassword, "simple"} -> % deprecated
+        ok = validate_password(ClearPassword),
         Salt = couch_uuids:random(),
         PasswordSha = couch_passwords:simple(ClearPassword, Salt),
         Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?SIMPLE),
@@ -85,6 +88,7 @@ save_doc(#doc{body={Body}} = Doc) ->
         Body3 = proplists:delete(?PASSWORD, Body2),
         Doc#doc{body={Body3}};
     {ClearPassword, "pbkdf2"} ->
+        ok = validate_password(ClearPassword),
         Iterations = list_to_integer(config:get("couch_httpd_auth", "iterations", "1000")),
         Salt = couch_uuids:random(),
         DerivedKey = couch_passwords:pbkdf2(ClearPassword, Salt, Iterations),
@@ -103,7 +107,81 @@ save_doc(#doc{body={Body}} = Doc) ->
             details => "password_scheme must one of (simple, pbkdf2)"
         }),
         couch_log:error("[couch_httpd_auth] password_scheme value of '~p' is invalid.", [Scheme]),
-        throw({forbidden, "Server cannot hash passwords at this time."})
+        throw({forbidden, ?PASSWORD_SERVER_ERROR})
+    end.
+
+% Validate if a new password matches all RegExp in the password_regexp setting.
+% Throws if not.
+% In this function the [couch_httpd_auth] password_regexp config is parsed.
+validate_password(ClearPassword) ->
+    case config:get("couch_httpd_auth", "password_regexp", "") of
+        "" ->
+            ok;
+        "[]" ->
+            ok;
+        ValidateConfig ->
+            RequirementList = case couch_util:parse_term(ValidateConfig) of
+                {ok, RegExpList} when is_list(RegExpList) ->
+                    RegExpList;
+                {ok, NonListValue} ->
+                    couch_log:error(
+                        "[couch_httpd_auth] password_regexp value of '~p'"
+                        " is not a list.",
+                        [NonListValue]
+                    ),
+                    throw({forbidden, ?PASSWORD_SERVER_ERROR});
+                {error, ErrorInfo} ->
+                    couch_log:error(
+                        "[couch_httpd_auth] password_regexp value of '~p'"
+                        " could not get parsed. ~p",
+                        [ValidateConfig, ErrorInfo]
+                    ),
+                    throw({forbidden, ?PASSWORD_SERVER_ERROR})
+            end,
+            % Check the password on every RegExp.
+            lists:foreach(fun(RegExpTuple) ->
+                case get_password_regexp_and_error_msg(RegExpTuple) of
+                    {ok, RegExp, PasswordErrorMsg} ->
+                        check_password(ClearPassword, RegExp, PasswordErrorMsg);
+                    {error} ->
+                        couch_log:error(
+                            "[couch_httpd_auth] password_regexp part of '~p' "
+                            "is not a RegExp string or "
+                            "a RegExp and Reason tuple.",
+                            [RegExpTuple]
+                        ),
+                        throw({forbidden, ?PASSWORD_SERVER_ERROR})
+                end
+            end, RequirementList),
+            ok
+    end.
+
+% Get the RegExp out of the tuple and combine the the error message.
+% First is with a Reason string.
+get_password_regexp_and_error_msg({RegExp, Reason}) 
+        when is_list(RegExp) andalso is_list(Reason)
+        andalso length(Reason) > 0 ->
+    {ok, RegExp, lists:concat([?REQUIREMENT_ERROR, " ", Reason])};
+% With a not correct Reason string.
+get_password_regexp_and_error_msg({RegExp, _Reason}) when is_list(RegExp) ->
+    {ok, RegExp, ?REQUIREMENT_ERROR};
+% Without a Reason string.
+get_password_regexp_and_error_msg({RegExp}) when is_list(RegExp) ->
+    {ok, RegExp, ?REQUIREMENT_ERROR};
+% If the RegExp is only a list/string.
+get_password_regexp_and_error_msg(RegExp) when is_list(RegExp) ->
+    {ok, RegExp, ?REQUIREMENT_ERROR};
+% Not correct RegExpValue.
+get_password_regexp_and_error_msg(_) ->
+    {error}.
+
+% Check the password if it matches a RegExp.
+check_password(Password, RegExp, ErrorMsg) ->
+    case re:run(Password, RegExp, [{capture, none}]) of
+        match ->
+            ok;
+        _ ->
+            throw({bad_request, ErrorMsg})
     end.
 
 
diff --git a/test/elixir/test/users_db_test.exs b/test/elixir/test/users_db_test.exs
index 4ef6d53..34fae19 100644
--- a/test/elixir/test/users_db_test.exs
+++ b/test/elixir/test/users_db_test.exs
@@ -300,11 +300,21 @@ defmodule UsersDbTest do
     assert resp.body["userCtx"]["name"] == "foo@example.org"
   end
 
+  @tag :with_db
   test "users password requirements", context do
     set_config({
       "couch_httpd_auth",
-      "password_reqexp",
-      "[{\".{10,}\"}, {\"[A-Z]+\", \"Requirement 2.\"}, {\"[a-z]+\", \"\"}, {\"\\\\d+\", \"Req 4.\"}]"
+      "password_regexp",
+      Enum.join(
+        [
+          "[{\".{10,}\"},", # 10 chars
+          "{\"[A-Z]+\", \"Requirement 2.\"},", # a uppercase char
+          "{\"[a-z]+\", \"\"},", # a lowercase char
+          "{\"\\\\d+\", \"Req 4.\"},", # A number
+          "\"[!\.,\(\)]+\"]" # A special char
+        ],
+        " "
+      )
     })
 
     session = login("jan", "apple")
@@ -320,8 +330,8 @@ defmodule UsersDbTest do
       @users_db_name,
       jchris_user_doc,
       use_session: session,
-      expect_response: 403,
-      error_message: "forbidden",
+      expect_response: 400,
+      error_message: "bad_request",
       error_reason: "Password does not conform to requirements."
     )
 
@@ -332,8 +342,8 @@ defmodule UsersDbTest do
       @users_db_name,
       jchris_user_doc2,
       use_session: session,
-      expect_response: 403,
-      error_message: "forbidden",
+      expect_response: 400,
+      error_message: "bad_request",
       error_reason: "Password does not conform to requirements. Requirement 2."
     )
 
@@ -344,25 +354,87 @@ defmodule UsersDbTest do
       @users_db_name,
       jchris_user_doc3,
       use_session: session,
-      expect_response: 403,
-      error_message: "forbidden",
+      expect_response: 400,
+      error_message: "bad_request",
       error_reason: "Password does not conform to requirements."
     )
 
-    # With password that match all but the last requirements.
+    # With password that match the first three requirements.
     # Requirement does have a reason text.
     jchris_user_doc4 = Map.put(jchris_user_doc, "password", "funnnnnyBONE")
     save_as(
       @users_db_name,
       jchris_user_doc4,
       use_session: session,
-      expect_response: 403,
-      error_message: "forbidden",
+      expect_response: 400,
+      error_message: "bad_request",
       error_reason: "Password does not conform to requirements. Req 4."
     )
 
-    # With password that match all requirements.
+    # With password that match all but the last requirements.
+    # Requirement does have a reason text.
     jchris_user_doc5 = Map.put(jchris_user_doc, "password", "funnnnnyB0N3")
-    save_as(@users_db_name, jchris_user_doc5, use_session: session, expect_response: 201)
+    save_as(
+      @users_db_name,
+      jchris_user_doc5,
+      use_session: session,
+      expect_response: 400,
+      error_message: "bad_request",
+      error_reason: "Password does not conform to requirements."
+    )
+
+    # With password that match all requirements.
+    jchris_user_doc6 = Map.put(jchris_user_doc, "password", "funnnnnyB0N3!")
+    save_as(@users_db_name, jchris_user_doc6, use_session: session, expect_response: 201)
+  end
+
+  @tag :with_db
+  test "users password requirements with non list value", context do
+    set_config({
+      "couch_httpd_auth",
+      "password_regexp",
+      "{{\".{10,}\"}}"
+    })
+
+    session = login("jan", "apple")
+
+    jchris_user_doc =
+      prepare_user_doc([
+        {:name, "jchris@apache.org"},
+        {:password, "funnybone"}
+      ])
+    save_as(
+      @users_db_name,
+      jchris_user_doc,
+      use_session: session,
+      expect_response: 403,
+      error_message: "forbidden",
+      error_reason: "Server cannot hash passwords at this time."
+    )
+  end
+
+  @tag :with_db
+  test "users password requirements with not correct syntax", context do
+    set_config({
+      "couch_httpd_auth",
+      "password_regexp",
+      "[{\".{10,}\"]"
+    })
+
+    session = login("jan", "apple")
+
+    jchris_user_doc =
+      prepare_user_doc([
+        {:name, "jchris@apache.org"},
+        {:password, "funnybone"}
+      ])
+    save_as(
+      @users_db_name,
+      jchris_user_doc,
+      use_session: session,
+      expect_response: 403,
+      error_message: "forbidden",
+      error_reason: "Server cannot hash passwords at this time."
+    )
   end
 end