Merge pull request #7939 from savonarola/authz-generalize

feat(authz): add default authn-based authz source
This commit is contained in:
Ilya Averyanov 2022-05-13 17:12:56 +03:00 committed by GitHub
commit 4d661cd67b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 214 additions and 229 deletions

View File

@ -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"""
}
}
}

View File

@ -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;

View File

@ -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">>
},

View File

@ -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."""

View File

@ -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}).

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_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.

View File

@ -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.

View File

@ -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.

View File

@ -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 = #{