summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobert Newson <rnewson@apache.org>2020-03-23 12:24:03 +0000
committerGitHub <noreply@github.com>2020-03-23 12:24:03 +0000
commit55f704d1e88c8eeaf243477967dea386e4251d29 (patch)
treef04a22bb858f37869f19e81862962715e094352d
parentf6a4f8ee787a9418c527f952b785ed293be806af (diff)
parent16b3c8d6e1c39e2bd0b0bb8524e3b28ce5457973 (diff)
downloadcouchdb-55f704d1e88c8eeaf243477967dea386e4251d29.tar.gz
Merge pull request #2687 from apache/jwt-enhancements
Jwt enhancements
-rw-r--r--mix.exs2
-rw-r--r--rel/overlay/etc/default.ini16
-rw-r--r--src/couch/src/couch_httpd_auth.erl12
-rw-r--r--src/jwtf/src/jwtf.app.src2
-rw-r--r--src/jwtf/src/jwtf_app.erl28
-rw-r--r--src/jwtf/src/jwtf_keystore.erl118
-rw-r--r--src/jwtf/src/jwtf_sup.erl38
-rw-r--r--test/elixir/test/jwtauth_test.exs100
8 files changed, 300 insertions, 16 deletions
diff --git a/mix.exs b/mix.exs
index 2e4a7aa85..bab22f12f 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.3.1", only: [:dev, :test, :integration], runtime: false}
diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 82a56590f..25daa4813 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 7c55f390e..f5387d18f 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 304bb9e0a..24081bf6f 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.erl b/src/jwtf/src/jwtf_app.erl
new file mode 100644
index 000000000..bd708e2a3
--- /dev/null
+++ b/src/jwtf/src/jwtf_app.erl
@@ -0,0 +1,28 @@
+% 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_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 000000000..2f2f24744
--- /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 000000000..6f44808de
--- /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 2e78ee989..3f26e1eaf 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"