diff --git a/big_tests/default.spec b/big_tests/default.spec index 1cc9051c24d..cb857b274c6 100644 --- a/big_tests/default.spec +++ b/big_tests/default.spec @@ -17,6 +17,7 @@ {suites, "tests", amp_big_SUITE}. {suites, "tests", anonymous_SUITE}. {suites, "tests", bind2_SUITE}. +{suites, "tests", fast_auth_token_SUITE}. {suites, "tests", bosh_SUITE}. {suites, "tests", carboncopy_SUITE}. {suites, "tests", connect_SUITE}. diff --git a/big_tests/dynamic_domains.spec b/big_tests/dynamic_domains.spec index b88a9dc765d..7125ebbfd01 100644 --- a/big_tests/dynamic_domains.spec +++ b/big_tests/dynamic_domains.spec @@ -16,6 +16,7 @@ {suites, "tests", amp_big_SUITE}. {suites, "tests", anonymous_SUITE}. {suites, "tests", bind2_SUITE}. +{suites, "tests", fast_auth_token_SUITE}. {suites, "tests", bosh_SUITE}. {suites, "tests", carboncopy_SUITE}. {suites, "tests", connect_SUITE}. diff --git a/big_tests/src/time_helper.erl b/big_tests/src/time_helper.erl new file mode 100644 index 00000000000..7ea66733a42 --- /dev/null +++ b/big_tests/src/time_helper.erl @@ -0,0 +1,24 @@ +-module(time_helper). +-export([validate_datetime/1]). + +%% @doc Validates that string is in ISO 8601 format +-spec validate_datetime(string()) -> boolean(). +validate_datetime(TimeStr) -> + [Date, Time] = string:tokens(TimeStr, "T"), + validate_date(Date) and validate_time(Time). + +validate_date(Date) -> + [Y, M, D] = string:tokens(Date, "-"), + Date1 = {list_to_integer(Y), list_to_integer(M), list_to_integer(D)}, + calendar:valid_date(Date1). + +validate_time(Time) -> + [T | _] = string:tokens(Time, "Z"), + validate_time1(T). + +validate_time1(Time) -> + [H, M, S] = string:tokens(Time, ":"), + check_list([{H, 24}, {M, 60}, {S, 60}]). + +check_list(List) -> + lists:all(fun({V, L}) -> I = list_to_integer(V), I >= 0 andalso I < L end, List). diff --git a/big_tests/tests/fast_auth_token_SUITE.erl b/big_tests/tests/fast_auth_token_SUITE.erl new file mode 100644 index 00000000000..d4509b2ffc6 --- /dev/null +++ b/big_tests/tests/fast_auth_token_SUITE.erl @@ -0,0 +1,512 @@ +%% Tests for XEP-0484: Fast Authentication Streamlining Tokens +-module(fast_auth_token_SUITE). + +-compile([export_all, nowarn_export_all]). + +-include_lib("stdlib/include/assert.hrl"). +-include_lib("exml/include/exml.hrl"). +-include_lib("jid/include/jid.hrl"). +-include_lib("escalus/include/escalus.hrl"). +-include_lib("escalus/include/escalus_xmlns.hrl"). + +-define(NS_SASL_2, <<"urn:xmpp:sasl:2">>). +-define(NS_BIND_2, <<"urn:xmpp:bind:0">>). +-define(NS_FAST, <<"urn:xmpp:fast:0">>). + +%%-------------------------------------------------------------------- +%% Suite configuration +%%-------------------------------------------------------------------- + +all() -> + [{group, Group} || {Group, _, _} <- groups()]. + +groups() -> + [{Group, [parallel], tests()} || {Group, _Mech} <- mechanisms()]. + +tests() -> + [server_advertises_support_for_fast, + request_token_with_initial_authentication, + request_token_with_unknown_mechanism_type, + client_authenticates_using_fast, + client_authenticate_several_times_with_the_same_token, + token_auth_fails_when_token_is_wrong, + token_auth_fails_when_token_is_not_found, + server_initiates_token_rotation, + could_still_use_old_token_when_server_initiates_token_rotation, + server_initiates_token_rotation_for_the_current_slot, + could_still_use_old_token_when_server_initiates_token_rotation_for_the_current_slot, + cannot_use_expired_token, + cannot_use_expired_token_in_the_current_slot, + rerequest_token_with_initial_authentication, + can_use_new_token_after_rerequest_token_with_initial_authentication, + can_use_current_token_after_rerequest_token_with_initial_authentication, + client_requests_token_invalidation, + client_requests_token_invalidation_1, + both_tokens_do_not_work_after_invalidation, + token_auth_fails_when_mechanism_does_not_match + ]. + +mechanisms() -> + [{ht_sha_256_none, <<"HT-SHA-256-NONE">>}, + {ht_sha_384_none, <<"HT-SHA-384-NONE">>}, + {ht_sha_512_none, <<"HT-SHA-512-NONE">>}, + {ht_sha_3_256_none, <<"HT-SHA-3-256-NONE">>}, + {ht_sha_3_384_none, <<"HT-SHA-3-384-NONE">>}, + {ht_sha_3_512_none, <<"HT-SHA-3-512-NONE">>}]. + +mech_to_algo(<<"HT-SHA-256-NONE">>) -> sha256; +mech_to_algo(<<"HT-SHA-384-NONE">>) -> sha384; +mech_to_algo(<<"HT-SHA-512-NONE">>) -> sha512; +mech_to_algo(<<"HT-SHA-3-256-NONE">>) -> sha3_256; +mech_to_algo(<<"HT-SHA-3-384-NONE">>) -> sha3_384; +mech_to_algo(<<"HT-SHA-3-512-NONE">>) -> sha3_512. + +another_mechanism(<<"HT-SHA-256-NONE">>) -> <<"HT-SHA-3-512-NONE">>; +another_mechanism(<<"HT-SHA-384-NONE">>) -> <<"HT-SHA-3-256-NONE">>; +another_mechanism(<<"HT-SHA-512-NONE">>) -> <<"HT-SHA-3-512-NONE">>; +another_mechanism(<<"HT-SHA-3-512-NONE">>) -> <<"HT-SHA-256-NONE">>; +another_mechanism(<<"HT-SHA-3-384-NONE">>) -> <<"HT-SHA-384-NONE">>; +another_mechanism(<<"HT-SHA-3-256-NONE">>) -> <<"HT-SHA-512-NONE">>. + +%%-------------------------------------------------------------------- +%% Init & teardown +%%-------------------------------------------------------------------- + +init_per_suite(Config) -> + case mongoose_helper:is_rdbms_enabled(domain_helper:host_type()) of + false -> + {skip, "No RDBMS enabled"}; + true -> + Config1 = load_modules(Config), + escalus:init_per_suite(Config1) + end. + +end_per_suite(Config) -> + escalus_fresh:clean(), + dynamic_modules:restore_modules(Config), + escalus:end_per_suite(Config). + +init_per_group(Group, Config) -> + case lists:keyfind(Group, 1, mechanisms()) of + {Group, Mech} when is_binary(Mech) -> + [{ht_mech, Mech} | Config]; + false -> + Config + end. + +end_per_group(_GroupName, Config) -> + Config. + +init_per_testcase(Name, Config) -> + escalus:init_per_testcase(Name, Config). + +end_per_testcase(Name, Config) -> + escalus:end_per_testcase(Name, Config). + +load_modules(Config) -> + HostType = domain_helper:host_type(), + Config1 = dynamic_modules:save_modules(HostType, Config), + sasl2_helper:load_all_sasl2_modules(HostType), + Config1. + +%%-------------------------------------------------------------------- +%% tests +%%-------------------------------------------------------------------- + +%% 3.1 Server advertises support for FAST +%% https://xmpp.org/extensions/xep-0484.html#support +server_advertises_support_for_fast(Config) -> + Steps = [create_connect_tls, start_stream_get_features], + #{features := Features} = sasl2_helper:apply_steps(Steps, Config), + Fast = exml_query:path(Features, [{element_with_ns, <<"authentication">>, ?NS_SASL_2}, + {element, <<"inline">>}, + {element_with_ns, <<"fast">>, ?NS_FAST}]), + ?assertNotEqual(undefined, Fast). + +%% Client performs initial authentication +%% https://xmpp.org/extensions/xep-0484.html#initial-auth +%% 3.3 Server provides token to client +%% https://xmpp.org/extensions/xep-0484.html#token-response +request_token_with_initial_authentication(Config) -> + #{token := Token, expire := Expire} = connect_and_ask_for_token(Config), + ?assertEqual(true, byte_size(Token) > 5), + %% Check timestamp in ISO 8601 format + ?assertEqual(true, time_helper:validate_datetime(binary_to_list(Expire))). + +request_token_with_unknown_mechanism_type(Config0) -> + Config = [{ht_mech, <<"HT-WEIRD-ONE">>} | Config0], + Steps = [start_new_user, {?MODULE, auth_and_request_token}, + receive_features], + #{answer := Success} = sasl2_helper:apply_steps(Steps, Config), + ?assertMatch(#xmlel{name = <<"success">>, + attrs = #{<<"xmlns">> := ?NS_SASL_2}}, Success), + Fast = exml_query:path(Success, [{element_with_ns, <<"token">>, ?NS_FAST}]), + ?assertEqual(undefined, Fast). + +%% 3.4 Client authenticates using FAST +%% https://xmpp.org/extensions/xep-0484.html#fast-auth +client_authenticates_using_fast(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + auth_with_token(success, Token, Config, Spec). + +%% Check that we can reuse the token +client_authenticate_several_times_with_the_same_token(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + auth_with_token(success, Token, Config, Spec), + auth_with_token(success, Token, Config, Spec), + auth_with_token(success, Token, Config, Spec). + +token_auth_fails_when_token_is_wrong(Config) -> + %% New token is not set, but we try to login with a wrong one + #{spec := Spec} = connect_and_ask_for_token(Config), + Token = <<"wrongtoken">>, + auth_with_token(failure, Token, Config, Spec). + +token_auth_fails_when_token_is_not_found(Config) -> + %% New token is not set + Steps = [start_new_user], + #{spec := Spec} = sasl2_helper:apply_steps(Steps, Config), + Token = <<"wrongtoken">>, + auth_with_token(failure, Token, Config, Spec). + +%% 3.5 Server initiates token rotation +%% If client connects with the `current' token (and it is about to expire), we +%% create a brand new token and set it to the `new' slot. +%% Most likely the client lost his token from tthe `new' slot. +%% +%% If client connects with the `new' token (and it is about to expire), we +%% should set this token into the `current' position and generate a `new' token. +%% +%% Output from server is in the same format as for the regular token request. +server_initiates_token_rotation(Config) -> + #{new_token := NewToken, spec := Spec} = connect_with_almost_expired_token(Config), + %% Can use new token + auth_with_token(success, NewToken, Config, Spec). + +could_still_use_old_token_when_server_initiates_token_rotation(Config) -> + #{old_token := OldToken, spec := Spec} = connect_with_almost_expired_token(Config), + %% Can still use old token + auth_with_token(success, OldToken, Config, Spec). + +%% Connect with almost exired token in the current slot +server_initiates_token_rotation_for_the_current_slot(Config) -> + #{new_token := NewToken, spec := Spec} = connect_with_almost_expired_token_in_the_current_slot(Config), + %% Can use new token + auth_with_token(success, NewToken, Config, Spec). + +could_still_use_old_token_when_server_initiates_token_rotation_for_the_current_slot(Config) -> + #{old_token := OldToken, spec := Spec} = connect_with_almost_expired_token_in_the_current_slot(Config), + %% Can still use old token + auth_with_token(success, OldToken, Config, Spec). + +cannot_use_expired_token(Config) -> + #{expired_token := Token, spec := Spec} = start_new_user_and_make_expired_token(Config), + auth_with_token(failure, Token, Config, Spec). + +cannot_use_expired_token_in_the_current_slot(Config) -> + #{new_token := NewToken, spec := Spec, old_token := CurrentToken} = + start_new_user_and_make_expired_token_in_the_current_slot(Config), + auth_with_token(failure, CurrentToken, Config, Spec), + %% But could use the new non-expired token + auth_with_token(success, NewToken, Config, Spec), + %% NewToken should be moved into the current slot and still work + auth_with_token(success, NewToken, Config, Spec). + +rerequest_token_with_initial_authentication(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + ConnectRes = auth_with_token(success, Token, Config, Spec, request_token), + #{token := NewToken} = parse_connect_result(ConnectRes), + ?assertNotEqual(Token, NewToken), + #{token => Token, new_token => NewToken, spec => Spec}. + +can_use_new_token_after_rerequest_token_with_initial_authentication(Config) -> + #{new_token := Token, spec := Spec} = rerequest_token_with_initial_authentication(Config), + auth_with_token(success, Token, Config, Spec). + +can_use_current_token_after_rerequest_token_with_initial_authentication(Config) -> + #{token := Token, spec := Spec} = rerequest_token_with_initial_authentication(Config), + auth_with_token(success, Token, Config, Spec). + +client_requests_token_invalidation(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + auth_with_token(success, Token, Config, Spec, request_invalidation), + auth_with_token(failure, Token, Config, Spec). + +client_requests_token_invalidation_1(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + auth_with_token(success, Token, Config, Spec, request_invalidation_1), + auth_with_token(failure, Token, Config, Spec). + +both_tokens_do_not_work_after_invalidation(Config) -> + #{new_token := NewToken, token := Token, spec := Spec} = + rerequest_token_with_initial_authentication(Config), + auth_with_token(success, Token, Config, Spec, request_invalidation), + auth_with_token(failure, NewToken, Config, Spec), + auth_with_token(failure, Token, Config, Spec). + +%% Servers MUST bind tokens to the mechanism selected by the client in its +%% original request, and reject attempts to use them with other mechanisms. +%% For example, if the client selected a mechanism capable of channel binding, +%% an attempt to use a mechanism without channel binding MUST fail even if the +%% token would otherwise be accepted by that mechanism. +token_auth_fails_when_mechanism_does_not_match(Config) -> + #{token := Token, spec := Spec} = connect_and_ask_for_token(Config), + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Config2 = [{ht_mech, another_mechanism(Mech)} | Config], + auth_with_token(failure, Token, Config2, Spec). + +%%-------------------------------------------------------------------- +%% helpers +%%-------------------------------------------------------------------- + +start_new_user_and_make_expired_token(Config) -> + Steps = [start_new_user], + #{spec := Spec} = sasl2_helper:apply_steps(Steps, Config), + HostType = domain_helper:host_type(), + {LUser, LServer} = spec_to_lus(Spec), + AgentId = user_agent_id(), + Token = <<"verysecret">>, + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + ExpireTS = erlang:system_time(second) - 600, %% 10 minutes ago + %% Set almost expiring token into the new slot + Args = [HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, false], + ok = distributed_helper:rpc(distributed_helper:mim(), mod_fast_auth_token_backend, store_new_token, Args), + #{expired_token => Token, spec => Spec}. + +start_new_user_and_make_expired_token_in_the_current_slot(Config) -> + Now = erlang:system_time(second), + Steps = [start_new_user], + #{spec := Spec} = sasl2_helper:apply_steps(Steps, Config), + HostType = domain_helper:host_type(), + {LUser, LServer} = spec_to_lus(Spec), + AgentId = user_agent_id(), + Token = <<"verysecret">>, + CurrentToken = <<"currentsecret">>, + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + ExpireTS = Now + 86400, %% 24 hours into the future + SetCurrent = #{ + current_token => CurrentToken, + current_expire => Now - 600, %% 10 minutes ago + current_count => 0, + current_mech => Mech + }, + %% Set almost expiring token into the new slot + Args = [HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent], + ok = distributed_helper:rpc(distributed_helper:mim(), mod_fast_auth_token_backend, store_new_token, Args), + #{new_token => Token, spec => Spec, old_token => CurrentToken}. + +connect_with_almost_expired_token(Config) -> + Steps = [start_new_user], + #{spec := Spec} = sasl2_helper:apply_steps(Steps, Config), + HostType = domain_helper:host_type(), + {LUser, LServer} = spec_to_lus(Spec), + AgentId = user_agent_id(), + Token = <<"verysecret">>, + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + ExpireTS = erlang:system_time(second) + 600, %% 10 minutes into the future + %% Set almost expiring token into the new slot + Args = [HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, false], + ok = distributed_helper:rpc(distributed_helper:mim(), mod_fast_auth_token_backend, store_new_token, Args), + ConnectRes = auth_with_token(success, Token, Config, Spec), + #{token := NewToken} = parse_connect_result(ConnectRes), + ?assertNotEqual(Token, NewToken), + #{new_token => NewToken, spec => Spec, old_token => Token}. + +connect_with_almost_expired_token_in_the_current_slot(Config) -> + Now = erlang:system_time(second), + Steps = [start_new_user], + #{spec := Spec} = sasl2_helper:apply_steps(Steps, Config), + HostType = domain_helper:host_type(), + {LUser, LServer} = spec_to_lus(Spec), + AgentId = user_agent_id(), + Token = <<"verysecret">>, + CurrentToken = <<"currentsecret">>, + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + ExpireTS = Now + 86400, %% 24 hours into the future + SetCurrent = #{ + current_token => CurrentToken, + current_expire => Now + 600, %% 10 minutes into the future + current_count => 0, + current_mech => Mech + }, + %% Set almost expiring token into the new slot + Args = [HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent], + ok = distributed_helper:rpc(distributed_helper:mim(), mod_fast_auth_token_backend, store_new_token, Args), + ConnectRes = auth_with_token(success, CurrentToken, Config, Spec), + #{token := NewToken} = parse_connect_result(ConnectRes), + ?assertNotEqual(Token, NewToken), + ?assertNotEqual(Token, CurrentToken), + #{new_token => NewToken, spec => Spec, old_token => CurrentToken}. + +connect_and_ask_for_token(Config) -> + Steps = [start_new_user, {?MODULE, auth_and_request_token}, + receive_features], + ConnectRes = sasl2_helper:apply_steps(Steps, Config), + parse_connect_result(ConnectRes). + +parse_connect_result(#{answer := Success, spec := Spec}) -> + ?assertMatch(#xmlel{name = <<"success">>, + attrs = #{<<"xmlns">> := ?NS_SASL_2}}, Success), + Fast = exml_query:path(Success, [{element_with_ns, <<"token">>, ?NS_FAST}]), + Expire = exml_query:attr(Fast, <<"expire">>), + Token = exml_query:attr(Fast, <<"token">>), + #{expire => Expire, token => Token, spec => Spec}. + +auth_and_request_token(Config, Client, Data) -> + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Extra = [request_token(Mech), user_agent()], + auth_with_method(Config, Client, Data, [], Extra, <<"PLAIN">>). + +auth_using_token(Config, Client, Data) -> + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Extra = [user_agent()], + auth_with_method(Config, Client, Data, [], Extra, Mech). + +auth_using_token_and_request_token(Config, Client, Data) -> + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Extra = [request_token(Mech), user_agent()], + auth_with_method(Config, Client, Data, [], Extra, Mech). + +auth_using_token_and_request_invalidation(Config, Client, Data) -> + %% While XEP does not specify, what to do with another tokens, + %% we invalidate both new and current tokens. + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Extra = [request_invalidation(), user_agent()], + auth_with_method(Config, Client, Data, [], Extra, Mech). + +auth_using_token_and_request_invalidation_1(Config, Client, Data) -> + Mech = proplists:get_value(ht_mech, Config, <<"HT-SHA-256-NONE">>), + Extra = [request_invalidation_1(), user_agent()], + auth_with_method(Config, Client, Data, [], Extra, Mech). + +%% +request_token(Mech) -> + #xmlel{name = <<"request-token">>, + attrs = #{<<"xmlns">> => ?NS_FAST, + <<"mechanism">> => Mech}}. + +%% +request_invalidation() -> + #xmlel{name = <<"fast">>, + attrs = #{<<"xmlns">> => ?NS_FAST, + <<"invalidate">> => <<"true">>}}. + +%% or +request_invalidation_1() -> + #xmlel{name = <<"fast">>, + attrs = #{<<"xmlns">> => ?NS_FAST, + <<"invalidate">> => <<"1">>}}. + +auth_with_token(Success, Token, Config, Spec) -> + auth_with_token(Success, Token, Config, Spec, dont_request_token). + +auth_with_token(Success, Token, Config, Spec, RequestToken) -> + Spec2 = [{secret_token, Token} | Spec], + Steps = steps(Success, auth_function(RequestToken)), + Data = #{spec => Spec2}, + Res = sasl2_helper:apply_steps(Steps, Config, undefined, Data), + #{answer := Answer} = Res, + case Success of + success -> + ?assertMatch(#xmlel{name = <<"success">>, + attrs = #{<<"xmlns">> := ?NS_SASL_2}}, Answer); + failure -> + ?assertMatch(#xmlel{name = <<"failure">>, + attrs = #{<<"xmlns">> := ?NS_SASL_2}, + children = [#xmlel{name = <<"not-authorized">>}]}, + Answer) + end, + Res. + +auth_function(dont_request_token) -> + auth_using_token; +auth_function(request_token) -> + auth_using_token_and_request_token; +auth_function(request_invalidation) -> + auth_using_token_and_request_invalidation; +auth_function(request_invalidation_1) -> + auth_using_token_and_request_invalidation_1. + +steps(success, AuthFun) -> + [connect_tls, start_stream_get_features, + {?MODULE, AuthFun}, + receive_features, + has_no_more_stanzas]; +steps(failure, AuthFun) -> + [connect_tls, start_stream_get_features, + {?MODULE, AuthFun}]. + +user_agent_id() -> + <<"d4565fa7-4d72-4749-b3d3-740edbf87770">>. + +user_agent() -> + #xmlel{name = <<"user-agent">>, + attrs = #{<<"id">> => user_agent_id()}, + children = [cdata_elem(<<"software">>, <<"AwesomeXMPP">>), + cdata_elem(<<"device">>, <<"Kiva's Phone">>)]}. + +cdata_elem(Name, Value) -> + #xmlel{name = Name, + children = [#xmlcdata{content = Value}]}. + +%% See bind2_SUITE:plain_auth +auth_with_method(_Config, Client, Data, BindElems, Extra, Method) -> + %% we need proof of posesion mechanism + InitEl = case Method of + <<"PLAIN">> -> + sasl2_helper:plain_auth_initial_response(Client); + <<"HT-", _/binary>> -> + ht_auth_initial_response(Client, Method) + end, + BindEl = #xmlel{name = <<"bind">>, + attrs = #{<<"xmlns">> => ?NS_BIND_2}, + children = BindElems}, + Authenticate = auth_elem(Method, [InitEl, BindEl | Extra]), + escalus:send(Client, Authenticate), + Answer = escalus_client:wait_for_stanza(Client), + ct:log("Answer ~p", [Answer]), + Identifier = exml_query:path(Answer, [{element, <<"authorization-identifier">>}, cdata]), + case Identifier of + undefined -> + {Client, Data#{answer => Answer}}; + _ -> + #jid{lresource = LResource} = jid:from_binary(Identifier), + {Client, Data#{answer => Answer, client_1_jid => Identifier, bind2_resource => LResource}} + end. + +auth_elem(Mech, Children) -> + #xmlel{name = <<"authenticate">>, + attrs = #{<<"xmlns">> => ?NS_SASL_2, <<"mechanism">> => Mech}, + children = Children}. + +%% Creates "Initiator First Message" +%% https://www.ietf.org/archive/id/draft-schmaus-kitten-sasl-ht-09.html#section-3.1 +%% +%% The HT mechanism starts with the initiator-msg, send by the initiator to the +%% responder. The following lists the ABNF grammar for the initiator-msg: +%% +%% initiator-msg = authcid NUL initiator-hashed-token +%% authcid = 1*SAFE ; MUST accept up to 255 octets +%% initiator-hashed-token = 1*OCTET +%% +%% NUL = %0x00 ; The null octet +%% SAFE = UTF8-encoded string +ht_auth_initial_response(#client{props = Props}, Method) -> + %% authcid is the username before "@" sign. + Username = proplists:get_value(username, Props), + Token = proplists:get_value(secret_token, Props), + CBData = <<>>, + ToHash = <<"Initiator", CBData/binary>>, + Algo = mech_to_algo(Method), + InitiatorHashedToken = crypto:mac(hmac, Algo, Token, ToHash), + Payload = <>, + initial_response_elem(Payload). + +initial_response_elem(Payload) -> + Encoded = base64:encode(Payload), + #xmlel{name = <<"initial-response">>, + children = [#xmlcdata{content = Encoded}]}. + +spec_to_lus(Spec) -> + #{username := Username, server := Server} = maps:from_list(Spec), + jid:to_lus(jid:make_bare(Username, Server)). diff --git a/big_tests/tests/gdpr_SUITE.erl b/big_tests/tests/gdpr_SUITE.erl index 752d51a94ff..d19c11a4d87 100644 --- a/big_tests/tests/gdpr_SUITE.erl +++ b/big_tests/tests/gdpr_SUITE.erl @@ -963,7 +963,7 @@ retrieve_offline(Config) -> #{ "packet" => [{contains, Body}], "from" => binary_to_list(From), "to" => binary_to_list(To), - "timestamp" => [{validate, fun validate_datetime/1}]} + "timestamp" => [{validate, fun time_helper:validate_datetime/1}]} end, Expected), retrieve_and_validate_personal_data( @@ -1777,28 +1777,6 @@ send_and_assert_is_chat_message(UserFrom, UserTo, Body) -> Msg = escalus:wait_for_stanza(UserTo), escalus:assert(is_chat_message, [Body], Msg). -validate_datetime(TimeStr) -> - [Date, Time] = string:tokens(TimeStr, "T"), - validate_date(Date), - validate_time(Time). - -validate_date(Date) -> - [Y, M, D] = string:tokens(Date, "-"), - Date1 = {list_to_integer(Y), list_to_integer(M), list_to_integer(D)}, - calendar:valid_date(Date1). - -validate_time(Time) -> - [T | _] = string:tokens(Time, "Z"), - validate_time1(T). - - -validate_time1(Time) -> - [H, M, S] = string:tokens(Time, ":"), - check_list([{H, 24}, {M, 60}, {S, 60}]). - -check_list(List) -> - lists:all(fun({V, L}) -> I = list_to_integer(V), I >= 0 andalso I < L end, List). - expected_header(mod_roster) -> ["jid", "name", "subscription", "ask", "groups", "askmessage", "xs"]. diff --git a/big_tests/tests/sasl2_helper.erl b/big_tests/tests/sasl2_helper.erl index 2a7376e7c29..5a669555d8f 100644 --- a/big_tests/tests/sasl2_helper.erl +++ b/big_tests/tests/sasl2_helper.erl @@ -21,9 +21,18 @@ load_all_sasl2_modules(HostType) -> {mod_sasl2, default_mod_config(mod_sasl2)}, {mod_csi, default_mod_config(mod_csi)}, {mod_carboncopy, default_mod_config(mod_carboncopy)}, - {mod_stream_management, mod_config(mod_stream_management, SMOpts)}], + {mod_stream_management, mod_config(mod_stream_management, SMOpts)}] + ++ rdbms_mods(), dynamic_modules:ensure_modules(HostType, Modules). +rdbms_mods() -> + case mongoose_helper:is_rdbms_enabled(domain_helper:host_type()) of + true -> + [{mod_fast_auth_token, mod_config(mod_fast_auth_token, #{backend => rdbms})}]; + false -> + [] + end. + apply_steps(Steps, Config) -> apply_steps(Steps, Config, undefined, #{}). diff --git a/doc/configuration/Modules.md b/doc/configuration/Modules.md index 1b8ab7efdeb..a6ac1bffdcf 100644 --- a/doc/configuration/Modules.md +++ b/doc/configuration/Modules.md @@ -99,6 +99,9 @@ This applies to situations such as sending messages or presences to mobile/SMS/e Implements [XEP-0215: External Service Discovery](http://xmpp.org/extensions/xep-0215.html) for discovering information about services external to the XMPP network. The main use-case is to help discover STUN/TURN servers to allow for negotiating media exchanges. +### [mod_fast_auth_token](../modules/mod_fast_auth_token.md) +A module that implements [XEP-0484: Fast Authentication Streamlining Tokens](https://xmpp.org/extensions/xep-0484.html).. + ### [mod_http_upload](../modules/mod_http_upload.md) Implements [XEP-0363: HTTP File Upload](https://xmpp.org/extensions/xep-0363.html) for coordinating with an XMPP server to upload files via HTTP and receive URLs that can be shared in messages. diff --git a/doc/modules/mod_fast_auth_token.md b/doc/modules/mod_fast_auth_token.md new file mode 100644 index 00000000000..03f8959861c --- /dev/null +++ b/doc/modules/mod_fast_auth_token.md @@ -0,0 +1,39 @@ +## Module Description + +This module implements [XEP-0484: Fast Authentication Streamlining Tokens](https://xmpp.org/extensions/xep-0484.html). +It provides services necessary to: + +* issue auth tokens for authenticated users; +* reconnect to the server using the tokens instead of the original auth method. + +Tokens are stored in RDBMS. + +It is not related to another similar module `mod_auth_token`. + +## Options + +### `modules.mod_fast_auth_token.backend` +* **Syntax:** non-empty string +* **Default:** `"rdbms"` +* **Example:** `backend = "rdbms"` + +Token storage backend. Currently only `"rdbms"` is supported. + +### `modules.mod_fast_auth_token.validity_period` +* **Syntax:** TOML table. Each key is either `access` or `rotate_before_expire`.Each value is a nested TOML table with the following mandatory keys: `value` (non-negative integer) and `unit` (`"days"`, `"hours"`, `"minutes"` or `"seconds"`). +* **Default:** `{access = {value = 3, unit = "days"}, rotate_before_expire = {value = 6, unit = "hours"}}` +* **Example:** `validity_period.access = {value = 30, unit = "minutes"}` + +The user can use each token for `access` period of time before it expired. + +The server would [send](https://xmpp.org/extensions/xep-0484.html#token-rotation) +a new token at the login time `rotate_before_expire` time before it expires. +Set it to 0 to disable automatic rotation. + +## Example configuration + +```toml +[modules.mod_fast_auth_token] + validity_period.access = {value = 1, unit = "days"} + validity_period.rotate_before_expire = {value = 0, unit = "days"} +``` diff --git a/include/mongoose_ns.hrl b/include/mongoose_ns.hrl index ba3abd6ea1a..a98eca60171 100644 --- a/include/mongoose_ns.hrl +++ b/include/mongoose_ns.hrl @@ -85,6 +85,7 @@ -define(NS_SESSION, <<"urn:ietf:params:xml:ns:xmpp-session">>). -define(NS_BIND, <<"urn:ietf:params:xml:ns:xmpp-bind">>). -define(NS_BIND_2, <<"urn:xmpp:bind:0">>). +-define(NS_FAST, <<"urn:xmpp:fast:0">>). -define(NS_FEATURE_IQAUTH, <<"http://jabber.org/features/iq-auth">>). -define(NS_FEATURE_IQREGISTER, <<"http://jabber.org/features/iq-register">>). diff --git a/mkdocs.yml b/mkdocs.yml index 2b5b7d7f44e..c125abf834b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,6 +133,7 @@ nav: - 'RabbitMQ backend': 'modules/mod_event_pusher_rabbit.md' - 'SNS backend': 'modules/mod_event_pusher_sns.md' - 'mod_extdisco': 'modules/mod_extdisco.md' + - 'mod_fast_auth_token': 'modules/mod_fast_auth_token.md' - 'mod_global_distrib': 'modules/mod_global_distrib.md' - 'mod_http_upload': 'modules/mod_http_upload.md' - 'mod_inbox': 'modules/mod_inbox.md' diff --git a/priv/cockroachdb.sql b/priv/cockroachdb.sql index fcb99c38dfe..ec52d1430bf 100644 --- a/priv/cockroachdb.sql +++ b/priv/cockroachdb.sql @@ -524,3 +524,23 @@ CREATE TABLE caps ( features text NOT NULL, PRIMARY KEY (node, sub_node) ); + +-- XEP-0484: Fast Authentication Streamlining Tokens +-- Module: mod_fast_auth_token +CREATE TABLE fast_auth_token( + server VARCHAR(250) NOT NULL, + username VARCHAR(250) NOT NULL, + -- Device installation ID (User-Agent ID) + -- Unique for each device + -- https://xmpp.org/extensions/xep-0388.html#initiation + user_agent_id VARCHAR(250) NOT NULL, + current_token VARCHAR(250), + current_expire BIGINT, -- seconds unix timestamp + current_count INT, -- replay counter + current_mech_id smallint, + new_token VARCHAR(250), + new_expire BIGINT, -- seconds unix timestamp + new_count INT, + new_mech_id smallint, + PRIMARY KEY(server, username, user_agent_id) +); diff --git a/priv/mssql2012.sql b/priv/mssql2012.sql index e686afe1f4a..f35ef92351e 100644 --- a/priv/mssql2012.sql +++ b/priv/mssql2012.sql @@ -768,3 +768,23 @@ CREATE TABLE caps ( features text NOT NULL, PRIMARY KEY (node, sub_node) ); + +-- XEP-0484: Fast Authentication Streamlining Tokens +-- Module: mod_fast_auth_token +CREATE TABLE fast_auth_token( + server VARCHAR(250) NOT NULL, + username VARCHAR(250) NOT NULL, + -- Device installation ID (User-Agent ID) + -- Unique for each device + -- https://xmpp.org/extensions/xep-0388.html#initiation + user_agent_id VARCHAR(250) NOT NULL, + current_token VARCHAR(250), + current_expire BIGINT, -- seconds unix timestamp + current_count INT, -- replay counter + current_mech_id TINYINT, + new_token VARCHAR(250), + new_expire BIGINT, -- seconds unix timestamp + new_count INT, + new_mech_id TINYINT, + PRIMARY KEY(server, username, user_agent_id) +); diff --git a/priv/mysql.sql b/priv/mysql.sql index 664b5bae0a1..e53e70a3f3c 100644 --- a/priv/mysql.sql +++ b/priv/mysql.sql @@ -557,3 +557,23 @@ CREATE TABLE caps ( features text NOT NULL, PRIMARY KEY (node, sub_node) ); + +-- XEP-0484: Fast Authentication Streamlining Tokens +-- Module: mod_fast_auth_token +CREATE TABLE fast_auth_token( + server VARCHAR(250) NOT NULL, + username VARCHAR(250) NOT NULL, + -- Device installation ID (User-Agent ID) + -- Unique for each device + -- https://xmpp.org/extensions/xep-0388.html#initiation + user_agent_id VARCHAR(250) NOT NULL, + current_token VARCHAR(250), + current_expire BIGINT, -- seconds unix timestamp + current_count INT, -- replay counter + current_mech_id TINYINT UNSIGNED, + new_token VARCHAR(250), + new_expire BIGINT, -- seconds unix timestamp + new_count INT, + new_mech_id TINYINT UNSIGNED, + PRIMARY KEY(server, username, user_agent_id) +); diff --git a/priv/pg.sql b/priv/pg.sql index 66874f4eff5..9de3017fb06 100644 --- a/priv/pg.sql +++ b/priv/pg.sql @@ -499,3 +499,23 @@ CREATE TABLE caps ( features text NOT NULL, PRIMARY KEY (node, sub_node) ); + +-- XEP-0484: Fast Authentication Streamlining Tokens +-- Module: mod_fast_auth_token +CREATE TABLE fast_auth_token( + server VARCHAR(250) NOT NULL, + username VARCHAR(250) NOT NULL, + -- Device installation ID (User-Agent ID) + -- Unique for each device + -- https://xmpp.org/extensions/xep-0388.html#initiation + user_agent_id VARCHAR(250) NOT NULL, + current_token VARCHAR(250), + current_expire BIGINT, -- seconds unix timestamp + current_count INT, -- replay counter + current_mech_id smallint, + new_token VARCHAR(250), + new_expire BIGINT, -- seconds unix timestamp + new_count INT, + new_mech_id smallint, + PRIMARY KEY(server, username, user_agent_id) +); diff --git a/src/c2s/mongoose_c2s.erl b/src/c2s/mongoose_c2s.erl index 5395d6cf026..c9c4671336e 100644 --- a/src/c2s/mongoose_c2s.erl +++ b/src/c2s/mongoose_c2s.erl @@ -20,7 +20,8 @@ get_info/1, set_info/2, get_mod_state/2, get_listener_opts/1, merge_mod_state/2, remove_mod_state/2, get_ip/1, get_socket/1, get_lang/1, get_stream_id/1, hook_arg/5]). --export([get_auth_mechs/1, c2s_stream_error/2, maybe_retry_state/1, merge_states/2]). +-export([get_auth_mechs/1, get_auth_mechs_to_announce/1, + c2s_stream_error/2, maybe_retry_state/1, merge_states/2]). -export([route/2, reroute_buffer/2, reroute_buffer_to_pid/3, open_session/1]). -export([set_jid/2, set_auth_module/2, state_timeout/1, handle_state_after_packet/3]). -export([replace_resource/2, generate_random_resource/0]). @@ -1158,6 +1159,16 @@ create_data(#{host_type := HostType, jid := Jid}) -> get_auth_mechs(#c2s_data{host_type = HostType} = StateData) -> [M || M <- cyrsasl:listmech(HostType), filter_mechanism(StateData, M)]. +%% Mechanisms without XEP-0484 token mechanisms +%% (HT mechanisms are announced as inlined instead) +-spec get_auth_mechs_to_announce(data()) -> [mongoose_c2s_sasl:mechanism()]. +get_auth_mechs_to_announce(StateData) -> + [M || M <- get_auth_mechs(StateData), not skip_announce_mechanism(M)]. + +-spec skip_announce_mechanism(binary()) -> boolean(). +skip_announce_mechanism(Mech) -> + mod_fast_auth_token_generic_mech:skip_announce_mechanism(Mech). + -spec filter_mechanism(data(), binary()) -> boolean(). filter_mechanism(#c2s_data{socket = Socket}, <<"SCRAM-SHA-1-PLUS">>) -> mongoose_c2s_socket:is_channel_binding_supported(Socket); diff --git a/src/c2s/mongoose_c2s_sasl.erl b/src/c2s/mongoose_c2s_sasl.erl index 65db1674ce0..4542214808e 100644 --- a/src/c2s/mongoose_c2s_sasl.erl +++ b/src/c2s/mongoose_c2s_sasl.erl @@ -18,7 +18,8 @@ -type maybe_username() :: undefined | jid:luser(). -type success() :: #{server_out := undefined | binary(), jid := jid:jid(), - auth_module := cyrsasl:sasl_module()}. + auth_module := cyrsasl:sasl_module(), + creds := mongoose_credentials:t()}. -type continue() :: #{server_out := binary()}. -type failure() :: #{server_out := binary() | {binary(), undefined | iodata()}, maybe_username := maybe_username()}. @@ -48,7 +49,10 @@ start(C2SData, SaslAcc, Mech, ClientIn) -> {error, SaslAcc, #{type => policy_violation, text => <<"Use of STARTTLS required">>}}; _ -> AuthMech = mongoose_c2s:get_auth_mechs(C2SData), - SocketData = #{socket => Socket, auth_mech => AuthMech, listener_opts => LOpts}, + %% Provide SaslAcc for readonly access, so the cyrsasl mechanism + %% has more visibility to initialize the mechanism state. + SocketData = #{socket => Socket, auth_mech => AuthMech, listener_opts => LOpts, + sasl_state => SaslAcc}, CyrSaslState = get_cyrsasl_state_from_acc(SaslAcc), CyrSaslResult = cyrsasl:server_start(CyrSaslState, Mech, ClientIn, SocketData), handle_sasl_step(C2SData, CyrSaslResult, SaslAcc) @@ -78,7 +82,7 @@ handle_sasl_success(C2SData, Creds, SaslAcc) -> User = mongoose_credentials:get(Creds, username), LServer = mongoose_c2s:get_lserver(C2SData), Jid = jid:make_bare(User, LServer), - Ret = #{server_out => ServerOut, jid => Jid, auth_module => AuthModule}, + Ret = #{server_out => ServerOut, jid => Jid, auth_module => AuthModule, creds => Creds}, {success, SaslAcc, Ret}. -spec handle_sasl_continue( diff --git a/src/c2s/mongoose_c2s_stanzas.erl b/src/c2s/mongoose_c2s_stanzas.erl index aa1f8f9b91e..fbf7c1a1642 100644 --- a/src/c2s/mongoose_c2s_stanzas.erl +++ b/src/c2s/mongoose_c2s_stanzas.erl @@ -71,7 +71,7 @@ determine_features(StateData, HostType, LServer, _, _) -> -spec maybe_sasl_mechanisms(mongoose_c2s:data()) -> [exml:element()]. maybe_sasl_mechanisms(StateData) -> - case mongoose_c2s:get_auth_mechs(StateData) of + case mongoose_c2s:get_auth_mechs_to_announce(StateData) of [] -> []; Mechanisms -> [#xmlel{name = <<"mechanisms">>, diff --git a/src/config/mongoose_config_spec.erl b/src/config/mongoose_config_spec.erl index 3843d273044..0502cc37c18 100644 --- a/src/config/mongoose_config_spec.erl +++ b/src/config/mongoose_config_spec.erl @@ -32,6 +32,10 @@ process_domain_cert/1, process_infinity_as_zero/1]). +%% For tests +-export([configurable_modules/0]). +-ignore_xref([configurable_modules/0]). + -include("mongoose_config_spec.hrl"). -type config_node() :: config_section() | config_list() | config_option(). @@ -753,6 +757,7 @@ configurable_modules() -> mod_disco, mod_event_pusher, mod_extdisco, + mod_fast_auth_token, mod_global_distrib, mod_http_upload, mod_inbox, diff --git a/src/fast_auth_token/mod_fast_auth_token.erl b/src/fast_auth_token/mod_fast_auth_token.erl new file mode 100644 index 00000000000..ffb91528be2 --- /dev/null +++ b/src/fast_auth_token/mod_fast_auth_token.erl @@ -0,0 +1,405 @@ +-module(mod_fast_auth_token). +-xep([{xep, 484}, {version, "0.2.0"}]). +-behaviour(gen_mod). +-include("mongoose_ns.hrl"). +-include("mongoose.hrl"). +-include("jlib.hrl"). +-include("mongoose_config_spec.hrl"). + +%% `gen_mod' callbacks +-export([start/2, + stop/1, + hooks/1, + config_spec/0, + supported_features/0]). + +%% hooks handlers +-export([sasl2_stream_features/3, + sasl2_start/3, + sasl2_success/3, + remove_user/3, + remove_domain/3]). + +-export([read_tokens/4]). + +%% For mocking +-export([utc_now_as_seconds/0, + generate_unique_token/0]). + +-type seconds() :: integer(). +-type counter() :: non_neg_integer(). +%% Base64 encoded token +-type token() :: binary(). +-type agent_id() :: binary(). +-type mechanism() :: binary(). + +-type validity_type() :: days | hours | minutes | seconds. +-type period() :: #{value := non_neg_integer(), + unit := days | hours | minutes | seconds}. +-type token_type() :: access | rotate_before_expire. +-type token_slot() :: new | current. +-type add_reason() :: requested | auto_rotate. + +-define(REQ, mod_fast_auth_token_request). +-define(FAST, mod_fast_auth_token_fast). + +-export_type([tokens_data/0, seconds/0, counter/0, token/0, agent_id/0, + mechanism/0, token_slot/0, set_current/0]). + +-type tokens_data() :: #{ + now_timestamp := seconds(), + current_token := token() | undefined, + current_expire := seconds() | undefined, + current_count := counter() | undefined, + current_mech := mechanism() | undefined, + new_token := token() | undefined, + new_expire := seconds() | undefined, + new_count := counter() | undefined, + new_mech := mechanism() | undefined + }. + +-type set_current() :: #{ + current_token := token(), + current_expire := seconds(), + current_count := counter(), + current_mech := mechanism() + }. + +-spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. +start(HostType, Opts) -> + mod_fast_auth_token_backend:init(HostType, Opts), + ok. + +-spec stop(mongooseim:host_type()) -> ok. +stop(_HostType) -> + ok. + +-spec hooks(mongooseim:host_type()) -> gen_hook:hook_list(). +hooks(HostType) -> + [{sasl2_stream_features, HostType, fun ?MODULE:sasl2_stream_features/3, #{}, 50}, + {sasl2_start, HostType, fun ?MODULE:sasl2_start/3, #{}, 50}, + {sasl2_success, HostType, fun ?MODULE:sasl2_success/3, #{}, 50}, + {remove_user, HostType, fun ?MODULE:remove_user/3, #{}, 50}, + {remove_domain, HostType, fun ?MODULE:remove_domain/3, #{}, 50}]. + +-spec config_spec() -> mongoose_config_spec:config_section(). +config_spec() -> + #section{ + items = #{<<"backend">> => #option{type = atom, + validate = {module, ?MODULE}}, + <<"validity_period">> => validity_periods_spec()}, + defaults = #{<<"backend">> => rdbms} + }. + +validity_periods_spec() -> + #section{ + items = #{<<"access">> => validity_period_spec(), + <<"rotate_before_expire">> => validity_period_spec()}, + defaults = #{<<"access">> => #{value => 3, unit => days}, + <<"rotate_before_expire">> => #{value => 6, unit => hours}}, + include = always + }. + +validity_period_spec() -> + #section{ + items = #{<<"value">> => #option{type = integer, + validate = non_negative}, + <<"unit">> => #option{type = atom, + validate = {enum, [days, hours, minutes, seconds]}} + }, + required = all + }. + +-spec supported_features() -> [atom()]. +supported_features() -> [dynamic_domains]. + +-spec remove_user(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mongoose_acc:t(), + Params :: map(), + Extra :: gen_hook:extra(). +remove_user(Acc, #{jid := #jid{luser = LUser, lserver = LServer}}, #{host_type := HostType}) -> + mod_fast_auth_token_backend:remove_user(HostType, LUser, LServer), + {ok, Acc}. + +-spec remove_domain(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mongoose_domain_api:remove_domain_acc(), + Params :: map(), + Extra :: gen_hook:extra(). +remove_domain(Acc, #{domain := Domain}, #{host_type := HostType}) -> + mod_fast_auth_token_backend:remove_domain(HostType, Domain), + {ok, Acc}. + +-spec sasl2_stream_features(Acc, #{c2s_data := mongoose_c2s:data()}, gen_hook:extra()) -> + {ok, Acc} when Acc :: [exml:element()]. +sasl2_stream_features(Acc, _, _) -> + {ok, [fast() | Acc]}. + +fast() -> + #xmlel{name = <<"fast">>, + attrs = #{<<"xmlns">> => ?NS_FAST}, + children = mechanisms_elems(mechanisms())}. + +mechanisms_elems(Mechs) -> + [#xmlel{name = <<"mechanism">>, + children = [#xmlcdata{content = Mech}]} || Mech <- Mechs]. + +-spec mechanisms() -> [mechanism()]. +mechanisms() -> + mod_fast_auth_token_generic_mech:mechanisms(). + +-spec sasl2_start(SaslAcc, #{stanza := exml:element()}, gen_hook:extra()) -> + {ok, SaslAcc} when SaslAcc :: mongoose_acc:t(). +sasl2_start(SaslAcc, #{stanza := El}, _) -> + Req = exml_query:path(El, [{element_with_ns, <<"request-token">>, ?NS_FAST}]), + Fast = exml_query:path(El, [{element_with_ns, <<"fast">>, ?NS_FAST}]), + AgentId = exml_query:path(El, [{element, <<"user-agent">>}, {attr, <<"id">>}]), + SaslAcc2 = mongoose_acc:set(?MODULE, agent_id, AgentId, SaslAcc), + SaslAcc3 = maybe_put_inline_request(SaslAcc2, ?REQ, Req), + {ok, maybe_put_inline_request(SaslAcc3, ?FAST, Fast)}. + +maybe_put_inline_request(SaslAcc, _Module, undefined) -> + SaslAcc; +maybe_put_inline_request(SaslAcc, Module, Request) -> + mod_sasl2:put_inline_request(SaslAcc, Module, Request). + +-spec sasl2_success(SaslAcc, mod_sasl2:c2s_state_data(), gen_hook:extra()) -> + {ok, SaslAcc} when SaslAcc :: mongoose_acc:t(). +sasl2_success(SaslAcc, C2SStateData = #{creds := Creds}, #{host_type := HostType}) -> + #{c2s_data := C2SData} = C2SStateData, + #jid{luser = LUser, lserver = LServer} = mongoose_c2s:get_jid(C2SData), + case check_if_should_add_token(HostType, SaslAcc, Creds) of + skip -> + {ok, SaslAcc}; + invalidate -> + AgentId = mongoose_acc:get(?MODULE, agent_id, undefined, SaslAcc), + invalidate_token(HostType, LServer, LUser, AgentId), + {ok, SaslAcc}; + {ok, Mech, AddReason} -> + AgentId = mongoose_acc:get(?MODULE, agent_id, undefined, SaslAcc), + %% Attach Token to the response to be used to authentificate + Response = make_fast_token_response(HostType, LServer, LUser, Mech, AgentId, Creds), + SaslAcc2 = maybe_init_inline_request(AddReason, SaslAcc), + SaslAcc3 = mod_sasl2:update_inline_request(SaslAcc2, ?REQ, Response, success), + {ok, SaslAcc3} + end. + +-spec check_if_should_add_token(HostType :: mongooseim:host_type(), + SaslAcc :: mongoose_acc:t(), + Creds :: mongoose_credentials:t()) -> + skip | invalidate | {ok, mechanism(), Reason :: add_reason()}. +check_if_should_add_token(HostType, SaslAcc, Creds) -> + Parsed = parse_inline_requests(SaslAcc), + case Parsed of + #{invalidate := true} -> + invalidate; + #{mech := Mech} -> + case lists:member(Mech, mechanisms()) of + true -> + {ok, Mech, requested}; + false -> + skip + end; + #{} -> + maybe_auto_rotate(HostType, Creds) + end. + +-spec parse_inline_requests(SaslAcc :: mongoose_acc:t()) -> map(). +parse_inline_requests(SaslAcc) -> + Req = mod_sasl2:get_inline_request(SaslAcc, ?REQ, undefined), + Fast = mod_sasl2:get_inline_request(SaslAcc, ?FAST, undefined), + map_skip_undefined(maps:merge(parse_request(Req), parse_fast(Fast))). + +-spec map_skip_undefined(map()) -> map(). +map_skip_undefined(Map) -> + maps:filter(fun(_, Val) -> Val =/= undefined end, Map). + +parse_request(#{request := Req = #xmlel{name = <<"request-token">>}}) -> + Mech = exml_query:attr(Req, <<"mechanism">>), + #{mech => Mech}; +parse_request(undefined) -> + #{}. + +parse_fast(#{request := Fast = #xmlel{name = <<"fast">>}}) -> + Inv = is_true(exml_query:attr(Fast, <<"invalidate">>)), + Count = exml_query:attr(Fast, <<"count">>), + #{invalidate => Inv, count => maybe_parse_integer(Count)}; +parse_fast(undefined) -> + #{}. + +is_true(<<"true">>) -> true; +is_true(<<"1">>) -> true; +is_true(_) -> false. + +maybe_parse_integer(X) when is_binary(X) -> + binary_to_integer(X); +maybe_parse_integer(undefined) -> + undefined. + +-spec maybe_auto_rotate(HostType :: mongooseim:host_type(), + Creds :: mongoose_credentials:t()) -> + skip | {ok, mechanism(), Reason :: add_reason()}. +maybe_auto_rotate(HostType, Creds) -> + %% Creds could contain data from mod_fast_auth_token_generic_mech + SlotUsed = mongoose_credentials:get(Creds, fast_token_slot_used, undefined), + DataUsed = mongoose_credentials:get(Creds, fast_token_data, undefined), + case user_used_token_to_login(SlotUsed) of + true -> + case is_used_token_about_to_expire(HostType, SlotUsed, DataUsed) of + true -> + {ok, data_used_to_mech_type(SlotUsed, DataUsed), auto_rotate}; + false -> + skip + end; + false -> + skip + end. + +-spec is_used_token_about_to_expire(HostType :: mongooseim:host_type(), + SlotUsed :: token_slot(), + DataUsed :: tokens_data()) -> boolean(). +is_used_token_about_to_expire(HostType, SlotUsed, DataUsed) -> + is_timestamp_about_to_expire(HostType, + slot_to_expire_timestamp(SlotUsed, DataUsed)). + +-spec is_timestamp_about_to_expire(HostType :: mongooseim:host_type(), + Timestamp :: seconds()) -> boolean(). +is_timestamp_about_to_expire(HostType, Timestamp) -> + Now = utc_now_as_seconds(), + TimeBeforeRotate = get_time_to_rotate_before_expire_seconds(HostType), + SecondsBeforeExpire = Timestamp - Now, + SecondsBeforeExpire =< TimeBeforeRotate. + +-spec user_used_token_to_login(token_slot() | undefined) -> boolean(). +user_used_token_to_login(SlotUsed) -> + undefined =/= SlotUsed. + +-spec slot_to_expire_timestamp(Slot :: token_slot(), Data :: tokens_data()) -> seconds(). +slot_to_expire_timestamp(new, #{new_expire := Timestamp}) -> + Timestamp; +slot_to_expire_timestamp(current, #{current_expire := Timestamp}) -> + Timestamp. + +-spec data_used_to_mech_type(SlotUsed :: token_slot(), + DataUsed :: tokens_data()) -> Mech :: mechanism(). +data_used_to_mech_type(new, #{new_mech := Mech}) -> + Mech; +data_used_to_mech_type(current, #{current_mech := Mech}) -> + Mech. + +-spec maybe_init_inline_request(AddReason, SaslAcc) -> SaslAcc + when AddReason :: add_reason(), + SaslAcc :: mongoose_acc:t(). +maybe_init_inline_request(requested, SaslAcc) -> + SaslAcc; +maybe_init_inline_request(auto_rotate, SaslAcc) -> + %% Add something, so update_inline_request would actually attach data + mod_sasl2:put_inline_request(SaslAcc, ?REQ, #xmlel{name = <<"auto">>}). + +%% Generate expirable auth token and store it in DB +-spec make_fast_token_response(HostType, LServer, LUser, Mech, AgentId, Creds) -> exml:element() + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: agent_id(), + Mech :: mechanism(), + Creds :: mongoose_credentials:t(). +make_fast_token_response(HostType, LServer, LUser, Mech, AgentId, Creds) -> + TTLSeconds = get_ttl_seconds(HostType), + NowTS = ?MODULE:utc_now_as_seconds(), + ExpireTS = NowTS + TTLSeconds, + Expire = seconds_to_binary(ExpireTS), + Token = ?MODULE:generate_unique_token(), + SetCurrent = maybe_set_current_slot(Creds), + store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent), + #xmlel{name = <<"token">>, + attrs = #{<<"xmlns">> => ?NS_FAST, <<"expire">> => Expire, + <<"token">> => Token}}. + +-spec maybe_set_current_slot(Creds :: mongoose_credentials:t()) -> + SetCurrent :: set_current(). +maybe_set_current_slot(Creds) -> + %% Creds could contain data from mod_fast_auth_token_generic_mech + SlotUsed = mongoose_credentials:get(Creds, fast_token_slot_used, undefined), + DataUsed = mongoose_credentials:get(Creds, fast_token_data, undefined), + case SlotUsed of + new -> + token_data_to_set_current(DataUsed); + _ -> + false + end. + +-spec token_data_to_set_current(DataUsed :: tokens_data()) -> set_current(). +token_data_to_set_current(#{ + new_token := Token, + new_expire := Expire, + new_count := Counter, + new_mech := Mech}) -> + #{current_token => Token, + current_expire => Expire, + current_count => Counter, + current_mech => Mech}. + +-spec seconds_to_binary(seconds()) -> binary(). +seconds_to_binary(Secs) -> + Opts = [{offset, "Z"}, {unit, second}], + list_to_binary(calendar:system_time_to_rfc3339(Secs, Opts)). + +-spec utc_now_as_seconds() -> seconds(). +utc_now_as_seconds() -> + erlang:system_time(second). + +-spec get_ttl_seconds(mongooseim:host_type()) -> seconds(). +get_ttl_seconds(HostType) -> + #{value := Value, unit := Unit} = get_validity_period(HostType, access), + period_to_seconds(Value, Unit). + +-spec get_time_to_rotate_before_expire_seconds(mongooseim:host_type()) -> seconds(). +get_time_to_rotate_before_expire_seconds(HostType) -> + #{value := Value, unit := Unit} = get_validity_period(HostType, rotate_before_expire), + period_to_seconds(Value, Unit). + +-spec get_validity_period(mongooseim:host_type(), token_type()) -> period(). +get_validity_period(HostType, Type) -> + gen_mod:get_module_opt(HostType, ?MODULE, [validity_period, Type]). + +-spec period_to_seconds(non_neg_integer(), validity_type()) -> seconds(). +period_to_seconds(Days, days) -> 24 * 3600 * Days; +period_to_seconds(Hours, hours) -> 3600 * Hours; +period_to_seconds(Minutes, minutes) -> 60 * Minutes; +period_to_seconds(Seconds, seconds) -> Seconds. + +-spec generate_unique_token() -> token(). +generate_unique_token() -> + base64:encode(crypto:strong_rand_bytes(25)). + +-spec store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, + Token, Mech, SetCurrent) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: agent_id(), + ExpireTS :: seconds(), + Token :: token(), + Mech :: mechanism(), + SetCurrent :: set_current() | false. +store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent) -> + mod_fast_auth_token_backend:store_new_token( + HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent). + +-spec invalidate_token(HostType, LServer, LUser, AgentId) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: agent_id(). +invalidate_token(HostType, LServer, LUser, AgentId) -> + mod_fast_auth_token_backend:invalidate_token(HostType, LServer, LUser, AgentId), + ok. + +-spec read_tokens(HostType, LServer, LUser, AgentId) -> + {ok, tokens_data()} | {error, not_found} + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: agent_id(). +read_tokens(HostType, LServer, LUser, AgentId) -> + mod_fast_auth_token_backend:read_tokens(HostType, LServer, LUser, AgentId). diff --git a/src/fast_auth_token/mod_fast_auth_token_backend.erl b/src/fast_auth_token/mod_fast_auth_token_backend.erl new file mode 100644 index 00000000000..111c9c9e880 --- /dev/null +++ b/src/fast_auth_token/mod_fast_auth_token_backend.erl @@ -0,0 +1,97 @@ +-module(mod_fast_auth_token_backend). + +-export([init/2, + store_new_token/8, + read_tokens/4, + invalidate_token/4, + remove_user/3, + remove_domain/2]). + +-define(MAIN_MODULE, mod_fast_auth_token). + +-callback init(mongooseim:host_type(), gen_mod:module_opts()) -> ok. + +-callback store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, + Token, Mech, SetCurrent) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(), + ExpireTS :: mod_fast_auth_token:seconds(), + Token :: mod_fast_auth_token:token(), + Mech :: mod_fast_auth_token:mechanism(), + SetCurrent :: mod_fast_auth_token:set_current() | false. + +-callback read_tokens(HostType, LServer, LUser, AgentId) -> + {ok, mod_fast_auth_token:tokens_data()} | {error, not_found} + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). + +-callback invalidate_token(HostType, LServer, LUser, AgentId) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). + +-callback remove_user(mongooseim:host_type(), jid:luser(), jid:lserver()) -> ok. + +-callback remove_domain(mongooseim:host_type(), jid:lserver()) -> ok. + +-optional_callbacks([remove_domain/2]). + +-spec init(mongooseim:host_type(), gen_mod:module_opts()) -> ok. +init(HostType, Opts) -> + Tracked = [store_new_token, read_tokens, invalidate_token], + mongoose_backend:init(HostType, ?MAIN_MODULE, Tracked, Opts), + Args = [HostType, Opts], + mongoose_backend:call(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, + Token, Mech, SetCurrent) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(), + ExpireTS :: mod_fast_auth_token:seconds(), + Token :: mod_fast_auth_token:token(), + Mech :: mod_fast_auth_token:mechanism(), + SetCurrent :: mod_fast_auth_token:set_current() | false. +store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent) -> + Args = [HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent], + mongoose_backend:call_tracked(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec read_tokens(HostType, LServer, LUser, AgentId) -> + {ok, mod_fast_auth_token:tokens_data()} | {error, not_found} + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). +read_tokens(HostType, LServer, LUser, AgentId) -> + Args = [HostType, LServer, LUser, AgentId], + mongoose_backend:call_tracked(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec invalidate_token(HostType, LServer, LUser, AgentId) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). +invalidate_token(HostType, LServer, LUser, AgentId) -> + Args = [HostType, LServer, LUser, AgentId], + mongoose_backend:call_tracked(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec remove_user(mongooseim:host_type(), jid:luser(), jid:lserver()) -> ok. +remove_user(HostType, LUser, LServer) -> + Args = [HostType, LUser, LServer], + mongoose_backend:call(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec remove_domain(mongooseim:host_type(), jid:lserver()) -> ok. +remove_domain(HostType, LServer) -> + Args = [HostType, LServer], + case mongoose_backend:is_exported(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, 2) of + true -> + mongoose_backend:call(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args); + false -> + ok + end. diff --git a/src/fast_auth_token/mod_fast_auth_token_generic_mech.erl b/src/fast_auth_token/mod_fast_auth_token_generic_mech.erl new file mode 100644 index 00000000000..04475613469 --- /dev/null +++ b/src/fast_auth_token/mod_fast_auth_token_generic_mech.erl @@ -0,0 +1,200 @@ +-module(mod_fast_auth_token_generic_mech). + +-export([mech_new/4, mech_step/2]). +%% Called from mongoose_c2s +-export([skip_announce_mechanism/1]). +%% Called from mod_fast_auth_token +-export([mechanisms/0]). +%% Called from mod_fast_auth_token_rdbms +-export([mech_id/1, mech_name/1]). +%% Called from cyrsasl +-export([supports_sasl_module/2, sasl_modules/0]). + +-record(fast_info, { + creds :: mongoose_credentials:t(), + agent_id :: mod_fast_auth_token:agent_id(), + mechanism :: mod_fast_auth_token:mechanism() + }). +-include("mongoose.hrl"). +-type fast_info() :: #fast_info{}. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term(), + Mech :: mod_fast_auth_token:mechanism()) -> {ok, fast_info()} | {error, binary()}. +mech_new(_Host, Creds, _SocketData = #{sasl_state := SaslState}, Mech) -> + SaslModState = mod_sasl2:get_mod_state(SaslState), + case SaslModState of + #{encoded_id := AgentId} -> + {ok, #fast_info{creds = Creds, agent_id = AgentId, mechanism = Mech}}; + _ -> + {error, <<"not-sasl2">>} + end; +mech_new(_Host, _Creds, _SocketData, _Mech) -> + {error, <<"not-sasl2">>}. + +-spec mech_step(State :: fast_info(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(#fast_info{creds = Creds, agent_id = AgentId, mechanism = Mech}, SerializedToken) -> + %% SerializedToken is base64 decoded. + Parts = binary:split(SerializedToken, <<0>>), + [Username, InitiatorHashedToken] = Parts, + HostType = mongoose_credentials:host_type(Creds), + LServer = mongoose_credentials:lserver(Creds), + LUser = jid:nodeprep(Username), + case mod_fast_auth_token:read_tokens(HostType, LServer, LUser, AgentId) of + {ok, TokenData} -> + CBData = <<>>, + case handle_auth(TokenData, InitiatorHashedToken, CBData, Mech) of + {true, TokenSlot} -> + {ok, mongoose_credentials:extend(Creds, + [{username, LUser}, + {auth_module, ?MODULE}, + {fast_token_slot_used, TokenSlot}, + {fast_token_data, TokenData}])}; + false -> + {error, <<"not-authorized">>} + end; + {error, _Reason} -> + {error, <<"not-authorized">>} + end. + + +%% For every client using FAST, have two token slots - 'current' and 'new'. +%% Whenever generating a new token, always place it into the 'new' slot. +%% During authentication, first check against the token +%% in the 'new' slot (if any). +%% If successful, move the token from the 'new' slot to the 'current' slot +%% (overwrite any existing token in that slot). +%% +%% If the client's provided token does not match the token in the 'new' slot, +%% or if the 'new' slot is empty, compare against the token +%% in the 'current' slot (if any). +-spec handle_auth(Data :: mod_fast_auth_token:tokens_data(), + InitiatorHashedToken :: binary(), + CBData :: binary(), + Mech :: mod_fast_auth_token:mechanism()) -> + {true, Slot :: mod_fast_auth_token:token_slot()} | false. +handle_auth(#{ + now_timestamp := NowTimestamp, + current_token := CurrentToken, + current_expire := CurrentExpire, + current_count := CurrentCount, + current_mech := CurrentMech, + new_token := NewToken, + new_expire := NewExpire, + new_count := NewCount, + new_mech := NewMech + }, InitiatorHashedToken, CBData, Mech) -> + ToHash = <<"Initiator", CBData/binary>>, + TokenNew = {NewToken, NewExpire, NewCount, NewMech}, + TokenCur = {CurrentToken, CurrentExpire, CurrentCount, CurrentMech}, + Shared = {NowTimestamp, ToHash, InitiatorHashedToken, Mech}, + case check_token(TokenNew, Shared) of + true -> + {true, new}; + false -> + case check_token(TokenCur, Shared) of + true -> + {true, current}; + false -> + false + end + end. + +%% Mech of the token in DB should match the mech the client is using. +check_token({Token, Expire, _Count, Mech}, + {NowTimestamp, ToHash, InitiatorHashedToken, Mech}) + when is_binary(Token), Expire > NowTimestamp -> + Algo = mech_to_algo(Mech), + ComputedToken = crypto:mac(hmac, Algo, Token, ToHash), + %% To be theoretically safe against timing attacks (attacks that measure + %% the time it take to compare to binaries to guess how many bytes were + %% guessed correctly when the comparison is executed byte-by-byte and + %% shortcircuit upon the first difference) + crypto:hash_equals(ComputedToken, InitiatorHashedToken); +check_token(_, _) -> + false. + +%% List: +%% https://www.iana.org/assignments/named-information/named-information.xhtml#hash-alg +%% 1 sha-256 256 bits [RFC6920] current +%% 2 sha-256-128 128 bits [RFC6920] current +%% 3 sha-256-120 120 bits [RFC6920] current +%% 4 sha-256-96 96 bits [RFC6920] current +%% 5 sha-256-64 64 bits [RFC6920] current +%% 6 sha-256-32 32 bits [RFC6920] current +%% 7 sha-384 384 bits [FIPS 180-4] current +%% 8 sha-512 512 bits [FIPS 180-4] current +%% 9 sha3-224 224 bits [FIPS 202] current +%% 10 sha3-256 256 bits [FIPS 202] current +%% 11 sha3-384 384 bits [FIPS 202] current +%% 12 sha3-512 512 bits [FIPS 202] current +%% blake2s-256 256 bits [RFC7693] current +%% blake2b-256 256 bits [RFC7693] current +%% blake2b-512 512 bits [RFC7693] current +%% k12-256 256 bits [draft-irtf-cfrg-kangarootwelve-06] current +%% k12-512 512 bits [draft-irtf-cfrg-kangarootwelve-06] current +-spec mech_to_algo(mod_fast_auth_token:mechanism()) -> atom(). +mech_to_algo(<<"HT-SHA-256-NONE">>) -> sha256; +mech_to_algo(<<"HT-SHA-384-NONE">>) -> sha384; +mech_to_algo(<<"HT-SHA-512-NONE">>) -> sha512; + +mech_to_algo(<<"HT-SHA-3-256-NONE">>) -> sha3_256; +mech_to_algo(<<"HT-SHA-3-384-NONE">>) -> sha3_384; +mech_to_algo(<<"HT-SHA-3-512-NONE">>) -> sha3_512; + +mech_to_algo(_) -> unknown. + +-spec skip_announce_mechanism(mod_fast_auth_token:mechanism()) -> boolean(). +skip_announce_mechanism(Mech) -> + mech_to_algo(Mech) =/= unknown. + +-spec mechanisms() -> [mod_fast_auth_token:mechanism()]. +mechanisms() -> + %% Mechanisms described in + %% https://www.ietf.org/archive/id/draft-schmaus-kitten-sasl-ht-09.html + [ + <<"HT-SHA-256-NONE">>, + <<"HT-SHA-384-NONE">>, + <<"HT-SHA-512-NONE">>, + + <<"HT-SHA-3-256-NONE">>, + <<"HT-SHA-3-384-NONE">>, + <<"HT-SHA-3-512-NONE">> + ]. + +-spec mech_id(mod_fast_auth_token:mechanism()) -> non_neg_integer(). +mech_id(<<"HT-SHA-256-NONE">>) -> 1; +mech_id(<<"HT-SHA-384-NONE">>) -> 7; +mech_id(<<"HT-SHA-512-NONE">>) -> 8; + +mech_id(<<"HT-SHA-3-256-NONE">>) -> 10; +mech_id(<<"HT-SHA-3-384-NONE">>) -> 11; +mech_id(<<"HT-SHA-3-512-NONE">>) -> 12. + +-spec mech_name(non_neg_integer()) -> mod_fast_auth_token:mechanism(). +mech_name(1) -> <<"HT-SHA-256-NONE">>; +mech_name(7) -> <<"HT-SHA-384-NONE">>; +mech_name(8) -> <<"HT-SHA-512-NONE">>; + +mech_name(10) -> <<"HT-SHA-3-256-NONE">>; +mech_name(11) -> <<"HT-SHA-3-384-NONE">>; +mech_name(12) -> <<"HT-SHA-3-512-NONE">>; + +mech_name(_) -> <<"UNKNOWN-MECH">>. %% Just in case DB has an unknown mech_id + +-spec supports_sasl_module(mongooseim:host_type(), module()) -> boolean(). +supports_sasl_module(HostType, Module) -> + lists:member(Module, sasl_modules()) + andalso gen_mod:is_loaded(HostType, mod_fast_auth_token). + +sasl_modules() -> + [cyrsasl_ht_sha256_none, + cyrsasl_ht_sha384_none, + cyrsasl_ht_sha512_none, + + cyrsasl_ht_sha3_256_none, + cyrsasl_ht_sha3_384_none, + cyrsasl_ht_sha3_512_none]. diff --git a/src/fast_auth_token/mod_fast_auth_token_rdbms.erl b/src/fast_auth_token/mod_fast_auth_token_rdbms.erl new file mode 100644 index 00000000000..9cd4c4f181a --- /dev/null +++ b/src/fast_auth_token/mod_fast_auth_token_rdbms.erl @@ -0,0 +1,154 @@ +-module(mod_fast_auth_token_rdbms). +-behaviour(mod_fast_auth_token_backend). +-include("mongoose_logger.hrl"). + +-export([init/2, + store_new_token/8, + read_tokens/4, + invalidate_token/4, + remove_user/3, + remove_domain/2]). + +-import(mongoose_rdbms, [prepare/4, execute/3, execute_successfully/3]). + +-spec init(mongooseim:host_type(), gen_mod:module_opts()) -> ok. +init(HostType, _Opts) -> + prepare_upsert(HostType), + prepare_upsert_and_set_current(HostType), + prepare(fast_select, fast_auth_token, + [server, username, user_agent_id], + <<"SELECT " + "current_token, current_expire, current_count, current_mech_id, " + "new_token, new_expire, new_count, new_mech_id " + "FROM fast_auth_token " + "WHERE server = ? AND username = ? AND user_agent_id = ?">>), + prepare(fast_invalidate_token, fast_auth_token, + [server, username, user_agent_id], + <<"DELETE FROM fast_auth_token " + "WHERE server = ? AND username = ? AND user_agent_id = ?">>), + prepare(fast_remove_user, fast_auth_token, + [server, username], + <<"DELETE FROM fast_auth_token " + "WHERE server = ? AND username = ?">>), + prepare(fast_remove_domain, fast_auth_token, + [server], + <<"DELETE FROM fast_auth_token WHERE server = ?">>), + ok. + +prepare_upsert(HostType) -> + Key = [<<"server">>, <<"username">>, <<"user_agent_id">>], + Upd = [<<"new_token">>, <<"new_expire">>, <<"new_count">>, <<"new_mech_id">>], + Ins = Key ++ Upd, + rdbms_queries:prepare_upsert(HostType, fast_upsert, fast_auth_token, Ins, Upd, Key). + +prepare_upsert_and_set_current(HostType) -> + Key = [<<"server">>, <<"username">>, <<"user_agent_id">>], + Upd = [<<"new_token">>, <<"new_expire">>, <<"new_count">>, <<"new_mech_id">>, + <<"current_token">>, <<"current_expire">>, <<"current_count">>, <<"current_mech_id">>], + Ins = Key ++ Upd, + rdbms_queries:prepare_upsert(HostType, fast_upsert_and_set_current, fast_auth_token, Ins, Upd, Key). + +-spec store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, + Token, Mech, SetCurrent) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(), + ExpireTS :: mod_fast_auth_token:seconds(), + Token :: mod_fast_auth_token:token(), + Mech :: mod_fast_auth_token:mechanism(), + SetCurrent :: mod_fast_auth_token:set_current() | false. +store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, false) -> + Key = [LServer, LUser, AgentId], + Upd = [Token, ExpireTS, 0, mech_id(Mech)], + Ins = Key ++ Upd, + rdbms_queries:execute_upsert(HostType, fast_upsert, Ins, Upd, Key), + ok; +store_new_token(HostType, LServer, LUser, AgentId, ExpireTS, Token, Mech, SetCurrent) -> + %% Move new_token into the current_token slot + %% We pass data directly instead of checking from the database to avoid + %% race conditions + #{current_token := CurrentToken, + current_expire := CurrentExpire, + current_count := CurrentCount, + current_mech := CurrentMechId} = SetCurrent, + Key = [LServer, LUser, AgentId], + Upd = [Token, ExpireTS, 0, mech_id(Mech), + CurrentToken, CurrentExpire, CurrentCount, mech_id(CurrentMechId)], + Ins = Key ++ Upd, + rdbms_queries:execute_upsert(HostType, fast_upsert_and_set_current, Ins, Upd, Key), + ok. + +-spec read_tokens(HostType, LServer, LUser, AgentId) -> + {ok, mod_fast_auth_token:tokens_data()} | {error, not_found} + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). +read_tokens(HostType, LServer, LUser, AgentId) -> + case execute(HostType, fast_select, [LServer, LUser, AgentId]) of + {selected, []} -> + {error, not_found}; + {selected, [{CurrentToken, CurrentExpire, CurrentCount, CurrentMechId, + NewToken, NewExpire, NewCount, NewMechId}]} -> + Data = #{ + now_timestamp => mod_fast_auth_token:utc_now_as_seconds(), + current_token => null_as_undefined(CurrentToken), + current_expire => maybe_to_integer(CurrentExpire), + current_count => maybe_to_integer(CurrentCount), + current_mech => maybe_to_mech(CurrentMechId), + new_token => null_as_undefined(NewToken), + new_expire => maybe_to_integer(NewExpire), + new_count => maybe_to_integer(NewCount), + new_mech => maybe_to_mech(NewMechId) + }, + {ok, Data}; + Other -> + ?LOG_ERROR(#{what => fast_token_read_failed, + username => LUser, server => LServer, result => Other}), + {error, not_found} + end. + +maybe_to_integer(null) -> + undefined; +maybe_to_integer(Result) -> + mongoose_rdbms:result_to_integer(Result). + +maybe_to_mech(null) -> + undefined; +maybe_to_mech(Result) -> + Int = mongoose_rdbms:result_to_integer(Result), + mech_name(Int). + +null_as_undefined(null) -> + undefined; +null_as_undefined(Result) -> + Result. + +-spec remove_user(mongooseim:host_type(), jid:luser(), jid:lserver()) -> ok. +remove_user(HostType, LUser, LServer) -> + execute_successfully(HostType, fast_remove_user, [LServer, LUser]), + ok. + +-spec invalidate_token(HostType, LServer, LUser, AgentId) -> ok + when HostType :: mongooseim:host_type(), + LServer :: jid:lserver(), + LUser :: jid:luser(), + AgentId :: mod_fast_auth_token:agent_id(). +invalidate_token(HostType, LServer, LUser, AgentId) -> + execute_successfully(HostType, fast_invalidate_token, + [LServer, LUser, AgentId]), + ok. + +-spec remove_domain(mongooseim:host_type(), jid:lserver()) -> ok. +remove_domain(HostType, LServer) -> + execute_successfully(HostType, fast_remove_domain, [LServer]), + ok. + +-spec mech_id(mod_fast_auth_token:mechanism()) -> non_neg_integer(). +mech_id(Mech) -> + mod_fast_auth_token_generic_mech:mech_id(Mech). + +-spec mech_name(non_neg_integer()) -> mod_fast_auth_token:mechanism(). +mech_name(MechId) -> + mod_fast_auth_token_generic_mech:mech_name(MechId). diff --git a/src/hooks/mongoose_hooks.erl b/src/hooks/mongoose_hooks.erl index 475178c77e1..02665bd33d7 100644 --- a/src/hooks/mongoose_hooks.erl +++ b/src/hooks/mongoose_hooks.erl @@ -42,7 +42,7 @@ bind2_stream_features/2, bind2_enable_features/3, sasl2_start/3, - sasl2_success/3]). + sasl2_success/4]). -export([get_pep_recipients/2, filter_pep_recipient/3, @@ -515,13 +515,14 @@ sasl2_start(HostType, Acc, Element) -> run_hook_for_host_type(sasl2_start, HostType, Acc, Params). %% If SASL authentication is successful, inline features can be triggered --spec sasl2_success(HostType, Acc, Params) -> Result when +-spec sasl2_success(HostType, Acc, Params, Creds) -> Result when HostType :: mongooseim:host_type(), Acc :: mongoose_acc:t(), Params :: mod_sasl2:c2s_state_data(), + Creds :: mongoose_credentials:t(), Result :: mongoose_acc:t(). -sasl2_success(HostType, Acc, Params) -> - run_hook_for_host_type(sasl2_success, HostType, Acc, Params). +sasl2_success(HostType, Acc, Params, Creds) -> + run_hook_for_host_type(sasl2_success, HostType, Acc, Params#{creds => Creds}). -spec check_bl_c2s(IP) -> Result when IP :: inet:ip_address(), diff --git a/src/mod_sasl2.erl b/src/mod_sasl2.erl index 4765d2ba451..b543be0dfdc 100644 --- a/src/mod_sasl2.erl +++ b/src/mod_sasl2.erl @@ -36,7 +36,8 @@ -export([get_inline_request/2, get_inline_request/3, put_inline_request/3, append_inline_response/3, update_inline_request/4, get_state_data/1, set_state_data/2, - request_block_future_stream_features/1]). + request_block_future_stream_features/1, + get_mod_state/1]). -type maybe_binary() :: undefined | binary(). -type status() :: pending | success | failure. @@ -45,6 +46,7 @@ status := status()}. -type mod_state() :: #{authenticated := boolean(), id := not_provided | uuid:uuid(), + encoded_id := not_provided | binary(), software := not_provided | binary(), device := not_provided | binary()}. -type c2s_state_data() :: #{c2s_state := mongoose_c2s:state(), @@ -227,7 +229,7 @@ handle_sasl_step({error, NewSaslAcc, #{type := Type}}, OriginalStateData, Retrie -spec handle_sasl_success(mongoose_acc:t(), mongoose_c2s_sasl:success(), c2s_state_data()) -> mongoose_c2s:fsm_res(). handle_sasl_success(SaslAcc, - #{server_out := MaybeServerOut, jid := Jid, auth_module := AuthMod}, + #{server_out := MaybeServerOut, jid := Jid, auth_module := AuthMod, creds := Creds}, #{c2s_data := C2SData} = OriginalStateData) -> C2SData1 = build_final_c2s_data(C2SData, Jid, AuthMod), OriginalStateData1 = OriginalStateData#{c2s_data := C2SData1}, @@ -235,7 +237,7 @@ handle_sasl_success(SaslAcc, user => jid:to_binary(Jid), c2s_state => C2SData1}), HostType = mongoose_c2s:get_host_type(C2SData1), SaslAcc1 = set_state_data(SaslAcc, OriginalStateData1), - SaslAcc2 = mongoose_hooks:sasl2_success(HostType, SaslAcc1, OriginalStateData1), + SaslAcc2 = mongoose_hooks:sasl2_success(HostType, SaslAcc1, OriginalStateData1, Creds), process_sasl2_success(SaslAcc2, OriginalStateData1, MaybeServerOut). -spec handle_sasl_continue( @@ -392,7 +394,8 @@ get_initial_response(El) -> -spec init_mod_state(not_provided | exml:element()) -> invalid_agent | mod_state(). init_mod_state(not_provided) -> - #{authenticated => false, id => not_provided, software => not_provided, device => not_provided}; + #{authenticated => false, id => not_provided, software => not_provided, device => not_provided, + encoded_id => not_provided}; init_mod_state(El) -> MaybeId = exml_query:attr(El, <<"id">>, not_provided), case if_provided_then_is_not_invalid_uuid_v4(MaybeId) of @@ -401,7 +404,8 @@ init_mod_state(El) -> Value -> Software = exml_query:path(El, [{element, <<"software">>}, cdata], not_provided), Device = exml_query:path(El, [{element, <<"device">>}, cdata], not_provided), - #{authenticated => false, id => Value, software => Software, device => Device} + #{authenticated => false, id => Value, software => Software, device => Device, + encoded_id => MaybeId} end. -spec if_provided_then_is_not_invalid_uuid_v4(not_provided | binary()) -> diff --git a/src/offline/mod_offline.erl b/src/offline/mod_offline.erl index abacc68bf84..8808d3c5eb1 100644 --- a/src/offline/mod_offline.erl +++ b/src/offline/mod_offline.erl @@ -469,12 +469,7 @@ remove_user(Acc, #{jid := #jid{luser = LUser, lserver = LServer}}, #{host_type : Params :: map(), Extra :: gen_hook:extra(). remove_domain(Acc, #{domain := Domain}, #{host_type := HostType}) -> - case mongoose_lib:is_exported(mod_offline_backend, remove_domain, 2) of - true -> - mod_offline_backend:remove_domain(HostType, Domain); - false -> - ok - end, + mod_offline_backend:remove_domain(HostType, Domain), {ok, Acc}. -spec disco_features(Acc, Params, Extra) -> {ok, Acc} when diff --git a/src/sasl/cyrsasl.erl b/src/sasl/cyrsasl.erl index 80d6418390e..a944b2efdc3 100644 --- a/src/sasl/cyrsasl.erl +++ b/src/sasl/cyrsasl.erl @@ -120,7 +120,9 @@ server_start(#sasl_state{myname = Host, host_type = HostType} = State, is_module_supported(HostType, cyrsasl_oauth) -> gen_mod:is_loaded(HostType, mod_auth_token); is_module_supported(HostType, Module) -> - mongoose_fips:supports_sasl_module(Module) andalso ejabberd_auth:supports_sasl_module(HostType, Module). + mod_fast_auth_token_generic_mech:supports_sasl_module(HostType, Module) + orelse (mongoose_fips:supports_sasl_module(Module) andalso + ejabberd_auth:supports_sasl_module(HostType, Module)). -spec server_step(State :: sasl_state(), ClientIn :: binary()) -> Result when Result :: sasl_result(). @@ -156,4 +158,5 @@ default_modules() -> cyrsasl_scram_sha1, cyrsasl_plain, cyrsasl_anonymous, - cyrsasl_oauth]. + cyrsasl_oauth] + ++ mod_fast_auth_token_generic_mech:sasl_modules(). diff --git a/src/sasl/cyrsasl_ht_sha256_none.erl b/src/sasl/cyrsasl_ht_sha256_none.erl new file mode 100644 index 00000000000..afbc8e9c852 --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha256_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha256_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-256-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/src/sasl/cyrsasl_ht_sha384_none.erl b/src/sasl/cyrsasl_ht_sha384_none.erl new file mode 100644 index 00000000000..f0f4e59b3f9 --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha384_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha384_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-384-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/src/sasl/cyrsasl_ht_sha3_256_none.erl b/src/sasl/cyrsasl_ht_sha3_256_none.erl new file mode 100644 index 00000000000..9ac306d0438 --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha3_256_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha3_256_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-3-256-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/src/sasl/cyrsasl_ht_sha3_384_none.erl b/src/sasl/cyrsasl_ht_sha3_384_none.erl new file mode 100644 index 00000000000..632fecdec88 --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha3_384_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha3_384_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-3-384-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/src/sasl/cyrsasl_ht_sha3_512_none.erl b/src/sasl/cyrsasl_ht_sha3_512_none.erl new file mode 100644 index 00000000000..40cd28f9ddd --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha3_512_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha3_512_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-3-512-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/src/sasl/cyrsasl_ht_sha512_none.erl b/src/sasl/cyrsasl_ht_sha512_none.erl new file mode 100644 index 00000000000..a2aaeb436f0 --- /dev/null +++ b/src/sasl/cyrsasl_ht_sha512_none.erl @@ -0,0 +1,21 @@ +-module(cyrsasl_ht_sha512_none). +-behaviour(cyrsasl). + +-export([mechanism/0, mech_new/3, mech_step/2]). +-ignore_xref([mech_new/3]). + +-spec mechanism() -> cyrsasl:mechanism(). +mechanism() -> + <<"HT-SHA-512-NONE">>. + +-spec mech_new(Host :: jid:server(), + Creds :: mongoose_credentials:t(), + SocketData :: term()) -> {ok, tuple()} | {error, binary()}. +mech_new(Host, Creds, SocketData) -> + mod_fast_auth_token_generic_mech:mech_new(Host, Creds, SocketData, mechanism()). + +-spec mech_step(State :: tuple(), + ClientIn :: binary()) -> {ok, mongoose_credentials:t()} + | {error, binary()}. +mech_step(State, SerializedToken) -> + mod_fast_auth_token_generic_mech:mech_step(State, SerializedToken). diff --git a/test/common/config_parser_helper.erl b/test/common/config_parser_helper.erl index 9d78f925b0f..4a37fda9d6e 100644 --- a/test/common/config_parser_helper.erl +++ b/test/common/config_parser_helper.erl @@ -867,6 +867,10 @@ default_mod_config(mod_auth_token) -> #{backend => rdbms, iqdisc => no_queue, validity_period => #{access => #{unit => hours, value => 1}, refresh => #{unit => days, value => 25}}}; +default_mod_config(mod_fast_auth_token) -> + #{backend => rdbms, + validity_period => #{access => #{unit => days, value => 3}, + rotate_before_expire => #{unit => hours, value => 6}}}; default_mod_config(mod_bind2) -> #{}; default_mod_config(mod_blocking) -> diff --git a/test/config_parser_SUITE.erl b/test/config_parser_SUITE.erl index 90c6ce5eaa2..577348b7847 100644 --- a/test/config_parser_SUITE.erl +++ b/test/config_parser_SUITE.erl @@ -173,6 +173,7 @@ groups() -> s2s_max_retry_delay]}, {modules, [parallel], [mod_adhoc, mod_auth_token, + mod_fast_auth_token, mod_blocking, mod_bosh, mod_caps, @@ -1493,6 +1494,27 @@ mod_auth_token(_Config) -> ?errh(T(<<"validity_period">>, #{<<"access">> => #{<<"value">> => 10}})), ?errh(T(<<"validity_period">>, #{<<"access">> => #{<<"unit">> => <<"days">>}})). +mod_fast_auth_token(_Config) -> + check_module_defaults(mod_fast_auth_token), + P = [modules, mod_fast_auth_token], + T = fun(K, V) -> #{<<"modules">> => #{<<"mod_fast_auth_token">> => #{K => V}}} end, + ?cfgh(P ++ [backend], rdbms, T(<<"backend">>, <<"rdbms">>)), + ?cfgh(P ++ [validity_period, access], #{unit => minutes, value => 13}, + T(<<"validity_period">>, + #{<<"access">> => #{<<"value">> => 13, <<"unit">> => <<"minutes">>}})), + + ?cfgh(P ++ [validity_period, rotate_before_expire], #{unit => days, value => 31}, + T(<<"validity_period">>, + #{<<"rotate_before_expire">> => #{<<"value">> => 31, <<"unit">> => <<"days">>}})), + + ?errh(T(<<"backend">>, <<"nosql">>)), + ?errh(T(<<"validity_period">>, + #{<<"access">> => #{<<"value">> => -1, <<"unit">> => <<"minutes">>}})), + ?errh(T(<<"validity_period">>, + #{<<"access">> => #{<<"value">> => 10, <<"unit">> => <<"centuries">>}})), + ?errh(T(<<"validity_period">>, #{<<"access">> => #{<<"value">> => 10}})), + ?errh(T(<<"validity_period">>, #{<<"access">> => #{<<"unit">> => <<"days">>}})). + mod_blocking(_Config) -> test_privacy_opts(mod_blocking). @@ -3096,8 +3118,22 @@ check_iqdisc(ParentP, ParentT) when is_function(ParentT, 1) -> check_module_defaults(Mod) -> ExpectedCfg = default_mod_config(Mod), + case maps:size(ExpectedCfg) of + 0 -> + ok; + _ -> + assert_configurable_module(mod_fast_auth_token) + end, ?cfgh([modules, Mod], ExpectedCfg, #{<<"modules">> => #{atom_to_binary(Mod) => #{}}}). +assert_configurable_module(Module) -> + case lists:member(Module, mongoose_config_spec:configurable_modules()) of + true -> ok; + false -> + ct:fail({assert_configurable_module, Module, + "Don't forget to add module into mongoose_config_spec:configurable_modules/1"}) + end. + %% helpers for 'listen' tests listener(Type, Opts) ->