Merge pull request #7730 from savonarola/jwt-authz

feat(emqx_auth_jwt): use JWT for ACL checks
This commit is contained in:
JianBo He 2022-04-26 14:18:50 +08:00 committed by GitHub
commit 9f35dd7f80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 486 additions and 23 deletions

View File

@ -1654,14 +1654,14 @@ do_authenticate(
) ->
Properties = #{'Authentication-Method' => AuthMethod},
case emqx_access_control:authenticate(Credential) of
{ok, Result} ->
{ok, AuthResult} ->
{ok, Properties, Channel#channel{
clientinfo = ClientInfo#{is_superuser => maps:get(is_superuser, Result, false)},
clientinfo = merge_auth_result(ClientInfo, AuthResult),
auth_cache = #{}
}};
{ok, Result, AuthData} ->
{ok, AuthResult, AuthData} ->
{ok, Properties#{'Authentication-Data' => AuthData}, Channel#channel{
clientinfo = ClientInfo#{is_superuser => maps:get(is_superuser, Result, false)},
clientinfo = merge_auth_result(ClientInfo, AuthResult),
auth_cache = #{}
}};
{continue, AuthCache} ->
@ -1675,12 +1675,16 @@ do_authenticate(
end;
do_authenticate(Credential, #channel{clientinfo = ClientInfo} = Channel) ->
case emqx_access_control:authenticate(Credential) of
{ok, #{is_superuser := IsSuperuser}} ->
{ok, #{}, Channel#channel{clientinfo = ClientInfo#{is_superuser => IsSuperuser}}};
{ok, AuthResult} ->
{ok, #{}, Channel#channel{clientinfo = merge_auth_result(ClientInfo, AuthResult)}};
{error, Reason} ->
{error, emqx_reason_codes:connack_error(Reason)}
end.
merge_auth_result(ClientInfo, AuthResult) when is_map(ClientInfo) andalso is_map(AuthResult) ->
IsSuperuser = maps:get(is_superuser, AuthResult, false),
maps:merge(ClientInfo, AuthResult#{is_superuser => IsSuperuser}).
%%--------------------------------------------------------------------
%% Process Topic Alias

View File

@ -376,7 +376,7 @@ do_verify(JWS, [JWK | More], VerifyClaims) ->
Claims = emqx_json:decode(Payload, [return_maps]),
case verify_claims(Claims, VerifyClaims) of
ok ->
{ok, emqx_authn_utils:is_superuser(Claims)};
{ok, maps:put(jwt, Claims, emqx_authn_utils:is_superuser(Claims))};
{error, Reason} ->
{error, Reason}
end;
@ -393,13 +393,13 @@ verify_claims(Claims, VerifyClaims0) ->
VerifyClaims =
[
{<<"exp">>, fun(ExpireTime) ->
Now < ExpireTime
is_integer(ExpireTime) andalso Now < ExpireTime
end},
{<<"iat">>, fun(IssueAt) ->
IssueAt =< Now
is_integer(IssueAt) andalso IssueAt =< Now
end},
{<<"nbf">>, fun(NotBefore) ->
NotBefore =< Now
is_integer(NotBefore) andalso NotBefore =< Now
end}
] ++ VerifyClaims0,
do_verify_claims(Claims, VerifyClaims).

View File

@ -70,7 +70,7 @@ t_jwt_authenticator_hmac_based(_) ->
username => <<"myuser">>,
password => JWS
},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential, State)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential, State)),
Payload1 = #{<<"username">> => <<"myuser">>, <<"is_superuser">> => true},
JWS1 = generate_jws('hmac-based', Payload1, Secret),
@ -78,7 +78,7 @@ t_jwt_authenticator_hmac_based(_) ->
username => <<"myuser">>,
password => JWS1
},
?assertEqual({ok, #{is_superuser => true}}, emqx_authn_jwt:authenticate(Credential1, State)),
?assertMatch({ok, #{is_superuser := true}}, emqx_authn_jwt:authenticate(Credential1, State)),
BadJWS = generate_jws('hmac-based', Payload, <<"bad_secret">>),
Credential2 = Credential#{password => BadJWS},
@ -90,7 +90,7 @@ t_jwt_authenticator_hmac_based(_) ->
secret_base64_encoded => true
},
{ok, State2} = emqx_authn_jwt:update(Config2, State),
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential, State2)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential, State2)),
%% invalid secret
BadConfig = Config#{
@ -101,7 +101,7 @@ t_jwt_authenticator_hmac_based(_) ->
Config3 = Config#{verify_claims => [{<<"username">>, <<"${username}">>}]},
{ok, State3} = emqx_authn_jwt:update(Config3, State2),
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential, State3)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential, State3)),
?assertEqual(
{error, bad_username_or_password},
emqx_authn_jwt:authenticate(Credential#{username => <<"otheruser">>}, State3)
@ -124,7 +124,7 @@ t_jwt_authenticator_hmac_based(_) ->
},
JWS4 = generate_jws('hmac-based', Payload4, Secret),
Credential4 = Credential#{password => JWS4},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential4, State3)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential4, State3)),
%% Issued At
Payload5 = #{
@ -133,7 +133,7 @@ t_jwt_authenticator_hmac_based(_) ->
},
JWS5 = generate_jws('hmac-based', Payload5, Secret),
Credential5 = Credential#{password => JWS5},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential5, State3)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential5, State3)),
Payload6 = #{
<<"username">> => <<"myuser">>,
@ -152,7 +152,7 @@ t_jwt_authenticator_hmac_based(_) ->
},
JWS7 = generate_jws('hmac-based', Payload7, Secret),
Credential7 = Credential6#{password => JWS7},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential7, State3)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential7, State3)),
Payload8 = #{
<<"username">> => <<"myuser">>,
@ -185,7 +185,7 @@ t_jwt_authenticator_public_key(_) ->
username => <<"myuser">>,
password => JWS
},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential, State)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential, State)),
?assertEqual(
ignore, emqx_authn_jwt:authenticate(Credential#{password => <<"badpassword">>}, State)
),
@ -280,7 +280,7 @@ t_jwks_renewal(_Config) ->
ok = snabbkaffe:stop(),
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential1, State2)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential1, State2)),
?assertEqual(
{error, bad_username_or_password},
emqx_authn_jwt:authenticate(Credential1#{password => JWS2}, State2)
@ -307,7 +307,7 @@ t_jwt_authenticator_verify_claims(_) ->
username => <<"myuser">>,
password => JWS0
},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential0, State0)),
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential0, State0)),
Config1 = Config0#{
verify_claims => [{<<"foo">>, <<"${username}">>}]
@ -340,7 +340,7 @@ t_jwt_authenticator_verify_claims(_) ->
username => <<"myuser">>,
password => JWS3
},
?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential3, State1)).
?assertMatch({ok, #{is_superuser := false}}, emqx_authn_jwt:authenticate(Credential3, State1)).
%%------------------------------------------------------------------------------
%% Helpers

View File

@ -340,6 +340,28 @@ Commands can support following wildcards:\n
}
}
jwt {
desc {
en: """Authorization using ACL rules from authentication JWT."""
zh: """Authorization using ACL rules from authentication JWT."""
}
label {
en: """jwt"""
zh: """jwt"""
}
}
acl_claim_name {
desc {
en: """JWT claim name to use for getting ACL rules."""
zh: """JWT claim name to use for getting ACL rules."""
}
label {
en: """acl_claim_name"""
zh: """acl_claim_name"""
}
}
cmd {
desc {
en: """Database query used to retrieve authorization data."""

View File

@ -384,6 +384,8 @@ type(postgresql) -> postgresql;
type(<<"postgresql">>) -> postgresql;
type(built_in_database) -> built_in_database;
type(<<"built_in_database">>) -> built_in_database;
type(jwt) -> jwt;
type(<<"jwt">>) -> jwt;
%% should never happen if the input is type-checked by hocon schema
type(Unknown) -> throw({unknown_authz_source_type, Unknown}).

View File

@ -0,0 +1,111 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% 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(emqx_authz_jwt).
-include_lib("emqx/include/logger.hrl").
-behaviour(emqx_authz).
-ifdef(TEST).
-compile(export_all).
-compile(nowarn_export_all).
-endif.
%% APIs
-export([
description/0,
init/1,
destroy/1,
authorize/4
]).
-define(JWT_RULE_NAMES, [
{<<"pub">>, publish},
{<<"sub">>, subscribe},
{<<"all">>, all}
]).
%%--------------------------------------------------------------------
%% emqx_authz callbacks
%%--------------------------------------------------------------------
description() ->
"AuthZ with JWT".
init(#{acl_claim_name := _AclClaimName} = Source) ->
Source.
destroy(_Source) -> ok.
authorize(#{jwt := JWT} = Client, PubSub, Topic, #{acl_claim_name := AclClaimName}) ->
case verify(JWT) of
{ok, #{AclClaimName := Rules}} when is_map(Rules) ->
do_authorize(Client, PubSub, Topic, Rules);
_ ->
{matched, deny}
end;
authorize(_Client, _PubSub, _Topic, _Source) ->
nomatch.
%%--------------------------------------------------------------------
%% Internal functions
%%--------------------------------------------------------------------
verify(JWT) ->
Now = erlang:system_time(second),
VerifyClaims =
[
{<<"exp">>, fun(ExpireTime) ->
is_integer(ExpireTime) andalso Now < ExpireTime
end},
{<<"iat">>, fun(IssueAt) ->
is_integer(IssueAt) andalso IssueAt =< Now
end},
{<<"nbf">>, fun(NotBefore) ->
is_integer(NotBefore) andalso NotBefore =< Now
end}
],
IsValid = lists:all(
fun({ClaimName, Validator}) ->
(not maps:is_key(ClaimName, JWT)) orelse
Validator(maps:get(ClaimName, JWT))
end,
VerifyClaims
),
case IsValid of
true -> {ok, JWT};
false -> error
end.
do_authorize(Client, PubSub, Topic, AclRules) ->
do_authorize(Client, PubSub, Topic, AclRules, ?JWT_RULE_NAMES).
do_authorize(_Client, _PubSub, _Topic, _AclRules, []) ->
{matched, deny};
do_authorize(Client, PubSub, Topic, AclRules, [{Key, Action} | JWTRuleNames]) ->
TopicFilters = maps:get(Key, AclRules, []),
case
emqx_authz_rule:match(
Client,
PubSub,
Topic,
emqx_authz_rule:compile({allow, all, Action, TopicFilters})
)
of
{matched, Permission} -> {matched, Permission};
nomatch -> do_authorize(Client, PubSub, Topic, AclRules, JWTRuleNames)
end.

View File

@ -67,7 +67,8 @@ fields("authorization") ->
hoconsc:ref(?MODULE, postgresql),
hoconsc:ref(?MODULE, redis_single),
hoconsc:ref(?MODULE, redis_sentinel),
hoconsc:ref(?MODULE, redis_cluster)
hoconsc:ref(?MODULE, redis_cluster),
hoconsc:ref(?MODULE, jwt)
]
),
default => [],
@ -124,7 +125,16 @@ fields(redis_sentinel) ->
fields(redis_cluster) ->
authz_common_fields(redis) ++
connector_fields(redis, cluster) ++
[{cmd, cmd()}].
[{cmd, cmd()}];
fields(jwt) ->
authz_common_fields(jwt) ++
[
{acl_claim_name, #{
type => binary(),
default => <<"acl">>,
desc => ?DESC(acl_claim_name)
}}
].
desc(?CONF_NS) ->
?DESC(?CONF_NS);
@ -152,6 +162,8 @@ desc(redis_sentinel) ->
?DESC(redis_sentinel);
desc(redis_cluster) ->
?DESC(redis_cluster);
desc(jwt) ->
?DESC(jwt);
desc(_) ->
undefined.

View File

@ -0,0 +1,312 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% 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(emqx_authz_jwt_SUITE).
-compile(nowarn_export_all).
-compile(export_all).
-include_lib("emqx/include/emqx_placeholder.hrl").
-include_lib("emqx_authn/include/emqx_authn.hrl").
-include_lib("emqx/include/emqx_mqtt.hrl").
-include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.hrl").
-define(SECRET, <<"some_secret">>).
-define(AUTHN_PATH, [authentication]).
all() ->
emqx_common_test_helpers:all(?MODULE).
groups() ->
[].
init_per_suite(Config) ->
ok = emqx_common_test_helpers:start_apps(
[emqx_conf, emqx_authn, emqx_authz],
fun set_special_configs/1
),
ok = emqx_authentication:initialize_authentication(?GLOBAL, []),
Config.
end_per_suite(_Config) ->
ok = emqx_common_test_helpers:stop_apps([emqx_authn, emqx_authz, emqx_conf]).
init_per_testcase(_TestCase, Config) ->
emqx_authn_test_lib:delete_authenticators(
?AUTHN_PATH,
?GLOBAL
),
AuthConfig = authn_config(),
{ok, _} = emqx:update_config(
?AUTHN_PATH,
{create_authenticator, ?GLOBAL, AuthConfig}
),
ok = emqx_authz_test_lib:reset_authorizers(),
{ok, _} = emqx_authz:update(replace, [authz_config()]),
Config.
end_per_testcase(_TestCase, _Config) ->
emqx_authn_test_lib:delete_authenticators(
?AUTHN_PATH,
?GLOBAL
),
ok = emqx_authz_test_lib:restore_authorizers().
set_special_configs(emqx_authz) ->
ok = emqx_authz_test_lib:reset_authorizers();
set_special_configs(_) ->
ok.
%%------------------------------------------------------------------------------
%% Tests
%%------------------------------------------------------------------------------
t_topic_rules(_Config) ->
Payload = #{
<<"exp">> => erlang:system_time(second) + 60,
<<"acl">> => #{
<<"pub">> => [
<<"eq testpub1/${username}">>,
<<"testpub2/${clientid}">>,
<<"testpub3/#">>
],
<<"sub">> => [
<<"eq testsub1/${username}">>,
<<"testsub2/${clientid}">>,
<<"testsub3/#">>
],
<<"all">> => [
<<"eq testall1/${username}">>,
<<"testall2/${clientid}">>,
<<"testall3/#">>
]
},
<<"username">> => <<"username">>
},
JWT = generate_jws(Payload),
{ok, C} = emqtt:start_link(
[
{clean_start, true},
{proto_ver, v5},
{clientid, <<"clientid">>},
{username, <<"username">>},
{password, JWT}
]
),
{ok, _} = emqtt:connect(C),
Cases = [
{deny, <<"testpub1/username">>},
{deny, <<"testpub2/clientid">>},
{deny, <<"testpub3/foobar">>},
{deny, <<"testsub1/username">>},
{allow, <<"testsub1/${username}">>},
{allow, <<"testsub2/clientid">>},
{allow, <<"testsub3/foobar">>},
{allow, <<"testsub3/+/foobar">>},
{allow, <<"testsub3/#">>},
{deny, <<"testsub2/username">>},
{deny, <<"testsub1/clientid">>},
{deny, <<"testsub4/foobar">>},
{deny, <<"testall1/username">>},
{allow, <<"testall1/${username}">>},
{allow, <<"testall2/clientid">>},
{allow, <<"testall3/foobar">>},
{allow, <<"testall3/+/foobar">>},
{allow, <<"testall3/#">>},
{deny, <<"testall2/username">>},
{deny, <<"testall1/clientid">>},
{deny, <<"testall4/foobar">>}
],
lists:foreach(
fun
({allow, Topic}) ->
?assertMatch(
{ok, #{}, [0]},
emqtt:subscribe(C, Topic, 0)
);
({deny, Topic}) ->
?assertMatch(
{ok, #{}, [?RC_NOT_AUTHORIZED]},
emqtt:subscribe(C, Topic, 0)
)
end,
Cases
),
ok = emqtt:disconnect(C).
t_check_pub(_Config) ->
Payload = #{
<<"username">> => <<"username">>,
<<"acl">> => #{<<"sub">> => [<<"a/b">>]},
<<"exp">> => erlang:system_time(second) + 10
},
JWT = generate_jws(Payload),
{ok, C} = emqtt:start_link(
[
{clean_start, true},
{proto_ver, v5},
{clientid, <<"clientid">>},
{username, <<"username">>},
{password, JWT}
]
),
{ok, _} = emqtt:connect(C),
ok = emqtt:publish(C, <<"a/b">>, <<"hi">>, 0),
receive
{publish, #{topic := <<"a/b">>}} ->
?assert(false, "Publish to `a/b` should not be allowed")
after 100 -> ok
end,
ok = emqtt:disconnect(C).
t_check_no_recs(_Config) ->
Payload = #{
<<"username">> => <<"username">>,
<<"acl">> => #{},
<<"exp">> => erlang:system_time(second) + 10
},
JWT = generate_jws(Payload),
{ok, C} = emqtt:start_link(
[
{clean_start, true},
{proto_ver, v5},
{clientid, <<"clientid">>},
{username, <<"username">>},
{password, JWT}
]
),
{ok, _} = emqtt:connect(C),
?assertMatch(
{ok, #{}, [?RC_NOT_AUTHORIZED]},
emqtt:subscribe(C, <<"a/b">>, 0)
),
ok = emqtt:disconnect(C).
t_check_no_acl_claim(_Config) ->
Payload = #{
<<"username">> => <<"username">>,
<<"exp">> => erlang:system_time(second) + 10
},
JWT = generate_jws(Payload),
{ok, C} = emqtt:start_link(
[
{clean_start, true},
{proto_ver, v5},
{clientid, <<"clientid">>},
{username, <<"username">>},
{password, JWT}
]
),
{ok, _} = emqtt:connect(C),
?assertMatch(
{ok, #{}, [?RC_NOT_AUTHORIZED]},
emqtt:subscribe(C, <<"a/b">>, 0)
),
ok = emqtt:disconnect(C).
t_check_expire(_Config) ->
Payload = #{
<<"username">> => <<"username">>,
<<"acl">> => #{<<"sub">> => [<<"a/b">>]},
<<"exp">> => erlang:system_time(second) + 1
},
JWT = generate_jws(Payload),
{ok, C} = emqtt:start_link(
[
{clean_start, true},
{proto_ver, v5},
{clientid, <<"clientid">>},
{username, <<"username">>},
{password, JWT}
]
),
{ok, _} = emqtt:connect(C),
?assertMatch(
{ok, #{}, [0]},
emqtt:subscribe(C, <<"a/b">>, 0)
),
?assertMatch(
{ok, #{}, [0]},
emqtt:unsubscribe(C, <<"a/b">>)
),
timer:sleep(2000),
?assertMatch(
{ok, #{}, [?RC_NOT_AUTHORIZED]},
emqtt:subscribe(C, <<"a/b">>, 0)
),
ok = emqtt:disconnect(C).
%%------------------------------------------------------------------------------
%% Helpers
%%------------------------------------------------------------------------------
authn_config() ->
#{
<<"mechanism">> => <<"jwt">>,
<<"use_jwks">> => <<"false">>,
<<"algorithm">> => <<"hmac-based">>,
<<"secret">> => ?SECRET,
<<"secret_base64_encoded">> => <<"false">>,
<<"verify_claims">> => #{
<<"username">> => ?PH_USERNAME
}
}.
authz_config() ->
#{
<<"type">> => <<"jwt">>,
<<"acl_claim_name">> => <<"acl">>
}.
generate_jws(Payload) ->
JWK = jose_jwk:from_oct(?SECRET),
Header = #{
<<"alg">> => <<"HS256">>,
<<"typ">> => <<"JWT">>
},
Signed = jose_jwt:sign(JWK, Header, Payload),
{_, JWS} = jose_jws:compact(Signed),
JWS.