1: %%==============================================================================
    2: %% Copyright 2015 Erlang Solutions Ltd.
    3: %%
    4: %% Licensed under the Apache License, Version 2.0 (the "License");
    5: %% you may not use this file except in compliance with the License.
    6: %% You may obtain a copy of the License at
    7: %%
    8: %% http://www.apache.org/licenses/LICENSE-2.0
    9: %%
   10: %% Unless required by applicable law or agreed to in writing, software
   11: %% distributed under the License is distributed on an "AS IS" BASIS,
   12: %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   13: %% See the License for the specific language governing permissions and
   14: %% limitations under the License.
   15: %%==============================================================================
   16: 
   17: -module(oauth_SUITE).
   18: -compile([export_all, nowarn_export_all]).
   19: 
   20: -include_lib("escalus/include/escalus.hrl").
   21: -include_lib("escalus/include/escalus_xmlns.hrl").
   22: -include_lib("common_test/include/ct.hrl").
   23: -include_lib("exml/include/exml.hrl").
   24: 
   25: -import(distributed_helper, [mim/0,
   26:                              require_rpc_nodes/1,
   27:                              rpc/4]).
   28: 
   29: -import(domain_helper, [domain/0]).
   30: 
   31: %%--------------------------------------------------------------------
   32: %% Suite configuration
   33: %%--------------------------------------------------------------------
   34: 
   35: all() ->
   36:     [
   37:      {group, token_login},
   38:      {group, token_revocation},
   39:      {group, provision_token},
   40:      {group, cleanup},
   41:      {group, sasl_mechanisms}
   42:     ].
   43: 
   44: groups() ->
   45:     G = [
   46:          {token_login, [sequence], token_login_tests()},
   47:          {token_revocation, [sequence], token_revocation_tests()},
   48:          {provision_token, [], [provision_token_login]},
   49:          {cleanup, [], [token_removed_on_user_removal]},
   50:          {sasl_mechanisms, [], [check_for_oauth_with_mod_auth_token_not_loaded,
   51:                                 check_for_oauth_with_mod_auth_token_loaded]}
   52:         ],
   53:     ct_helper:repeat_all_until_all_ok(G).
   54: 
   55: token_login_tests() ->
   56:     [
   57:      disco_test,
   58:      request_tokens_test,
   59:      login_access_token_test,
   60:      login_refresh_token_test,
   61:      login_with_other_users_token,
   62:      login_with_malformed_token
   63:     ].
   64: 
   65: token_revocation_tests() ->
   66:     [
   67:      login_with_revoked_token_test,
   68:      token_revocation_test
   69:     ].
   70: 
   71: suite() ->
   72:     require_rpc_nodes([mim]) ++ escalus:suite().
   73: 
   74: %%--------------------------------------------------------------------
   75: %% Init & teardown
   76: %%--------------------------------------------------------------------
   77: 
   78: init_per_suite(Config0) ->
   79:     case mongoose_helper:is_rdbms_enabled(domain_helper:host_type()) of
   80:         true ->
   81:             HostType = domain_helper:host_type(),
   82:             Config = dynamic_modules:save_modules(HostType, Config0),
   83:             dynamic_modules:ensure_modules(HostType, required_modules()),
   84:             escalus:init_per_suite(Config);
   85:         false ->
   86:             {skip, "RDBMS not available"}
   87:     end.
   88: 
   89: end_per_suite(Config) ->
   90:     dynamic_modules:restore_modules(Config),
   91:     escalus:end_per_suite(Config).
   92: 
   93: init_per_group(GroupName, Config) ->
   94:     AuthOpts = mongoose_helper:auth_opts_with_password_format(password_format(GroupName)),
   95:     HostType = domain_helper:host_type(),
   96:     Config1 = mongoose_helper:backup_and_set_config_option(Config, {auth, HostType}, AuthOpts),
   97:     Config2 = escalus:create_users(Config1, escalus:get_users([bob, alice])),
   98:     assert_password_format(GroupName, Config2).
   99: 
  100: password_format(login_scram) -> scram;
  101: password_format(_) -> plain.
  102: 
  103: end_per_group(cleanup, Config) ->
  104:     mongoose_helper:restore_config(Config),
  105:     escalus:delete_users(Config, escalus:get_users([alice]));
  106: end_per_group(_GroupName, Config) ->
  107:     mongoose_helper:restore_config(Config),
  108:     escalus:delete_users(Config, escalus:get_users([bob, alice])).
  109: 
  110: init_per_testcase(check_for_oauth_with_mod_auth_token_not_loaded, Config) ->
  111:     HostType = domain_helper:host_type(),
  112:     dynamic_modules:stop(HostType, mod_auth_token),
  113:     init_per_testcase(generic, Config);
  114: init_per_testcase(CaseName, Config) ->
  115:     clean_token_db(),
  116:     escalus:init_per_testcase(CaseName, Config).
  117: 
  118: 
  119: end_per_testcase(check_for_oauth_with_mod_auth_token_not_loaded, Config) ->
  120:     HostType = domain_helper:host_type(),
  121:     dynamic_modules:start(HostType, mod_auth_token, auth_token_opts()),
  122:     end_per_testcase(generic, Config);
  123: end_per_testcase(CaseName, Config) ->
  124:     clean_token_db(),
  125:     escalus:end_per_testcase(CaseName, Config).
  126: 
  127: 
  128: %%
  129: %% Tests
  130: %%
  131: 
  132: disco_test(Config) ->
  133:     escalus:story(
  134:       Config, [{alice, 1}],
  135:       fun(Alice) ->
  136:               escalus_client:send(Alice, escalus_stanza:disco_info(domain())),
  137:               Response = escalus_client:wait_for_stanza(Alice),
  138:               escalus:assert(has_feature, [?NS_ESL_TOKEN_AUTH], Response)
  139:       end).
  140: 
  141: request_tokens_test(Config) ->
  142:     request_tokens_once_logged_in_impl(Config, bob).
  143: 
  144: login_with_revoked_token_test(Config) ->
  145:     %% given
  146:     RevokedToken = get_revoked_token(Config, bob),
  147:     token_login_failure(Config, bob, RevokedToken).
  148: 
  149: token_login_failure(Config, User, Token) ->
  150:     %% when
  151:     Result = login_with_token(Config, User, Token),
  152:     % then
  153:     {{auth_failed, _}, _} = Result.
  154: 
  155: get_revoked_token(Config, UserName) ->
  156:     BJID = escalus_users:get_jid(Config, UserName),
  157:     JID = jid:from_binary(BJID),
  158:     HostType = domain_helper:host_type(),
  159:     Token = rpc(mim(), mod_auth_token, token, [HostType, JID, refresh]),
  160:     ValidSeqNo = rpc(mim(), mod_auth_token_rdbms, get_valid_sequence_number, [HostType, JID]),
  161:     RevokedToken0 = record_set(Token, [{5, invalid_sequence_no(ValidSeqNo)},
  162:                                        {7, undefined},
  163:                                        {8, undefined}]),
  164:     RevokedToken = rpc(mim(), mod_auth_token, token_with_mac, [HostType, RevokedToken0]),
  165:     rpc(mim(), mod_auth_token, serialize, [RevokedToken]).
  166: 
  167: invalid_sequence_no(SeqNo) ->
  168:     SeqNo - 1.
  169: 
  170: request_tokens_once_logged_in(Config) ->
  171:     request_tokens_once_logged_in_impl(Config, bob).
  172: 
  173: request_tokens_once_logged_in_impl(Config, User) ->
  174:     Self = self(),
  175:     Ref = make_ref(),
  176:     Fun = fun(Client) ->
  177:               ClientShortJid = escalus_utils:get_short_jid(Client),
  178:               R = escalus_stanza:query_el(?NS_ESL_TOKEN_AUTH, []),
  179:               IQ = escalus_stanza:iq(ClientShortJid, <<"get">>, [R]),
  180:               escalus:send(Client, IQ),
  181:               Result = escalus:wait_for_stanza(Client),
  182:               {AT, RT} = extract_tokens(Result),
  183:               Self ! {tokens, Ref, {AT, RT}}
  184:           end,
  185:     escalus:story(Config, [{User, 1}], Fun),
  186:     receive
  187:         {tokens, Ref, Tokens} ->
  188:             Tokens
  189:     after
  190:         1000 -> error
  191:     end.
  192: 
  193: login_access_token_test(Config) ->
  194:     Tokens = request_tokens_once_logged_in_impl(Config, bob),
  195:     login_access_token_impl(Config, Tokens).
  196: 
  197: login_refresh_token_test(Config) ->
  198:     Tokens = request_tokens_once_logged_in_impl(Config, bob),
  199:     login_refresh_token_impl(Config, Tokens).
  200: 
  201: %% Scenario describing JID spoofing with an eavesdropped / stolen token.
  202: login_with_other_users_token(Config) ->
  203:     %% given user and another user's token
  204:     {_, BobsToken} = request_tokens_once_logged_in_impl(Config, bob),
  205:     AliceSpec = user_authenticating_with_token(Config, alice, BobsToken),
  206:     %% when we try to log in
  207:     ConnSteps = [start_stream,
  208:                  stream_features,
  209:                  maybe_use_ssl,
  210:                  authenticate,
  211:                  fun (Alice = #client{props = Props}, Features) ->
  212:                          escalus:send(Alice, escalus_stanza:bind(<<"test-resource">>)),
  213:                          BindReply = escalus_connection:get_stanza(Alice, bind_reply),
  214:                          {Alice#client{props = [{bind_reply, BindReply} | Props]}, Features}
  215:                  end],
  216:     {ok, #client{props = Props}, _} = escalus_connection:start(AliceSpec, ConnSteps),
  217:     %% then the server recognizes us as the other user
  218:     LoggedInAs = extract_bound_jid(proplists:get_value(bind_reply, Props)),
  219:     true = escalus_utils:get_username(LoggedInAs) /= escalus_users:get_username(Config, AliceSpec).
  220: 
  221: login_with_malformed_token(Config) ->
  222:     %% given
  223:     MalformedToken = <<"malformed ", (crypto:strong_rand_bytes(64))/bytes>>,
  224:     %% when / then
  225:     token_login_failure(Config, bob, MalformedToken).
  226: 
  227: login_refresh_token_impl(Config, {_AccessToken, RefreshToken}) ->
  228:     BobSpec = escalus_users:get_userspec(Config, bob),
  229: 
  230:     ConnSteps = [start_stream,
  231:                  stream_features,
  232:                  maybe_use_ssl,
  233:                  maybe_use_compression
  234:                 ],
  235: 
  236:     {ok, ClientConnection = #client{props = Props}, _Features} = escalus_connection:start(BobSpec, ConnSteps),
  237:     Props2 = lists:keystore(oauth_token, 1, Props, {oauth_token, RefreshToken}),
  238:     (catch escalus_auth:auth_sasl_oauth(ClientConnection, Props2)),
  239:     ok.
  240: 
  241: %% users logs in using access token he obtained in previous session (stream has been
  242: %% already reset)
  243: login_access_token_impl(Config, {AccessToken, _RefreshToken}) ->
  244:     {{ok, Props}, ClientConnection} = login_with_token(Config, bob, AccessToken),
  245:     escalus_connection:reset_parser(ClientConnection),
  246:     ClientConn1 = escalus_session:start_stream(ClientConnection#client{props = Props}),
  247:     {ClientConn1, _} = escalus_session:stream_features(ClientConn1, []),
  248:     %todo: create step out of above lines
  249:     ClientConn2 = escalus_session:bind(ClientConn1),
  250:     ClientConn3 = escalus_session:session(ClientConn2),
  251:     escalus:send(ClientConn3, escalus_stanza:presence(<<"available">>)),
  252:     escalus:assert(is_presence, escalus:wait_for_stanza(ClientConn3)).
  253: 
  254: login_with_token(Config, User, Token) ->
  255:     UserSpec = escalus_users:get_userspec(Config, User),
  256:     ConnSteps = [start_stream,
  257:                  stream_features,
  258:                  maybe_use_ssl,
  259:                  maybe_use_compression],
  260:     {ok, ClientConnection = #client{props = Props}, _Features} = escalus_connection:start(UserSpec, ConnSteps),
  261:     Props2 = lists:keystore(oauth_token, 1, Props, {oauth_token, Token}),
  262:     AuthResult = (catch escalus_auth:auth_sasl_oauth(ClientConnection, Props2)),
  263:     {AuthResult, ClientConnection}.
  264: 
  265: token_revocation_test(Config) ->
  266:     %% given
  267:     {Owner, _SeqNoToRevoke, Token} = get_owner_seqno_to_revoke(Config, bob),
  268:     %% when
  269:     ok = revoke_token(Owner),
  270:     %% then
  271:     token_login_failure(Config, bob, Token).
  272: 
  273: get_owner_seqno_to_revoke(Config, User) ->
  274:     {_, RefreshToken} = request_tokens_once_logged_in_impl(Config, User),
  275:     [_, BOwner, _, SeqNo, _] = binary:split(RefreshToken, <<0>>, [global]),
  276:     Owner = jid:from_binary(BOwner),
  277:     {Owner, binary_to_integer(SeqNo), RefreshToken}.
  278: 
  279: revoke_token(Owner) ->
  280:     rpc(mim(), mod_auth_token, revoke, [domain_helper:host_type(), Owner]).
  281: 
  282: token_removed_on_user_removal(Config) ->
  283:     %% given existing user with token and XMPP (de)registration available
  284:     _Tokens = request_tokens_once_logged_in_impl(Config, bob),
  285:     true = is_xmpp_registration_available(domain_helper:host_type()),
  286:     %% when user account is deleted
  287:     S = fun (Bob) ->
  288:                 IQ = escalus_stanza:remove_account(),
  289:                 escalus:send(Bob, IQ),
  290:                 escalus:assert(is_iq_result, [IQ], escalus:wait_for_stanza(Bob))
  291:         end,
  292:     escalus:story(Config, [{bob, 1}], S),
  293:     %% then token database doesn't contain user's tokens (cleanup is done after IQ result)
  294:     mongoose_helper:wait_until(fun() -> get_users_token(Config, bob) end, {selected, []}).
  295: 
  296: provision_token_login(Config) ->
  297:     %% given
  298:     VCard = make_vcard(Config, bob),
  299:     ProvisionToken = make_provision_token(Config, bob, VCard),
  300:     UserSpec = user_authenticating_with_token(Config, bob, ProvisionToken),
  301:     %% when logging in with provision token
  302:     {ok, Conn, _} = escalus_connection:start(UserSpec),
  303:     escalus:send(Conn, escalus_stanza:vcard_request()),
  304:     %% then user's vcard is placed into the database on login
  305:     Result = escalus:wait_for_stanza(Conn),
  306:     VCard = exml_query:subelement(Result, <<"vCard">>).
  307: 
  308: 
  309: check_for_oauth_with_mod_auth_token_not_loaded(Config) ->
  310:     AliceSpec = escalus_users:get_userspec(Config, alice),
  311:     ConnSteps = [start_stream,
  312:                  stream_features,
  313:                  maybe_use_ssl,
  314:                  maybe_use_compression],
  315:     {ok, _, Features} = escalus_connection:start(AliceSpec, ConnSteps),
  316:     false = lists:member(<<"X-OAUTH">>, proplists:get_value(sasl_mechanisms,
  317:                                                            Features, [])).
  318: 
  319: check_for_oauth_with_mod_auth_token_loaded(Config) ->
  320:     AliceSpec = escalus_users:get_userspec(Config, alice),
  321:     ConnSteps = [start_stream,
  322:                  stream_features,
  323:                  maybe_use_ssl,
  324:                  maybe_use_compression],
  325:     {ok, _, Features} = escalus_connection:start(AliceSpec, ConnSteps),
  326:     true = lists:member(<<"X-OAUTH">>, proplists:get_value(sasl_mechanisms,
  327:                                                            Features, [])).
  328: 
  329: 
  330: %%
  331: %% Helpers
  332: %%
  333: 
  334: extract_tokens(#xmlel{name = <<"iq">>, children = [#xmlel{name = <<"items">>} = Items ]}) ->
  335:     ATD = exml_query:path(Items, [{element, <<"access_token">>}, cdata]),
  336:     RTD = exml_query:path(Items, [{element, <<"refresh_token">>}, cdata]),
  337:     {base64:decode(ATD), base64:decode(RTD)}.
  338: 
  339: assert_password_format(GroupName, Config) ->
  340:     Users = proplists:get_value(escalus_users, Config),
  341:     [verify_format(GroupName, User) || User <- Users],
  342:     Config.
  343: 
  344: verify_format(GroupName, {_User, Props}) ->
  345:     Username = escalus_utils:jid_to_lower(proplists:get_value(username, Props)),
  346:     Server = proplists:get_value(server, Props),
  347:     Password = proplists:get_value(password, Props),
  348:     JID = mongoose_helper:make_jid(Username, Server),
  349:     {SPassword, _} = rpc(mim(), ejabberd_auth, get_passterm_with_authmodule,
  350:                          [domain_helper:host_type(), JID]),
  351:     do_verify_format(GroupName, Password, SPassword).
  352: 
  353: do_verify_format(login_scram, _Password, SPassword) ->
  354:     %% returned password is a tuple containing scram data
  355:     {_, _, _, _} = SPassword;
  356: do_verify_format(_, Password, SPassword) ->
  357:     Password = SPassword.
  358: 
  359: %% @doc Set Fields of the Record to Values,
  360: %% when {Field, Value} <- FieldValues (in list comprehension syntax).
  361: record_set(Record, FieldValues) ->
  362:     F = fun({Field, Value}, Rec) ->
  363:                 setelement(Field, Rec, Value)
  364:         end,
  365:     lists:foldl(F, Record, FieldValues).
  366: 
  367: mimctl(Config, CmdAndArgs) ->
  368:     Node = ct:get_config({hosts, mim, node}),
  369:     ejabberd_node_utils:call_ctl_with_args(Node, convert_args(CmdAndArgs), Config).
  370: 
  371: convert_args(Args) -> [ convert_arg(A) || A <- Args ].
  372: 
  373: convert_arg(B) when is_binary(B) -> binary_to_list(B);
  374: convert_arg(A) when is_atom(A) -> atom_to_list(A);
  375: convert_arg(S) when is_list(S) -> S.
  376: 
  377: clean_token_db() ->
  378:     Q = [<<"DELETE FROM auth_token">>],
  379:     {updated, _} = rpc(mim(), mongoose_rdbms, sql_query, [domain_helper:host_type(), Q]).
  380: 
  381: get_users_token(C, User) ->
  382:     Q = ["SELECT * FROM auth_token at "
  383:          "WHERE at.owner = '", to_lower(escalus_users:get_jid(C, User)), "';"],
  384:     rpc(mim(), mongoose_rdbms, sql_query, [escalus_users:get_server(C, User), Q]).
  385: 
  386: is_xmpp_registration_available(Domain) ->
  387:     rpc(mim(), gen_mod, is_loaded, [Domain, mod_register]).
  388: 
  389: user_authenticating_with_token(Config, UserName, Token) ->
  390:     Spec1 = lists:keystore(oauth_token, 1, escalus_users:get_userspec(Config, UserName),
  391:                            {oauth_token, Token}),
  392:     lists:keystore(auth, 1, Spec1, {auth, {escalus_auth, auth_sasl_oauth}}).
  393: 
  394: extract_bound_jid(BindReply) ->
  395:     exml_query:path(BindReply, [{element, <<"bind">>}, {element, <<"jid">>},
  396:                                 cdata]).
  397: 
  398: get_provision_key(Domain) ->
  399:     RPCArgs = [Domain, provision_pre_shared],
  400:     [{_, RawKey}] = rpc(mim(), mongoose_hooks, get_key, RPCArgs),
  401:     RawKey.
  402: 
  403: make_vcard(Config, User) ->
  404:     T = <<"<vCard xmlns='vcard-temp'>"
  405:           "<FN>Full Name</FN>"
  406:           "<NICKNAME>{{nick}}</NICKNAME>"
  407:           "</vCard>">>,
  408:     escalus_stanza:from_template(T, [{nick, escalus_users:get_username(Config, User)}]).
  409: 
  410: make_provision_token(Config, User, VCard) ->
  411:     ExpiryFarInTheFuture = {{2055, 10, 27}, {10, 54, 22}},
  412:     Username = escalus_users:get_username(Config, User),
  413:     Domain = escalus_users:get_server(Config, User),
  414:     ServerSideJID = jid:make(Username, Domain, <<>>),
  415:     T0 = {token, provision,
  416:           ExpiryFarInTheFuture,
  417:           ServerSideJID,
  418:           %% sequence no
  419:           undefined,
  420:           VCard,
  421:           %% MAC
  422:           undefined,
  423:           %% body
  424:           undefined},
  425:     T = rpc(mim(), mod_auth_token, token_with_mac, [domain_helper:host_type(), T0]),
  426:     %% assert no RPC error occured
  427:     {token, provision} = {element(1, T), element(2, T)},
  428:     serialize(T).
  429: 
  430: serialize(ServerSideToken) ->
  431:     Serialized = rpc(mim(), mod_auth_token, serialize, [ServerSideToken]),
  432:     case is_binary(Serialized) of
  433:         true -> Serialized;
  434:         false -> error(Serialized)
  435:     end.
  436: 
  437: to_lower(B) when is_binary(B) ->
  438:     list_to_binary(string:to_lower(binary_to_list(B))).
  439: 
  440: required_modules() ->
  441:     KeyOpts = #{backend => ct_helper:get_internal_database(),
  442:                 keys => #{token_secret => ram,
  443:                          %% This is a hack for tests! As the name implies,
  444:                          %% a pre-shared key should be read from a file stored
  445:                          %% on disk. This way it can be shared with trusted 3rd
  446:                          %% parties who can use it to sign tokens for users
  447:                          %% to authenticate with and MongooseIM to verify.
  448:                          provision_pre_shared => ram}},
  449:     KeyStoreOpts = config_parser_helper:mod_config(mod_keystore, KeyOpts),
  450:     [{mod_last, stopped},
  451:      {mod_keystore, KeyStoreOpts},
  452:      {mod_auth_token, auth_token_opts()}].
  453: 
  454: auth_token_opts() ->
  455:     Defaults = config_parser_helper:default_mod_config(mod_auth_token),
  456:     Defaults#{validity_period => #{access => #{value => 60, unit => minutes},
  457:                                    refresh => #{value => 1, unit => days}}}.