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 2020/04/01 14:31:12 UTC

[couchdb] 05/08: Merge pull request #2687 from apache/jwt-enhancements

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

rnewson pushed a commit to branch backport-jwt-3.x
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit a42698b8bc7e85fd3b4794ca1b42ef90e90dbfc6
Author: Robert Newson <rn...@apache.org>
AuthorDate: Mon Mar 23 12:24:03 2020 +0000

    Merge pull request #2687 from apache/jwt-enhancements
    
    Jwt enhancements
---
 mix.exs                                     |   2 +
 rel/overlay/etc/default.ini                 |  16 +++-
 src/couch/src/couch_httpd_auth.erl          |  12 +--
 src/jwtf/src/jwtf.app.src                   |   2 +
 src/jwtf/src/{jwtf.app.src => jwtf_app.erl} |  34 ++++----
 src/jwtf/src/jwtf_keystore.erl              | 118 ++++++++++++++++++++++++++++
 src/jwtf/src/jwtf_sup.erl                   |  38 +++++++++
 test/elixir/test/jwtauth_test.exs           | 100 +++++++++++++++++++++--
 8 files changed, 288 insertions(+), 34 deletions(-)

diff --git a/mix.exs b/mix.exs
index d717e4b..c74dce8 100644
--- a/mix.exs
+++ b/mix.exs
@@ -65,7 +65,9 @@ defmodule CouchDBTest.Mixfile do
       {:junit_formatter, "~> 3.0", only: [:dev, :test, :integration]},
       {:httpotion, ">= 3.1.3", only: [:dev, :test, :integration], runtime: false},
       {:excoveralls, "~> 0.12", only: :test},
+      {:b64url, path: Path.expand("src/b64url", __DIR__)},
       {:jiffy, path: Path.expand("src/jiffy", __DIR__)},
+      {:jwtf, path: Path.expand("src/jwtf", __DIR__)},
       {:ibrowse,
        path: Path.expand("src/ibrowse", __DIR__), override: true, compile: false},
       {:credo, "~> 1.0.0", only: [:dev, :test, :integration], runtime: false}
diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 79924e9..8e5ce61 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -141,12 +141,24 @@ max_db_number_for_dbs_info_req = 100
 ; admin_only_all_dbs = true
 
 ;[jwt_auth]
-; Symmetric secret to be used when checking JWT token signatures
-; secret =
 ; List of claims to validate
 ; required_claims = exp
 ; List of algorithms to accept during checks
 ; allowed_algorithms = HS256
+;
+; [jwt_keys]
+; Configure at least one key here if using the JWT auth handler.
+; If your JWT tokens do not include a "kid" attribute, use "_default"
+; as the config key, otherwise use the kid as the config key.
+; Examples
+; _default = aGVsbG8=
+; foo = aGVsbG8=
+; The config values can represent symmetric and asymmetrics keys.
+; For symmetrics keys, the value is base64 encoded;
+; _default = aGVsbG8= # base64-encoded form of "hello"
+; For asymmetric keys, the value is the PEM encoding of the public
+; key with newlines replaced with the escape sequence \n.
+; foo = -----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEDsr0lz/Dg3luarb+Kua0Wcj9WrfR23os\nwHzakglb8GhWRDn+oZT0Bt/26sX8uB4/ij9PEOLHPo+IHBtX4ELFFVr5GTzlqcJe\nyctaTDd1OOAPXYuc67EWtGZ3pDAzztRs\n-----END PUBLIC KEY-----\n\n
 
 [couch_peruser]
 ; If enabled, couch_peruser ensures that a private per-user database
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 58d90b1..383cd66 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -189,11 +189,11 @@ proxy_auth_user(Req) ->
     end.
 
 jwt_authentication_handler(Req) ->
-    case {config:get("jwt_auth", "secret"), header_value(Req, "Authorization")} of
-        {Secret, "Bearer " ++ Jwt} when Secret /= undefined ->
+    case header_value(Req, "Authorization") of
+        "Bearer " ++ Jwt ->
             RequiredClaims = get_configured_claims(),
             AllowedAlgorithms = get_configured_algorithms(),
-            case jwtf:decode(?l2b(Jwt), [{alg, AllowedAlgorithms} | RequiredClaims], fun(_,_) -> Secret end) of
+            case jwtf:decode(?l2b(Jwt), [{alg, AllowedAlgorithms} | RequiredClaims], fun jwtf_keystore:get/2) of
                 {ok, {Claims}} ->
                     case lists:keyfind(<<"sub">>, 1, Claims) of
                         false -> throw({unauthorized, <<"Token missing sub claim.">>});
@@ -202,16 +202,16 @@ jwt_authentication_handler(Req) ->
                         }}
                     end;
                 {error, Reason} ->
-                    throw({unauthorized, Reason})
+                    throw(Reason)
             end;
-        {_, _} -> Req
+        _ -> Req
     end.
 
 get_configured_algorithms() ->
     re:split(config:get("jwt_auth", "allowed_algorithms", "HS256"), "\s*,\s*", [{return, binary}]).
 
 get_configured_claims() ->
-    lists:usort(re:split(config:get("jwt_auth", "required_claims", ""), "\s*,\s*", [{return, binary}])).
+    re:split(config:get("jwt_auth", "required_claims", ""), "\s*,\s*", [{return, binary}]).
 
 cookie_authentication_handler(Req) ->
     cookie_authentication_handler(Req, couch_auth_cache).
diff --git a/src/jwtf/src/jwtf.app.src b/src/jwtf/src/jwtf.app.src
index 304bb9e..24081bf 100644
--- a/src/jwtf/src/jwtf.app.src
+++ b/src/jwtf/src/jwtf.app.src
@@ -18,10 +18,12 @@
         kernel,
         stdlib,
         b64url,
+        config,
         crypto,
         jiffy,
         public_key
     ]},
+    {mod, {jwtf_app, []}},
     {env,[]},
     {modules, []},
     {maintainers, []},
diff --git a/src/jwtf/src/jwtf.app.src b/src/jwtf/src/jwtf_app.erl
similarity index 60%
copy from src/jwtf/src/jwtf.app.src
copy to src/jwtf/src/jwtf_app.erl
index 304bb9e..bd708e2 100644
--- a/src/jwtf/src/jwtf.app.src
+++ b/src/jwtf/src/jwtf_app.erl
@@ -10,21 +10,19 @@
 % License for the specific language governing permissions and limitations under
 % the License.
 
-{application, jwtf, [
-    {description, "JSON Web Token Functions"},
-    {vsn, git},
-    {registered, []},
-    {applications, [
-        kernel,
-        stdlib,
-        b64url,
-        crypto,
-        jiffy,
-        public_key
-    ]},
-    {env,[]},
-    {modules, []},
-    {maintainers, []},
-    {licenses, []},
-    {links, []}
-]}.
+-module(jwtf_app).
+
+-behaviour(application).
+
+%% Application callbacks
+-export([start/2, stop/1]).
+
+%% ===================================================================
+%% Application callbacks
+%% ===================================================================
+
+start(_StartType, _StartArgs) ->
+    jwtf_sup:start_link().
+
+stop(_State) ->
+    ok.
diff --git a/src/jwtf/src/jwtf_keystore.erl b/src/jwtf/src/jwtf_keystore.erl
new file mode 100644
index 0000000..2f2f247
--- /dev/null
+++ b/src/jwtf/src/jwtf_keystore.erl
@@ -0,0 +1,118 @@
+% 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(jwtf_keystore).
+-behaviour(gen_server).
+-behaviour(config_listener).
+
+% public api.
+-export([
+    get/2,
+    start_link/0
+]).
+
+% gen_server api.
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
+    code_change/3, terminate/2]).
+
+% config_listener api
+-export([handle_config_change/5, handle_config_terminate/3]).
+
+% public functions
+
+get(Alg, undefined) ->
+    get(Alg, "_default");
+
+get(Alg, KID) when is_binary(KID) ->
+    get(Alg, binary_to_list(KID));
+
+get(Alg, KID) ->
+    case ets:lookup(?MODULE, KID) of
+        [] ->
+            Key = get_from_config(Alg, KID),
+            ok = gen_server:call(?MODULE, {set, KID, Key}),
+            Key;
+        [{KID, Key}] ->
+             Key
+    end.
+
+
+start_link() ->
+    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+% gen_server functions
+
+init(_) ->
+    ok = config:listen_for_changes(?MODULE, nil),
+    ets:new(?MODULE, [public, named_table]),
+    {ok, nil}.
+
+
+handle_call({set, KID, Key}, _From, State) ->
+    true = ets:insert(?MODULE, {KID, Key}),
+    {reply, ok, State}.
+
+
+handle_cast({delete, KID}, State) ->
+    true = ets:delete(?MODULE, KID),
+    {noreply, State};
+
+handle_cast(_Msg, State) ->
+    {noreply, State}.
+
+
+handle_info(restart_config_listener, State) ->
+    ok = config:listen_for_changes(?MODULE, nil),
+    {noreply, State};
+
+handle_info(_Msg, State) ->
+    {noreply, State}.
+
+
+terminate(_Reason, _State) ->
+    ok.
+
+
+code_change(_OldVsn, State, _Extra) ->
+    {ok, State}.
+
+
+% config listener callback
+
+handle_config_change("jwt_keys", KID, _Value, _, _) ->
+    {ok, gen_server:cast(?MODULE, {delete, KID})};
+
+handle_config_change(_, _, _, _, _) ->
+    {ok, nil}.
+
+handle_config_terminate(_Server, stop, _State) ->
+    ok;
+
+handle_config_terminate(_Server, _Reason, _State) ->
+    erlang:send_after(100, whereis(?MODULE), restart_config_listener).
+
+% private functions
+
+get_from_config(Alg, KID) ->
+    case config:get("jwt_keys", KID) of
+        undefined ->
+            throw({bad_request, <<"Unknown kid">>});
+        Key ->
+            case jwtf:verification_algorithm(Alg) of
+                {hmac, _} ->
+                    base64:decode(Key);
+                {public_key, _} ->
+                    BinKey = iolist_to_binary(string:replace(Key, "\\n", "\n", all)),
+                    [PEMEntry] = public_key:pem_decode(BinKey),
+                    public_key:pem_entry_decode(PEMEntry)
+            end
+    end.
diff --git a/src/jwtf/src/jwtf_sup.erl b/src/jwtf/src/jwtf_sup.erl
new file mode 100644
index 0000000..6f44808
--- /dev/null
+++ b/src/jwtf/src/jwtf_sup.erl
@@ -0,0 +1,38 @@
+% 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(jwtf_sup).
+
+-behaviour(supervisor).
+
+%% API
+-export([start_link/0]).
+
+%% Supervisor callbacks
+-export([init/1]).
+
+%% Helper macro for declaring children of supervisor
+-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).
+
+%% ===================================================================
+%% API functions
+%% ===================================================================
+
+start_link() ->
+    supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+%% ===================================================================
+%% Supervisor callbacks
+%% ===================================================================
+
+init([]) ->
+    {ok, { {one_for_one, 5, 10}, [?CHILD(jwtf_keystore, worker)]} }.
diff --git a/test/elixir/test/jwtauth_test.exs b/test/elixir/test/jwtauth_test.exs
index 2e78ee9..3f26e1e 100644
--- a/test/elixir/test/jwtauth_test.exs
+++ b/test/elixir/test/jwtauth_test.exs
@@ -3,26 +3,110 @@ defmodule JwtAuthTest do
 
   @moduletag :authentication
 
-  test "jwt auth with secret", _context do
+  test "jwt auth with HMAC secret", _context do
 
     secret = "zxczxc12zxczxc12"
 
     server_config = [
       %{
+        :section => "jwt_keys",
+        :key => "_default",
+        :value => :base64.encode(secret)
+      },
+      %{
+        :section => "jwt_auth",
+        :key => "allowed_algorithms",
+        :value => "HS256, HS384, HS512"
+      }
+    ]
+
+    run_on_modified_server(server_config, fn -> test_fun("HS256", secret) end)
+    run_on_modified_server(server_config, fn -> test_fun("HS384", secret) end)
+    run_on_modified_server(server_config, fn -> test_fun("HS512", secret) end)
+  end
+
+  defmodule RSA do
+    require Record
+    Record.defrecord :public, :RSAPublicKey,
+      Record.extract(:RSAPublicKey, from_lib: "public_key/include/public_key.hrl")
+    Record.defrecord :private, :RSAPrivateKey,
+      Record.extract(:RSAPrivateKey, from_lib: "public_key/include/public_key.hrl")
+  end
+
+  test "jwt auth with RSA secret", _context do
+    require JwtAuthTest.RSA
+
+    private_key = :public_key.generate_key({:rsa, 2048, 17})
+    public_key = RSA.public(
+      modulus: RSA.private(private_key, :modulus),
+      publicExponent: RSA.private(private_key, :publicExponent))
+
+    public_pem = :public_key.pem_encode(
+      [:public_key.pem_entry_encode(
+          :SubjectPublicKeyInfo, public_key)])
+    public_pem = String.replace(public_pem, "\n", "\\n")
+
+    server_config = [
+      %{
+        :section => "jwt_keys",
+        :key => "_default",
+        :value => public_pem
+      },
+      %{
         :section => "jwt_auth",
-        :key => "secret",
-        :value => secret
+        :key => "allowed_algorithms",
+        :value => "RS256, RS384, RS512"
       }
     ]
 
-    run_on_modified_server(server_config, fn ->
-      test_fun()
-    end)
+    run_on_modified_server(server_config, fn -> test_fun("RS256", private_key) end)
+    run_on_modified_server(server_config, fn -> test_fun("RS384", private_key) end)
+    run_on_modified_server(server_config, fn -> test_fun("RS512", private_key) end)
   end
 
-  def test_fun() do
+  defmodule EC do
+    require Record
+    Record.defrecord :point, :ECPoint,
+      Record.extract(:ECPoint, from_lib: "public_key/include/public_key.hrl")
+    Record.defrecord :private, :ECPrivateKey,
+      Record.extract(:ECPrivateKey, from_lib: "public_key/include/public_key.hrl")
+  end
+
+  test "jwt auth with EC secret", _context do
+    require JwtAuthTest.EC
+
+    private_key = :public_key.generate_key({:namedCurve, :secp384r1})
+    point = EC.point(point: EC.private(private_key, :publicKey))
+    public_key = {point, EC.private(private_key, :parameters)}
+
+    public_pem = :public_key.pem_encode(
+      [:public_key.pem_entry_encode(
+          :SubjectPublicKeyInfo, public_key)])
+    public_pem = String.replace(public_pem, "\n", "\\n")
+
+    server_config = [
+      %{
+        :section => "jwt_keys",
+        :key => "_default",
+        :value => public_pem
+      },
+      %{
+        :section => "jwt_auth",
+        :key => "allowed_algorithms",
+        :value => "ES256, ES384, ES512"
+      }
+    ]
+
+    run_on_modified_server(server_config, fn -> test_fun("ES256", private_key) end)
+    run_on_modified_server(server_config, fn -> test_fun("ES384", private_key) end)
+    run_on_modified_server(server_config, fn -> test_fun("ES512", private_key) end)
+  end
+
+  def test_fun(alg, key) do
+    {:ok, token} = :jwtf.encode({[{"alg", alg}, {"typ", "JWT"}]}, {[{"sub", "couch@apache.org"}]}, key)
+
     resp = Couch.get("/_session",
-      headers: [authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb3VjaEBhcGFjaGUub3JnIn0.KYHmGXWj0HNHzZCjfOfsIfZWdguEBSn31jUdDUA9118"]
+      headers: [authorization: "Bearer #{token}"]
     )
 
     assert resp.body["userCtx"]["name"] == "couch@apache.org"