You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by rn...@apache.org on 2023/07/26 14:21:56 UTC

[couchdb] branch basic-auth-to-session updated (0f14a36ac -> 0fe6de554)

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

rnewson pushed a change to branch basic-auth-to-session
in repository https://gitbox.apache.org/repos/asf/couchdb.git


    omit 0f14a36ac send a session cookie after successful basic auth
     new 0fe6de554 send a session cookie after successful basic auth

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (0f14a36ac)
            \
             N -- N -- N   refs/heads/basic-auth-to-session (0fe6de554)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../eunit/chttpd_auth_hash_algorithms_tests.erl    |  2 +-
 src/couch/src/couch_httpd_auth.erl                 | 38 +++++++++++++++-------
 2 files changed, 27 insertions(+), 13 deletions(-)


[couchdb] 01/01: send a session cookie after successful basic auth

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch basic-auth-to-session
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 0fe6de554d3e47d64a0949952946d1e4d66bbef0
Author: Robert Newson <rn...@apache.org>
AuthorDate: Tue Jul 25 17:47:00 2023 +0100

    send a session cookie after successful basic auth
---
 src/couch/src/couch_httpd_auth.erl    | 77 +++++++++++++++++++++++++++++------
 src/docs/src/api/server/authn.rst     |  7 ++++
 test/elixir/lib/couch.ex              | 25 +++++++-----
 test/elixir/test/cookie_auth_test.exs | 42 +++++++++++++++++++
 4 files changed, 127 insertions(+), 24 deletions(-)

diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 652fb3996..5ffbddd2c 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -30,7 +30,6 @@
 
 -export([authenticate/2, verify_totp/2]).
 -export([ensure_cookie_auth_secret/0, make_cookie_time/0]).
--export([cookie_auth_cookie/4, cookie_scheme/1]).
 -export([maybe_value/3]).
 
 -export([jwt_authentication_handler/1]).
@@ -114,12 +113,23 @@ default_authentication_handler(Req, AuthModule) ->
                     Password = ?l2b(Pass),
                     case authenticate(Password, UserProps) of
                         true ->
-                            Req#httpd{
+                            Req0 = Req#httpd{
                                 user_ctx = #user_ctx{
                                     name = UserName,
                                     roles = couch_util:get_value(<<"roles">>, UserProps, [])
                                 }
-                            };
+                            },
+                            case chttpd_util:get_chttpd_auth_config("secret") of
+                                undefined ->
+                                    Req0;
+                                SecretStr ->
+                                    Secret = ?l2b(SecretStr),
+                                    UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<"">>),
+                                    FullSecret = <<Secret/binary, UserSalt/binary>>,
+                                    Req0#httpd{
+                                        auth = {FullSecret, true, true}
+                                    }
+                            end;
                         false ->
                             authentication_warning(Req, UserName),
                             throw({unauthorized, <<"Name or password is incorrect.">>})
@@ -331,11 +341,15 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
         [] ->
             Req;
         Cookie ->
-            [User, TimeStr, HashStr] =
+            % TimestampStr is expanded to be a list of options, separated
+            % by commas. The new second option is 'MustMatchBasic', a 0 or
+            % 1 to indicate if the basic auth username must match the cookie
+            % if present.
+            [User, OptionsStr, HashStr] =
                 try
                     AuthSession = couch_util:decodeBase64Url(Cookie),
                     [_A, _B, _Cs] = re:split(
-                        ?b2l(AuthSession),
+                        AuthSession,
                         ":",
                         [{return, list}, {parts, 3}]
                     )
@@ -344,6 +358,20 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
                         Reason = <<"Malformed AuthSession cookie. Please clear your cookies.">>,
                         throw({bad_request, Reason})
                 end,
+            [TimeStr, MustMatchBasic] =
+                case re:split(OptionsStr, ",", [{return, list}]) of
+                    [T, M] ->
+                        [T, M];
+                    [T] ->
+                        [T, "0"]
+                end,
+            BasicAuthUser =
+                case basic_name_pw(Req) of
+                    {U, _P} ->
+                        U;
+                    nil ->
+                        nil
+                end,
             % Verify expiry and hash
             CurrentTime = make_cookie_time(),
             HashAlgorithms = couch_util:get_config_hash_algorithms(),
@@ -351,6 +379,9 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
                 undefined ->
                     couch_log:debug("cookie auth secret is not set", []),
                     Req;
+                _ when MustMatchBasic == "1", BasicAuthUser /= nil, User /= BasicAuthUser ->
+                    % ignoring pre-emptive cookie
+                    Req;
                 SecretStr ->
                     Secret = ?l2b(SecretStr),
                     case AuthModule:get_user_creds(Req, User) of
@@ -361,7 +392,13 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
                             FullSecret = <<Secret/binary, UserSalt/binary>>,
                             Hash = ?l2b(HashStr),
                             VerifyHash = fun(HashAlg) ->
-                                Hmac = couch_util:hmac(HashAlg, FullSecret, User ++ ":" ++ TimeStr),
+                                Hmac = couch_util:hmac(
+                                    HashAlg,
+                                    FullSecret,
+                                    lists:join(":", [
+                                        User, lists:join(",", [TimeStr, MustMatchBasic])
+                                    ])
+                                ),
                                 couch_passwords:verify(Hmac, Hash)
                             end,
                             Timeout = chttpd_util:get_chttpd_auth_config_integer(
@@ -384,7 +421,9 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
                                                         <<"roles">>, UserProps, []
                                                     )
                                                 },
-                                                auth = {FullSecret, TimeLeft < Timeout * 0.9}
+                                                auth =
+                                                    {FullSecret, TimeLeft < Timeout * 0.9,
+                                                        MustMatchBasic == "1"}
                                             };
                                         _Else ->
                                             Req
@@ -398,7 +437,11 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
 
 cookie_auth_header(#httpd{user_ctx = #user_ctx{name = null}}, _Headers) ->
     [];
-cookie_auth_header(#httpd{user_ctx = #user_ctx{name = User}, auth = {Secret, true}} = Req, Headers) ->
+cookie_auth_header(
+    #httpd{user_ctx = #user_ctx{name = User}, auth = {Secret, _SendCookie = true, MustMatchBasic}} =
+        Req,
+    Headers
+) ->
     % Note: we only set the AuthSession cookie if:
     %  * a valid AuthSession cookie has been received
     %  * we are outside a 10% timeout window
@@ -412,20 +455,28 @@ cookie_auth_header(#httpd{user_ctx = #user_ctx{name = User}, auth = {Secret, tru
     if
         AuthSession == undefined ->
             TimeStamp = make_cookie_time(),
-            [cookie_auth_cookie(Req, ?b2l(User), Secret, TimeStamp)];
+            [cookie_auth_cookie(Req, User, Secret, TimeStamp, MustMatchBasic)];
         true ->
             []
     end;
 cookie_auth_header(_Req, _Headers) ->
     [].
 
-cookie_auth_cookie(Req, User, Secret, TimeStamp) ->
-    SessionData = User ++ ":" ++ erlang:integer_to_list(TimeStamp, 16),
+cookie_auth_cookie(Req, User, Secret, TimeStamp, MustMatchBasic) ->
+    MustMatchBasicStr =
+        case MustMatchBasic of
+            true -> "1";
+            false -> "0"
+        end,
+    SessionData = lists:join(":", [
+        User,
+        lists:join(",", [erlang:integer_to_list(TimeStamp, 16), MustMatchBasicStr])
+    ]),
     [HashAlgorithm | _] = couch_util:get_config_hash_algorithms(),
     Hash = couch_util:hmac(HashAlgorithm, Secret, SessionData),
     mochiweb_cookies:cookie(
         "AuthSession",
-        couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)),
+        couch_util:encodeBase64Url(lists:join(":", [SessionData, Hash])),
         cookie_attributes(Req)
     ).
 
@@ -493,7 +544,7 @@ handle_session_req(#httpd{method = 'POST', mochi_req = MochiReq} = Req, AuthModu
             UserSalt = couch_util:get_value(<<"salt">>, UserProps),
             CurrentTime = make_cookie_time(),
             Cookie = cookie_auth_cookie(
-                Req, ?b2l(UserName), <<Secret/binary, UserSalt/binary>>, CurrentTime
+                Req, UserName, <<Secret/binary, UserSalt/binary>>, CurrentTime, false
             ),
             % TODO document the "next" feature in Futon
             {Code, Headers} =
diff --git a/src/docs/src/api/server/authn.rst b/src/docs/src/api/server/authn.rst
index 7744d12fd..789eec277 100644
--- a/src/docs/src/api/server/authn.rst
+++ b/src/docs/src/api/server/authn.rst
@@ -27,6 +27,13 @@ Interfaces for obtaining session and authorization data.
 Basic Authentication
 ====================
 
+.. versionchanged:: 3.4 In order to aid transition to stronger password hashing
+    without causing a performance penalty, CouchDB will send a Set-Cookie header
+    when a request authenticates successfully with Basic authentication. All browsers
+    and many http libraries will automatically send this cookie on subsequent requests.
+    The cost of verifying the cookie is significantly less than PBKDF2 with a high
+    iteration count, for example.
+
 `Basic authentication`_ (:rfc:`2617`) is a quick and simple way to authenticate
 with CouchDB. The main drawback is the need to send user credentials with each
 request which may be insecure and could hurt operation performance (since
diff --git a/test/elixir/lib/couch.ex b/test/elixir/lib/couch.ex
index d9751c416..efdfe31eb 100644
--- a/test/elixir/lib/couch.ex
+++ b/test/elixir/lib/couch.ex
@@ -138,18 +138,21 @@ defmodule Couch do
   end
 
   def set_auth_options(options) do
-    if Keyword.get(options, :cookie) == nil do
-      headers = Keyword.get(options, :headers, [])
-      if headers[:basic_auth] != nil or headers[:authorization] != nil
-         or List.keymember?(headers, :"X-Auth-CouchDB-UserName", 0) do
+    cond do
+      Keyword.get(options, :no_auth, false) ->
+        options
+      Keyword.get(options, :cookie) == nil ->
+        headers = Keyword.get(options, :headers, [])
+        if headers[:basic_auth] != nil or headers[:authorization] != nil
+          or List.keymember?(headers, :"X-Auth-CouchDB-UserName", 0) do
+          options
+        else
+          username = System.get_env("EX_USERNAME") || "adm"
+          password = System.get_env("EX_PASSWORD") || "pass"
+          Keyword.put(options, :basic_auth, {username, password})
+        end
+      true ->
         options
-      else
-        username = System.get_env("EX_USERNAME") || "adm"
-        password = System.get_env("EX_PASSWORD") || "pass"
-        Keyword.put(options, :basic_auth, {username, password})
-      end
-    else
-      options
     end
   end
 
diff --git a/test/elixir/test/cookie_auth_test.exs b/test/elixir/test/cookie_auth_test.exs
index 6e42963f0..000b1d623 100644
--- a/test/elixir/test/cookie_auth_test.exs
+++ b/test/elixir/test/cookie_auth_test.exs
@@ -392,4 +392,46 @@ defmodule CookieAuthTest do
     # log in one last time so run_on_modified_server can clean up the admin account
     login("jan", "apple")
   end
+
+  test "basic+cookie auth interaction" do
+    # performing a successful basic authentication will create a session cookie
+    resp = Couch.get(
+      "/_all_dbs",
+      no_auth: true,
+      headers: [authorization: "Basic #{:base64.encode("jan:apple")}"])
+    assert resp.status_code == 200
+
+    # extract cookie value
+    cookie = resp.headers[:"set-cookie"]
+    [token | _] = String.split(cookie, ";")
+
+    # Cookie is usable on its own
+    resp = Couch.get(
+      "/_session",
+      no_auth: true,
+      headers: [cookie: token])
+    assert resp.status_code == 200
+    assert resp.body["userCtx"]["name"]  == "jan"
+    assert resp.body["info"]["authenticated"] == "cookie"
+
+    # Cookie is usable with basic auth if usernames match
+    resp = Couch.get(
+      "/_session",
+      no_auth: true,
+      headers: [
+        authorization: "Basic #{:base64.encode("jan:apple")}",
+        cookie: token])
+    assert resp.status_code == 200
+    assert resp.body["userCtx"]["name"] == "jan"
+    assert resp.body["info"]["authenticated"] == "cookie"
+
+    # Cookie is not usable with basic auth if usernames don't match
+    resp = Couch.get(
+      "/_session",
+      no_auth: true,
+      headers: [
+        authorization: "Basic #{:base64.encode("notjan:banana")}",
+        cookie: token])
+    assert resp.status_code == 401
+  end
 end