Bladeren bron

Merge pull request #13504 from lafirest/feat/scram-http

feat(authn): added a HTTP backend for the authentication mechanism scram
lafirest 1 jaar geleden
bovenliggende
commit
8a344a8646

+ 0 - 8
apps/emqx_auth/test/emqx_authn/emqx_authn_schema_SUITE.erl

@@ -122,14 +122,6 @@ t_union_member_selector(_) ->
         },
         check(BadMechanism)
     ),
-    BadCombination = Base#{<<"mechanism">> => <<"scram">>, <<"backend">> => <<"http">>},
-    ?assertThrow(
-        #{
-            reason := "unknown_mechanism",
-            expected := "password_based"
-        },
-        check(BadCombination)
-    ),
     ok.
 
 t_http_auth_selector(_) ->

+ 5 - 0
apps/emqx_auth_http/include/emqx_auth_http.hrl

@@ -22,8 +22,13 @@
 
 -define(AUTHN_MECHANISM, password_based).
 -define(AUTHN_MECHANISM_BIN, <<"password_based">>).
+
+-define(AUTHN_MECHANISM_SCRAM, scram).
+-define(AUTHN_MECHANISM_SCRAM_BIN, <<"scram">>).
+
 -define(AUTHN_BACKEND, http).
 -define(AUTHN_BACKEND_BIN, <<"http">>).
 -define(AUTHN_TYPE, {?AUTHN_MECHANISM, ?AUTHN_BACKEND}).
+-define(AUTHN_TYPE_SCRAM, {?AUTHN_MECHANISM_SCRAM, ?AUTHN_BACKEND}).
 
 -endif.

+ 2 - 0
apps/emqx_auth_http/src/emqx_auth_http_app.erl

@@ -25,10 +25,12 @@
 start(_StartType, _StartArgs) ->
     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_SCRAM, emqx_authn_scram_http),
     {ok, Sup} = emqx_auth_http_sup:start_link(),
     {ok, Sup}.
 
 stop(_State) ->
     ok = emqx_authn:deregister_provider(?AUTHN_TYPE),
+    ok = emqx_authn:deregister_provider(?AUTHN_TYPE_SCRAM),
     ok = emqx_authz:unregister_source(?AUTHZ_TYPE),
     ok.

+ 7 - 0
apps/emqx_auth_http/src/emqx_authn_http.erl

@@ -28,6 +28,13 @@
     destroy/1
 ]).
 
+-export([
+    with_validated_config/2,
+    generate_request/2,
+    request_for_log/2,
+    response_for_log/1
+]).
+
 %%------------------------------------------------------------------------------
 %% APIs
 %%------------------------------------------------------------------------------

+ 2 - 6
apps/emqx_auth_http/src/emqx_authn_http_schema.erl

@@ -27,6 +27,8 @@
     namespace/0
 ]).
 
+-export([url/1, headers/1, headers_no_content_type/1, request_timeout/1]).
+
 -include("emqx_auth_http.hrl").
 -include_lib("emqx_auth/include/emqx_authn.hrl").
 -include_lib("hocon/include/hoconsc.hrl").
@@ -61,12 +63,6 @@ select_union_member(
                 got => Else
             })
     end;
-select_union_member(#{<<"backend">> := ?AUTHN_BACKEND_BIN}) ->
-    throw(#{
-        reason => "unknown_mechanism",
-        expected => "password_based",
-        got => undefined
-    });
 select_union_member(_Value) ->
     undefined.
 

+ 179 - 0
apps/emqx_auth_http/src/emqx_authn_scram_http.erl

@@ -0,0 +1,179 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_scram_http).
+
+-include_lib("emqx_auth/include/emqx_authn.hrl").
+-include_lib("emqx/include/logger.hrl").
+
+-behaviour(emqx_authn_provider).
+
+-export([
+    create/2,
+    update/2,
+    authenticate/2,
+    destroy/1
+]).
+
+-define(REQUIRED_USER_INFO_KEYS, [
+    <<"stored_key">>,
+    <<"server_key">>,
+    <<"salt">>
+]).
+
+-define(OPTIONAL_USER_INFO_KEYS, [
+    <<"is_superuser">>
+]).
+
+%%------------------------------------------------------------------------------
+%% APIs
+%%------------------------------------------------------------------------------
+
+create(_AuthenticatorID, Config) ->
+    create(Config).
+
+create(Config0) ->
+    emqx_authn_http:with_validated_config(Config0, fun(Config, State) ->
+        ResourceId = emqx_authn_utils:make_resource_id(?MODULE),
+        % {Config, State} = parse_config(Config0),
+        {ok, _Data} = emqx_authn_utils:create_resource(
+            ResourceId,
+            emqx_bridge_http_connector,
+            Config
+        ),
+        {ok, merge_scram_conf(Config, State#{resource_id => ResourceId})}
+    end).
+
+update(Config0, #{resource_id := ResourceId} = _State) ->
+    emqx_authn_http:with_validated_config(Config0, fun(Config, NState) ->
+        % {Config, NState} = parse_config(Config0),
+        case emqx_authn_utils:update_resource(emqx_bridge_http_connector, Config, ResourceId) of
+            {error, Reason} ->
+                error({load_config_error, Reason});
+            {ok, _} ->
+                {ok, merge_scram_conf(Config, NState#{resource_id => ResourceId})}
+        end
+    end).
+
+authenticate(
+    #{
+        auth_method := AuthMethod,
+        auth_data := AuthData,
+        auth_cache := AuthCache
+    } = Credential,
+    State
+) ->
+    RetrieveFun = fun(Username) ->
+        retrieve(Username, Credential, State)
+    end,
+    OnErrFun = fun(Msg, Reason) ->
+        ?TRACE_AUTHN_PROVIDER(Msg, #{
+            reason => Reason
+        })
+    end,
+    emqx_utils_scram:authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, State);
+authenticate(_Credential, _State) ->
+    ignore.
+
+destroy(#{resource_id := ResourceId}) ->
+    _ = emqx_resource:remove_local(ResourceId),
+    ok.
+
+%%--------------------------------------------------------------------
+%% Internal functions
+%%--------------------------------------------------------------------
+
+retrieve(
+    Username,
+    Credential,
+    #{
+        resource_id := ResourceId,
+        method := Method,
+        request_timeout := RequestTimeout
+    } = State
+) ->
+    Request = emqx_authn_http:generate_request(Credential#{username := Username}, State),
+    Response = emqx_resource:simple_sync_query(ResourceId, {Method, Request, RequestTimeout}),
+    ?TRACE_AUTHN_PROVIDER("scram_http_response", #{
+        request => emqx_authn_http:request_for_log(Credential, State),
+        response => emqx_authn_http:response_for_log(Response),
+        resource => ResourceId
+    }),
+    case Response of
+        {ok, 200, Headers, Body} ->
+            handle_response(Headers, Body);
+        {ok, _StatusCode, _Headers} ->
+            {error, bad_response};
+        {ok, _StatusCode, _Headers, _Body} ->
+            {error, bad_response};
+        {error, _Reason} = Error ->
+            Error
+    end.
+
+handle_response(Headers, Body) ->
+    ContentType = proplists:get_value(<<"content-type">>, Headers),
+    case safely_parse_body(ContentType, Body) of
+        {ok, NBody} ->
+            body_to_user_info(NBody);
+        {error, Reason} = Error ->
+            ?TRACE_AUTHN_PROVIDER(
+                error,
+                "parse_scram_http_response_failed",
+                #{content_type => ContentType, body => Body, reason => Reason}
+            ),
+            Error
+    end.
+
+body_to_user_info(Body) ->
+    Required0 = maps:with(?REQUIRED_USER_INFO_KEYS, Body),
+    case maps:size(Required0) =:= erlang:length(?REQUIRED_USER_INFO_KEYS) of
+        true ->
+            case safely_convert_hex(Required0) of
+                {ok, Required} ->
+                    UserInfo0 = maps:merge(Required, maps:with(?OPTIONAL_USER_INFO_KEYS, Body)),
+                    UserInfo1 = emqx_utils_maps:safe_atom_key_map(UserInfo0),
+                    UserInfo = maps:merge(#{is_superuser => false}, UserInfo1),
+                    {ok, UserInfo};
+                Error ->
+                    Error
+            end;
+        _ ->
+            ?TRACE_AUTHN_PROVIDER("bad_response_body", #{http_body => Body}),
+            {error, bad_response}
+    end.
+
+safely_parse_body(ContentType, Body) ->
+    try
+        parse_body(ContentType, Body)
+    catch
+        _Class:_Reason ->
+            {error, invalid_body}
+    end.
+
+safely_convert_hex(Required) ->
+    try
+        {ok,
+            maps:map(
+                fun(_Key, Hex) ->
+                    binary:decode_hex(Hex)
+                end,
+                Required
+            )}
+    catch
+        _Class:Reason ->
+            {error, Reason}
+    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) ->
+    maps:merge(maps:with([algorithm, iteration_count], Conf), State).

+ 81 - 0
apps/emqx_auth_http/src/emqx_authn_scram_http_schema.erl

@@ -0,0 +1,81 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_scram_http_schema).
+
+-behaviour(emqx_authn_schema).
+
+-export([
+    fields/1,
+    validations/0,
+    desc/1,
+    refs/0,
+    select_union_member/1,
+    namespace/0
+]).
+
+-include("emqx_auth_http.hrl").
+-include_lib("emqx_auth/include/emqx_authn.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+
+namespace() -> "authn".
+
+refs() ->
+    [?R_REF(scram_http_get), ?R_REF(scram_http_post)].
+
+select_union_member(
+    #{<<"mechanism">> := ?AUTHN_MECHANISM_SCRAM_BIN, <<"backend">> := ?AUTHN_BACKEND_BIN} = Value
+) ->
+    case maps:get(<<"method">>, Value, undefined) of
+        <<"get">> ->
+            [?R_REF(scram_http_get)];
+        <<"post">> ->
+            [?R_REF(scramm_http_post)];
+        Else ->
+            throw(#{
+                reason => "unknown_http_method",
+                expected => "get | post",
+                field_name => method,
+                got => Else
+            })
+    end;
+select_union_member(_Value) ->
+    undefined.
+
+fields(scram_http_get) ->
+    [
+        {method, #{type => get, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
+        {headers, fun emqx_authn_http_schema:headers_no_content_type/1}
+    ] ++ common_fields();
+fields(scram_http_post) ->
+    [
+        {method, #{type => post, required => true, desc => ?DESC(emqx_authn_http_schema, method)}},
+        {headers, fun emqx_authn_http_schema:headers/1}
+    ] ++ common_fields().
+
+desc(scram_http_get) ->
+    ?DESC(emqx_authn_http_schema, get);
+desc(scram_http_post) ->
+    ?DESC(emqx_authn_http_schema, post);
+desc(_) ->
+    undefined.
+
+validations() ->
+    emqx_authn_http_schema:validations().
+
+common_fields() ->
+    emqx_authn_schema:common_fields() ++
+        [
+            {mechanism, emqx_authn_schema:mechanism(?AUTHN_MECHANISM_SCRAM)},
+            {backend, emqx_authn_schema:backend(?AUTHN_BACKEND)},
+            {algorithm, fun emqx_authn_scram_mnesia_schema:algorithm/1},
+            {iteration_count, fun emqx_authn_scram_mnesia_schema:iteration_count/1},
+            {url, fun emqx_authn_http_schema:url/1},
+            {body,
+                hoconsc:mk(typerefl:alias("map", map([{fuzzy, term(), binary()}])), #{
+                    required => false, desc => ?DESC(emqx_authn_http_schema, body)
+                })},
+            {request_timeout, fun emqx_authn_http_schema:request_timeout/1}
+        ] ++
+        proplists:delete(pool_type, emqx_bridge_http_connector:fields(config)).

+ 438 - 0
apps/emqx_auth_http/test/emqx_authn_scram_http_SUITE.erl

@@ -0,0 +1,438 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_scram_http_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("common_test/include/ct.hrl").
+
+-include_lib("emqx/include/emqx_mqtt.hrl").
+-include_lib("emqx_auth/include/emqx_authn.hrl").
+
+-define(PATH, [authentication]).
+
+-define(HTTP_PORT, 34333).
+-define(HTTP_PATH, "/user/[...]").
+-define(ALGORITHM, sha512).
+-define(ALGORITHM_STR, <<"sha512">>).
+-define(ITERATION_COUNT, 4096).
+
+-include_lib("emqx/include/emqx_placeholder.hrl").
+
+all() ->
+    case emqx_release:edition() of
+        ce ->
+            [];
+        _ ->
+            emqx_common_test_helpers:all(?MODULE)
+    end.
+
+init_per_suite(Config) ->
+    Apps = emqx_cth_suite:start([cowboy, emqx, emqx_conf, emqx_auth, emqx_auth_http], #{
+        work_dir => ?config(priv_dir, Config)
+    }),
+
+    IdleTimeout = emqx_config:get([mqtt, idle_timeout]),
+    [{apps, Apps}, {idle_timeout, IdleTimeout} | Config].
+
+end_per_suite(Config) ->
+    ok = emqx_config:put([mqtt, idle_timeout], ?config(idle_timeout, Config)),
+    emqx_authn_test_lib:delete_authenticators(
+        [authentication],
+        ?GLOBAL
+    ),
+    ok = emqx_cth_suite:stop(?config(apps, Config)),
+    ok.
+
+init_per_testcase(_Case, Config) ->
+    {ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000),
+    emqx_authn_test_lib:delete_authenticators(
+        [authentication],
+        ?GLOBAL
+    ),
+    {ok, _} = emqx_authn_scram_http_test_server:start_link(?HTTP_PORT, ?HTTP_PATH),
+    Config.
+
+end_per_testcase(_Case, _Config) ->
+    ok = emqx_authn_scram_http_test_server:stop().
+
+%%------------------------------------------------------------------------------
+%% Tests
+%%------------------------------------------------------------------------------
+
+t_create(_Config) ->
+    AuthConfig = raw_config(),
+
+    {ok, _} = emqx:update_config(
+        ?PATH,
+        {create_authenticator, ?GLOBAL, AuthConfig}
+    ),
+
+    {ok, [#{provider := emqx_authn_scram_http}]} = emqx_authn_chains:list_authenticators(?GLOBAL).
+
+t_create_invalid(_Config) ->
+    AuthConfig = raw_config(),
+
+    InvalidConfigs =
+        [
+            AuthConfig#{<<"headers">> => []},
+            AuthConfig#{<<"method">> => <<"delete">>},
+            AuthConfig#{<<"url">> => <<"localhost">>},
+            AuthConfig#{<<"url">> => <<"http://foo.com/xxx#fragment">>},
+            AuthConfig#{<<"url">> => <<"http://${foo}.com/xxx">>},
+            AuthConfig#{<<"url">> => <<"//foo.com/xxx">>},
+            AuthConfig#{<<"algorithm">> => <<"sha128">>}
+        ],
+
+    lists:foreach(
+        fun(Config) ->
+            ct:pal("creating authenticator with invalid config: ~p", [Config]),
+            {error, _} =
+                try
+                    emqx:update_config(
+                        ?PATH,
+                        {create_authenticator, ?GLOBAL, Config}
+                    )
+                catch
+                    throw:Error ->
+                        {error, Error}
+                end,
+            ?assertEqual(
+                {error, {not_found, {chain, ?GLOBAL}}},
+                emqx_authn_chains:list_authenticators(?GLOBAL)
+            )
+        end,
+        InvalidConfigs
+    ).
+
+t_authenticate(_Config) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password),
+    init_auth(),
+
+    ok = emqx_config:put([mqtt, idle_timeout], 500),
+
+    {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}
+    ).
+
+t_authenticate_bad_props(_Config) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password),
+    init_auth(),
+
+    {ok, Pid} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
+
+    ConnectPacket = ?CONNECT_PACKET(
+        #mqtt_packet_connect{
+            proto_ver = ?MQTT_PROTO_V5,
+            properties = #{
+                'Authentication-Method' => <<"SCRAM-SHA-512">>
+            }
+        }
+    ),
+
+    ok = emqx_authn_mqtt_test_client:send(Pid, ConnectPacket),
+
+    ?CONNACK_PACKET(?RC_NOT_AUTHORIZED) = receive_packet().
+
+t_authenticate_bad_username(_Config) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password),
+    init_auth(),
+
+    {ok, Pid} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
+
+    ClientFirstMessage = esasl_scram:client_first_message(<<"badusername">>),
+
+    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),
+
+    ?CONNACK_PACKET(?RC_NOT_AUTHORIZED) = receive_packet().
+
+t_authenticate_bad_password(_Config) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password),
+    init_auth(),
+
+    {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),
+
+    ?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 => <<"badpassword">>,
+                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_NOT_AUTHORIZED) = receive_packet().
+
+t_destroy(_Config) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password),
+    init_auth(),
+
+    ok = emqx_config:put([mqtt, idle_timeout], 500),
+
+    {ok, Pid} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
+
+    ConnectPacket = ?CONNECT_PACKET(
+        #mqtt_packet_connect{
+            proto_ver = ?MQTT_PROTO_V5,
+            properties = #{
+                'Authentication-Method' => <<"SCRAM-SHA-512">>
+            }
+        }
+    ),
+
+    ok = emqx_authn_mqtt_test_client:send(Pid, ConnectPacket),
+
+    ok = ct:sleep(1000),
+
+    ?CONNACK_PACKET(?RC_NOT_AUTHORIZED) = receive_packet(),
+
+    %% emqx_authn_mqtt_test_client:stop(Pid),
+
+    emqx_authn_test_lib:delete_authenticators(
+        [authentication],
+        ?GLOBAL
+    ),
+
+    {ok, Pid2} = emqx_authn_mqtt_test_client:start_link("127.0.0.1", 1883),
+
+    ok = emqx_authn_mqtt_test_client:send(Pid2, ConnectPacket),
+
+    ok = ct:sleep(1000),
+
+    ?CONNACK_PACKET(
+        ?RC_SUCCESS,
+        _,
+        _
+    ) = receive_packet().
+
+t_is_superuser() ->
+    State = init_auth(),
+    ok = test_is_superuser(State, false),
+    ok = test_is_superuser(State, true),
+    ok = test_is_superuser(State, false).
+
+test_is_superuser(State, ExpectedIsSuperuser) ->
+    Username = <<"u">>,
+    Password = <<"p">>,
+
+    set_user_handler(Username, Password, ExpectedIsSuperuser),
+
+    ClientFirstMessage = esasl_scram:client_first_message(Username),
+
+    {continue, ServerFirstMessage, ServerCache} =
+        emqx_authn_scram_http:authenticate(
+            #{
+                auth_method => <<"SCRAM-SHA-512">>,
+                auth_data => ClientFirstMessage,
+                auth_cache => #{}
+            },
+            State
+        ),
+
+    {continue, ClientFinalMessage, ClientCache} =
+        esasl_scram:check_server_first_message(
+            ServerFirstMessage,
+            #{
+                client_first_message => ClientFirstMessage,
+                password => Password,
+                algorithm => ?ALGORITHM
+            }
+        ),
+
+    {ok, UserInfo1, ServerFinalMessage} =
+        emqx_authn_scram_http:authenticate(
+            #{
+                auth_method => <<"SCRAM-SHA-512">>,
+                auth_data => ClientFinalMessage,
+                auth_cache => ServerCache
+            },
+            State
+        ),
+
+    ok = esasl_scram:check_server_final_message(
+        ServerFinalMessage, ClientCache#{algorithm => ?ALGORITHM}
+    ),
+
+    ?assertMatch(#{is_superuser := ExpectedIsSuperuser}, UserInfo1).
+
+%%------------------------------------------------------------------------------
+%% Helpers
+%%------------------------------------------------------------------------------
+
+raw_config() ->
+    #{
+        <<"mechanism">> => <<"scram">>,
+        <<"backend">> => <<"http">>,
+        <<"enable">> => <<"true">>,
+        <<"method">> => <<"get">>,
+        <<"url">> => <<"http://127.0.0.1:34333/user">>,
+        <<"body">> => #{<<"username">> => ?PH_USERNAME},
+        <<"headers">> => #{<<"X-Test-Header">> => <<"Test Value">>},
+        <<"algorithm">> => ?ALGORITHM_STR,
+        <<"iteration_count">> => ?ITERATION_COUNT
+    }.
+
+set_user_handler(Username, Password) ->
+    set_user_handler(Username, Password, false).
+set_user_handler(Username, Password, IsSuperuser) ->
+    %% HTTP Server
+    Handler = fun(Req0, State) ->
+        #{
+            username := Username
+        } = cowboy_req:match_qs([username], Req0),
+
+        UserInfo = make_user_info(Password, ?ALGORITHM, ?ITERATION_COUNT, IsSuperuser),
+        Req = cowboy_req:reply(
+            200,
+            #{<<"content-type">> => <<"application/json">>},
+            emqx_utils_json:encode(UserInfo),
+            Req0
+        ),
+        {ok, Req, State}
+    end,
+    ok = emqx_authn_scram_http_test_server:set_handler(Handler).
+
+init_auth() ->
+    init_auth(raw_config()).
+
+init_auth(Config) ->
+    {ok, _} = emqx:update_config(
+        ?PATH,
+        {create_authenticator, ?GLOBAL, Config}
+    ),
+
+    {ok, [#{state := State}]} = emqx_authn_chains:list_authenticators(?GLOBAL),
+    State.
+
+make_user_info(Password, Algorithm, IterationCount, IsSuperuser) ->
+    {StoredKey, ServerKey, Salt} = esasl_scram:generate_authentication_info(
+        Password,
+        #{
+            algorithm => Algorithm,
+            iteration_count => IterationCount
+        }
+    ),
+    #{
+        stored_key => binary:encode_hex(StoredKey),
+        server_key => binary:encode_hex(ServerKey),
+        salt => binary:encode_hex(Salt),
+        is_superuser => IsSuperuser
+    }.
+
+receive_packet() ->
+    receive
+        {packet, Packet} ->
+            ct:pal("Delivered packet: ~p", [Packet]),
+            Packet
+    after 1000 ->
+        ct:fail("Deliver timeout")
+    end.

+ 115 - 0
apps/emqx_auth_http/test/emqx_authn_scram_http_test_server.erl

@@ -0,0 +1,115 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_authn_scram_http_test_server).
+
+-behaviour(supervisor).
+-behaviour(cowboy_handler).
+
+% cowboy_server callbacks
+-export([init/2]).
+
+% supervisor callbacks
+-export([init/1]).
+
+% API
+-export([
+    start_link/2,
+    start_link/3,
+    stop/0,
+    set_handler/1
+]).
+
+%%------------------------------------------------------------------------------
+%% API
+%%------------------------------------------------------------------------------
+
+start_link(Port, Path) ->
+    start_link(Port, Path, false).
+
+start_link(Port, Path, SSLOpts) ->
+    supervisor:start_link({local, ?MODULE}, ?MODULE, [Port, Path, SSLOpts]).
+
+stop() ->
+    gen_server:stop(?MODULE).
+
+set_handler(F) when is_function(F, 2) ->
+    true = ets:insert(?MODULE, {handler, F}),
+    ok.
+
+%%------------------------------------------------------------------------------
+%% supervisor API
+%%------------------------------------------------------------------------------
+
+init([Port, Path, SSLOpts]) ->
+    Dispatch = cowboy_router:compile(
+        [
+            {'_', [{Path, ?MODULE, []}]}
+        ]
+    ),
+
+    ProtoOpts = #{env => #{dispatch => Dispatch}},
+
+    Tab = ets:new(?MODULE, [set, named_table, public]),
+    ets:insert(Tab, {handler, fun default_handler/2}),
+
+    {Transport, TransOpts, CowboyModule} = transport_settings(Port, SSLOpts),
+
+    ChildSpec = ranch:child_spec(?MODULE, Transport, TransOpts, CowboyModule, ProtoOpts),
+
+    {ok, {#{}, [ChildSpec]}}.
+
+%%------------------------------------------------------------------------------
+%% cowboy_server API
+%%------------------------------------------------------------------------------
+
+init(Req, State) ->
+    [{handler, Handler}] = ets:lookup(?MODULE, handler),
+    Handler(Req, State).
+
+%%------------------------------------------------------------------------------
+%% Internal functions
+%%------------------------------------------------------------------------------
+
+transport_settings(Port, false) ->
+    TransOpts = #{
+        socket_opts => [{port, Port}],
+        connection_type => supervisor
+    },
+    {ranch_tcp, TransOpts, cowboy_clear};
+transport_settings(Port, SSLOpts) ->
+    TransOpts = #{
+        socket_opts => [
+            {port, Port},
+            {next_protocols_advertised, [<<"h2">>, <<"http/1.1">>]},
+            {alpn_preferred_protocols, [<<"h2">>, <<"http/1.1">>]}
+            | SSLOpts
+        ],
+        connection_type => supervisor
+    },
+    {ranch_ssl, TransOpts, cowboy_tls}.
+
+default_handler(Req0, State) ->
+    Req = cowboy_req:reply(
+        400,
+        #{<<"content-type">> => <<"text/plain">>},
+        <<"">>,
+        Req0
+    ),
+    {ok, Req, State}.
+
+make_user_info(Password, Algorithm, IterationCount) ->
+    {StoredKey, ServerKey, Salt} = esasl_scram:generate_authentication_info(
+        Password,
+        #{
+            algorithm => Algorithm,
+            iteration_count => IterationCount
+        }
+    ),
+    #{
+        stored_key => StoredKey,
+        server_key => ServerKey,
+        salt => Salt,
+        is_superuser => false
+    }.

+ 9 - 60
apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia.erl

@@ -133,17 +133,15 @@ authenticate(
     },
     State
 ) ->
-    case ensure_auth_method(AuthMethod, AuthData, State) of
-        true ->
-            case AuthCache of
-                #{next_step := client_final} ->
-                    check_client_final_message(AuthData, AuthCache, State);
-                _ ->
-                    check_client_first_message(AuthData, AuthCache, State)
-            end;
-        false ->
-            ignore
-    end;
+    RetrieveFun = fun(Username) ->
+        retrieve(Username, State)
+    end,
+    OnErrFun = fun(Msg, Reason) ->
+        ?TRACE_AUTHN_PROVIDER(Msg, #{
+            reason => Reason
+        })
+    end,
+    emqx_utils_scram:authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, State);
 authenticate(_Credential, _State) ->
     ignore.
 
@@ -257,55 +255,6 @@ run_fuzzy_filter(
 %% Internal functions
 %%------------------------------------------------------------------------------
 
-ensure_auth_method(_AuthMethod, undefined, _State) ->
-    false;
-ensure_auth_method(<<"SCRAM-SHA-256">>, _AuthData, #{algorithm := sha256}) ->
-    true;
-ensure_auth_method(<<"SCRAM-SHA-512">>, _AuthData, #{algorithm := sha512}) ->
-    true;
-ensure_auth_method(_AuthMethod, _AuthData, _State) ->
-    false.
-
-check_client_first_message(Bin, _Cache, #{iteration_count := IterationCount} = State) ->
-    RetrieveFun = fun(Username) ->
-        retrieve(Username, State)
-    end,
-    case
-        esasl_scram:check_client_first_message(
-            Bin,
-            #{
-                iteration_count => IterationCount,
-                retrieve => RetrieveFun
-            }
-        )
-    of
-        {continue, ServerFirstMessage, Cache} ->
-            {continue, ServerFirstMessage, Cache};
-        ignore ->
-            ignore;
-        {error, Reason} ->
-            ?TRACE_AUTHN_PROVIDER("check_client_first_message_error", #{
-                reason => Reason
-            }),
-            {error, not_authorized}
-    end.
-
-check_client_final_message(Bin, #{is_superuser := IsSuperuser} = Cache, #{algorithm := Alg}) ->
-    case
-        esasl_scram:check_client_final_message(
-            Bin,
-            Cache#{algorithm => Alg}
-        )
-    of
-        {ok, ServerFinalMessage} ->
-            {ok, #{is_superuser => IsSuperuser}, ServerFinalMessage};
-        {error, Reason} ->
-            ?TRACE_AUTHN_PROVIDER("check_client_final_message_error", #{
-                reason => Reason
-            }),
-            {error, not_authorized}
-    end.
-
 user_info_record(
     #{
         user_id := UserID,

+ 2 - 5
apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia_schema.erl

@@ -29,6 +29,8 @@
     select_union_member/1
 ]).
 
+-export([algorithm/1, iteration_count/1]).
+
 namespace() -> "authn".
 
 refs() ->
@@ -38,11 +40,6 @@ select_union_member(#{
     <<"mechanism">> := ?AUTHN_MECHANISM_SCRAM_BIN, <<"backend">> := ?AUTHN_BACKEND_BIN
 }) ->
     refs();
-select_union_member(#{<<"mechanism">> := ?AUTHN_MECHANISM_SCRAM_BIN}) ->
-    throw(#{
-        reason => "unknown_backend",
-        expected => ?AUTHN_BACKEND
-    });
 select_union_member(_) ->
     undefined.
 

+ 4 - 1
apps/emqx_conf/src/emqx_conf_schema_inject.erl

@@ -49,7 +49,10 @@ authn_mods(ce) ->
     ];
 authn_mods(ee) ->
     authn_mods(ce) ++
-        [emqx_gcp_device_authn_schema].
+        [
+            emqx_gcp_device_authn_schema,
+            emqx_authn_scram_http_schema
+        ].
 
 authz() ->
     [{emqx_authz_schema, authz_mods()}].

+ 4 - 1
apps/emqx_gateway/src/emqx_gateway_api_authn.erl

@@ -381,6 +381,9 @@ params_fuzzy_in_qs() ->
 
 schema_authn() ->
     emqx_dashboard_swagger:schema_with_examples(
-        emqx_authn_schema:authenticator_type_without([emqx_authn_scram_mnesia_schema]),
+        emqx_authn_schema:authenticator_type_without([
+            emqx_authn_scram_mnesia_schema,
+            emqx_authn_scram_http_schema
+        ]),
         emqx_authn_api:authenticator_examples()
     ).

+ 81 - 0
apps/emqx_utils/src/emqx_utils_scram.erl

@@ -0,0 +1,81 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2021-2024 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_utils_scram).
+
+-export([authenticate/6]).
+
+%%------------------------------------------------------------------------------
+%% Authentication
+%%------------------------------------------------------------------------------
+authenticate(AuthMethod, AuthData, AuthCache, RetrieveFun, OnErrFun, Conf) ->
+    case ensure_auth_method(AuthMethod, AuthData, Conf) of
+        true ->
+            case AuthCache of
+                #{next_step := client_final} ->
+                    check_client_final_message(AuthData, AuthCache, Conf, OnErrFun);
+                _ ->
+                    check_client_first_message(AuthData, AuthCache, Conf, RetrieveFun, OnErrFun)
+            end;
+        false ->
+            ignore
+    end.
+
+ensure_auth_method(_AuthMethod, undefined, _Conf) ->
+    false;
+ensure_auth_method(<<"SCRAM-SHA-256">>, _AuthData, #{algorithm := sha256}) ->
+    true;
+ensure_auth_method(<<"SCRAM-SHA-512">>, _AuthData, #{algorithm := sha512}) ->
+    true;
+ensure_auth_method(_AuthMethod, _AuthData, _Conf) ->
+    false.
+
+check_client_first_message(
+    Bin, _Cache, #{iteration_count := IterationCount}, RetrieveFun, OnErrFun
+) ->
+    case
+        esasl_scram:check_client_first_message(
+            Bin,
+            #{
+                iteration_count => IterationCount,
+                retrieve => RetrieveFun
+            }
+        )
+    of
+        {continue, ServerFirstMessage, Cache} ->
+            {continue, ServerFirstMessage, Cache};
+        ignore ->
+            ignore;
+        {error, Reason} ->
+            OnErrFun("check_client_first_message_error", Reason),
+            {error, not_authorized}
+    end.
+
+check_client_final_message(
+    Bin, #{is_superuser := IsSuperuser} = Cache, #{algorithm := Alg}, OnErrFun
+) ->
+    case
+        esasl_scram:check_client_final_message(
+            Bin,
+            Cache#{algorithm => Alg}
+        )
+    of
+        {ok, ServerFinalMessage} ->
+            {ok, #{is_superuser => IsSuperuser}, ServerFinalMessage};
+        {error, Reason} ->
+            OnErrFun("check_client_final_message_error", Reason),
+            {error, not_authorized}
+    end.

+ 1 - 0
changes/ee/feat-13504.en.md

@@ -0,0 +1 @@
+Added a HTTP backend for the authentication mechanism `scram`.