Merge pull request #13520 from lafirest/feat/scram-rest-acl
feat(scram): supports ACL rules in `scram_restapi` backend
This commit is contained in:
commit
60aefd1065
|
@ -31,4 +31,6 @@
|
||||||
-define(AUTHN_TYPE, {?AUTHN_MECHANISM, ?AUTHN_BACKEND}).
|
-define(AUTHN_TYPE, {?AUTHN_MECHANISM, ?AUTHN_BACKEND}).
|
||||||
-define(AUTHN_TYPE_SCRAM, {?AUTHN_MECHANISM_SCRAM, ?AUTHN_BACKEND}).
|
-define(AUTHN_TYPE_SCRAM, {?AUTHN_MECHANISM_SCRAM, ?AUTHN_BACKEND}).
|
||||||
|
|
||||||
|
-define(AUTHN_DATA_FIELDS, [is_superuser, client_attrs, expire_at, acl]).
|
||||||
|
|
||||||
-endif.
|
-endif.
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
start(_StartType, _StartArgs) ->
|
start(_StartType, _StartArgs) ->
|
||||||
ok = emqx_authz:register_source(?AUTHZ_TYPE, emqx_authz_http),
|
ok = emqx_authz:register_source(?AUTHZ_TYPE, emqx_authz_http),
|
||||||
ok = emqx_authn:register_provider(?AUTHN_TYPE, emqx_authn_http),
|
ok = emqx_authn:register_provider(?AUTHN_TYPE, emqx_authn_http),
|
||||||
ok = emqx_authn:register_provider(?AUTHN_TYPE_SCRAM, emqx_authn_scram_http),
|
ok = emqx_authn:register_provider(?AUTHN_TYPE_SCRAM, emqx_authn_scram_restapi),
|
||||||
{ok, Sup} = emqx_auth_http_sup:start_link(),
|
{ok, Sup} = emqx_auth_http_sup:start_link(),
|
||||||
{ok, Sup}.
|
{ok, Sup}.
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,9 @@
|
||||||
with_validated_config/2,
|
with_validated_config/2,
|
||||||
generate_request/2,
|
generate_request/2,
|
||||||
request_for_log/2,
|
request_for_log/2,
|
||||||
response_for_log/1
|
response_for_log/1,
|
||||||
|
extract_auth_data/2,
|
||||||
|
safely_parse_body/2
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
|
@ -209,34 +211,14 @@ handle_response(Headers, Body) ->
|
||||||
case safely_parse_body(ContentType, Body) of
|
case safely_parse_body(ContentType, Body) of
|
||||||
{ok, NBody} ->
|
{ok, NBody} ->
|
||||||
body_to_auth_data(NBody);
|
body_to_auth_data(NBody);
|
||||||
{error, Reason} ->
|
{error, _Reason} ->
|
||||||
?TRACE_AUTHN_PROVIDER(
|
|
||||||
error,
|
|
||||||
"parse_http_response_failed",
|
|
||||||
#{content_type => ContentType, body => Body, reason => Reason}
|
|
||||||
),
|
|
||||||
ignore
|
ignore
|
||||||
end.
|
end.
|
||||||
|
|
||||||
body_to_auth_data(Body) ->
|
body_to_auth_data(Body) ->
|
||||||
case maps:get(<<"result">>, Body, <<"ignore">>) of
|
case maps:get(<<"result">>, Body, <<"ignore">>) of
|
||||||
<<"allow">> ->
|
<<"allow">> ->
|
||||||
IsSuperuser = emqx_authn_utils:is_superuser(Body),
|
extract_auth_data(http, Body);
|
||||||
Attrs = emqx_authn_utils:client_attrs(Body),
|
|
||||||
try
|
|
||||||
ExpireAt = expire_at(Body),
|
|
||||||
ACL = acl(ExpireAt, Body),
|
|
||||||
Result = merge_maps([ExpireAt, IsSuperuser, ACL, Attrs]),
|
|
||||||
{ok, Result}
|
|
||||||
catch
|
|
||||||
throw:{bad_acl_rule, Reason} ->
|
|
||||||
%% it's a invalid token, so ok to log
|
|
||||||
?TRACE_AUTHN_PROVIDER("bad_acl_rule", Reason#{http_body => Body}),
|
|
||||||
{error, bad_username_or_password};
|
|
||||||
throw:Reason ->
|
|
||||||
?TRACE_AUTHN_PROVIDER("bad_response_body", Reason#{http_body => Body}),
|
|
||||||
{error, bad_username_or_password}
|
|
||||||
end;
|
|
||||||
<<"deny">> ->
|
<<"deny">> ->
|
||||||
{error, not_authorized};
|
{error, not_authorized};
|
||||||
<<"ignore">> ->
|
<<"ignore">> ->
|
||||||
|
@ -245,6 +227,24 @@ body_to_auth_data(Body) ->
|
||||||
ignore
|
ignore
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
extract_auth_data(Source, Body) ->
|
||||||
|
IsSuperuser = emqx_authn_utils:is_superuser(Body),
|
||||||
|
Attrs = emqx_authn_utils:client_attrs(Body),
|
||||||
|
try
|
||||||
|
ExpireAt = expire_at(Body),
|
||||||
|
ACL = acl(ExpireAt, Source, Body),
|
||||||
|
Result = merge_maps([ExpireAt, IsSuperuser, ACL, Attrs]),
|
||||||
|
{ok, Result}
|
||||||
|
catch
|
||||||
|
throw:{bad_acl_rule, Reason} ->
|
||||||
|
%% it's a invalid token, so ok to log
|
||||||
|
?TRACE_AUTHN_PROVIDER("bad_acl_rule", Reason#{http_body => Body}),
|
||||||
|
{error, bad_username_or_password};
|
||||||
|
throw:Reason ->
|
||||||
|
?TRACE_AUTHN_PROVIDER("bad_response_body", Reason#{http_body => Body}),
|
||||||
|
{error, bad_username_or_password}
|
||||||
|
end.
|
||||||
|
|
||||||
merge_maps([]) -> #{};
|
merge_maps([]) -> #{};
|
||||||
merge_maps([Map | Maps]) -> maps:merge(Map, merge_maps(Maps)).
|
merge_maps([Map | Maps]) -> maps:merge(Map, merge_maps(Maps)).
|
||||||
|
|
||||||
|
@ -283,40 +283,43 @@ expire_sec(#{<<"expire_at">> := _}) ->
|
||||||
expire_sec(_) ->
|
expire_sec(_) ->
|
||||||
undefined.
|
undefined.
|
||||||
|
|
||||||
acl(#{expire_at := ExpireTimeMs}, #{<<"acl">> := Rules}) ->
|
acl(#{expire_at := ExpireTimeMs}, Source, #{<<"acl">> := Rules}) ->
|
||||||
#{
|
#{
|
||||||
acl => #{
|
acl => #{
|
||||||
source_for_logging => http,
|
source_for_logging => Source,
|
||||||
rules => emqx_authz_rule_raw:parse_and_compile_rules(Rules),
|
rules => emqx_authz_rule_raw:parse_and_compile_rules(Rules),
|
||||||
%% It's seconds level precision (like JWT) for authz
|
%% It's seconds level precision (like JWT) for authz
|
||||||
%% see emqx_authz_client_info:check/1
|
%% see emqx_authz_client_info:check/1
|
||||||
expire => erlang:convert_time_unit(ExpireTimeMs, millisecond, second)
|
expire => erlang:convert_time_unit(ExpireTimeMs, millisecond, second)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
acl(_NoExpire, #{<<"acl">> := Rules}) ->
|
acl(_NoExpire, Source, #{<<"acl">> := Rules}) ->
|
||||||
#{
|
#{
|
||||||
acl => #{
|
acl => #{
|
||||||
source_for_logging => http,
|
source_for_logging => Source,
|
||||||
rules => emqx_authz_rule_raw:parse_and_compile_rules(Rules)
|
rules => emqx_authz_rule_raw:parse_and_compile_rules(Rules)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
acl(_, _) ->
|
acl(_, _, _) ->
|
||||||
#{}.
|
#{}.
|
||||||
|
|
||||||
safely_parse_body(ContentType, Body) ->
|
safely_parse_body(ContentType, Body) ->
|
||||||
try
|
try
|
||||||
parse_body(ContentType, Body)
|
parse_body(ContentType, Body)
|
||||||
catch
|
catch
|
||||||
_Class:_Reason ->
|
_Class:Reason ->
|
||||||
|
?TRACE_AUTHN_PROVIDER(
|
||||||
|
error,
|
||||||
|
"parse_http_response_failed",
|
||||||
|
#{content_type => ContentType, body => Body, reason => Reason}
|
||||||
|
),
|
||||||
{error, invalid_body}
|
{error, invalid_body}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
parse_body(<<"application/json", _/binary>>, Body) ->
|
parse_body(<<"application/json", _/binary>>, Body) ->
|
||||||
{ok, emqx_utils_json:decode(Body, [return_maps])};
|
{ok, emqx_utils_json:decode(Body, [return_maps])};
|
||||||
parse_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) ->
|
parse_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) ->
|
||||||
Flags = [<<"result">>, <<"is_superuser">>],
|
NBody = maps:from_list(cow_qs:parse_qs(Body)),
|
||||||
RawMap = maps:from_list(cow_qs:parse_qs(Body)),
|
|
||||||
NBody = maps:with(Flags, RawMap),
|
|
||||||
{ok, NBody};
|
{ok, NBody};
|
||||||
parse_body(ContentType, _) ->
|
parse_body(ContentType, _) ->
|
||||||
{error, {unsupported_content_type, ContentType}}.
|
{error, {unsupported_content_type, ContentType}}.
|
||||||
|
|
|
@ -2,10 +2,19 @@
|
||||||
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
-module(emqx_authn_scram_http).
|
%% Note:
|
||||||
|
%% This is not an implementation of the RFC 7804:
|
||||||
|
%% Salted Challenge Response HTTP Authentication Mechanism.
|
||||||
|
%% This backend is an implementation of scram,
|
||||||
|
%% which uses an external web resource as a source of user information.
|
||||||
|
|
||||||
-include_lib("emqx_auth/include/emqx_authn.hrl").
|
-module(emqx_authn_scram_restapi).
|
||||||
|
|
||||||
|
-feature(maybe_expr, enable).
|
||||||
|
|
||||||
|
-include("emqx_auth_http.hrl").
|
||||||
-include_lib("emqx/include/logger.hrl").
|
-include_lib("emqx/include/logger.hrl").
|
||||||
|
-include_lib("emqx_auth/include/emqx_authn.hrl").
|
||||||
|
|
||||||
-behaviour(emqx_authn_provider).
|
-behaviour(emqx_authn_provider).
|
||||||
|
|
||||||
|
@ -22,10 +31,6 @@
|
||||||
<<"salt">>
|
<<"salt">>
|
||||||
]).
|
]).
|
||||||
|
|
||||||
-define(OPTIONAL_USER_INFO_KEYS, [
|
|
||||||
<<"is_superuser">>
|
|
||||||
]).
|
|
||||||
|
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
%% APIs
|
%% APIs
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
|
@ -72,7 +77,9 @@ authenticate(
|
||||||
reason => Reason
|
reason => Reason
|
||||||
})
|
})
|
||||||
end,
|
end,
|
||||||
emqx_utils_scram:authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, State);
|
emqx_utils_scram:authenticate(
|
||||||
|
AuthMethod, AuthData, AuthCache, State, RetrieveFun, OnErrFun, ?AUTHN_DATA_FIELDS
|
||||||
|
);
|
||||||
authenticate(_Credential, _State) ->
|
authenticate(_Credential, _State) ->
|
||||||
ignore.
|
ignore.
|
||||||
|
|
||||||
|
@ -95,7 +102,7 @@ retrieve(
|
||||||
) ->
|
) ->
|
||||||
Request = emqx_authn_http:generate_request(Credential#{username := Username}, State),
|
Request = emqx_authn_http:generate_request(Credential#{username := Username}, State),
|
||||||
Response = emqx_resource:simple_sync_query(ResourceId, {Method, Request, RequestTimeout}),
|
Response = emqx_resource:simple_sync_query(ResourceId, {Method, Request, RequestTimeout}),
|
||||||
?TRACE_AUTHN_PROVIDER("scram_http_response", #{
|
?TRACE_AUTHN_PROVIDER("scram_restapi_response", #{
|
||||||
request => emqx_authn_http:request_for_log(Credential, State),
|
request => emqx_authn_http:request_for_log(Credential, State),
|
||||||
response => emqx_authn_http:response_for_log(Response),
|
response => emqx_authn_http:response_for_log(Response),
|
||||||
resource => ResourceId
|
resource => ResourceId
|
||||||
|
@ -113,16 +120,11 @@ retrieve(
|
||||||
|
|
||||||
handle_response(Headers, Body) ->
|
handle_response(Headers, Body) ->
|
||||||
ContentType = proplists:get_value(<<"content-type">>, Headers),
|
ContentType = proplists:get_value(<<"content-type">>, Headers),
|
||||||
case safely_parse_body(ContentType, Body) of
|
maybe
|
||||||
{ok, NBody} ->
|
{ok, NBody} ?= emqx_authn_http:safely_parse_body(ContentType, Body),
|
||||||
body_to_user_info(NBody);
|
{ok, UserInfo} ?= body_to_user_info(NBody),
|
||||||
{error, Reason} = Error ->
|
{ok, AuthData} ?= emqx_authn_http:extract_auth_data(scram_restapi, NBody),
|
||||||
?TRACE_AUTHN_PROVIDER(
|
{ok, maps:merge(AuthData, UserInfo)}
|
||||||
error,
|
|
||||||
"parse_scram_http_response_failed",
|
|
||||||
#{content_type => ContentType, body => Body, reason => Reason}
|
|
||||||
),
|
|
||||||
Error
|
|
||||||
end.
|
end.
|
||||||
|
|
||||||
body_to_user_info(Body) ->
|
body_to_user_info(Body) ->
|
||||||
|
@ -131,26 +133,16 @@ body_to_user_info(Body) ->
|
||||||
true ->
|
true ->
|
||||||
case safely_convert_hex(Required0) of
|
case safely_convert_hex(Required0) of
|
||||||
{ok, Required} ->
|
{ok, Required} ->
|
||||||
UserInfo0 = maps:merge(Required, maps:with(?OPTIONAL_USER_INFO_KEYS, Body)),
|
{ok, emqx_utils_maps:safe_atom_key_map(Required)};
|
||||||
UserInfo1 = emqx_utils_maps:safe_atom_key_map(UserInfo0),
|
|
||||||
UserInfo = maps:merge(#{is_superuser => false}, UserInfo1),
|
|
||||||
{ok, UserInfo};
|
|
||||||
Error ->
|
Error ->
|
||||||
|
?TRACE_AUTHN_PROVIDER("decode_keys_failed", #{http_body => Body}),
|
||||||
Error
|
Error
|
||||||
end;
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
?TRACE_AUTHN_PROVIDER("bad_response_body", #{http_body => Body}),
|
?TRACE_AUTHN_PROVIDER("missing_requried_keys", #{http_body => Body}),
|
||||||
{error, bad_response}
|
{error, bad_response}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
safely_parse_body(ContentType, Body) ->
|
|
||||||
try
|
|
||||||
parse_body(ContentType, Body)
|
|
||||||
catch
|
|
||||||
_Class:_Reason ->
|
|
||||||
{error, invalid_body}
|
|
||||||
end.
|
|
||||||
|
|
||||||
safely_convert_hex(Required) ->
|
safely_convert_hex(Required) ->
|
||||||
try
|
try
|
||||||
{ok,
|
{ok,
|
||||||
|
@ -165,15 +157,5 @@ safely_convert_hex(Required) ->
|
||||||
{error, Reason}
|
{error, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
parse_body(<<"application/json", _/binary>>, Body) ->
|
|
||||||
{ok, emqx_utils_json:decode(Body, [return_maps])};
|
|
||||||
parse_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) ->
|
|
||||||
Flags = ?REQUIRED_USER_INFO_KEYS ++ ?OPTIONAL_USER_INFO_KEYS,
|
|
||||||
RawMap = maps:from_list(cow_qs:parse_qs(Body)),
|
|
||||||
NBody = maps:with(Flags, RawMap),
|
|
||||||
{ok, NBody};
|
|
||||||
parse_body(ContentType, _) ->
|
|
||||||
{error, {unsupported_content_type, ContentType}}.
|
|
||||||
|
|
||||||
merge_scram_conf(Conf, State) ->
|
merge_scram_conf(Conf, State) ->
|
||||||
maps:merge(maps:with([algorithm, iteration_count], Conf), State).
|
maps:merge(maps:with([algorithm, iteration_count], Conf), State).
|
|
@ -2,7 +2,7 @@
|
||||||
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
-module(emqx_authn_scram_http_schema).
|
-module(emqx_authn_scram_restapi_schema).
|
||||||
|
|
||||||
-behaviour(emqx_authn_schema).
|
-behaviour(emqx_authn_schema).
|
||||||
|
|
||||||
|
@ -22,16 +22,16 @@
|
||||||
namespace() -> "authn".
|
namespace() -> "authn".
|
||||||
|
|
||||||
refs() ->
|
refs() ->
|
||||||
[?R_REF(scram_http_get), ?R_REF(scram_http_post)].
|
[?R_REF(scram_restapi_get), ?R_REF(scram_restapi_post)].
|
||||||
|
|
||||||
select_union_member(
|
select_union_member(
|
||||||
#{<<"mechanism">> := ?AUTHN_MECHANISM_SCRAM_BIN, <<"backend">> := ?AUTHN_BACKEND_BIN} = Value
|
#{<<"mechanism">> := ?AUTHN_MECHANISM_SCRAM_BIN, <<"backend">> := ?AUTHN_BACKEND_BIN} = Value
|
||||||
) ->
|
) ->
|
||||||
case maps:get(<<"method">>, Value, undefined) of
|
case maps:get(<<"method">>, Value, undefined) of
|
||||||
<<"get">> ->
|
<<"get">> ->
|
||||||
[?R_REF(scram_http_get)];
|
[?R_REF(scram_restapi_get)];
|
||||||
<<"post">> ->
|
<<"post">> ->
|
||||||
[?R_REF(scramm_http_post)];
|
[?R_REF(scram_restapi_post)];
|
||||||
Else ->
|
Else ->
|
||||||
throw(#{
|
throw(#{
|
||||||
reason => "unknown_http_method",
|
reason => "unknown_http_method",
|
||||||
|
@ -43,20 +43,20 @@ select_union_member(
|
||||||
select_union_member(_Value) ->
|
select_union_member(_Value) ->
|
||||||
undefined.
|
undefined.
|
||||||
|
|
||||||
fields(scram_http_get) ->
|
fields(scram_restapi_get) ->
|
||||||
[
|
[
|
||||||
{method, #{type => get, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
|
{method, #{type => get, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
|
||||||
{headers, fun emqx_authn_http_schema:headers_no_content_type/1}
|
{headers, fun emqx_authn_http_schema:headers_no_content_type/1}
|
||||||
] ++ common_fields();
|
] ++ common_fields();
|
||||||
fields(scram_http_post) ->
|
fields(scram_restapi_post) ->
|
||||||
[
|
[
|
||||||
{method, #{type => post, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
|
{method, #{type => post, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
|
||||||
{headers, fun emqx_authn_http_schema:headers/1}
|
{headers, fun emqx_authn_http_schema:headers/1}
|
||||||
] ++ common_fields().
|
] ++ common_fields().
|
||||||
|
|
||||||
desc(scram_http_get) ->
|
desc(scram_restapi_get) ->
|
||||||
?DESC(emqx_authn_http_schema, get);
|
?DESC(emqx_authn_http_schema, get);
|
||||||
desc(scram_http_post) ->
|
desc(scram_restapi_post) ->
|
||||||
?DESC(emqx_authn_http_schema, post);
|
?DESC(emqx_authn_http_schema, post);
|
||||||
desc(_) ->
|
desc(_) ->
|
||||||
undefined.
|
undefined.
|
|
@ -2,7 +2,7 @@
|
||||||
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
-module(emqx_authn_scram_http_SUITE).
|
-module(emqx_authn_scram_restapi_SUITE).
|
||||||
|
|
||||||
-compile(export_all).
|
-compile(export_all).
|
||||||
-compile(nowarn_export_all).
|
-compile(nowarn_export_all).
|
||||||
|
@ -21,6 +21,9 @@
|
||||||
-define(ALGORITHM_STR, <<"sha512">>).
|
-define(ALGORITHM_STR, <<"sha512">>).
|
||||||
-define(ITERATION_COUNT, 4096).
|
-define(ITERATION_COUNT, 4096).
|
||||||
|
|
||||||
|
-define(T_ACL_USERNAME, <<"username">>).
|
||||||
|
-define(T_ACL_PASSWORD, <<"password">>).
|
||||||
|
|
||||||
-include_lib("emqx/include/emqx_placeholder.hrl").
|
-include_lib("emqx/include/emqx_placeholder.hrl").
|
||||||
|
|
||||||
all() ->
|
all() ->
|
||||||
|
@ -54,11 +57,11 @@ init_per_testcase(_Case, Config) ->
|
||||||
[authentication],
|
[authentication],
|
||||||
?GLOBAL
|
?GLOBAL
|
||||||
),
|
),
|
||||||
{ok, _} = emqx_authn_scram_http_test_server:start_link(?HTTP_PORT, ?HTTP_PATH),
|
{ok, _} = emqx_authn_scram_restapi_test_server:start_link(?HTTP_PORT, ?HTTP_PATH),
|
||||||
Config.
|
Config.
|
||||||
|
|
||||||
end_per_testcase(_Case, _Config) ->
|
end_per_testcase(_Case, _Config) ->
|
||||||
ok = emqx_authn_scram_http_test_server:stop().
|
ok = emqx_authn_scram_restapi_test_server:stop().
|
||||||
|
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
%% Tests
|
%% Tests
|
||||||
|
@ -72,7 +75,9 @@ t_create(_Config) ->
|
||||||
{create_authenticator, ?GLOBAL, AuthConfig}
|
{create_authenticator, ?GLOBAL, AuthConfig}
|
||||||
),
|
),
|
||||||
|
|
||||||
{ok, [#{provider := emqx_authn_scram_http}]} = emqx_authn_chains:list_authenticators(?GLOBAL).
|
{ok, [#{provider := emqx_authn_scram_restapi}]} = emqx_authn_chains:list_authenticators(
|
||||||
|
?GLOBAL
|
||||||
|
).
|
||||||
|
|
||||||
t_create_invalid(_Config) ->
|
t_create_invalid(_Config) ->
|
||||||
AuthConfig = raw_config(),
|
AuthConfig = raw_config(),
|
||||||
|
@ -118,59 +123,8 @@ t_authenticate(_Config) ->
|
||||||
|
|
||||||
ok = emqx_config:put([mqtt, idle_timeout], 500),
|
ok = emqx_config:put([mqtt, idle_timeout], 500),
|
||||||
|
|
||||||
{ok, Pid} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
|
{ok, Pid} = create_connection(Username, Password),
|
||||||
|
emqx_authn_mqtt_test_client:stop(Pid).
|
||||||
ClientFirstMessage = esasl_scram:client_first_message(Username),
|
|
||||||
|
|
||||||
ConnectPacket = ?CONNECT_PACKET(
|
|
||||||
#mqtt_packet_connect{
|
|
||||||
proto_ver = ?MQTT_PROTO_V5,
|
|
||||||
properties = #{
|
|
||||||
'Authentication-Method' => <<"SCRAM-SHA-512">>,
|
|
||||||
'Authentication-Data' => ClientFirstMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
ok = emqx_authn_mqtt_test_client:send(Pid, ConnectPacket),
|
|
||||||
|
|
||||||
%% Intentional sleep to trigger idle timeout for the connection not yet authenticated
|
|
||||||
ok = ct:sleep(1000),
|
|
||||||
|
|
||||||
?AUTH_PACKET(
|
|
||||||
?RC_CONTINUE_AUTHENTICATION,
|
|
||||||
#{'Authentication-Data' := ServerFirstMessage}
|
|
||||||
) = receive_packet(),
|
|
||||||
|
|
||||||
{continue, ClientFinalMessage, ClientCache} =
|
|
||||||
esasl_scram:check_server_first_message(
|
|
||||||
ServerFirstMessage,
|
|
||||||
#{
|
|
||||||
client_first_message => ClientFirstMessage,
|
|
||||||
password => Password,
|
|
||||||
algorithm => ?ALGORITHM
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
AuthContinuePacket = ?AUTH_PACKET(
|
|
||||||
?RC_CONTINUE_AUTHENTICATION,
|
|
||||||
#{
|
|
||||||
'Authentication-Method' => <<"SCRAM-SHA-512">>,
|
|
||||||
'Authentication-Data' => ClientFinalMessage
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
ok = emqx_authn_mqtt_test_client:send(Pid, AuthContinuePacket),
|
|
||||||
|
|
||||||
?CONNACK_PACKET(
|
|
||||||
?RC_SUCCESS,
|
|
||||||
_,
|
|
||||||
#{'Authentication-Data' := ServerFinalMessage}
|
|
||||||
) = receive_packet(),
|
|
||||||
|
|
||||||
ok = esasl_scram:check_server_final_message(
|
|
||||||
ServerFinalMessage, ClientCache#{algorithm => ?ALGORITHM}
|
|
||||||
).
|
|
||||||
|
|
||||||
t_authenticate_bad_props(_Config) ->
|
t_authenticate_bad_props(_Config) ->
|
||||||
Username = <<"u">>,
|
Username = <<"u">>,
|
||||||
|
@ -314,6 +268,47 @@ t_destroy(_Config) ->
|
||||||
_
|
_
|
||||||
) = receive_packet().
|
) = receive_packet().
|
||||||
|
|
||||||
|
t_acl(_Config) ->
|
||||||
|
init_auth(),
|
||||||
|
|
||||||
|
ACL = emqx_authn_http_SUITE:acl_rules(),
|
||||||
|
set_user_handler(?T_ACL_USERNAME, ?T_ACL_PASSWORD, #{acl => ACL}),
|
||||||
|
{ok, Pid} = create_connection(?T_ACL_USERNAME, ?T_ACL_PASSWORD),
|
||||||
|
|
||||||
|
Cases = [
|
||||||
|
{allow, <<"http-authn-acl/#">>},
|
||||||
|
{deny, <<"http-authn-acl/1">>},
|
||||||
|
{deny, <<"t/#">>}
|
||||||
|
],
|
||||||
|
|
||||||
|
try
|
||||||
|
lists:foreach(
|
||||||
|
fun(Case) ->
|
||||||
|
test_acl(Case, Pid)
|
||||||
|
end,
|
||||||
|
Cases
|
||||||
|
)
|
||||||
|
after
|
||||||
|
ok = emqx_authn_mqtt_test_client:stop(Pid)
|
||||||
|
end.
|
||||||
|
|
||||||
|
t_auth_expire(_Config) ->
|
||||||
|
init_auth(),
|
||||||
|
|
||||||
|
ExpireSec = 3,
|
||||||
|
WaitTime = timer:seconds(ExpireSec + 1),
|
||||||
|
ACL = emqx_authn_http_SUITE:acl_rules(),
|
||||||
|
|
||||||
|
set_user_handler(?T_ACL_USERNAME, ?T_ACL_PASSWORD, #{
|
||||||
|
acl => ACL,
|
||||||
|
expire_at =>
|
||||||
|
erlang:system_time(second) + ExpireSec
|
||||||
|
}),
|
||||||
|
{ok, Pid} = create_connection(?T_ACL_USERNAME, ?T_ACL_PASSWORD),
|
||||||
|
|
||||||
|
timer:sleep(WaitTime),
|
||||||
|
?assertEqual(false, erlang:is_process_alive(Pid)).
|
||||||
|
|
||||||
t_is_superuser() ->
|
t_is_superuser() ->
|
||||||
State = init_auth(),
|
State = init_auth(),
|
||||||
ok = test_is_superuser(State, false),
|
ok = test_is_superuser(State, false),
|
||||||
|
@ -324,12 +319,12 @@ test_is_superuser(State, ExpectedIsSuperuser) ->
|
||||||
Username = <<"u">>,
|
Username = <<"u">>,
|
||||||
Password = <<"p">>,
|
Password = <<"p">>,
|
||||||
|
|
||||||
set_user_handler(Username, Password, ExpectedIsSuperuser),
|
set_user_handler(Username, Password, #{is_superuser => ExpectedIsSuperuser}),
|
||||||
|
|
||||||
ClientFirstMessage = esasl_scram:client_first_message(Username),
|
ClientFirstMessage = esasl_scram:client_first_message(Username),
|
||||||
|
|
||||||
{continue, ServerFirstMessage, ServerCache} =
|
{continue, ServerFirstMessage, ServerCache} =
|
||||||
emqx_authn_scram_http:authenticate(
|
emqx_authn_scram_restapi:authenticate(
|
||||||
#{
|
#{
|
||||||
auth_method => <<"SCRAM-SHA-512">>,
|
auth_method => <<"SCRAM-SHA-512">>,
|
||||||
auth_data => ClientFirstMessage,
|
auth_data => ClientFirstMessage,
|
||||||
|
@ -349,7 +344,7 @@ test_is_superuser(State, ExpectedIsSuperuser) ->
|
||||||
),
|
),
|
||||||
|
|
||||||
{ok, UserInfo1, ServerFinalMessage} =
|
{ok, UserInfo1, ServerFinalMessage} =
|
||||||
emqx_authn_scram_http:authenticate(
|
emqx_authn_scram_restapi:authenticate(
|
||||||
#{
|
#{
|
||||||
auth_method => <<"SCRAM-SHA-512">>,
|
auth_method => <<"SCRAM-SHA-512">>,
|
||||||
auth_data => ClientFinalMessage,
|
auth_data => ClientFinalMessage,
|
||||||
|
@ -382,24 +377,25 @@ raw_config() ->
|
||||||
}.
|
}.
|
||||||
|
|
||||||
set_user_handler(Username, Password) ->
|
set_user_handler(Username, Password) ->
|
||||||
set_user_handler(Username, Password, false).
|
set_user_handler(Username, Password, #{is_superuser => false}).
|
||||||
set_user_handler(Username, Password, IsSuperuser) ->
|
set_user_handler(Username, Password, Extra0) ->
|
||||||
%% HTTP Server
|
%% HTTP Server
|
||||||
Handler = fun(Req0, State) ->
|
Handler = fun(Req0, State) ->
|
||||||
#{
|
#{
|
||||||
username := Username
|
username := Username
|
||||||
} = cowboy_req:match_qs([username], Req0),
|
} = cowboy_req:match_qs([username], Req0),
|
||||||
|
|
||||||
UserInfo = make_user_info(Password, ?ALGORITHM, ?ITERATION_COUNT, IsSuperuser),
|
UserInfo = make_user_info(Password, ?ALGORITHM, ?ITERATION_COUNT),
|
||||||
|
Extra = maps:merge(#{is_superuser => false}, Extra0),
|
||||||
Req = cowboy_req:reply(
|
Req = cowboy_req:reply(
|
||||||
200,
|
200,
|
||||||
#{<<"content-type">> => <<"application/json">>},
|
#{<<"content-type">> => <<"application/json">>},
|
||||||
emqx_utils_json:encode(UserInfo),
|
emqx_utils_json:encode(maps:merge(Extra, UserInfo)),
|
||||||
Req0
|
Req0
|
||||||
),
|
),
|
||||||
{ok, Req, State}
|
{ok, Req, State}
|
||||||
end,
|
end,
|
||||||
ok = emqx_authn_scram_http_test_server:set_handler(Handler).
|
ok = emqx_authn_scram_restapi_test_server:set_handler(Handler).
|
||||||
|
|
||||||
init_auth() ->
|
init_auth() ->
|
||||||
init_auth(raw_config()).
|
init_auth(raw_config()).
|
||||||
|
@ -413,7 +409,7 @@ init_auth(Config) ->
|
||||||
{ok, [#{state := State}]} = emqx_authn_chains:list_authenticators(?GLOBAL),
|
{ok, [#{state := State}]} = emqx_authn_chains:list_authenticators(?GLOBAL),
|
||||||
State.
|
State.
|
||||||
|
|
||||||
make_user_info(Password, Algorithm, IterationCount, IsSuperuser) ->
|
make_user_info(Password, Algorithm, IterationCount) ->
|
||||||
{StoredKey, ServerKey, Salt} = esasl_scram:generate_authentication_info(
|
{StoredKey, ServerKey, Salt} = esasl_scram:generate_authentication_info(
|
||||||
Password,
|
Password,
|
||||||
#{
|
#{
|
||||||
|
@ -424,8 +420,7 @@ make_user_info(Password, Algorithm, IterationCount, IsSuperuser) ->
|
||||||
#{
|
#{
|
||||||
stored_key => binary:encode_hex(StoredKey),
|
stored_key => binary:encode_hex(StoredKey),
|
||||||
server_key => binary:encode_hex(ServerKey),
|
server_key => binary:encode_hex(ServerKey),
|
||||||
salt => binary:encode_hex(Salt),
|
salt => binary:encode_hex(Salt)
|
||||||
is_superuser => IsSuperuser
|
|
||||||
}.
|
}.
|
||||||
|
|
||||||
receive_packet() ->
|
receive_packet() ->
|
||||||
|
@ -436,3 +431,79 @@ receive_packet() ->
|
||||||
after 1000 ->
|
after 1000 ->
|
||||||
ct:fail("Deliver timeout")
|
ct:fail("Deliver timeout")
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
create_connection(Username, Password) ->
|
||||||
|
{ok, Pid} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
|
||||||
|
|
||||||
|
ClientFirstMessage = esasl_scram:client_first_message(Username),
|
||||||
|
|
||||||
|
ConnectPacket = ?CONNECT_PACKET(
|
||||||
|
#mqtt_packet_connect{
|
||||||
|
proto_ver = ?MQTT_PROTO_V5,
|
||||||
|
properties = #{
|
||||||
|
'Authentication-Method' => <<"SCRAM-SHA-512">>,
|
||||||
|
'Authentication-Data' => ClientFirstMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
ok = emqx_authn_mqtt_test_client:send(Pid, ConnectPacket),
|
||||||
|
|
||||||
|
%% Intentional sleep to trigger idle timeout for the connection not yet authenticated
|
||||||
|
ok = ct:sleep(1000),
|
||||||
|
|
||||||
|
?AUTH_PACKET(
|
||||||
|
?RC_CONTINUE_AUTHENTICATION,
|
||||||
|
#{'Authentication-Data' := ServerFirstMessage}
|
||||||
|
) = receive_packet(),
|
||||||
|
|
||||||
|
{continue, ClientFinalMessage, ClientCache} =
|
||||||
|
esasl_scram:check_server_first_message(
|
||||||
|
ServerFirstMessage,
|
||||||
|
#{
|
||||||
|
client_first_message => ClientFirstMessage,
|
||||||
|
password => Password,
|
||||||
|
algorithm => ?ALGORITHM
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
AuthContinuePacket = ?AUTH_PACKET(
|
||||||
|
?RC_CONTINUE_AUTHENTICATION,
|
||||||
|
#{
|
||||||
|
'Authentication-Method' => <<"SCRAM-SHA-512">>,
|
||||||
|
'Authentication-Data' => ClientFinalMessage
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
ok = emqx_authn_mqtt_test_client:send(Pid, AuthContinuePacket),
|
||||||
|
|
||||||
|
?CONNACK_PACKET(
|
||||||
|
?RC_SUCCESS,
|
||||||
|
_,
|
||||||
|
#{'Authentication-Data' := ServerFinalMessage}
|
||||||
|
) = receive_packet(),
|
||||||
|
|
||||||
|
ok = esasl_scram:check_server_final_message(
|
||||||
|
ServerFinalMessage, ClientCache#{algorithm => ?ALGORITHM}
|
||||||
|
),
|
||||||
|
{ok, Pid}.
|
||||||
|
|
||||||
|
test_acl({allow, Topic}, C) ->
|
||||||
|
?assertMatch(
|
||||||
|
[0],
|
||||||
|
send_subscribe(C, Topic)
|
||||||
|
);
|
||||||
|
test_acl({deny, Topic}, C) ->
|
||||||
|
?assertMatch(
|
||||||
|
[?RC_NOT_AUTHORIZED],
|
||||||
|
send_subscribe(C, Topic)
|
||||||
|
).
|
||||||
|
|
||||||
|
send_subscribe(Client, Topic) ->
|
||||||
|
TopicOpts = #{nl => 0, rap => 0, rh => 0, qos => 0},
|
||||||
|
Packet = ?SUBSCRIBE_PACKET(1, [{Topic, TopicOpts}]),
|
||||||
|
emqx_authn_mqtt_test_client:send(Client, Packet),
|
||||||
|
timer:sleep(200),
|
||||||
|
|
||||||
|
?SUBACK_PACKET(1, ReasonCode) = receive_packet(),
|
||||||
|
ReasonCode.
|
|
@ -2,7 +2,7 @@
|
||||||
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
-module(emqx_authn_scram_http_test_server).
|
-module(emqx_authn_scram_restapi_test_server).
|
||||||
|
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
-behaviour(cowboy_handler).
|
-behaviour(cowboy_handler).
|
|
@ -141,7 +141,9 @@ authenticate(
|
||||||
reason => Reason
|
reason => Reason
|
||||||
})
|
})
|
||||||
end,
|
end,
|
||||||
emqx_utils_scram:authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, State);
|
emqx_utils_scram:authenticate(
|
||||||
|
AuthMethod, AuthData, AuthCache, State, RetrieveFun, OnErrFun, [is_superuser]
|
||||||
|
);
|
||||||
authenticate(_Credential, _State) ->
|
authenticate(_Credential, _State) ->
|
||||||
ignore.
|
ignore.
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,7 @@ authn_mods(ee) ->
|
||||||
authn_mods(ce) ++
|
authn_mods(ce) ++
|
||||||
[
|
[
|
||||||
emqx_gcp_device_authn_schema,
|
emqx_gcp_device_authn_schema,
|
||||||
emqx_authn_scram_http_schema
|
emqx_authn_scram_restapi_schema
|
||||||
].
|
].
|
||||||
|
|
||||||
authz() ->
|
authz() ->
|
||||||
|
|
|
@ -383,7 +383,7 @@ schema_authn() ->
|
||||||
emqx_dashboard_swagger:schema_with_examples(
|
emqx_dashboard_swagger:schema_with_examples(
|
||||||
emqx_authn_schema:authenticator_type_without([
|
emqx_authn_schema:authenticator_type_without([
|
||||||
emqx_authn_scram_mnesia_schema,
|
emqx_authn_scram_mnesia_schema,
|
||||||
emqx_authn_scram_http_schema
|
emqx_authn_scram_restapi_schema
|
||||||
]),
|
]),
|
||||||
emqx_authn_api:authenticator_examples()
|
emqx_authn_api:authenticator_examples()
|
||||||
).
|
).
|
||||||
|
|
|
@ -16,17 +16,17 @@
|
||||||
|
|
||||||
-module(emqx_utils_scram).
|
-module(emqx_utils_scram).
|
||||||
|
|
||||||
-export([authenticate/6]).
|
-export([authenticate/7]).
|
||||||
|
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
%% Authentication
|
%% Authentication
|
||||||
%%------------------------------------------------------------------------------
|
%%------------------------------------------------------------------------------
|
||||||
authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, Conf) ->
|
authenticate(AuthMethod, AuthData, AuthCache, Conf, RetrieveFun, OnErrFun, ResultKeys) ->
|
||||||
case ensure_auth_method(AuthMethod, AuthData, Conf) of
|
case ensure_auth_method(AuthMethod, AuthData, Conf) of
|
||||||
true ->
|
true ->
|
||||||
case AuthCache of
|
case AuthCache of
|
||||||
#{next_step := client_final} ->
|
#{next_step := client_final} ->
|
||||||
check_client_final_message(AuthData, AuthCache, Conf, OnErrFun);
|
check_client_final_message(AuthData, AuthCache, Conf, OnErrFun, ResultKeys);
|
||||||
_ ->
|
_ ->
|
||||||
check_client_first_message(AuthData, AuthCache, Conf, RetrieveFun, OnErrFun)
|
check_client_first_message(AuthData, AuthCache, Conf, RetrieveFun, OnErrFun)
|
||||||
end;
|
end;
|
||||||
|
@ -64,9 +64,7 @@ check_client_first_message(
|
||||||
{error, not_authorized}
|
{error, not_authorized}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
check_client_final_message(
|
check_client_final_message(Bin, Cache, #{algorithm := Alg}, OnErrFun, ResultKeys) ->
|
||||||
Bin, #{is_superuser := IsSuperuser} = Cache, #{algorithm := Alg}, OnErrFun
|
|
||||||
) ->
|
|
||||||
case
|
case
|
||||||
esasl_scram:check_client_final_message(
|
esasl_scram:check_client_final_message(
|
||||||
Bin,
|
Bin,
|
||||||
|
@ -74,7 +72,7 @@ check_client_final_message(
|
||||||
)
|
)
|
||||||
of
|
of
|
||||||
{ok, ServerFinalMessage} ->
|
{ok, ServerFinalMessage} ->
|
||||||
{ok, #{is_superuser => IsSuperuser}, ServerFinalMessage};
|
{ok, maps:with(ResultKeys, Cache), ServerFinalMessage};
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
OnErrFun("check_client_final_message_error", Reason),
|
OnErrFun("check_client_final_message_error", Reason),
|
||||||
{error, not_authorized}
|
{error, not_authorized}
|
||||||
|
|
|
@ -1 +1,5 @@
|
||||||
Added a HTTP backend for the authentication mechanism `scram`.
|
Added a HTTP backend for the authentication mechanism `scram`.
|
||||||
|
|
||||||
|
Note: This is not an implementation of the RFC 7804: Salted Challenge Response HTTP Authentication Mechanism.
|
||||||
|
|
||||||
|
This backend is an implementation of scram that uses an external web resource as a source of SCRAM authentication data, including stored key of the client, server key, and the salt. It support other authentication and authorization extension fields like HTTP auth backend, namely: `is_superuser`, `client_attrs`, `expire_at` and `acl`.
|
||||||
|
|
Loading…
Reference in New Issue