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 = 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 = 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:make(Username, Domain, <<>>), 437: T0 = {token, provision, 438: ExpiryFarInTheFuture, 439: ServerSideJID, 440: %% sequence no 441: undefined, 442: VCard, 443: %% MAC 444: undefined, 445: %% body 446: undefined}, 447: T = rpc(mim(), mod_auth_token, token_with_mac, [domain_helper:host_type(), T0]), 448: %% assert no RPC error occured 449: {token, provision} = {element(1, T), element(2, T)}, 450: serialize(T). 451: 452: serialize(ServerSideToken) -> 453: Serialized = rpc(mim(), mod_auth_token, serialize, [ServerSideToken]), 454: case is_binary(Serialized) of 455: true -> Serialized; 456: false -> error(Serialized) 457: end. 458: 459: to_lower(B) when is_binary(B) -> 460: list_to_binary(string:to_lower(binary_to_list(B))). 461: 462: required_modules() -> 463: KeyOpts = #{keys => #{token_secret => ram, 464: %% This is a hack for tests! As the name implies, 465: %% a pre-shared key should be read from a file stored 466: %% on disk. This way it can be shared with trusted 3rd 467: %% parties who can use it to sign tokens for users 468: %% to authenticate with and MongooseIM to verify. 469: provision_pre_shared => ram}}, 470: KeyStoreOpts = config_parser_helper:mod_config(mod_keystore, KeyOpts), 471: [{mod_last, stopped}, 472: {mod_keystore, KeyStoreOpts}, 473: {mod_auth_token, auth_token_opts()}]. 474: 475: auth_token_opts() -> 476: Defaults = config_parser_helper:default_mod_config(mod_auth_token), 477: Defaults#{validity_period => #{access => #{value => 60, unit => minutes}, 478: refresh => #{value => 1, unit => days}}}.