You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1349 lines
52 KiB

  1. # -*- coding: utf-8 -*-
  2. # Copyright 2014 - 2016 OpenMarket Ltd
  3. # Copyright 2017 Vector Creations Ltd
  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. import inspect
  17. import logging
  18. import time
  19. import unicodedata
  20. import urllib.parse
  21. from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
  22. import attr
  23. import bcrypt # type: ignore[import]
  24. import pymacaroons
  25. from synapse.api.constants import LoginType
  26. from synapse.api.errors import (
  27. AuthError,
  28. Codes,
  29. InteractiveAuthIncompleteError,
  30. LoginError,
  31. StoreError,
  32. SynapseError,
  33. UserDeactivatedError,
  34. )
  35. from synapse.api.ratelimiting import Ratelimiter
  36. from synapse.handlers.ui_auth import INTERACTIVE_AUTH_CHECKERS
  37. from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
  38. from synapse.http.server import finish_request, respond_with_html
  39. from synapse.http.site import SynapseRequest
  40. from synapse.logging.context import defer_to_thread
  41. from synapse.metrics.background_process_metrics import run_as_background_process
  42. from synapse.module_api import ModuleApi
  43. from synapse.types import JsonDict, Requester, UserID
  44. from synapse.util import stringutils as stringutils
  45. from synapse.util.msisdn import phone_number_to_msisdn
  46. from synapse.util.threepids import canonicalise_email
  47. from ._base import BaseHandler
  48. logger = logging.getLogger(__name__)
  49. def convert_client_dict_legacy_fields_to_identifier(
  50. submission: JsonDict,
  51. ) -> Dict[str, str]:
  52. """
  53. Convert a legacy-formatted login submission to an identifier dict.
  54. Legacy login submissions (used in both login and user-interactive authentication)
  55. provide user-identifying information at the top-level instead.
  56. These are now deprecated and replaced with identifiers:
  57. https://matrix.org/docs/spec/client_server/r0.6.1#identifier-types
  58. Args:
  59. submission: The client dict to convert
  60. Returns:
  61. The matching identifier dict
  62. Raises:
  63. SynapseError: If the format of the client dict is invalid
  64. """
  65. identifier = submission.get("identifier", {})
  66. # Generate an m.id.user identifier if "user" parameter is present
  67. user = submission.get("user")
  68. if user:
  69. identifier = {"type": "m.id.user", "user": user}
  70. # Generate an m.id.thirdparty identifier if "medium" and "address" parameters are present
  71. medium = submission.get("medium")
  72. address = submission.get("address")
  73. if medium and address:
  74. identifier = {
  75. "type": "m.id.thirdparty",
  76. "medium": medium,
  77. "address": address,
  78. }
  79. # We've converted valid, legacy login submissions to an identifier. If the
  80. # submission still doesn't have an identifier, it's invalid
  81. if not identifier:
  82. raise SynapseError(400, "Invalid login submission", Codes.INVALID_PARAM)
  83. # Ensure the identifier has a type
  84. if "type" not in identifier:
  85. raise SynapseError(
  86. 400, "'identifier' dict has no key 'type'", errcode=Codes.MISSING_PARAM,
  87. )
  88. return identifier
  89. def login_id_phone_to_thirdparty(identifier: JsonDict) -> Dict[str, str]:
  90. """
  91. Convert a phone login identifier type to a generic threepid identifier.
  92. Args:
  93. identifier: Login identifier dict of type 'm.id.phone'
  94. Returns:
  95. An equivalent m.id.thirdparty identifier dict
  96. """
  97. if "country" not in identifier or (
  98. # The specification requires a "phone" field, while Synapse used to require a "number"
  99. # field. Accept both for backwards compatibility.
  100. "phone" not in identifier
  101. and "number" not in identifier
  102. ):
  103. raise SynapseError(
  104. 400, "Invalid phone-type identifier", errcode=Codes.INVALID_PARAM
  105. )
  106. # Accept both "phone" and "number" as valid keys in m.id.phone
  107. phone_number = identifier.get("phone", identifier["number"])
  108. # Convert user-provided phone number to a consistent representation
  109. msisdn = phone_number_to_msisdn(identifier["country"], phone_number)
  110. return {
  111. "type": "m.id.thirdparty",
  112. "medium": "msisdn",
  113. "address": msisdn,
  114. }
  115. @attr.s(slots=True)
  116. class SsoLoginExtraAttributes:
  117. """Data we track about SAML2 sessions"""
  118. # time the session was created, in milliseconds
  119. creation_time = attr.ib(type=int)
  120. extra_attributes = attr.ib(type=JsonDict)
  121. class AuthHandler(BaseHandler):
  122. SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
  123. def __init__(self, hs):
  124. """
  125. Args:
  126. hs (synapse.server.HomeServer):
  127. """
  128. super().__init__(hs)
  129. self.checkers = {} # type: Dict[str, UserInteractiveAuthChecker]
  130. for auth_checker_class in INTERACTIVE_AUTH_CHECKERS:
  131. inst = auth_checker_class(hs)
  132. if inst.is_enabled():
  133. self.checkers[inst.AUTH_TYPE] = inst # type: ignore
  134. self.bcrypt_rounds = hs.config.bcrypt_rounds
  135. # we can't use hs.get_module_api() here, because to do so will create an
  136. # import loop.
  137. #
  138. # TODO: refactor this class to separate the lower-level stuff that
  139. # ModuleApi can use from the higher-level stuff that uses ModuleApi, as
  140. # better way to break the loop
  141. account_handler = ModuleApi(hs, self)
  142. self.password_providers = [
  143. module(config=config, account_handler=account_handler)
  144. for module, config in hs.config.password_providers
  145. ]
  146. logger.info("Extra password_providers: %r", self.password_providers)
  147. self.hs = hs # FIXME better possibility to access registrationHandler later?
  148. self.macaroon_gen = hs.get_macaroon_generator()
  149. self._password_enabled = hs.config.password_enabled
  150. self._sso_enabled = (
  151. hs.config.cas_enabled or hs.config.saml2_enabled or hs.config.oidc_enabled
  152. )
  153. # we keep this as a list despite the O(N^2) implication so that we can
  154. # keep PASSWORD first and avoid confusing clients which pick the first
  155. # type in the list. (NB that the spec doesn't require us to do so and
  156. # clients which favour types that they don't understand over those that
  157. # they do are technically broken)
  158. login_types = []
  159. if self._password_enabled:
  160. login_types.append(LoginType.PASSWORD)
  161. for provider in self.password_providers:
  162. if hasattr(provider, "get_supported_login_types"):
  163. for t in provider.get_supported_login_types().keys():
  164. if t not in login_types:
  165. login_types.append(t)
  166. self._supported_login_types = login_types
  167. # Login types and UI Auth types have a heavy overlap, but are not
  168. # necessarily identical. Login types have SSO (and other login types)
  169. # added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET.
  170. ui_auth_types = login_types.copy()
  171. if self._sso_enabled:
  172. ui_auth_types.append(LoginType.SSO)
  173. self._supported_ui_auth_types = ui_auth_types
  174. # Ratelimiter for failed auth during UIA. Uses same ratelimit config
  175. # as per `rc_login.failed_attempts`.
  176. self._failed_uia_attempts_ratelimiter = Ratelimiter(
  177. clock=self.clock,
  178. rate_hz=self.hs.config.rc_login_failed_attempts.per_second,
  179. burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
  180. )
  181. self._clock = self.hs.get_clock()
  182. # Expire old UI auth sessions after a period of time.
  183. if hs.config.run_background_tasks:
  184. self._clock.looping_call(
  185. run_as_background_process,
  186. 5 * 60 * 1000,
  187. "expire_old_sessions",
  188. self._expire_old_sessions,
  189. )
  190. # Load the SSO HTML templates.
  191. # The following template is shown to the user during a client login via SSO,
  192. # after the SSO completes and before redirecting them back to their client.
  193. # It notifies the user they are about to give access to their matrix account
  194. # to the client.
  195. self._sso_redirect_confirm_template = hs.config.sso_redirect_confirm_template
  196. # The following template is shown during user interactive authentication
  197. # in the fallback auth scenario. It notifies the user that they are
  198. # authenticating for an operation to occur on their account.
  199. self._sso_auth_confirm_template = hs.config.sso_auth_confirm_template
  200. # The following template is shown after a successful user interactive
  201. # authentication session. It tells the user they can close the window.
  202. self._sso_auth_success_template = hs.config.sso_auth_success_template
  203. # The following template is shown during the SSO authentication process if
  204. # the account is deactivated.
  205. self._sso_account_deactivated_template = (
  206. hs.config.sso_account_deactivated_template
  207. )
  208. self._server_name = hs.config.server_name
  209. # cast to tuple for use with str.startswith
  210. self._whitelisted_sso_clients = tuple(hs.config.sso_client_whitelist)
  211. # A mapping of user ID to extra attributes to include in the login
  212. # response.
  213. self._extra_attributes = {} # type: Dict[str, SsoLoginExtraAttributes]
  214. async def validate_user_via_ui_auth(
  215. self,
  216. requester: Requester,
  217. request: SynapseRequest,
  218. request_body: Dict[str, Any],
  219. clientip: str,
  220. description: str,
  221. ) -> Tuple[dict, str]:
  222. """
  223. Checks that the user is who they claim to be, via a UI auth.
  224. This is used for things like device deletion and password reset where
  225. the user already has a valid access token, but we want to double-check
  226. that it isn't stolen by re-authenticating them.
  227. Args:
  228. requester: The user, as given by the access token
  229. request: The request sent by the client.
  230. request_body: The body of the request sent by the client
  231. clientip: The IP address of the client.
  232. description: A human readable string to be displayed to the user that
  233. describes the operation happening on their account.
  234. Returns:
  235. A tuple of (params, session_id).
  236. 'params' contains the parameters for this request (which may
  237. have been given only in a previous call).
  238. 'session_id' is the ID of this session, either passed in by the
  239. client or assigned by this call
  240. Raises:
  241. InteractiveAuthIncompleteError if the client has not yet completed
  242. any of the permitted login flows
  243. AuthError if the client has completed a login flow, and it gives
  244. a different user to `requester`
  245. LimitExceededError if the ratelimiter's failed request count for this
  246. user is too high to proceed
  247. """
  248. user_id = requester.user.to_string()
  249. # Check if we should be ratelimited due to too many previous failed attempts
  250. self._failed_uia_attempts_ratelimiter.ratelimit(user_id, update=False)
  251. # build a list of supported flows
  252. flows = [[login_type] for login_type in self._supported_ui_auth_types]
  253. try:
  254. result, params, session_id = await self.check_ui_auth(
  255. flows, request, request_body, clientip, description
  256. )
  257. except LoginError:
  258. # Update the ratelimiter to say we failed (`can_do_action` doesn't raise).
  259. self._failed_uia_attempts_ratelimiter.can_do_action(user_id)
  260. raise
  261. # find the completed login type
  262. for login_type in self._supported_ui_auth_types:
  263. if login_type not in result:
  264. continue
  265. user_id = result[login_type]
  266. break
  267. else:
  268. # this can't happen
  269. raise Exception("check_auth returned True but no successful login type")
  270. # check that the UI auth matched the access token
  271. if user_id != requester.user.to_string():
  272. raise AuthError(403, "Invalid auth")
  273. return params, session_id
  274. def get_enabled_auth_types(self):
  275. """Return the enabled user-interactive authentication types
  276. Returns the UI-Auth types which are supported by the homeserver's current
  277. config.
  278. """
  279. return self.checkers.keys()
  280. async def check_ui_auth(
  281. self,
  282. flows: List[List[str]],
  283. request: SynapseRequest,
  284. clientdict: Dict[str, Any],
  285. clientip: str,
  286. description: str,
  287. ) -> Tuple[dict, dict, str]:
  288. """
  289. Takes a dictionary sent by the client in the login / registration
  290. protocol and handles the User-Interactive Auth flow.
  291. If no auth flows have been completed successfully, raises an
  292. InteractiveAuthIncompleteError. To handle this, you can use
  293. synapse.rest.client.v2_alpha._base.interactive_auth_handler as a
  294. decorator.
  295. Args:
  296. flows: A list of login flows. Each flow is an ordered list of
  297. strings representing auth-types. At least one full
  298. flow must be completed in order for auth to be successful.
  299. request: The request sent by the client.
  300. clientdict: The dictionary from the client root level, not the
  301. 'auth' key: this method prompts for auth if none is sent.
  302. clientip: The IP address of the client.
  303. description: A human readable string to be displayed to the user that
  304. describes the operation happening on their account.
  305. Returns:
  306. A tuple of (creds, params, session_id).
  307. 'creds' contains the authenticated credentials of each stage.
  308. 'params' contains the parameters for this request (which may
  309. have been given only in a previous call).
  310. 'session_id' is the ID of this session, either passed in by the
  311. client or assigned by this call
  312. Raises:
  313. InteractiveAuthIncompleteError if the client has not yet completed
  314. all the stages in any of the permitted flows.
  315. """
  316. authdict = None
  317. sid = None # type: Optional[str]
  318. if clientdict and "auth" in clientdict:
  319. authdict = clientdict["auth"]
  320. del clientdict["auth"]
  321. if "session" in authdict:
  322. sid = authdict["session"]
  323. # Convert the URI and method to strings.
  324. uri = request.uri.decode("utf-8")
  325. method = request.method.decode("utf-8")
  326. # If there's no session ID, create a new session.
  327. if not sid:
  328. session = await self.store.create_ui_auth_session(
  329. clientdict, uri, method, description
  330. )
  331. else:
  332. try:
  333. session = await self.store.get_ui_auth_session(sid)
  334. except StoreError:
  335. raise SynapseError(400, "Unknown session ID: %s" % (sid,))
  336. # If the client provides parameters, update what is persisted,
  337. # otherwise use whatever was last provided.
  338. #
  339. # This was designed to allow the client to omit the parameters
  340. # and just supply the session in subsequent calls so it split
  341. # auth between devices by just sharing the session, (eg. so you
  342. # could continue registration from your phone having clicked the
  343. # email auth link on there). It's probably too open to abuse
  344. # because it lets unauthenticated clients store arbitrary objects
  345. # on a homeserver.
  346. #
  347. # Revisit: Assuming the REST APIs do sensible validation, the data
  348. # isn't arbitrary.
  349. #
  350. # Note that the registration endpoint explicitly removes the
  351. # "initial_device_display_name" parameter if it is provided
  352. # without a "password" parameter. See the changes to
  353. # synapse.rest.client.v2_alpha.register.RegisterRestServlet.on_POST
  354. # in commit 544722bad23fc31056b9240189c3cbbbf0ffd3f9.
  355. if not clientdict:
  356. clientdict = session.clientdict
  357. # Ensure that the queried operation does not vary between stages of
  358. # the UI authentication session. This is done by generating a stable
  359. # comparator and storing it during the initial query. Subsequent
  360. # queries ensure that this comparator has not changed.
  361. #
  362. # The comparator is based on the requested URI and HTTP method. The
  363. # client dict (minus the auth dict) should also be checked, but some
  364. # clients are not spec compliant, just warn for now if the client
  365. # dict changes.
  366. if (session.uri, session.method) != (uri, method):
  367. raise SynapseError(
  368. 403,
  369. "Requested operation has changed during the UI authentication session.",
  370. )
  371. if session.clientdict != clientdict:
  372. logger.warning(
  373. "Requested operation has changed during the UI "
  374. "authentication session. A future version of Synapse "
  375. "will remove this capability."
  376. )
  377. # For backwards compatibility, changes to the client dict are
  378. # persisted as clients modify them throughout their user interactive
  379. # authentication flow.
  380. await self.store.set_ui_auth_clientdict(sid, clientdict)
  381. user_agent = request.requestHeaders.getRawHeaders(b"User-Agent", default=[b""])[
  382. 0
  383. ].decode("ascii", "surrogateescape")
  384. await self.store.add_user_agent_ip_to_ui_auth_session(
  385. session.session_id, user_agent, clientip
  386. )
  387. if not authdict:
  388. raise InteractiveAuthIncompleteError(
  389. session.session_id, self._auth_dict_for_flows(flows, session.session_id)
  390. )
  391. # check auth type currently being presented
  392. errordict = {} # type: Dict[str, Any]
  393. if "type" in authdict:
  394. login_type = authdict["type"] # type: str
  395. try:
  396. result = await self._check_auth_dict(authdict, clientip)
  397. if result:
  398. await self.store.mark_ui_auth_stage_complete(
  399. session.session_id, login_type, result
  400. )
  401. except LoginError as e:
  402. if login_type == LoginType.EMAIL_IDENTITY:
  403. # riot used to have a bug where it would request a new
  404. # validation token (thus sending a new email) each time it
  405. # got a 401 with a 'flows' field.
  406. # (https://github.com/vector-im/vector-web/issues/2447).
  407. #
  408. # Grandfather in the old behaviour for now to avoid
  409. # breaking old riot deployments.
  410. raise
  411. # this step failed. Merge the error dict into the response
  412. # so that the client can have another go.
  413. errordict = e.error_dict()
  414. creds = await self.store.get_completed_ui_auth_stages(session.session_id)
  415. for f in flows:
  416. if len(set(f) - set(creds)) == 0:
  417. # it's very useful to know what args are stored, but this can
  418. # include the password in the case of registering, so only log
  419. # the keys (confusingly, clientdict may contain a password
  420. # param, creds is just what the user authed as for UI auth
  421. # and is not sensitive).
  422. logger.info(
  423. "Auth completed with creds: %r. Client dict has keys: %r",
  424. creds,
  425. list(clientdict),
  426. )
  427. return creds, clientdict, session.session_id
  428. ret = self._auth_dict_for_flows(flows, session.session_id)
  429. ret["completed"] = list(creds)
  430. ret.update(errordict)
  431. raise InteractiveAuthIncompleteError(session.session_id, ret)
  432. async def add_oob_auth(
  433. self, stagetype: str, authdict: Dict[str, Any], clientip: str
  434. ) -> bool:
  435. """
  436. Adds the result of out-of-band authentication into an existing auth
  437. session. Currently used for adding the result of fallback auth.
  438. """
  439. if stagetype not in self.checkers:
  440. raise LoginError(400, "", Codes.MISSING_PARAM)
  441. if "session" not in authdict:
  442. raise LoginError(400, "", Codes.MISSING_PARAM)
  443. result = await self.checkers[stagetype].check_auth(authdict, clientip)
  444. if result:
  445. await self.store.mark_ui_auth_stage_complete(
  446. authdict["session"], stagetype, result
  447. )
  448. return True
  449. return False
  450. def get_session_id(self, clientdict: Dict[str, Any]) -> Optional[str]:
  451. """
  452. Gets the session ID for a client given the client dictionary
  453. Args:
  454. clientdict: The dictionary sent by the client in the request
  455. Returns:
  456. The string session ID the client sent. If the client did
  457. not send a session ID, returns None.
  458. """
  459. sid = None
  460. if clientdict and "auth" in clientdict:
  461. authdict = clientdict["auth"]
  462. if "session" in authdict:
  463. sid = authdict["session"]
  464. return sid
  465. async def set_session_data(self, session_id: str, key: str, value: Any) -> None:
  466. """
  467. Store a key-value pair into the sessions data associated with this
  468. request. This data is stored server-side and cannot be modified by
  469. the client.
  470. Args:
  471. session_id: The ID of this session as returned from check_auth
  472. key: The key to store the data under
  473. value: The data to store
  474. """
  475. try:
  476. await self.store.set_ui_auth_session_data(session_id, key, value)
  477. except StoreError:
  478. raise SynapseError(400, "Unknown session ID: %s" % (session_id,))
  479. async def get_session_data(
  480. self, session_id: str, key: str, default: Optional[Any] = None
  481. ) -> Any:
  482. """
  483. Retrieve data stored with set_session_data
  484. Args:
  485. session_id: The ID of this session as returned from check_auth
  486. key: The key to store the data under
  487. default: Value to return if the key has not been set
  488. """
  489. try:
  490. return await self.store.get_ui_auth_session_data(session_id, key, default)
  491. except StoreError:
  492. raise SynapseError(400, "Unknown session ID: %s" % (session_id,))
  493. async def _expire_old_sessions(self):
  494. """
  495. Invalidate any user interactive authentication sessions that have expired.
  496. """
  497. now = self._clock.time_msec()
  498. expiration_time = now - self.SESSION_EXPIRE_MS
  499. await self.store.delete_old_ui_auth_sessions(expiration_time)
  500. async def _check_auth_dict(
  501. self, authdict: Dict[str, Any], clientip: str
  502. ) -> Union[Dict[str, Any], str]:
  503. """Attempt to validate the auth dict provided by a client
  504. Args:
  505. authdict: auth dict provided by the client
  506. clientip: IP address of the client
  507. Returns:
  508. Result of the stage verification.
  509. Raises:
  510. StoreError if there was a problem accessing the database
  511. SynapseError if there was a problem with the request
  512. LoginError if there was an authentication problem.
  513. """
  514. login_type = authdict["type"]
  515. checker = self.checkers.get(login_type)
  516. if checker is not None:
  517. res = await checker.check_auth(authdict, clientip=clientip)
  518. return res
  519. # build a v1-login-style dict out of the authdict and fall back to the
  520. # v1 code
  521. user_id = authdict.get("user")
  522. if user_id is None:
  523. raise SynapseError(400, "", Codes.MISSING_PARAM)
  524. (canonical_id, callback) = await self.validate_login(user_id, authdict)
  525. return canonical_id
  526. def _get_params_recaptcha(self) -> dict:
  527. return {"public_key": self.hs.config.recaptcha_public_key}
  528. def _get_params_terms(self) -> dict:
  529. return {
  530. "policies": {
  531. "privacy_policy": {
  532. "version": self.hs.config.user_consent_version,
  533. "en": {
  534. "name": self.hs.config.user_consent_policy_name,
  535. "url": "%s_matrix/consent?v=%s"
  536. % (
  537. self.hs.config.public_baseurl,
  538. self.hs.config.user_consent_version,
  539. ),
  540. },
  541. }
  542. }
  543. }
  544. def _auth_dict_for_flows(
  545. self, flows: List[List[str]], session_id: str,
  546. ) -> Dict[str, Any]:
  547. public_flows = []
  548. for f in flows:
  549. public_flows.append(f)
  550. get_params = {
  551. LoginType.RECAPTCHA: self._get_params_recaptcha,
  552. LoginType.TERMS: self._get_params_terms,
  553. }
  554. params = {} # type: Dict[str, Any]
  555. for f in public_flows:
  556. for stage in f:
  557. if stage in get_params and stage not in params:
  558. params[stage] = get_params[stage]()
  559. return {
  560. "session": session_id,
  561. "flows": [{"stages": f} for f in public_flows],
  562. "params": params,
  563. }
  564. async def get_access_token_for_user_id(
  565. self, user_id: str, device_id: Optional[str], valid_until_ms: Optional[int]
  566. ):
  567. """
  568. Creates a new access token for the user with the given user ID.
  569. The user is assumed to have been authenticated by some other
  570. machanism (e.g. CAS), and the user_id converted to the canonical case.
  571. The device will be recorded in the table if it is not there already.
  572. Args:
  573. user_id: canonical User ID
  574. device_id: the device ID to associate with the tokens.
  575. None to leave the tokens unassociated with a device (deprecated:
  576. we should always have a device ID)
  577. valid_until_ms: when the token is valid until. None for
  578. no expiry.
  579. Returns:
  580. The access token for the user's session.
  581. Raises:
  582. StoreError if there was a problem storing the token.
  583. """
  584. fmt_expiry = ""
  585. if valid_until_ms is not None:
  586. fmt_expiry = time.strftime(
  587. " until %Y-%m-%d %H:%M:%S", time.localtime(valid_until_ms / 1000.0)
  588. )
  589. logger.info("Logging in user %s on device %s%s", user_id, device_id, fmt_expiry)
  590. await self.auth.check_auth_blocking(user_id)
  591. access_token = self.macaroon_gen.generate_access_token(user_id)
  592. await self.store.add_access_token_to_user(
  593. user_id, access_token, device_id, valid_until_ms
  594. )
  595. # the device *should* have been registered before we got here; however,
  596. # it's possible we raced against a DELETE operation. The thing we
  597. # really don't want is active access_tokens without a record of the
  598. # device, so we double-check it here.
  599. if device_id is not None:
  600. try:
  601. await self.store.get_device(user_id, device_id)
  602. except StoreError:
  603. await self.store.delete_access_token(access_token)
  604. raise StoreError(400, "Login raced against device deletion")
  605. return access_token
  606. async def check_user_exists(self, user_id: str) -> Optional[str]:
  607. """
  608. Checks to see if a user with the given id exists. Will check case
  609. insensitively, but return None if there are multiple inexact matches.
  610. Args:
  611. user_id: complete @user:id
  612. Returns:
  613. The canonical_user_id, or None if zero or multiple matches
  614. """
  615. res = await self._find_user_id_and_pwd_hash(user_id)
  616. if res is not None:
  617. return res[0]
  618. return None
  619. async def _find_user_id_and_pwd_hash(
  620. self, user_id: str
  621. ) -> Optional[Tuple[str, str]]:
  622. """Checks to see if a user with the given id exists. Will check case
  623. insensitively, but will return None if there are multiple inexact
  624. matches.
  625. Returns:
  626. A 2-tuple of `(canonical_user_id, password_hash)` or `None`
  627. if there is not exactly one match
  628. """
  629. user_infos = await self.store.get_users_by_id_case_insensitive(user_id)
  630. result = None
  631. if not user_infos:
  632. logger.warning("Attempted to login as %s but they do not exist", user_id)
  633. elif len(user_infos) == 1:
  634. # a single match (possibly not exact)
  635. result = user_infos.popitem()
  636. elif user_id in user_infos:
  637. # multiple matches, but one is exact
  638. result = (user_id, user_infos[user_id])
  639. else:
  640. # multiple matches, none of them exact
  641. logger.warning(
  642. "Attempted to login as %s but it matches more than one user "
  643. "inexactly: %r",
  644. user_id,
  645. user_infos.keys(),
  646. )
  647. return result
  648. def get_supported_login_types(self) -> Iterable[str]:
  649. """Get a the login types supported for the /login API
  650. By default this is just 'm.login.password' (unless password_enabled is
  651. False in the config file), but password auth providers can provide
  652. other login types.
  653. Returns:
  654. login types
  655. """
  656. return self._supported_login_types
  657. async def validate_login(
  658. self, username: str, login_submission: Dict[str, Any]
  659. ) -> Tuple[str, Optional[Callable[[Dict[str, str]], None]]]:
  660. """Authenticates the user for the /login API
  661. Also used by the user-interactive auth flow to validate
  662. m.login.password auth types.
  663. Args:
  664. username: username supplied by the user
  665. login_submission: the whole of the login submission
  666. (including 'type' and other relevant fields)
  667. Returns:
  668. A tuple of the canonical user id, and optional callback
  669. to be called once the access token and device id are issued
  670. Raises:
  671. StoreError if there was a problem accessing the database
  672. SynapseError if there was a problem with the request
  673. LoginError if there was an authentication problem.
  674. """
  675. if username.startswith("@"):
  676. qualified_user_id = username
  677. else:
  678. qualified_user_id = UserID(username, self.hs.hostname).to_string()
  679. login_type = login_submission.get("type")
  680. known_login_type = False
  681. # special case to check for "password" for the check_password interface
  682. # for the auth providers
  683. password = login_submission.get("password")
  684. if login_type == LoginType.PASSWORD:
  685. if not self._password_enabled:
  686. raise SynapseError(400, "Password login has been disabled.")
  687. if not password:
  688. raise SynapseError(400, "Missing parameter: password")
  689. for provider in self.password_providers:
  690. if hasattr(provider, "check_password") and login_type == LoginType.PASSWORD:
  691. known_login_type = True
  692. is_valid = await provider.check_password(qualified_user_id, password)
  693. if is_valid:
  694. return qualified_user_id, None
  695. if not hasattr(provider, "get_supported_login_types") or not hasattr(
  696. provider, "check_auth"
  697. ):
  698. # this password provider doesn't understand custom login types
  699. continue
  700. supported_login_types = provider.get_supported_login_types()
  701. if login_type not in supported_login_types:
  702. # this password provider doesn't understand this login type
  703. continue
  704. known_login_type = True
  705. login_fields = supported_login_types[login_type]
  706. missing_fields = []
  707. login_dict = {}
  708. for f in login_fields:
  709. if f not in login_submission:
  710. missing_fields.append(f)
  711. else:
  712. login_dict[f] = login_submission[f]
  713. if missing_fields:
  714. raise SynapseError(
  715. 400,
  716. "Missing parameters for login type %s: %s"
  717. % (login_type, missing_fields),
  718. )
  719. result = await provider.check_auth(username, login_type, login_dict)
  720. if result:
  721. if isinstance(result, str):
  722. result = (result, None)
  723. return result
  724. if login_type == LoginType.PASSWORD and self.hs.config.password_localdb_enabled:
  725. known_login_type = True
  726. canonical_user_id = await self._check_local_password(
  727. qualified_user_id, password # type: ignore
  728. )
  729. if canonical_user_id:
  730. return canonical_user_id, None
  731. if not known_login_type:
  732. raise SynapseError(400, "Unknown login type %s" % login_type)
  733. # We raise a 403 here, but note that if we're doing user-interactive
  734. # login, it turns all LoginErrors into a 401 anyway.
  735. raise LoginError(403, "Invalid password", errcode=Codes.FORBIDDEN)
  736. async def check_password_provider_3pid(
  737. self, medium: str, address: str, password: str
  738. ) -> Tuple[Optional[str], Optional[Callable[[Dict[str, str]], None]]]:
  739. """Check if a password provider is able to validate a thirdparty login
  740. Args:
  741. medium: The medium of the 3pid (ex. email).
  742. address: The address of the 3pid (ex. jdoe@example.com).
  743. password: The password of the user.
  744. Returns:
  745. A tuple of `(user_id, callback)`. If authentication is successful,
  746. `user_id`is the authenticated, canonical user ID. `callback` is
  747. then either a function to be later run after the server has
  748. completed login/registration, or `None`. If authentication was
  749. unsuccessful, `user_id` and `callback` are both `None`.
  750. """
  751. for provider in self.password_providers:
  752. if hasattr(provider, "check_3pid_auth"):
  753. # This function is able to return a deferred that either
  754. # resolves None, meaning authentication failure, or upon
  755. # success, to a str (which is the user_id) or a tuple of
  756. # (user_id, callback_func), where callback_func should be run
  757. # after we've finished everything else
  758. result = await provider.check_3pid_auth(medium, address, password)
  759. if result:
  760. # Check if the return value is a str or a tuple
  761. if isinstance(result, str):
  762. # If it's a str, set callback function to None
  763. result = (result, None)
  764. return result
  765. return None, None
  766. async def _check_local_password(self, user_id: str, password: str) -> Optional[str]:
  767. """Authenticate a user against the local password database.
  768. user_id is checked case insensitively, but will return None if there are
  769. multiple inexact matches.
  770. Args:
  771. user_id: complete @user:id
  772. password: the provided password
  773. Returns:
  774. The canonical_user_id, or None if unknown user/bad password
  775. """
  776. lookupres = await self._find_user_id_and_pwd_hash(user_id)
  777. if not lookupres:
  778. return None
  779. (user_id, password_hash) = lookupres
  780. # If the password hash is None, the account has likely been deactivated
  781. if not password_hash:
  782. deactivated = await self.store.get_user_deactivated_status(user_id)
  783. if deactivated:
  784. raise UserDeactivatedError("This account has been deactivated")
  785. result = await self.validate_hash(password, password_hash)
  786. if not result:
  787. logger.warning("Failed password login for user %s", user_id)
  788. return None
  789. return user_id
  790. async def validate_short_term_login_token_and_get_user_id(self, login_token: str):
  791. auth_api = self.hs.get_auth()
  792. user_id = None
  793. try:
  794. macaroon = pymacaroons.Macaroon.deserialize(login_token)
  795. user_id = auth_api.get_user_id_from_macaroon(macaroon)
  796. auth_api.validate_macaroon(macaroon, "login", user_id)
  797. except Exception:
  798. raise AuthError(403, "Invalid token", errcode=Codes.FORBIDDEN)
  799. await self.auth.check_auth_blocking(user_id)
  800. return user_id
  801. async def delete_access_token(self, access_token: str):
  802. """Invalidate a single access token
  803. Args:
  804. access_token: access token to be deleted
  805. """
  806. user_info = await self.auth.get_user_by_access_token(access_token)
  807. await self.store.delete_access_token(access_token)
  808. # see if any of our auth providers want to know about this
  809. for provider in self.password_providers:
  810. if hasattr(provider, "on_logged_out"):
  811. # This might return an awaitable, if it does block the log out
  812. # until it completes.
  813. result = provider.on_logged_out(
  814. user_id=str(user_info["user"]),
  815. device_id=user_info["device_id"],
  816. access_token=access_token,
  817. )
  818. if inspect.isawaitable(result):
  819. await result
  820. # delete pushers associated with this access token
  821. if user_info["token_id"] is not None:
  822. await self.hs.get_pusherpool().remove_pushers_by_access_token(
  823. str(user_info["user"]), (user_info["token_id"],)
  824. )
  825. async def delete_access_tokens_for_user(
  826. self,
  827. user_id: str,
  828. except_token_id: Optional[str] = None,
  829. device_id: Optional[str] = None,
  830. ):
  831. """Invalidate access tokens belonging to a user
  832. Args:
  833. user_id: ID of user the tokens belong to
  834. except_token_id: access_token ID which should *not* be deleted
  835. device_id: ID of device the tokens are associated with.
  836. If None, tokens associated with any device (or no device) will
  837. be deleted
  838. """
  839. tokens_and_devices = await self.store.user_delete_access_tokens(
  840. user_id, except_token_id=except_token_id, device_id=device_id
  841. )
  842. # see if any of our auth providers want to know about this
  843. for provider in self.password_providers:
  844. if hasattr(provider, "on_logged_out"):
  845. for token, token_id, device_id in tokens_and_devices:
  846. await provider.on_logged_out(
  847. user_id=user_id, device_id=device_id, access_token=token
  848. )
  849. # delete pushers associated with the access tokens
  850. await self.hs.get_pusherpool().remove_pushers_by_access_token(
  851. user_id, (token_id for _, token_id, _ in tokens_and_devices)
  852. )
  853. async def add_threepid(
  854. self, user_id: str, medium: str, address: str, validated_at: int
  855. ):
  856. # check if medium has a valid value
  857. if medium not in ["email", "msisdn"]:
  858. raise SynapseError(
  859. code=400,
  860. msg=("'%s' is not a valid value for 'medium'" % (medium,)),
  861. errcode=Codes.INVALID_PARAM,
  862. )
  863. # 'Canonicalise' email addresses down to lower case.
  864. # We've now moving towards the homeserver being the entity that
  865. # is responsible for validating threepids used for resetting passwords
  866. # on accounts, so in future Synapse will gain knowledge of specific
  867. # types (mediums) of threepid. For now, we still use the existing
  868. # infrastructure, but this is the start of synapse gaining knowledge
  869. # of specific types of threepid (and fixes the fact that checking
  870. # for the presence of an email address during password reset was
  871. # case sensitive).
  872. if medium == "email":
  873. address = canonicalise_email(address)
  874. await self.store.user_add_threepid(
  875. user_id, medium, address, validated_at, self.hs.get_clock().time_msec()
  876. )
  877. async def delete_threepid(
  878. self, user_id: str, medium: str, address: str, id_server: Optional[str] = None
  879. ) -> bool:
  880. """Attempts to unbind the 3pid on the identity servers and deletes it
  881. from the local database.
  882. Args:
  883. user_id: ID of user to remove the 3pid from.
  884. medium: The medium of the 3pid being removed: "email" or "msisdn".
  885. address: The 3pid address to remove.
  886. id_server: Use the given identity server when unbinding
  887. any threepids. If None then will attempt to unbind using the
  888. identity server specified when binding (if known).
  889. Returns:
  890. Returns True if successfully unbound the 3pid on
  891. the identity server, False if identity server doesn't support the
  892. unbind API.
  893. """
  894. # 'Canonicalise' email addresses as per above
  895. if medium == "email":
  896. address = canonicalise_email(address)
  897. identity_handler = self.hs.get_identity_handler()
  898. result = await identity_handler.try_unbind_threepid(
  899. user_id, {"medium": medium, "address": address, "id_server": id_server}
  900. )
  901. await self.store.user_delete_threepid(user_id, medium, address)
  902. return result
  903. async def hash(self, password: str) -> str:
  904. """Computes a secure hash of password.
  905. Args:
  906. password: Password to hash.
  907. Returns:
  908. Hashed password.
  909. """
  910. def _do_hash():
  911. # Normalise the Unicode in the password
  912. pw = unicodedata.normalize("NFKC", password)
  913. return bcrypt.hashpw(
  914. pw.encode("utf8") + self.hs.config.password_pepper.encode("utf8"),
  915. bcrypt.gensalt(self.bcrypt_rounds),
  916. ).decode("ascii")
  917. return await defer_to_thread(self.hs.get_reactor(), _do_hash)
  918. async def validate_hash(
  919. self, password: str, stored_hash: Union[bytes, str]
  920. ) -> bool:
  921. """Validates that self.hash(password) == stored_hash.
  922. Args:
  923. password: Password to hash.
  924. stored_hash: Expected hash value.
  925. Returns:
  926. Whether self.hash(password) == stored_hash.
  927. """
  928. def _do_validate_hash(checked_hash: bytes):
  929. # Normalise the Unicode in the password
  930. pw = unicodedata.normalize("NFKC", password)
  931. return bcrypt.checkpw(
  932. pw.encode("utf8") + self.hs.config.password_pepper.encode("utf8"),
  933. checked_hash,
  934. )
  935. if stored_hash:
  936. if not isinstance(stored_hash, bytes):
  937. stored_hash = stored_hash.encode("ascii")
  938. return await defer_to_thread(
  939. self.hs.get_reactor(), _do_validate_hash, stored_hash
  940. )
  941. else:
  942. return False
  943. async def start_sso_ui_auth(self, redirect_url: str, session_id: str) -> str:
  944. """
  945. Get the HTML for the SSO redirect confirmation page.
  946. Args:
  947. redirect_url: The URL to redirect to the SSO provider.
  948. session_id: The user interactive authentication session ID.
  949. Returns:
  950. The HTML to render.
  951. """
  952. try:
  953. session = await self.store.get_ui_auth_session(session_id)
  954. except StoreError:
  955. raise SynapseError(400, "Unknown session ID: %s" % (session_id,))
  956. return self._sso_auth_confirm_template.render(
  957. description=session.description, redirect_url=redirect_url,
  958. )
  959. async def complete_sso_ui_auth(
  960. self, registered_user_id: str, session_id: str, request: SynapseRequest,
  961. ):
  962. """Having figured out a mxid for this user, complete the HTTP request
  963. Args:
  964. registered_user_id: The registered user ID to complete SSO login for.
  965. request: The request to complete.
  966. client_redirect_url: The URL to which to redirect the user at the end of the
  967. process.
  968. """
  969. # Mark the stage of the authentication as successful.
  970. # Save the user who authenticated with SSO, this will be used to ensure
  971. # that the account be modified is also the person who logged in.
  972. await self.store.mark_ui_auth_stage_complete(
  973. session_id, LoginType.SSO, registered_user_id
  974. )
  975. # Render the HTML and return.
  976. html = self._sso_auth_success_template
  977. respond_with_html(request, 200, html)
  978. async def complete_sso_login(
  979. self,
  980. registered_user_id: str,
  981. request: SynapseRequest,
  982. client_redirect_url: str,
  983. extra_attributes: Optional[JsonDict] = None,
  984. ):
  985. """Having figured out a mxid for this user, complete the HTTP request
  986. Args:
  987. registered_user_id: The registered user ID to complete SSO login for.
  988. request: The request to complete.
  989. client_redirect_url: The URL to which to redirect the user at the end of the
  990. process.
  991. extra_attributes: Extra attributes which will be passed to the client
  992. during successful login. Must be JSON serializable.
  993. """
  994. # If the account has been deactivated, do not proceed with the login
  995. # flow.
  996. deactivated = await self.store.get_user_deactivated_status(registered_user_id)
  997. if deactivated:
  998. respond_with_html(request, 403, self._sso_account_deactivated_template)
  999. return
  1000. self._complete_sso_login(
  1001. registered_user_id, request, client_redirect_url, extra_attributes
  1002. )
  1003. def _complete_sso_login(
  1004. self,
  1005. registered_user_id: str,
  1006. request: SynapseRequest,
  1007. client_redirect_url: str,
  1008. extra_attributes: Optional[JsonDict] = None,
  1009. ):
  1010. """
  1011. The synchronous portion of complete_sso_login.
  1012. This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
  1013. """
  1014. # Store any extra attributes which will be passed in the login response.
  1015. # Note that this is per-user so it may overwrite a previous value, this
  1016. # is considered OK since the newest SSO attributes should be most valid.
  1017. if extra_attributes:
  1018. self._extra_attributes[registered_user_id] = SsoLoginExtraAttributes(
  1019. self._clock.time_msec(), extra_attributes,
  1020. )
  1021. # Create a login token
  1022. login_token = self.macaroon_gen.generate_short_term_login_token(
  1023. registered_user_id
  1024. )
  1025. # Append the login token to the original redirect URL (i.e. with its query
  1026. # parameters kept intact) to build the URL to which the template needs to
  1027. # redirect the users once they have clicked on the confirmation link.
  1028. redirect_url = self.add_query_param_to_url(
  1029. client_redirect_url, "loginToken", login_token
  1030. )
  1031. # if the client is whitelisted, we can redirect straight to it
  1032. if client_redirect_url.startswith(self._whitelisted_sso_clients):
  1033. request.redirect(redirect_url)
  1034. finish_request(request)
  1035. return
  1036. # Otherwise, serve the redirect confirmation page.
  1037. # Remove the query parameters from the redirect URL to get a shorter version of
  1038. # it. This is only to display a human-readable URL in the template, but not the
  1039. # URL we redirect users to.
  1040. redirect_url_no_params = client_redirect_url.split("?")[0]
  1041. html = self._sso_redirect_confirm_template.render(
  1042. display_url=redirect_url_no_params,
  1043. redirect_url=redirect_url,
  1044. server_name=self._server_name,
  1045. )
  1046. respond_with_html(request, 200, html)
  1047. async def _sso_login_callback(self, login_result: JsonDict) -> None:
  1048. """
  1049. A login callback which might add additional attributes to the login response.
  1050. Args:
  1051. login_result: The data to be sent to the client. Includes the user
  1052. ID and access token.
  1053. """
  1054. # Expire attributes before processing. Note that there shouldn't be any
  1055. # valid logins that still have extra attributes.
  1056. self._expire_sso_extra_attributes()
  1057. extra_attributes = self._extra_attributes.get(login_result["user_id"])
  1058. if extra_attributes:
  1059. login_result.update(extra_attributes.extra_attributes)
  1060. def _expire_sso_extra_attributes(self) -> None:
  1061. """
  1062. Iterate through the mapping of user IDs to extra attributes and remove any that are no longer valid.
  1063. """
  1064. # TODO This should match the amount of time the macaroon is valid for.
  1065. LOGIN_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000
  1066. expire_before = self._clock.time_msec() - LOGIN_TOKEN_EXPIRATION_TIME
  1067. to_expire = set()
  1068. for user_id, data in self._extra_attributes.items():
  1069. if data.creation_time < expire_before:
  1070. to_expire.add(user_id)
  1071. for user_id in to_expire:
  1072. logger.debug("Expiring extra attributes for user %s", user_id)
  1073. del self._extra_attributes[user_id]
  1074. @staticmethod
  1075. def add_query_param_to_url(url: str, param_name: str, param: Any):
  1076. url_parts = list(urllib.parse.urlparse(url))
  1077. query = dict(urllib.parse.parse_qsl(url_parts[4]))
  1078. query.update({param_name: param})
  1079. url_parts[4] = urllib.parse.urlencode(query)
  1080. return urllib.parse.urlunparse(url_parts)
  1081. @attr.s(slots=True)
  1082. class MacaroonGenerator:
  1083. hs = attr.ib()
  1084. def generate_access_token(
  1085. self, user_id: str, extra_caveats: Optional[List[str]] = None
  1086. ) -> str:
  1087. extra_caveats = extra_caveats or []
  1088. macaroon = self._generate_base_macaroon(user_id)
  1089. macaroon.add_first_party_caveat("type = access")
  1090. # Include a nonce, to make sure that each login gets a different
  1091. # access token.
  1092. macaroon.add_first_party_caveat(
  1093. "nonce = %s" % (stringutils.random_string_with_symbols(16),)
  1094. )
  1095. for caveat in extra_caveats:
  1096. macaroon.add_first_party_caveat(caveat)
  1097. return macaroon.serialize()
  1098. def generate_short_term_login_token(
  1099. self, user_id: str, duration_in_ms: int = (2 * 60 * 1000)
  1100. ) -> str:
  1101. macaroon = self._generate_base_macaroon(user_id)
  1102. macaroon.add_first_party_caveat("type = login")
  1103. now = self.hs.get_clock().time_msec()
  1104. expiry = now + duration_in_ms
  1105. macaroon.add_first_party_caveat("time < %d" % (expiry,))
  1106. return macaroon.serialize()
  1107. def generate_delete_pusher_token(self, user_id: str) -> str:
  1108. macaroon = self._generate_base_macaroon(user_id)
  1109. macaroon.add_first_party_caveat("type = delete_pusher")
  1110. return macaroon.serialize()
  1111. def _generate_base_macaroon(self, user_id: str) -> pymacaroons.Macaroon:
  1112. macaroon = pymacaroons.Macaroon(
  1113. location=self.hs.config.server_name,
  1114. identifier="key",
  1115. key=self.hs.config.macaroon_secret_key,
  1116. )
  1117. macaroon.add_first_party_caveat("gen = 1")
  1118. macaroon.add_first_party_caveat("user_id = %s" % (user_id,))
  1119. return macaroon