diff --git a/apps/emqx_auth/test/emqx_authn/emqx_authn_schema_SUITE.erl b/apps/emqx_auth/test/emqx_authn/emqx_authn_schema_SUITE.erl index f2688fff9..46eb18b82 100644 --- a/apps/emqx_auth/test/emqx_authn/emqx_authn_schema_SUITE.erl +++ b/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(_) -> diff --git a/apps/emqx_auth_http/include/emqx_auth_http.hrl b/apps/emqx_auth_http/include/emqx_auth_http.hrl index 9fc3b029e..c0bfa2177 100644 --- a/apps/emqx_auth_http/include/emqx_auth_http.hrl +++ b/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. diff --git a/apps/emqx_auth_http/src/emqx_auth_http_app.erl b/apps/emqx_auth_http/src/emqx_auth_http_app.erl index b97743b41..3d8ae0dad 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http_app.erl +++ b/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. diff --git a/apps/emqx_auth_http/src/emqx_authn_http.erl b/apps/emqx_auth_http/src/emqx_authn_http.erl index d9c5c5ed5..b294de24f 100644 --- a/apps/emqx_auth_http/src/emqx_authn_http.erl +++ b/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 %%------------------------------------------------------------------------------ diff --git a/apps/emqx_auth_http/src/emqx_authn_http_schema.erl b/apps/emqx_auth_http/src/emqx_authn_http_schema.erl index aff16b824..0167571c0 100644 --- a/apps/emqx_auth_http/src/emqx_authn_http_schema.erl +++ b/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. diff --git a/apps/emqx_auth_http/src/emqx_authn_scram_http.erl b/apps/emqx_auth_http/src/emqx_authn_scram_http.erl new file mode 100644 index 000000000..0e6190b4b --- /dev/null +++ b/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). diff --git a/apps/emqx_auth_http/src/emqx_authn_scram_http_schema.erl b/apps/emqx_auth_http/src/emqx_authn_scram_http_schema.erl new file mode 100644 index 000000000..ca43fe3a6 --- /dev/null +++ b/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)). diff --git a/apps/emqx_auth_http/test/emqx_authn_scram_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_authn_scram_http_SUITE.erl new file mode 100644 index 000000000..b00212cb1 --- /dev/null +++ b/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. diff --git a/apps/emqx_auth_http/test/emqx_authn_scram_http_test_server.erl b/apps/emqx_auth_http/test/emqx_authn_scram_http_test_server.erl new file mode 100644 index 000000000..5467df621 --- /dev/null +++ b/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 + }. diff --git a/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia.erl b/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia.erl index 611469c5b..d59afea28 100644 --- a/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia.erl +++ b/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, diff --git a/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia_schema.erl b/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia_schema.erl index 5d442cd57..dbad2118f 100644 --- a/apps/emqx_auth_mnesia/src/emqx_authn_scram_mnesia_schema.erl +++ b/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. diff --git a/apps/emqx_conf/src/emqx_conf_schema_inject.erl b/apps/emqx_conf/src/emqx_conf_schema_inject.erl index baab7cfe8..5c155bbf5 100644 --- a/apps/emqx_conf/src/emqx_conf_schema_inject.erl +++ b/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()}]. diff --git a/apps/emqx_gateway/src/emqx_gateway_api_authn.erl b/apps/emqx_gateway/src/emqx_gateway_api_authn.erl index 0ee16824d..0707c12aa 100644 --- a/apps/emqx_gateway/src/emqx_gateway_api_authn.erl +++ b/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() ). diff --git a/apps/emqx_utils/src/emqx_utils_scram.erl b/apps/emqx_utils/src/emqx_utils_scram.erl new file mode 100644 index 000000000..9d0543703 --- /dev/null +++ b/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.