test_auth.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2018 New Vector
  3. # Copyright 2020-2021 The Matrix.org Foundation C.I.C
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. from typing import Union
  17. from twisted.internet.defer import succeed
  18. import synapse.rest.admin
  19. from synapse.api.constants import LoginType
  20. from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
  21. from synapse.rest.client.v1 import login
  22. from synapse.rest.client.v2_alpha import auth, devices, register
  23. from synapse.rest.synapse.client import build_synapse_client_resource_tree
  24. from synapse.types import JsonDict, UserID
  25. from tests import unittest
  26. from tests.handlers.test_oidc import HAS_OIDC
  27. from tests.rest.client.v1.utils import TEST_OIDC_CONFIG
  28. from tests.server import FakeChannel
  29. from tests.unittest import override_config, skip_unless
  30. class DummyRecaptchaChecker(UserInteractiveAuthChecker):
  31. def __init__(self, hs):
  32. super().__init__(hs)
  33. self.recaptcha_attempts = []
  34. def check_auth(self, authdict, clientip):
  35. self.recaptcha_attempts.append((authdict, clientip))
  36. return succeed(True)
  37. class FallbackAuthTests(unittest.HomeserverTestCase):
  38. servlets = [
  39. auth.register_servlets,
  40. register.register_servlets,
  41. ]
  42. hijack_auth = False
  43. def make_homeserver(self, reactor, clock):
  44. config = self.default_config()
  45. config["enable_registration_captcha"] = True
  46. config["recaptcha_public_key"] = "brokencake"
  47. config["registrations_require_3pid"] = []
  48. hs = self.setup_test_homeserver(config=config)
  49. return hs
  50. def prepare(self, reactor, clock, hs):
  51. self.recaptcha_checker = DummyRecaptchaChecker(hs)
  52. auth_handler = hs.get_auth_handler()
  53. auth_handler.checkers[LoginType.RECAPTCHA] = self.recaptcha_checker
  54. def register(self, expected_response: int, body: JsonDict) -> FakeChannel:
  55. """Make a register request."""
  56. channel = self.make_request("POST", "register", body)
  57. self.assertEqual(channel.code, expected_response)
  58. return channel
  59. def recaptcha(
  60. self, session: str, expected_post_response: int, post_session: str = None
  61. ) -> None:
  62. """Get and respond to a fallback recaptcha. Returns the second request."""
  63. if post_session is None:
  64. post_session = session
  65. channel = self.make_request(
  66. "GET", "auth/m.login.recaptcha/fallback/web?session=" + session
  67. )
  68. self.assertEqual(channel.code, 200)
  69. channel = self.make_request(
  70. "POST",
  71. "auth/m.login.recaptcha/fallback/web?session="
  72. + post_session
  73. + "&g-recaptcha-response=a",
  74. )
  75. self.assertEqual(channel.code, expected_post_response)
  76. # The recaptcha handler is called with the response given
  77. attempts = self.recaptcha_checker.recaptcha_attempts
  78. self.assertEqual(len(attempts), 1)
  79. self.assertEqual(attempts[0][0]["response"], "a")
  80. def test_fallback_captcha(self):
  81. """Ensure that fallback auth via a captcha works."""
  82. # Returns a 401 as per the spec
  83. channel = self.register(
  84. 401, {"username": "user", "type": "m.login.password", "password": "bar"},
  85. )
  86. # Grab the session
  87. session = channel.json_body["session"]
  88. # Assert our configured public key is being given
  89. self.assertEqual(
  90. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  91. )
  92. # Complete the recaptcha step.
  93. self.recaptcha(session, 200)
  94. # also complete the dummy auth
  95. self.register(200, {"auth": {"session": session, "type": "m.login.dummy"}})
  96. # Now we should have fulfilled a complete auth flow, including
  97. # the recaptcha fallback step, we can then send a
  98. # request to the register API with the session in the authdict.
  99. channel = self.register(200, {"auth": {"session": session}})
  100. # We're given a registered user.
  101. self.assertEqual(channel.json_body["user_id"], "@user:test")
  102. def test_complete_operation_unknown_session(self):
  103. """
  104. Attempting to mark an invalid session as complete should error.
  105. """
  106. # Make the initial request to register. (Later on a different password
  107. # will be used.)
  108. # Returns a 401 as per the spec
  109. channel = self.register(
  110. 401, {"username": "user", "type": "m.login.password", "password": "bar"}
  111. )
  112. # Grab the session
  113. session = channel.json_body["session"]
  114. # Assert our configured public key is being given
  115. self.assertEqual(
  116. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  117. )
  118. # Attempt to complete the recaptcha step with an unknown session.
  119. # This results in an error.
  120. self.recaptcha(session, 400, session + "unknown")
  121. class UIAuthTests(unittest.HomeserverTestCase):
  122. servlets = [
  123. auth.register_servlets,
  124. devices.register_servlets,
  125. login.register_servlets,
  126. synapse.rest.admin.register_servlets_for_client_rest_resource,
  127. register.register_servlets,
  128. ]
  129. def default_config(self):
  130. config = super().default_config()
  131. config["public_baseurl"] = "https://synapse.test"
  132. if HAS_OIDC:
  133. # we enable OIDC as a way of testing SSO flows
  134. oidc_config = {}
  135. oidc_config.update(TEST_OIDC_CONFIG)
  136. oidc_config["allow_existing_users"] = True
  137. config["oidc_config"] = oidc_config
  138. return config
  139. def create_resource_dict(self):
  140. resource_dict = super().create_resource_dict()
  141. resource_dict.update(build_synapse_client_resource_tree(self.hs))
  142. return resource_dict
  143. def prepare(self, reactor, clock, hs):
  144. self.user_pass = "pass"
  145. self.user = self.register_user("test", self.user_pass)
  146. self.device_id = "dev1"
  147. self.user_tok = self.login("test", self.user_pass, self.device_id)
  148. def delete_device(
  149. self,
  150. access_token: str,
  151. device: str,
  152. expected_response: int,
  153. body: Union[bytes, JsonDict] = b"",
  154. ) -> FakeChannel:
  155. """Delete an individual device."""
  156. channel = self.make_request(
  157. "DELETE", "devices/" + device, body, access_token=access_token,
  158. )
  159. # Ensure the response is sane.
  160. self.assertEqual(channel.code, expected_response)
  161. return channel
  162. def delete_devices(self, expected_response: int, body: JsonDict) -> FakeChannel:
  163. """Delete 1 or more devices."""
  164. # Note that this uses the delete_devices endpoint so that we can modify
  165. # the payload half-way through some tests.
  166. channel = self.make_request(
  167. "POST", "delete_devices", body, access_token=self.user_tok,
  168. )
  169. # Ensure the response is sane.
  170. self.assertEqual(channel.code, expected_response)
  171. return channel
  172. def test_ui_auth(self):
  173. """
  174. Test user interactive authentication outside of registration.
  175. """
  176. # Attempt to delete this device.
  177. # Returns a 401 as per the spec
  178. channel = self.delete_device(self.user_tok, self.device_id, 401)
  179. # Grab the session
  180. session = channel.json_body["session"]
  181. # Ensure that flows are what is expected.
  182. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  183. # Make another request providing the UI auth flow.
  184. self.delete_device(
  185. self.user_tok,
  186. self.device_id,
  187. 200,
  188. {
  189. "auth": {
  190. "type": "m.login.password",
  191. "identifier": {"type": "m.id.user", "user": self.user},
  192. "password": self.user_pass,
  193. "session": session,
  194. },
  195. },
  196. )
  197. def test_grandfathered_identifier(self):
  198. """Check behaviour without "identifier" dict
  199. Synapse used to require clients to submit a "user" field for m.login.password
  200. UIA - check that still works.
  201. """
  202. channel = self.delete_device(self.user_tok, self.device_id, 401)
  203. session = channel.json_body["session"]
  204. # Make another request providing the UI auth flow.
  205. self.delete_device(
  206. self.user_tok,
  207. self.device_id,
  208. 200,
  209. {
  210. "auth": {
  211. "type": "m.login.password",
  212. "user": self.user,
  213. "password": self.user_pass,
  214. "session": session,
  215. },
  216. },
  217. )
  218. def test_can_change_body(self):
  219. """
  220. The client dict can be modified during the user interactive authentication session.
  221. Note that it is not spec compliant to modify the client dict during a
  222. user interactive authentication session, but many clients currently do.
  223. When Synapse is updated to be spec compliant, the call to re-use the
  224. session ID should be rejected.
  225. """
  226. # Create a second login.
  227. self.login("test", self.user_pass, "dev2")
  228. # Attempt to delete the first device.
  229. # Returns a 401 as per the spec
  230. channel = self.delete_devices(401, {"devices": [self.device_id]})
  231. # Grab the session
  232. session = channel.json_body["session"]
  233. # Ensure that flows are what is expected.
  234. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  235. # Make another request providing the UI auth flow, but try to delete the
  236. # second device.
  237. self.delete_devices(
  238. 200,
  239. {
  240. "devices": ["dev2"],
  241. "auth": {
  242. "type": "m.login.password",
  243. "identifier": {"type": "m.id.user", "user": self.user},
  244. "password": self.user_pass,
  245. "session": session,
  246. },
  247. },
  248. )
  249. def test_cannot_change_uri(self):
  250. """
  251. The initial requested URI cannot be modified during the user interactive authentication session.
  252. """
  253. # Create a second login.
  254. self.login("test", self.user_pass, "dev2")
  255. # Attempt to delete the first device.
  256. # Returns a 401 as per the spec
  257. channel = self.delete_device(self.user_tok, self.device_id, 401)
  258. # Grab the session
  259. session = channel.json_body["session"]
  260. # Ensure that flows are what is expected.
  261. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  262. # Make another request providing the UI auth flow, but try to delete the
  263. # second device. This results in an error.
  264. #
  265. # This makes use of the fact that the device ID is embedded into the URL.
  266. self.delete_device(
  267. self.user_tok,
  268. "dev2",
  269. 403,
  270. {
  271. "auth": {
  272. "type": "m.login.password",
  273. "identifier": {"type": "m.id.user", "user": self.user},
  274. "password": self.user_pass,
  275. "session": session,
  276. },
  277. },
  278. )
  279. @unittest.override_config({"ui_auth": {"session_timeout": 5 * 1000}})
  280. def test_can_reuse_session(self):
  281. """
  282. The session can be reused if configured.
  283. Compare to test_cannot_change_uri.
  284. """
  285. # Create a second and third login.
  286. self.login("test", self.user_pass, "dev2")
  287. self.login("test", self.user_pass, "dev3")
  288. # Attempt to delete a device. This works since the user just logged in.
  289. self.delete_device(self.user_tok, "dev2", 200)
  290. # Move the clock forward past the validation timeout.
  291. self.reactor.advance(6)
  292. # Deleting another devices throws the user into UI auth.
  293. channel = self.delete_device(self.user_tok, "dev3", 401)
  294. # Grab the session
  295. session = channel.json_body["session"]
  296. # Ensure that flows are what is expected.
  297. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  298. # Make another request providing the UI auth flow.
  299. self.delete_device(
  300. self.user_tok,
  301. "dev3",
  302. 200,
  303. {
  304. "auth": {
  305. "type": "m.login.password",
  306. "identifier": {"type": "m.id.user", "user": self.user},
  307. "password": self.user_pass,
  308. "session": session,
  309. },
  310. },
  311. )
  312. # Make another request, but try to delete the first device. This works
  313. # due to re-using the previous session.
  314. #
  315. # Note that *no auth* information is provided, not even a session iD!
  316. self.delete_device(self.user_tok, self.device_id, 200)
  317. @skip_unless(HAS_OIDC, "requires OIDC")
  318. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  319. def test_ui_auth_via_sso(self):
  320. """Test a successful UI Auth flow via SSO
  321. This includes:
  322. * hitting the UIA SSO redirect endpoint
  323. * checking it serves a confirmation page which links to the OIDC provider
  324. * calling back to the synapse oidc callback
  325. * checking that the original operation succeeds
  326. """
  327. # log the user in
  328. remote_user_id = UserID.from_string(self.user).localpart
  329. login_resp = self.helper.login_via_oidc(remote_user_id)
  330. self.assertEqual(login_resp["user_id"], self.user)
  331. # initiate a UI Auth process by attempting to delete the device
  332. channel = self.delete_device(self.user_tok, self.device_id, 401)
  333. # check that SSO is offered
  334. flows = channel.json_body["flows"]
  335. self.assertIn({"stages": ["m.login.sso"]}, flows)
  336. # run the UIA-via-SSO flow
  337. session_id = channel.json_body["session"]
  338. channel = self.helper.auth_via_oidc(
  339. {"sub": remote_user_id}, ui_auth_session_id=session_id
  340. )
  341. # that should serve a confirmation page
  342. self.assertEqual(channel.code, 200, channel.result)
  343. # and now the delete request should succeed.
  344. self.delete_device(
  345. self.user_tok, self.device_id, 200, body={"auth": {"session": session_id}},
  346. )
  347. @skip_unless(HAS_OIDC, "requires OIDC")
  348. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  349. def test_does_not_offer_password_for_sso_user(self):
  350. login_resp = self.helper.login_via_oidc("username")
  351. user_tok = login_resp["access_token"]
  352. device_id = login_resp["device_id"]
  353. # now call the device deletion API: we should get the option to auth with SSO
  354. # and not password.
  355. channel = self.delete_device(user_tok, device_id, 401)
  356. flows = channel.json_body["flows"]
  357. self.assertEqual(flows, [{"stages": ["m.login.sso"]}])
  358. def test_does_not_offer_sso_for_password_user(self):
  359. channel = self.delete_device(self.user_tok, self.device_id, 401)
  360. flows = channel.json_body["flows"]
  361. self.assertEqual(flows, [{"stages": ["m.login.password"]}])
  362. @skip_unless(HAS_OIDC, "requires OIDC")
  363. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  364. def test_offers_both_flows_for_upgraded_user(self):
  365. """A user that had a password and then logged in with SSO should get both flows
  366. """
  367. login_resp = self.helper.login_via_oidc(UserID.from_string(self.user).localpart)
  368. self.assertEqual(login_resp["user_id"], self.user)
  369. channel = self.delete_device(self.user_tok, self.device_id, 401)
  370. flows = channel.json_body["flows"]
  371. # we have no particular expectations of ordering here
  372. self.assertIn({"stages": ["m.login.password"]}, flows)
  373. self.assertIn({"stages": ["m.login.sso"]}, flows)
  374. self.assertEqual(len(flows), 2)
  375. @skip_unless(HAS_OIDC, "requires OIDC")
  376. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  377. def test_ui_auth_fails_for_incorrect_sso_user(self):
  378. """If the user tries to authenticate with the wrong SSO user, they get an error
  379. """
  380. # log the user in
  381. login_resp = self.helper.login_via_oidc(UserID.from_string(self.user).localpart)
  382. self.assertEqual(login_resp["user_id"], self.user)
  383. # start a UI Auth flow by attempting to delete a device
  384. channel = self.delete_device(self.user_tok, self.device_id, 401)
  385. flows = channel.json_body["flows"]
  386. self.assertIn({"stages": ["m.login.sso"]}, flows)
  387. session_id = channel.json_body["session"]
  388. # do the OIDC auth, but auth as the wrong user
  389. channel = self.helper.auth_via_oidc(
  390. {"sub": "wrong_user"}, ui_auth_session_id=session_id
  391. )
  392. # that should return a failure message
  393. self.assertSubstring("We were unable to validate", channel.text_body)
  394. # ... and the delete op should now fail with a 403
  395. self.delete_device(
  396. self.user_tok, self.device_id, 403, body={"auth": {"session": session_id}}
  397. )