From 91da4518033a7a5803023649f1d9e3a781fd19ba Mon Sep 17 00:00:00 2001 From: Ilya Averyanov Date: Fri, 13 May 2022 00:22:36 +0300 Subject: [PATCH] feat(authz): add default authn-based authz source --- apps/emqx_authn/i18n/emqx_authn_jwt_i18n.conf | 11 ++ .../src/simple_authn/emqx_authn_jwt.erl | 115 ++++++++------ apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl | 5 + .../i18n/emqx_authz_schema_i18n.conf | 22 --- apps/emqx_authz/src/emqx_authz.erl | 15 +- .../emqx_authz/src/emqx_authz_client_info.erl | 111 ++++++++++++++ apps/emqx_authz/src/emqx_authz_jwt.erl | 142 ------------------ apps/emqx_authz/src/emqx_authz_schema.erl | 14 +- apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl | 8 +- 9 files changed, 214 insertions(+), 229 deletions(-) create mode 100644 apps/emqx_authz/src/emqx_authz_client_info.erl delete mode 100644 apps/emqx_authz/src/emqx_authz_jwt.erl diff --git a/apps/emqx_authn/i18n/emqx_authn_jwt_i18n.conf b/apps/emqx_authn/i18n/emqx_authn_jwt_i18n.conf index 684421e71..69fb628a1 100644 --- a/apps/emqx_authn/i18n/emqx_authn_jwt_i18n.conf +++ b/apps/emqx_authn/i18n/emqx_authn_jwt_i18n.conf @@ -210,4 +210,15 @@ Authentication will verify that the value of claims in the JWT (taken from the P zh: """SSL 配置。""" } } + + 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""" + } + } } diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl index 0c00de01e..552cd5c49 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl @@ -111,6 +111,11 @@ desc(_) -> common_fields() -> [ {mechanism, emqx_authn_schema:mechanism('jwt')}, + {acl_claim_name, #{ + type => binary(), + default => <<"acl">>, + desc => ?DESC(acl_claim_name) + }}, {verify_claims, fun verify_claims/1} ] ++ emqx_authn_schema:common_fields(). @@ -231,17 +236,19 @@ authenticate( Credential = #{password := JWT}, #{ verify_claims := VerifyClaims0, - jwk := JWK + jwk := JWK, + acl_claim_name := AclClaimName } ) -> JWKs = [JWK], VerifyClaims = replace_placeholder(VerifyClaims0, Credential), - verify(JWT, JWKs, VerifyClaims); + verify(JWT, JWKs, VerifyClaims, AclClaimName); authenticate( Credential = #{password := JWT}, #{ verify_claims := VerifyClaims0, - jwk_resource := ResourceId + jwk_resource := ResourceId, + acl_claim_name := AclClaimName } ) -> case emqx_resource:query(ResourceId, get_jwks) of @@ -254,7 +261,7 @@ authenticate( ignore; {ok, JWKs} -> VerifyClaims = replace_placeholder(VerifyClaims0, Credential), - verify(JWT, JWKs, VerifyClaims) + verify(JWT, JWKs, VerifyClaims, AclClaimName) end. destroy(#{jwk_resource := ResourceId}) -> @@ -272,7 +279,8 @@ create2(#{ algorithm := 'hmac-based', secret := Secret0, secret_base64_encoded := Base64Encoded, - verify_claims := VerifyClaims + verify_claims := VerifyClaims, + acl_claim_name := AclClaimName }) -> case may_decode_secret(Base64Encoded, Secret0) of {error, Reason} -> @@ -281,24 +289,28 @@ create2(#{ JWK = jose_jwk:from_oct(Secret), {ok, #{ jwk => JWK, - verify_claims => VerifyClaims + verify_claims => VerifyClaims, + acl_claim_name => AclClaimName }} end; create2(#{ use_jwks := false, algorithm := 'public-key', public_key := PublicKey, - verify_claims := VerifyClaims + verify_claims := VerifyClaims, + acl_claim_name := AclClaimName }) -> JWK = create_jwk_from_public_key(PublicKey), {ok, #{ jwk => JWK, - verify_claims => VerifyClaims + verify_claims => VerifyClaims, + acl_claim_name => AclClaimName }}; create2( #{ use_jwks := true, - verify_claims := VerifyClaims + verify_claims := VerifyClaims, + acl_claim_name := AclClaimName } = Config ) -> ResourceId = emqx_authn_utils:make_resource_id(?MODULE), @@ -310,7 +322,8 @@ create2( ), {ok, #{ jwk_resource => ResourceId, - verify_claims => VerifyClaims + verify_claims => VerifyClaims, + acl_claim_name => AclClaimName }}. create_jwk_from_public_key(PublicKey) when @@ -352,23 +365,39 @@ replace_placeholder([{Name, {placeholder, PL}} | More], Variables, Acc) -> replace_placeholder([{Name, Value} | More], Variables, Acc) -> replace_placeholder(More, Variables, [{Name, Value} | Acc]). -verify(JWT, JWKs, VerifyClaims) -> +verify(JWT, JWKs, VerifyClaims, AclClaimName) -> case do_verify(JWT, JWKs, VerifyClaims) of - {ok, Extra} -> {ok, Extra}; + {ok, Extra} -> {ok, acl(Extra, AclClaimName)}; {error, {missing_claim, _}} -> {error, bad_username_or_password}; {error, invalid_signature} -> ignore; {error, {claims, _}} -> {error, bad_username_or_password} end. +acl(Claims, AclClaimName) -> + Acl = + case Claims of + #{<<"exp">> := Expire, AclClaimName := Rules} -> + #{ + acl => #{ + rules => Rules, + expire => Expire + } + }; + _ -> + #{} + end, + maps:merge(emqx_authn_utils:is_superuser(Claims), Acl). + do_verify(_JWS, [], _VerifyClaims) -> {error, invalid_signature}; do_verify(JWS, [JWK | More], VerifyClaims) -> try jose_jws:verify(JWK, JWS) of {true, Payload, _JWS} -> - Claims = emqx_json:decode(Payload, [return_maps]), + Claims0 = emqx_json:decode(Payload, [return_maps]), + Claims = try_convert_to_int(Claims0, [<<"exp">>, <<"iat">>, <<"nbf">>]), case verify_claims(Claims, VerifyClaims) of ok -> - {ok, maps:put(jwt, Claims, emqx_authn_utils:is_superuser(Claims))}; + {ok, Claims}; {error, Reason} -> {error, Reason} end; @@ -384,37 +413,39 @@ verify_claims(Claims, VerifyClaims0) -> Now = os:system_time(seconds), VerifyClaims = [ - {<<"exp">>, required, - with_int_value(fun(ExpireTime) -> - Now < ExpireTime - end)}, - {<<"iat">>, optional, - with_int_value(fun(IssueAt) -> - IssueAt =< Now - end)}, - {<<"nbf">>, optional, - with_int_value(fun(NotBefore) -> - NotBefore =< Now - end)} + {<<"exp">>, required, fun(ExpireTime) -> + is_integer(ExpireTime) andalso Now < ExpireTime + end}, + {<<"iat">>, optional, fun(IssueAt) -> + is_integer(IssueAt) andalso IssueAt =< Now + end}, + {<<"nbf">>, optional, fun(NotBefore) -> + is_integer(NotBefore) andalso NotBefore =< Now + end} ] ++ VerifyClaims0, do_verify_claims(Claims, VerifyClaims). -with_int_value(Fun) -> - fun(Value) -> - case Value of - Int when is_integer(Int) -> Fun(Int); - Bin when is_binary(Bin) -> - case string:to_integer(Bin) of - {Int, <<>>} -> Fun(Int); - _ -> false - end; - Str when is_list(Str) -> - case string:to_integer(Str) of - {Int, ""} -> Fun(Int); - _ -> false - end - end - end. +try_convert_to_int(Claims, [Name | Names]) -> + case Claims of + #{Name := Value} -> + case Value of + Int when is_integer(Int) -> + try_convert_to_int(Claims#{Name => Int}, Names); + Bin when is_binary(Bin) -> + case string:to_integer(Bin) of + {Int, <<>>} -> + try_convert_to_int(Claims#{Name => Int}, Names); + _ -> + try_convert_to_int(Claims, Names) + end; + _ -> + try_convert_to_int(Claims, Names) + end; + _ -> + try_convert_to_int(Claims, Names) + end; +try_convert_to_int(Claims, []) -> + Claims. do_verify_claims(_Claims, []) -> ok; diff --git a/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl b/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl index 30a0ee3c8..3b72aede1 100644 --- a/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl +++ b/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl @@ -56,6 +56,7 @@ t_jwt_authenticator_hmac_based(_) -> Secret = <<"abcdef">>, Config = #{ mechanism => jwt, + acl_claim_name => <<"acl">>, use_jwks => false, algorithm => 'hmac-based', secret => Secret, @@ -179,6 +180,7 @@ t_jwt_authenticator_public_key(_) -> PrivateKey = test_rsa_key(private), Config = #{ mechanism => jwt, + acl_claim_name => <<"acl">>, use_jwks => false, algorithm => 'public-key', public_key => PublicKey, @@ -214,6 +216,7 @@ t_jwks_renewal(_Config) -> BadConfig0 = #{ mechanism => jwt, + acl_claim_name => <<"acl">>, algorithm => 'public-key', ssl => #{enable => false}, verify_claims => [], @@ -308,6 +311,7 @@ t_jwt_authenticator_verify_claims(_) -> Secret = <<"abcdef">>, Config0 = #{ mechanism => jwt, + acl_claim_name => <<"acl">>, use_jwks => false, algorithm => 'hmac-based', secret => Secret, @@ -384,6 +388,7 @@ t_jwt_not_allow_empty_claim_name(_) -> Request = #{ <<"use_jwks">> => false, <<"algorithm">> => <<"hmac-based">>, + <<"acl_claim_name">> => <<"acl">>, <<"secret">> => <<"secret">>, <<"mechanism">> => <<"jwt">> }, diff --git a/apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf b/apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf index 3caf6ead8..161f6ed1b 100644 --- a/apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf +++ b/apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf @@ -348,28 +348,6 @@ Filter supports the following placeholders: } } - jwt { - desc { - en: """Authorization using ACL rules from authentication JWT.""" - zh: """使用 JWT 登录认证中携带的 ACL 规则来进行发布和订阅的授权。""" - } - 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.""" diff --git a/apps/emqx_authz/src/emqx_authz.erl b/apps/emqx_authz/src/emqx_authz.erl index e3e3bcaa5..b4dd180e3 100644 --- a/apps/emqx_authz/src/emqx_authz.erl +++ b/apps/emqx_authz/src/emqx_authz.erl @@ -95,6 +95,7 @@ register_metrics() -> init() -> ok = register_metrics(), + ok = init_metrics(client_info_source()), emqx_conf:add_handler(?CONF_KEY_PATH, ?MODULE), Sources = emqx_conf:get(?CONF_KEY_PATH, []), ok = check_dup_types(Sources), @@ -307,7 +308,7 @@ authorize( DefaultResult, Sources ) -> - case do_authorize(Client, PubSub, Topic, Sources) of + case do_authorize(Client, PubSub, Topic, sources_with_defaults(Sources)) of {{matched, allow}, AuthzSource} -> emqx:run_hook( 'client.check_authz_complete', @@ -392,6 +393,14 @@ get_enabled_authzs() -> %% Internal function %%-------------------------------------------------------------------- +client_info_source() -> + emqx_authz_client_info:create( + #{type => client_info, enable => true} + ). + +sources_with_defaults(Sources) -> + [client_info_source() | Sources]. + take(Type) -> take(Type, lookup()). %% Take the source of give type, the sources list is split into two parts @@ -431,8 +440,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; +type(client_info) -> client_info; +type(<<"client_info">>) -> client_info; %% should never happen if the input is type-checked by hocon schema type(Unknown) -> throw({unknown_authz_source_type, Unknown}). diff --git a/apps/emqx_authz/src/emqx_authz_client_info.erl b/apps/emqx_authz/src/emqx_authz_client_info.erl new file mode 100644 index 000000000..547ebdf99 --- /dev/null +++ b/apps/emqx_authz/src/emqx_authz_client_info.erl @@ -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_client_info). + +-include_lib("emqx/include/logger.hrl"). + +-behaviour(emqx_authz). + +-ifdef(TEST). +-compile(export_all). +-compile(nowarn_export_all). +-endif. + +%% APIs +-export([ + description/0, + create/1, + update/1, + destroy/1, + authorize/4 +]). + +-define(RULE_NAMES, [ + {[pub, <<"pub">>], publish}, + {[sub, <<"sub">>], subscribe}, + {[all, <<"all">>], all} +]). + +%%-------------------------------------------------------------------- +%% emqx_authz callbacks +%%-------------------------------------------------------------------- + +description() -> + "AuthZ with ClientInfo". + +create(Source) -> + Source. + +update(Source) -> + Source. + +destroy(_Source) -> ok. + +authorize(#{acl := Acl} = Client, PubSub, Topic, _Source) -> + case check(Acl) of + {ok, Rules} when is_map(Rules) -> + do_authorize(Client, PubSub, Topic, Rules); + {error, MatchResult} -> + MatchResult + end; +authorize(_Client, _PubSub, _Topic, _Source) -> + nomatch. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +check(#{expire := Expire, rules := Rules}) when is_map(Rules) -> + Now = erlang:system_time(second), + case Expire of + N when is_integer(N) andalso N > Now -> {ok, Rules}; + undefined -> {ok, Rules}; + _ -> {error, {matched, deny}} + end; +%% no expire +check(#{rules := Rules}) -> + {ok, Rules}; +%% no rules — no match +check(#{}) -> + {error, nomatch}. + +do_authorize(Client, PubSub, Topic, AclRules) -> + do_authorize(Client, PubSub, Topic, AclRules, ?RULE_NAMES). + +do_authorize(_Client, _PubSub, _Topic, _AclRules, []) -> + {matched, deny}; +do_authorize(Client, PubSub, Topic, AclRules, [{Keys, Action} | RuleNames]) -> + TopicFilters = get_topic_filters(Keys, 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, RuleNames) + end. + +get_topic_filters([], _Rules, Default) -> + Default; +get_topic_filters([Key | Keys], Rules, Default) -> + case Rules of + #{Key := Value} -> Value; + #{} -> get_topic_filters(Keys, Rules, Default) + end. diff --git a/apps/emqx_authz/src/emqx_authz_jwt.erl b/apps/emqx_authz/src/emqx_authz_jwt.erl deleted file mode 100644 index ed2a3dff4..000000000 --- a/apps/emqx_authz/src/emqx_authz_jwt.erl +++ /dev/null @@ -1,142 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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, - create/1, - update/1, - destroy/1, - authorize/4 -]). - --define(JWT_RULE_NAMES, [ - {<<"pub">>, publish}, - {<<"sub">>, subscribe}, - {<<"all">>, all} -]). - -%%-------------------------------------------------------------------- -%% emqx_authz callbacks -%%-------------------------------------------------------------------- - -description() -> - "AuthZ with JWT". - -create(#{acl_claim_name := _AclClaimName} = Source) -> - Source. - -update(#{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">>, required, - with_int_value(fun(ExpireTime) -> - Now < ExpireTime - end)}, - {<<"iat">>, optional, - with_int_value(fun(IssueAt) -> - IssueAt =< Now - end)}, - {<<"nbf">>, optional, - with_int_value(fun(NotBefore) -> - NotBefore =< Now - end)} - ], - IsValid = lists:all( - fun({ClaimName, Required, Validator}) -> - verify_claim(ClaimName, Required, JWT, Validator) - end, - VerifyClaims - ), - case IsValid of - true -> {ok, JWT}; - false -> error - end. - -with_int_value(Fun) -> - fun(Value) -> - case Value of - Int when is_integer(Int) -> Fun(Int); - Bin when is_binary(Bin) -> - case string:to_integer(Bin) of - {Int, <<>>} -> Fun(Int); - _ -> false - end; - Str when is_list(Str) -> - case string:to_integer(Str) of - {Int, ""} -> Fun(Int); - _ -> false - end - end - end. - -verify_claim(ClaimName, Required, JWT, Validator) -> - case JWT of - #{ClaimName := Value} -> - Validator(Value); - #{} -> - Required =:= optional - 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. diff --git a/apps/emqx_authz/src/emqx_authz_schema.erl b/apps/emqx_authz/src/emqx_authz_schema.erl index bced95f63..462cd7204 100644 --- a/apps/emqx_authz/src/emqx_authz_schema.erl +++ b/apps/emqx_authz/src/emqx_authz_schema.erl @@ -70,8 +70,7 @@ fields("authorization") -> hoconsc:ref(?MODULE, postgresql), hoconsc:ref(?MODULE, redis_single), hoconsc:ref(?MODULE, redis_sentinel), - hoconsc:ref(?MODULE, redis_cluster), - hoconsc:ref(?MODULE, jwt) + hoconsc:ref(?MODULE, redis_cluster) ] ), default => [], @@ -129,15 +128,6 @@ fields(redis_cluster) -> authz_common_fields(redis) ++ connector_fields(redis, cluster) ++ [{cmd, cmd()}]; -fields(jwt) -> - authz_common_fields(jwt) ++ - [ - {acl_claim_name, #{ - type => binary(), - default => <<"acl">>, - desc => ?DESC(acl_claim_name) - }} - ]; fields("metrics_status_fields") -> [ {"resource_metrics", mk(ref(?MODULE, "resource_metrics"), #{desc => ?DESC("metrics")})}, @@ -236,8 +226,6 @@ desc(redis_sentinel) -> ?DESC(redis_sentinel); desc(redis_cluster) -> ?DESC(redis_cluster); -desc(jwt) -> - ?DESC(jwt); desc(_) -> undefined. diff --git a/apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl b/apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl index 4755af1c8..4c36b0fa6 100644 --- a/apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl +++ b/apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl @@ -58,7 +58,6 @@ init_per_testcase(_TestCase, Config) -> ), ok = emqx_authz_test_lib:reset_authorizers(), - {ok, _} = emqx_authz:update(replace, [authz_config()]), Config. end_per_testcase(_TestCase, _Config) -> @@ -317,17 +316,12 @@ authn_config() -> <<"algorithm">> => <<"hmac-based">>, <<"secret">> => ?SECRET, <<"secret_base64_encoded">> => <<"false">>, + <<"acl_claim_name">> => <<"acl">>, <<"verify_claims">> => #{ <<"username">> => ?PH_USERNAME } }. -authz_config() -> - #{ - <<"type">> => <<"jwt">>, - <<"acl_claim_name">> => <<"acl">> - }. - generate_jws(Payload) -> JWK = jose_jwk:from_oct(?SECRET), Header = #{