Skip to content

Commit

Permalink
send a session cookie after successful basic auth
Browse files Browse the repository at this point in the history
  • Loading branch information
rnewson committed Jul 26, 2023
1 parent bd5781d commit 50c69a0
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 24 deletions.
75 changes: 62 additions & 13 deletions src/couch/src/couch_httpd_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -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.">>})
Expand Down Expand Up @@ -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}]
)
Expand All @@ -344,13 +358,30 @@ 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(),
case chttpd_util:get_chttpd_auth_config("secret") of
undefined ->
couch_log:debug("cookie auth secret is not set", []),
Req;
_ when MustMatchBasic == "1", BasicAuthUser /= nil, User /= BasicAuthUser ->
% ignore pre-emptive cookie
Req;
SecretStr ->
Secret = ?l2b(SecretStr),
case AuthModule:get_user_creds(Req, User) of
Expand All @@ -361,7 +392,11 @@ 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, OptionsStr])
),
couch_passwords:verify(Hmac, Hash)
end,
Timeout = chttpd_util:get_chttpd_auth_config_integer(
Expand All @@ -384,7 +419,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
Expand All @@ -398,7 +435,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
Expand All @@ -412,20 +453,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)
).

Expand Down Expand Up @@ -493,7 +542,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} =
Expand Down
7 changes: 7 additions & 0 deletions src/docs/src/api/server/authn.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions test/elixir/lib/couch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions test/elixir/test/cookie_auth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 50c69a0

Please sign in to comment.