소스 검색

Merge pull request #7730 from savonarola/jwt-authz

feat(emqx_auth_jwt): use JWT for ACL checks
JianBo He 3 년 전
부모
커밋
9f35dd7f80

+ 10 - 6
apps/emqx/src/emqx_channel.erl

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

+ 4 - 4
apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl

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

+ 11 - 11
apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl

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

+ 22 - 0
apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf

@@ -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 {
   cmd {
     desc {
     desc {
       en: """Database query used to retrieve authorization data."""
       en: """Database query used to retrieve authorization data."""

+ 2 - 0
apps/emqx_authz/src/emqx_authz.erl

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

+ 111 - 0
apps/emqx_authz/src/emqx_authz_jwt.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_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.

+ 14 - 2
apps/emqx_authz/src/emqx_authz_schema.erl

@@ -67,7 +67,8 @@ fields("authorization") ->
                     hoconsc:ref(?MODULE, postgresql),
                     hoconsc:ref(?MODULE, postgresql),
                     hoconsc:ref(?MODULE, redis_single),
                     hoconsc:ref(?MODULE, redis_single),
                     hoconsc:ref(?MODULE, redis_sentinel),
                     hoconsc:ref(?MODULE, redis_sentinel),
-                    hoconsc:ref(?MODULE, redis_cluster)
+                    hoconsc:ref(?MODULE, redis_cluster),
+                    hoconsc:ref(?MODULE, jwt)
                 ]
                 ]
             ),
             ),
             default => [],
             default => [],
@@ -124,7 +125,16 @@ fields(redis_sentinel) ->
 fields(redis_cluster) ->
 fields(redis_cluster) ->
     authz_common_fields(redis) ++
     authz_common_fields(redis) ++
         connector_fields(redis, cluster) ++
         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) ->
     ?DESC(?CONF_NS);
     ?DESC(?CONF_NS);
@@ -152,6 +162,8 @@ desc(redis_sentinel) ->
     ?DESC(redis_sentinel);
     ?DESC(redis_sentinel);
 desc(redis_cluster) ->
 desc(redis_cluster) ->
     ?DESC(redis_cluster);
     ?DESC(redis_cluster);
+desc(jwt) ->
+    ?DESC(jwt);
 desc(_) ->
 desc(_) ->
     undefined.
     undefined.
 
 

+ 312 - 0
apps/emqx_authz/test/emqx_authz_jwt_SUITE.erl

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