test_auth.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  1. # Copyright 2018 New Vector
  2. # Copyright 2020-2021 The Matrix.org Foundation C.I.C
  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. from http import HTTPStatus
  16. from typing import Any, Dict, List, Optional, Tuple, Union
  17. from twisted.internet.defer import succeed
  18. from twisted.test.proto_helpers import MemoryReactor
  19. from twisted.web.resource import Resource
  20. import synapse.rest.admin
  21. from synapse.api.constants import LoginType
  22. from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
  23. from synapse.rest.client import account, auth, devices, login, logout, register
  24. from synapse.rest.synapse.client import build_synapse_client_resource_tree
  25. from synapse.server import HomeServer
  26. from synapse.storage.database import LoggingTransaction
  27. from synapse.types import JsonDict, UserID
  28. from synapse.util import Clock
  29. from tests import unittest
  30. from tests.handlers.test_oidc import HAS_OIDC
  31. from tests.rest.client.utils import TEST_OIDC_CONFIG
  32. from tests.server import FakeChannel
  33. from tests.unittest import override_config, skip_unless
  34. class DummyRecaptchaChecker(UserInteractiveAuthChecker):
  35. def __init__(self, hs: HomeServer) -> None:
  36. super().__init__(hs)
  37. self.recaptcha_attempts: List[Tuple[dict, str]] = []
  38. def check_auth(self, authdict: dict, clientip: str) -> Any:
  39. self.recaptcha_attempts.append((authdict, clientip))
  40. return succeed(True)
  41. class FallbackAuthTests(unittest.HomeserverTestCase):
  42. servlets = [
  43. auth.register_servlets,
  44. register.register_servlets,
  45. ]
  46. hijack_auth = False
  47. def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
  48. config = self.default_config()
  49. config["enable_registration_captcha"] = True
  50. config["recaptcha_public_key"] = "brokencake"
  51. config["registrations_require_3pid"] = []
  52. hs = self.setup_test_homeserver(config=config)
  53. return hs
  54. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  55. self.recaptcha_checker = DummyRecaptchaChecker(hs)
  56. auth_handler = hs.get_auth_handler()
  57. auth_handler.checkers[LoginType.RECAPTCHA] = self.recaptcha_checker
  58. def register(self, expected_response: int, body: JsonDict) -> FakeChannel:
  59. """Make a register request."""
  60. channel = self.make_request("POST", "register", body)
  61. self.assertEqual(channel.code, expected_response)
  62. return channel
  63. def recaptcha(
  64. self,
  65. session: str,
  66. expected_post_response: int,
  67. post_session: Optional[str] = None,
  68. ) -> None:
  69. """Get and respond to a fallback recaptcha. Returns the second request."""
  70. if post_session is None:
  71. post_session = session
  72. channel = self.make_request(
  73. "GET", "auth/m.login.recaptcha/fallback/web?session=" + session
  74. )
  75. self.assertEqual(channel.code, HTTPStatus.OK)
  76. channel = self.make_request(
  77. "POST",
  78. "auth/m.login.recaptcha/fallback/web?session="
  79. + post_session
  80. + "&g-recaptcha-response=a",
  81. )
  82. self.assertEqual(channel.code, expected_post_response)
  83. # The recaptcha handler is called with the response given
  84. attempts = self.recaptcha_checker.recaptcha_attempts
  85. self.assertEqual(len(attempts), 1)
  86. self.assertEqual(attempts[0][0]["response"], "a")
  87. def test_fallback_captcha(self) -> None:
  88. """Ensure that fallback auth via a captcha works."""
  89. # Returns a 401 as per the spec
  90. channel = self.register(
  91. HTTPStatus.UNAUTHORIZED,
  92. {"username": "user", "type": "m.login.password", "password": "bar"},
  93. )
  94. # Grab the session
  95. session = channel.json_body["session"]
  96. # Assert our configured public key is being given
  97. self.assertEqual(
  98. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  99. )
  100. # Complete the recaptcha step.
  101. self.recaptcha(session, HTTPStatus.OK)
  102. # also complete the dummy auth
  103. self.register(
  104. HTTPStatus.OK, {"auth": {"session": session, "type": "m.login.dummy"}}
  105. )
  106. # Now we should have fulfilled a complete auth flow, including
  107. # the recaptcha fallback step, we can then send a
  108. # request to the register API with the session in the authdict.
  109. channel = self.register(HTTPStatus.OK, {"auth": {"session": session}})
  110. # We're given a registered user.
  111. self.assertEqual(channel.json_body["user_id"], "@user:test")
  112. def test_complete_operation_unknown_session(self) -> None:
  113. """
  114. Attempting to mark an invalid session as complete should error.
  115. """
  116. # Make the initial request to register. (Later on a different password
  117. # will be used.)
  118. # Returns a 401 as per the spec
  119. channel = self.register(
  120. HTTPStatus.UNAUTHORIZED,
  121. {"username": "user", "type": "m.login.password", "password": "bar"},
  122. )
  123. # Grab the session
  124. session = channel.json_body["session"]
  125. # Assert our configured public key is being given
  126. self.assertEqual(
  127. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  128. )
  129. # Attempt to complete the recaptcha step with an unknown session.
  130. # This results in an error.
  131. self.recaptcha(session, 400, session + "unknown")
  132. class UIAuthTests(unittest.HomeserverTestCase):
  133. servlets = [
  134. auth.register_servlets,
  135. devices.register_servlets,
  136. login.register_servlets,
  137. synapse.rest.admin.register_servlets_for_client_rest_resource,
  138. register.register_servlets,
  139. ]
  140. def default_config(self) -> Dict[str, Any]:
  141. config = super().default_config()
  142. # public_baseurl uses an http:// scheme because FakeChannel.isSecure() returns
  143. # False, so synapse will see the requested uri as http://..., so using http in
  144. # the public_baseurl stops Synapse trying to redirect to https.
  145. config["public_baseurl"] = "http://synapse.test"
  146. if HAS_OIDC:
  147. # we enable OIDC as a way of testing SSO flows
  148. oidc_config = {}
  149. oidc_config.update(TEST_OIDC_CONFIG)
  150. oidc_config["allow_existing_users"] = True
  151. config["oidc_config"] = oidc_config
  152. return config
  153. def create_resource_dict(self) -> Dict[str, Resource]:
  154. resource_dict = super().create_resource_dict()
  155. resource_dict.update(build_synapse_client_resource_tree(self.hs))
  156. return resource_dict
  157. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  158. self.user_pass = "pass"
  159. self.user = self.register_user("test", self.user_pass)
  160. self.device_id = "dev1"
  161. self.user_tok = self.login("test", self.user_pass, self.device_id)
  162. def delete_device(
  163. self,
  164. access_token: str,
  165. device: str,
  166. expected_response: int,
  167. body: Union[bytes, JsonDict] = b"",
  168. ) -> FakeChannel:
  169. """Delete an individual device."""
  170. channel = self.make_request(
  171. "DELETE",
  172. "devices/" + device,
  173. body,
  174. access_token=access_token,
  175. )
  176. # Ensure the response is sane.
  177. self.assertEqual(channel.code, expected_response)
  178. return channel
  179. def delete_devices(self, expected_response: int, body: JsonDict) -> FakeChannel:
  180. """Delete 1 or more devices."""
  181. # Note that this uses the delete_devices endpoint so that we can modify
  182. # the payload half-way through some tests.
  183. channel = self.make_request(
  184. "POST",
  185. "delete_devices",
  186. body,
  187. access_token=self.user_tok,
  188. )
  189. # Ensure the response is sane.
  190. self.assertEqual(channel.code, expected_response)
  191. return channel
  192. def test_ui_auth(self) -> None:
  193. """
  194. Test user interactive authentication outside of registration.
  195. """
  196. # Attempt to delete this device.
  197. # Returns a 401 as per the spec
  198. channel = self.delete_device(
  199. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  200. )
  201. # Grab the session
  202. session = channel.json_body["session"]
  203. # Ensure that flows are what is expected.
  204. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  205. # Make another request providing the UI auth flow.
  206. self.delete_device(
  207. self.user_tok,
  208. self.device_id,
  209. HTTPStatus.OK,
  210. {
  211. "auth": {
  212. "type": "m.login.password",
  213. "identifier": {"type": "m.id.user", "user": self.user},
  214. "password": self.user_pass,
  215. "session": session,
  216. },
  217. },
  218. )
  219. def test_grandfathered_identifier(self) -> None:
  220. """Check behaviour without "identifier" dict
  221. Synapse used to require clients to submit a "user" field for m.login.password
  222. UIA - check that still works.
  223. """
  224. channel = self.delete_device(
  225. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  226. )
  227. session = channel.json_body["session"]
  228. # Make another request providing the UI auth flow.
  229. self.delete_device(
  230. self.user_tok,
  231. self.device_id,
  232. HTTPStatus.OK,
  233. {
  234. "auth": {
  235. "type": "m.login.password",
  236. "user": self.user,
  237. "password": self.user_pass,
  238. "session": session,
  239. },
  240. },
  241. )
  242. def test_can_change_body(self) -> None:
  243. """
  244. The client dict can be modified during the user interactive authentication session.
  245. Note that it is not spec compliant to modify the client dict during a
  246. user interactive authentication session, but many clients currently do.
  247. When Synapse is updated to be spec compliant, the call to re-use the
  248. session ID should be rejected.
  249. """
  250. # Create a second login.
  251. self.login("test", self.user_pass, "dev2")
  252. # Attempt to delete the first device.
  253. # Returns a 401 as per the spec
  254. channel = self.delete_devices(
  255. HTTPStatus.UNAUTHORIZED, {"devices": [self.device_id]}
  256. )
  257. # Grab the session
  258. session = channel.json_body["session"]
  259. # Ensure that flows are what is expected.
  260. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  261. # Make another request providing the UI auth flow, but try to delete the
  262. # second device.
  263. self.delete_devices(
  264. HTTPStatus.OK,
  265. {
  266. "devices": ["dev2"],
  267. "auth": {
  268. "type": "m.login.password",
  269. "identifier": {"type": "m.id.user", "user": self.user},
  270. "password": self.user_pass,
  271. "session": session,
  272. },
  273. },
  274. )
  275. def test_cannot_change_uri(self) -> None:
  276. """
  277. The initial requested URI cannot be modified during the user interactive authentication session.
  278. """
  279. # Create a second login.
  280. self.login("test", self.user_pass, "dev2")
  281. # Attempt to delete the first device.
  282. # Returns a 401 as per the spec
  283. channel = self.delete_device(
  284. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  285. )
  286. # Grab the session
  287. session = channel.json_body["session"]
  288. # Ensure that flows are what is expected.
  289. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  290. # Make another request providing the UI auth flow, but try to delete the
  291. # second device. This results in an error.
  292. #
  293. # This makes use of the fact that the device ID is embedded into the URL.
  294. self.delete_device(
  295. self.user_tok,
  296. "dev2",
  297. HTTPStatus.FORBIDDEN,
  298. {
  299. "auth": {
  300. "type": "m.login.password",
  301. "identifier": {"type": "m.id.user", "user": self.user},
  302. "password": self.user_pass,
  303. "session": session,
  304. },
  305. },
  306. )
  307. @unittest.override_config({"ui_auth": {"session_timeout": "5s"}})
  308. def test_can_reuse_session(self) -> None:
  309. """
  310. The session can be reused if configured.
  311. Compare to test_cannot_change_uri.
  312. """
  313. # Create a second and third login.
  314. self.login("test", self.user_pass, "dev2")
  315. self.login("test", self.user_pass, "dev3")
  316. # Attempt to delete a device. This works since the user just logged in.
  317. self.delete_device(self.user_tok, "dev2", HTTPStatus.OK)
  318. # Move the clock forward past the validation timeout.
  319. self.reactor.advance(6)
  320. # Deleting another devices throws the user into UI auth.
  321. channel = self.delete_device(self.user_tok, "dev3", HTTPStatus.UNAUTHORIZED)
  322. # Grab the session
  323. session = channel.json_body["session"]
  324. # Ensure that flows are what is expected.
  325. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  326. # Make another request providing the UI auth flow.
  327. self.delete_device(
  328. self.user_tok,
  329. "dev3",
  330. HTTPStatus.OK,
  331. {
  332. "auth": {
  333. "type": "m.login.password",
  334. "identifier": {"type": "m.id.user", "user": self.user},
  335. "password": self.user_pass,
  336. "session": session,
  337. },
  338. },
  339. )
  340. # Make another request, but try to delete the first device. This works
  341. # due to re-using the previous session.
  342. #
  343. # Note that *no auth* information is provided, not even a session iD!
  344. self.delete_device(self.user_tok, self.device_id, HTTPStatus.OK)
  345. @skip_unless(HAS_OIDC, "requires OIDC")
  346. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  347. def test_ui_auth_via_sso(self) -> None:
  348. """Test a successful UI Auth flow via SSO
  349. This includes:
  350. * hitting the UIA SSO redirect endpoint
  351. * checking it serves a confirmation page which links to the OIDC provider
  352. * calling back to the synapse oidc callback
  353. * checking that the original operation succeeds
  354. """
  355. # log the user in
  356. remote_user_id = UserID.from_string(self.user).localpart
  357. login_resp = self.helper.login_via_oidc(remote_user_id)
  358. self.assertEqual(login_resp["user_id"], self.user)
  359. # initiate a UI Auth process by attempting to delete the device
  360. channel = self.delete_device(
  361. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  362. )
  363. # check that SSO is offered
  364. flows = channel.json_body["flows"]
  365. self.assertIn({"stages": ["m.login.sso"]}, flows)
  366. # run the UIA-via-SSO flow
  367. session_id = channel.json_body["session"]
  368. channel = self.helper.auth_via_oidc(
  369. {"sub": remote_user_id}, ui_auth_session_id=session_id
  370. )
  371. # that should serve a confirmation page
  372. self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
  373. # and now the delete request should succeed.
  374. self.delete_device(
  375. self.user_tok,
  376. self.device_id,
  377. HTTPStatus.OK,
  378. body={"auth": {"session": session_id}},
  379. )
  380. @skip_unless(HAS_OIDC, "requires OIDC")
  381. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  382. def test_does_not_offer_password_for_sso_user(self) -> None:
  383. login_resp = self.helper.login_via_oidc("username")
  384. user_tok = login_resp["access_token"]
  385. device_id = login_resp["device_id"]
  386. # now call the device deletion API: we should get the option to auth with SSO
  387. # and not password.
  388. channel = self.delete_device(user_tok, device_id, HTTPStatus.UNAUTHORIZED)
  389. flows = channel.json_body["flows"]
  390. self.assertEqual(flows, [{"stages": ["m.login.sso"]}])
  391. def test_does_not_offer_sso_for_password_user(self) -> None:
  392. channel = self.delete_device(
  393. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  394. )
  395. flows = channel.json_body["flows"]
  396. self.assertEqual(flows, [{"stages": ["m.login.password"]}])
  397. @skip_unless(HAS_OIDC, "requires OIDC")
  398. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  399. def test_offers_both_flows_for_upgraded_user(self) -> None:
  400. """A user that had a password and then logged in with SSO should get both flows"""
  401. login_resp = self.helper.login_via_oidc(UserID.from_string(self.user).localpart)
  402. self.assertEqual(login_resp["user_id"], self.user)
  403. channel = self.delete_device(
  404. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  405. )
  406. flows = channel.json_body["flows"]
  407. # we have no particular expectations of ordering here
  408. self.assertIn({"stages": ["m.login.password"]}, flows)
  409. self.assertIn({"stages": ["m.login.sso"]}, flows)
  410. self.assertEqual(len(flows), 2)
  411. @skip_unless(HAS_OIDC, "requires OIDC")
  412. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  413. def test_ui_auth_fails_for_incorrect_sso_user(self) -> None:
  414. """If the user tries to authenticate with the wrong SSO user, they get an error"""
  415. # log the user in
  416. login_resp = self.helper.login_via_oidc(UserID.from_string(self.user).localpart)
  417. self.assertEqual(login_resp["user_id"], self.user)
  418. # start a UI Auth flow by attempting to delete a device
  419. channel = self.delete_device(
  420. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  421. )
  422. flows = channel.json_body["flows"]
  423. self.assertIn({"stages": ["m.login.sso"]}, flows)
  424. session_id = channel.json_body["session"]
  425. # do the OIDC auth, but auth as the wrong user
  426. channel = self.helper.auth_via_oidc(
  427. {"sub": "wrong_user"}, ui_auth_session_id=session_id
  428. )
  429. # that should return a failure message
  430. self.assertSubstring("We were unable to validate", channel.text_body)
  431. # ... and the delete op should now fail with a 403
  432. self.delete_device(
  433. self.user_tok,
  434. self.device_id,
  435. HTTPStatus.FORBIDDEN,
  436. body={"auth": {"session": session_id}},
  437. )
  438. class RefreshAuthTests(unittest.HomeserverTestCase):
  439. servlets = [
  440. auth.register_servlets,
  441. account.register_servlets,
  442. login.register_servlets,
  443. logout.register_servlets,
  444. synapse.rest.admin.register_servlets_for_client_rest_resource,
  445. register.register_servlets,
  446. ]
  447. hijack_auth = False
  448. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  449. self.user_pass = "pass"
  450. self.user = self.register_user("test", self.user_pass)
  451. def use_refresh_token(self, refresh_token: str) -> FakeChannel:
  452. """
  453. Helper that makes a request to use a refresh token.
  454. """
  455. return self.make_request(
  456. "POST",
  457. "/_matrix/client/v1/refresh",
  458. {"refresh_token": refresh_token},
  459. )
  460. def is_access_token_valid(self, access_token: str) -> bool:
  461. """
  462. Checks whether an access token is valid, returning whether it is or not.
  463. """
  464. code = self.make_request(
  465. "GET", "/_matrix/client/v3/account/whoami", access_token=access_token
  466. ).code
  467. # Either 200 or 401 is what we get back; anything else is a bug.
  468. assert code in {HTTPStatus.OK, HTTPStatus.UNAUTHORIZED}
  469. return code == HTTPStatus.OK
  470. def test_login_issue_refresh_token(self) -> None:
  471. """
  472. A login response should include a refresh_token only if asked.
  473. """
  474. # Test login
  475. body = {
  476. "type": "m.login.password",
  477. "user": "test",
  478. "password": self.user_pass,
  479. }
  480. login_without_refresh = self.make_request(
  481. "POST", "/_matrix/client/r0/login", body
  482. )
  483. self.assertEqual(
  484. login_without_refresh.code, HTTPStatus.OK, login_without_refresh.result
  485. )
  486. self.assertNotIn("refresh_token", login_without_refresh.json_body)
  487. login_with_refresh = self.make_request(
  488. "POST",
  489. "/_matrix/client/r0/login",
  490. {"refresh_token": True, **body},
  491. )
  492. self.assertEqual(
  493. login_with_refresh.code, HTTPStatus.OK, login_with_refresh.result
  494. )
  495. self.assertIn("refresh_token", login_with_refresh.json_body)
  496. self.assertIn("expires_in_ms", login_with_refresh.json_body)
  497. def test_register_issue_refresh_token(self) -> None:
  498. """
  499. A register response should include a refresh_token only if asked.
  500. """
  501. register_without_refresh = self.make_request(
  502. "POST",
  503. "/_matrix/client/r0/register",
  504. {
  505. "username": "test2",
  506. "password": self.user_pass,
  507. "auth": {"type": LoginType.DUMMY},
  508. },
  509. )
  510. self.assertEqual(
  511. register_without_refresh.code,
  512. HTTPStatus.OK,
  513. register_without_refresh.result,
  514. )
  515. self.assertNotIn("refresh_token", register_without_refresh.json_body)
  516. register_with_refresh = self.make_request(
  517. "POST",
  518. "/_matrix/client/r0/register",
  519. {
  520. "username": "test3",
  521. "password": self.user_pass,
  522. "auth": {"type": LoginType.DUMMY},
  523. "refresh_token": True,
  524. },
  525. )
  526. self.assertEqual(
  527. register_with_refresh.code, HTTPStatus.OK, register_with_refresh.result
  528. )
  529. self.assertIn("refresh_token", register_with_refresh.json_body)
  530. self.assertIn("expires_in_ms", register_with_refresh.json_body)
  531. def test_token_refresh(self) -> None:
  532. """
  533. A refresh token can be used to issue a new access token.
  534. """
  535. body = {
  536. "type": "m.login.password",
  537. "user": "test",
  538. "password": self.user_pass,
  539. "refresh_token": True,
  540. }
  541. login_response = self.make_request(
  542. "POST",
  543. "/_matrix/client/r0/login",
  544. body,
  545. )
  546. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  547. refresh_response = self.make_request(
  548. "POST",
  549. "/_matrix/client/v1/refresh",
  550. {"refresh_token": login_response.json_body["refresh_token"]},
  551. )
  552. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  553. self.assertIn("access_token", refresh_response.json_body)
  554. self.assertIn("refresh_token", refresh_response.json_body)
  555. self.assertIn("expires_in_ms", refresh_response.json_body)
  556. # The access and refresh tokens should be different from the original ones after refresh
  557. self.assertNotEqual(
  558. login_response.json_body["access_token"],
  559. refresh_response.json_body["access_token"],
  560. )
  561. self.assertNotEqual(
  562. login_response.json_body["refresh_token"],
  563. refresh_response.json_body["refresh_token"],
  564. )
  565. @override_config({"refreshable_access_token_lifetime": "1m"})
  566. def test_refreshable_access_token_expiration(self) -> None:
  567. """
  568. The access token should have some time as specified in the config.
  569. """
  570. body = {
  571. "type": "m.login.password",
  572. "user": "test",
  573. "password": self.user_pass,
  574. "refresh_token": True,
  575. }
  576. login_response = self.make_request(
  577. "POST",
  578. "/_matrix/client/r0/login",
  579. body,
  580. )
  581. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  582. self.assertApproximates(
  583. login_response.json_body["expires_in_ms"], 60 * 1000, 100
  584. )
  585. refresh_response = self.make_request(
  586. "POST",
  587. "/_matrix/client/v1/refresh",
  588. {"refresh_token": login_response.json_body["refresh_token"]},
  589. )
  590. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  591. self.assertApproximates(
  592. refresh_response.json_body["expires_in_ms"], 60 * 1000, 100
  593. )
  594. access_token = refresh_response.json_body["access_token"]
  595. # Advance 59 seconds in the future (just shy of 1 minute, the time of expiry)
  596. self.reactor.advance(59.0)
  597. # Check that our token is valid
  598. self.assertEqual(
  599. self.make_request(
  600. "GET", "/_matrix/client/v3/account/whoami", access_token=access_token
  601. ).code,
  602. HTTPStatus.OK,
  603. )
  604. # Advance 2 more seconds (just past the time of expiry)
  605. self.reactor.advance(2.0)
  606. # Check that our token is invalid
  607. self.assertEqual(
  608. self.make_request(
  609. "GET", "/_matrix/client/v3/account/whoami", access_token=access_token
  610. ).code,
  611. HTTPStatus.UNAUTHORIZED,
  612. )
  613. @override_config(
  614. {
  615. "refreshable_access_token_lifetime": "1m",
  616. "nonrefreshable_access_token_lifetime": "10m",
  617. }
  618. )
  619. def test_different_expiry_for_refreshable_and_nonrefreshable_access_tokens(
  620. self,
  621. ) -> None:
  622. """
  623. Tests that the expiry times for refreshable and non-refreshable access
  624. tokens can be different.
  625. """
  626. body = {
  627. "type": "m.login.password",
  628. "user": "test",
  629. "password": self.user_pass,
  630. }
  631. login_response1 = self.make_request(
  632. "POST",
  633. "/_matrix/client/r0/login",
  634. {"refresh_token": True, **body},
  635. )
  636. self.assertEqual(login_response1.code, HTTPStatus.OK, login_response1.result)
  637. self.assertApproximates(
  638. login_response1.json_body["expires_in_ms"], 60 * 1000, 100
  639. )
  640. refreshable_access_token = login_response1.json_body["access_token"]
  641. login_response2 = self.make_request(
  642. "POST",
  643. "/_matrix/client/r0/login",
  644. body,
  645. )
  646. self.assertEqual(login_response2.code, HTTPStatus.OK, login_response2.result)
  647. nonrefreshable_access_token = login_response2.json_body["access_token"]
  648. # Advance 59 seconds in the future (just shy of 1 minute, the time of expiry)
  649. self.reactor.advance(59.0)
  650. # Both tokens should still be valid.
  651. self.assertTrue(self.is_access_token_valid(refreshable_access_token))
  652. self.assertTrue(self.is_access_token_valid(nonrefreshable_access_token))
  653. # Advance to 61 s (just past 1 minute, the time of expiry)
  654. self.reactor.advance(2.0)
  655. # Only the non-refreshable token is still valid.
  656. self.assertFalse(self.is_access_token_valid(refreshable_access_token))
  657. self.assertTrue(self.is_access_token_valid(nonrefreshable_access_token))
  658. # Advance to 599 s (just shy of 10 minutes, the time of expiry)
  659. self.reactor.advance(599.0 - 61.0)
  660. # It's still the case that only the non-refreshable token is still valid.
  661. self.assertFalse(self.is_access_token_valid(refreshable_access_token))
  662. self.assertTrue(self.is_access_token_valid(nonrefreshable_access_token))
  663. # Advance to 601 s (just past 10 minutes, the time of expiry)
  664. self.reactor.advance(2.0)
  665. # Now neither token is valid.
  666. self.assertFalse(self.is_access_token_valid(refreshable_access_token))
  667. self.assertFalse(self.is_access_token_valid(nonrefreshable_access_token))
  668. @override_config(
  669. {"refreshable_access_token_lifetime": "1m", "refresh_token_lifetime": "2m"}
  670. )
  671. def test_refresh_token_expiry(self) -> None:
  672. """
  673. The refresh token can be configured to have a limited lifetime.
  674. When that lifetime has ended, the refresh token can no longer be used to
  675. refresh the session.
  676. """
  677. body = {
  678. "type": "m.login.password",
  679. "user": "test",
  680. "password": self.user_pass,
  681. "refresh_token": True,
  682. }
  683. login_response = self.make_request(
  684. "POST",
  685. "/_matrix/client/r0/login",
  686. body,
  687. )
  688. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  689. refresh_token1 = login_response.json_body["refresh_token"]
  690. # Advance 119 seconds in the future (just shy of 2 minutes)
  691. self.reactor.advance(119.0)
  692. # Refresh our session. The refresh token should still JUST be valid right now.
  693. # By doing so, we get a new access token and a new refresh token.
  694. refresh_response = self.use_refresh_token(refresh_token1)
  695. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  696. self.assertIn(
  697. "refresh_token",
  698. refresh_response.json_body,
  699. "No new refresh token returned after refresh.",
  700. )
  701. refresh_token2 = refresh_response.json_body["refresh_token"]
  702. # Advance 121 seconds in the future (just a bit more than 2 minutes)
  703. self.reactor.advance(121.0)
  704. # Try to refresh our session, but instead notice that the refresh token is
  705. # not valid (it just expired).
  706. refresh_response = self.use_refresh_token(refresh_token2)
  707. self.assertEqual(
  708. refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result
  709. )
  710. @override_config(
  711. {
  712. "refreshable_access_token_lifetime": "2m",
  713. "refresh_token_lifetime": "2m",
  714. "session_lifetime": "3m",
  715. }
  716. )
  717. def test_ultimate_session_expiry(self) -> None:
  718. """
  719. The session can be configured to have an ultimate, limited lifetime.
  720. """
  721. body = {
  722. "type": "m.login.password",
  723. "user": "test",
  724. "password": self.user_pass,
  725. "refresh_token": True,
  726. }
  727. login_response = self.make_request(
  728. "POST",
  729. "/_matrix/client/r0/login",
  730. body,
  731. )
  732. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  733. refresh_token = login_response.json_body["refresh_token"]
  734. # Advance shy of 2 minutes into the future
  735. self.reactor.advance(119.0)
  736. # Refresh our session. The refresh token should still be valid right now.
  737. refresh_response = self.use_refresh_token(refresh_token)
  738. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  739. self.assertIn(
  740. "refresh_token",
  741. refresh_response.json_body,
  742. "No new refresh token returned after refresh.",
  743. )
  744. # Notice that our access token lifetime has been diminished to match the
  745. # session lifetime.
  746. # 3 minutes - 119 seconds = 61 seconds.
  747. self.assertEqual(refresh_response.json_body["expires_in_ms"], 61_000)
  748. refresh_token = refresh_response.json_body["refresh_token"]
  749. # Advance 61 seconds into the future. Our session should have expired
  750. # now, because we've had our 3 minutes.
  751. self.reactor.advance(61.0)
  752. # Try to issue a new, refreshed, access token.
  753. # This should fail because the refresh token's lifetime has also been
  754. # diminished as our session expired.
  755. refresh_response = self.use_refresh_token(refresh_token)
  756. self.assertEqual(
  757. refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result
  758. )
  759. def test_refresh_token_invalidation(self) -> None:
  760. """Refresh tokens are invalidated after first use of the next token.
  761. A refresh token is considered invalid if:
  762. - it was already used at least once
  763. - and either
  764. - the next access token was used
  765. - the next refresh token was used
  766. The chain of tokens goes like this:
  767. login -|-> first_refresh -> third_refresh (fails)
  768. |-> second_refresh -> fifth_refresh
  769. |-> fourth_refresh (fails)
  770. """
  771. body = {
  772. "type": "m.login.password",
  773. "user": "test",
  774. "password": self.user_pass,
  775. "refresh_token": True,
  776. }
  777. login_response = self.make_request(
  778. "POST",
  779. "/_matrix/client/r0/login",
  780. body,
  781. )
  782. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  783. # This first refresh should work properly
  784. first_refresh_response = self.make_request(
  785. "POST",
  786. "/_matrix/client/v1/refresh",
  787. {"refresh_token": login_response.json_body["refresh_token"]},
  788. )
  789. self.assertEqual(
  790. first_refresh_response.code, HTTPStatus.OK, first_refresh_response.result
  791. )
  792. # This one as well, since the token in the first one was never used
  793. second_refresh_response = self.make_request(
  794. "POST",
  795. "/_matrix/client/v1/refresh",
  796. {"refresh_token": login_response.json_body["refresh_token"]},
  797. )
  798. self.assertEqual(
  799. second_refresh_response.code, HTTPStatus.OK, second_refresh_response.result
  800. )
  801. # This one should not, since the token from the first refresh is not valid anymore
  802. third_refresh_response = self.make_request(
  803. "POST",
  804. "/_matrix/client/v1/refresh",
  805. {"refresh_token": first_refresh_response.json_body["refresh_token"]},
  806. )
  807. self.assertEqual(
  808. third_refresh_response.code,
  809. HTTPStatus.UNAUTHORIZED,
  810. third_refresh_response.result,
  811. )
  812. # The associated access token should also be invalid
  813. whoami_response = self.make_request(
  814. "GET",
  815. "/_matrix/client/r0/account/whoami",
  816. access_token=first_refresh_response.json_body["access_token"],
  817. )
  818. self.assertEqual(
  819. whoami_response.code, HTTPStatus.UNAUTHORIZED, whoami_response.result
  820. )
  821. # But all other tokens should work (they will expire after some time)
  822. for access_token in [
  823. second_refresh_response.json_body["access_token"],
  824. login_response.json_body["access_token"],
  825. ]:
  826. whoami_response = self.make_request(
  827. "GET", "/_matrix/client/r0/account/whoami", access_token=access_token
  828. )
  829. self.assertEqual(
  830. whoami_response.code, HTTPStatus.OK, whoami_response.result
  831. )
  832. # Now that the access token from the last valid refresh was used once, refreshing with the N-1 token should fail
  833. fourth_refresh_response = self.make_request(
  834. "POST",
  835. "/_matrix/client/v1/refresh",
  836. {"refresh_token": login_response.json_body["refresh_token"]},
  837. )
  838. self.assertEqual(
  839. fourth_refresh_response.code,
  840. HTTPStatus.FORBIDDEN,
  841. fourth_refresh_response.result,
  842. )
  843. # But refreshing from the last valid refresh token still works
  844. fifth_refresh_response = self.make_request(
  845. "POST",
  846. "/_matrix/client/v1/refresh",
  847. {"refresh_token": second_refresh_response.json_body["refresh_token"]},
  848. )
  849. self.assertEqual(
  850. fifth_refresh_response.code, HTTPStatus.OK, fifth_refresh_response.result
  851. )
  852. def test_many_token_refresh(self) -> None:
  853. """
  854. If a refresh is performed many times during a session, there shouldn't be
  855. extra 'cruft' built up over time.
  856. This test was written specifically to troubleshoot a case where logout
  857. was very slow if a lot of refreshes had been performed for the session.
  858. """
  859. def _refresh(refresh_token: str) -> Tuple[str, str]:
  860. """
  861. Performs one refresh, returning the next refresh token and access token.
  862. """
  863. refresh_response = self.use_refresh_token(refresh_token)
  864. self.assertEqual(
  865. refresh_response.code, HTTPStatus.OK, refresh_response.result
  866. )
  867. return (
  868. refresh_response.json_body["refresh_token"],
  869. refresh_response.json_body["access_token"],
  870. )
  871. def _table_length(table_name: str) -> int:
  872. """
  873. Helper to get the size of a table, in rows.
  874. For testing only; trivially vulnerable to SQL injection.
  875. """
  876. def _txn(txn: LoggingTransaction) -> int:
  877. txn.execute(f"SELECT COUNT(1) FROM {table_name}")
  878. row = txn.fetchone()
  879. # Query is infallible
  880. assert row is not None
  881. return row[0]
  882. return self.get_success(
  883. self.hs.get_datastores().main.db_pool.runInteraction(
  884. "_table_length", _txn
  885. )
  886. )
  887. # Before we log in, there are no access tokens.
  888. self.assertEqual(_table_length("access_tokens"), 0)
  889. self.assertEqual(_table_length("refresh_tokens"), 0)
  890. body = {
  891. "type": "m.login.password",
  892. "user": "test",
  893. "password": self.user_pass,
  894. "refresh_token": True,
  895. }
  896. login_response = self.make_request(
  897. "POST",
  898. "/_matrix/client/v3/login",
  899. body,
  900. )
  901. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  902. access_token = login_response.json_body["access_token"]
  903. refresh_token = login_response.json_body["refresh_token"]
  904. # Now that we have logged in, there should be one access token and one
  905. # refresh token
  906. self.assertEqual(_table_length("access_tokens"), 1)
  907. self.assertEqual(_table_length("refresh_tokens"), 1)
  908. for _ in range(5):
  909. refresh_token, access_token = _refresh(refresh_token)
  910. # After 5 sequential refreshes, there should only be the latest two
  911. # refresh/access token pairs.
  912. # (The last one is preserved because it's in use!
  913. # The one before that is preserved because it can still be used to
  914. # replace the last token pair, in case of e.g. a network interruption.)
  915. self.assertEqual(_table_length("access_tokens"), 2)
  916. self.assertEqual(_table_length("refresh_tokens"), 2)
  917. logout_response = self.make_request(
  918. "POST", "/_matrix/client/v3/logout", {}, access_token=access_token
  919. )
  920. self.assertEqual(logout_response.code, HTTPStatus.OK, logout_response.result)
  921. # Now that we have logged in, there should be no access token
  922. # and no refresh token
  923. self.assertEqual(_table_length("access_tokens"), 0)
  924. self.assertEqual(_table_length("refresh_tokens"), 0)