diff options
author | Robert Newson <rnewson@apache.org> | 2020-03-23 12:24:03 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-23 12:24:03 +0000 |
commit | 55f704d1e88c8eeaf243477967dea386e4251d29 (patch) | |
tree | f04a22bb858f37869f19e81862962715e094352d | |
parent | f6a4f8ee787a9418c527f952b785ed293be806af (diff) | |
parent | 16b3c8d6e1c39e2bd0b0bb8524e3b28ce5457973 (diff) | |
download | couchdb-55f704d1e88c8eeaf243477967dea386e4251d29.tar.gz |
Merge pull request #2687 from apache/jwt-enhancements
Jwt enhancements
-rw-r--r-- | mix.exs | 2 | ||||
-rw-r--r-- | rel/overlay/etc/default.ini | 16 | ||||
-rw-r--r-- | src/couch/src/couch_httpd_auth.erl | 12 | ||||
-rw-r--r-- | src/jwtf/src/jwtf.app.src | 2 | ||||
-rw-r--r-- | src/jwtf/src/jwtf_app.erl | 28 | ||||
-rw-r--r-- | src/jwtf/src/jwtf_keystore.erl | 118 | ||||
-rw-r--r-- | src/jwtf/src/jwtf_sup.erl | 38 | ||||
-rw-r--r-- | test/elixir/test/jwtauth_test.exs | 100 |
8 files changed, 300 insertions, 16 deletions
@@ -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" |