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