|
@@ -0,0 +1,216 @@
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+# Copyright 2019 The Matrix.org Foundation C.I.C.
|
|
|
+#
|
|
|
+# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
+# you may not use this file except in compliance with the License.
|
|
|
+# You may obtain a copy of the License at
|
|
|
+#
|
|
|
+# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
+#
|
|
|
+# Unless required by applicable law or agreed to in writing, software
|
|
|
+# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
+# See the License for the specific language governing permissions and
|
|
|
+# limitations under the License.
|
|
|
+import logging
|
|
|
+
|
|
|
+from canonicaljson import json
|
|
|
+
|
|
|
+from twisted.internet import defer
|
|
|
+from twisted.web.client import PartialDownloadError
|
|
|
+
|
|
|
+from synapse.api.constants import LoginType
|
|
|
+from synapse.api.errors import Codes, LoginError, SynapseError
|
|
|
+from synapse.config.emailconfig import ThreepidBehaviour
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+class UserInteractiveAuthChecker:
|
|
|
+ """Abstract base class for an interactive auth checker"""
|
|
|
+
|
|
|
+ def __init__(self, hs):
|
|
|
+ pass
|
|
|
+
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ """Given the authentication dict from the client, attempt to check this step
|
|
|
+
|
|
|
+ Args:
|
|
|
+ authdict (dict): authentication dictionary from the client
|
|
|
+ clientip (str): The IP address of the client.
|
|
|
+
|
|
|
+ Raises:
|
|
|
+ SynapseError if authentication failed
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Deferred: the result of authentication (to pass back to the client?)
|
|
|
+ """
|
|
|
+ raise NotImplementedError()
|
|
|
+
|
|
|
+
|
|
|
+class DummyAuthChecker(UserInteractiveAuthChecker):
|
|
|
+ AUTH_TYPE = LoginType.DUMMY
|
|
|
+
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ return defer.succeed(True)
|
|
|
+
|
|
|
+
|
|
|
+class TermsAuthChecker(UserInteractiveAuthChecker):
|
|
|
+ AUTH_TYPE = LoginType.TERMS
|
|
|
+
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ return defer.succeed(True)
|
|
|
+
|
|
|
+
|
|
|
+class RecaptchaAuthChecker(UserInteractiveAuthChecker):
|
|
|
+ AUTH_TYPE = LoginType.RECAPTCHA
|
|
|
+
|
|
|
+ def __init__(self, hs):
|
|
|
+ super().__init__(hs)
|
|
|
+ self._http_client = hs.get_simple_http_client()
|
|
|
+ self._url = hs.config.recaptcha_siteverify_api
|
|
|
+ self._secret = hs.config.recaptcha_private_key
|
|
|
+
|
|
|
+ @defer.inlineCallbacks
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ try:
|
|
|
+ user_response = authdict["response"]
|
|
|
+ except KeyError:
|
|
|
+ # Client tried to provide captcha but didn't give the parameter:
|
|
|
+ # bad request.
|
|
|
+ raise LoginError(
|
|
|
+ 400, "Captcha response is required", errcode=Codes.CAPTCHA_NEEDED
|
|
|
+ )
|
|
|
+
|
|
|
+ logger.info(
|
|
|
+ "Submitting recaptcha response %s with remoteip %s", user_response, clientip
|
|
|
+ )
|
|
|
+
|
|
|
+ # TODO: get this from the homeserver rather than creating a new one for
|
|
|
+ # each request
|
|
|
+ try:
|
|
|
+ resp_body = yield self._http_client.post_urlencoded_get_json(
|
|
|
+ self._url,
|
|
|
+ args={
|
|
|
+ "secret": self._secret,
|
|
|
+ "response": user_response,
|
|
|
+ "remoteip": clientip,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ except PartialDownloadError as pde:
|
|
|
+ # Twisted is silly
|
|
|
+ data = pde.response
|
|
|
+ resp_body = json.loads(data)
|
|
|
+
|
|
|
+ if "success" in resp_body:
|
|
|
+ # Note that we do NOT check the hostname here: we explicitly
|
|
|
+ # intend the CAPTCHA to be presented by whatever client the
|
|
|
+ # user is using, we just care that they have completed a CAPTCHA.
|
|
|
+ logger.info(
|
|
|
+ "%s reCAPTCHA from hostname %s",
|
|
|
+ "Successful" if resp_body["success"] else "Failed",
|
|
|
+ resp_body.get("hostname"),
|
|
|
+ )
|
|
|
+ if resp_body["success"]:
|
|
|
+ return True
|
|
|
+ raise LoginError(401, "", errcode=Codes.UNAUTHORIZED)
|
|
|
+
|
|
|
+
|
|
|
+class _BaseThreepidAuthChecker:
|
|
|
+ def __init__(self, hs):
|
|
|
+ self.hs = hs
|
|
|
+ self.store = hs.get_datastore()
|
|
|
+
|
|
|
+ @defer.inlineCallbacks
|
|
|
+ def _check_threepid(self, medium, authdict):
|
|
|
+ if "threepid_creds" not in authdict:
|
|
|
+ raise LoginError(400, "Missing threepid_creds", Codes.MISSING_PARAM)
|
|
|
+
|
|
|
+ threepid_creds = authdict["threepid_creds"]
|
|
|
+
|
|
|
+ identity_handler = self.hs.get_handlers().identity_handler
|
|
|
+
|
|
|
+ logger.info("Getting validated threepid. threepidcreds: %r", (threepid_creds,))
|
|
|
+ if self.hs.config.threepid_behaviour_email == ThreepidBehaviour.REMOTE:
|
|
|
+ if medium == "email":
|
|
|
+ threepid = yield identity_handler.threepid_from_creds(
|
|
|
+ self.hs.config.account_threepid_delegate_email, threepid_creds
|
|
|
+ )
|
|
|
+ elif medium == "msisdn":
|
|
|
+ threepid = yield identity_handler.threepid_from_creds(
|
|
|
+ self.hs.config.account_threepid_delegate_msisdn, threepid_creds
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ raise SynapseError(400, "Unrecognized threepid medium: %s" % (medium,))
|
|
|
+ elif self.hs.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
|
|
+ row = yield self.store.get_threepid_validation_session(
|
|
|
+ medium,
|
|
|
+ threepid_creds["client_secret"],
|
|
|
+ sid=threepid_creds["sid"],
|
|
|
+ validated=True,
|
|
|
+ )
|
|
|
+
|
|
|
+ threepid = (
|
|
|
+ {
|
|
|
+ "medium": row["medium"],
|
|
|
+ "address": row["address"],
|
|
|
+ "validated_at": row["validated_at"],
|
|
|
+ }
|
|
|
+ if row
|
|
|
+ else None
|
|
|
+ )
|
|
|
+
|
|
|
+ if row:
|
|
|
+ # Valid threepid returned, delete from the db
|
|
|
+ yield self.store.delete_threepid_session(threepid_creds["sid"])
|
|
|
+ else:
|
|
|
+ raise SynapseError(
|
|
|
+ 400, "Password resets are not enabled on this homeserver"
|
|
|
+ )
|
|
|
+
|
|
|
+ if not threepid:
|
|
|
+ raise LoginError(401, "", errcode=Codes.UNAUTHORIZED)
|
|
|
+
|
|
|
+ if threepid["medium"] != medium:
|
|
|
+ raise LoginError(
|
|
|
+ 401,
|
|
|
+ "Expecting threepid of type '%s', got '%s'"
|
|
|
+ % (medium, threepid["medium"]),
|
|
|
+ errcode=Codes.UNAUTHORIZED,
|
|
|
+ )
|
|
|
+
|
|
|
+ threepid["threepid_creds"] = authdict["threepid_creds"]
|
|
|
+
|
|
|
+ return threepid
|
|
|
+
|
|
|
+
|
|
|
+class EmailIdentityAuthChecker(UserInteractiveAuthChecker, _BaseThreepidAuthChecker):
|
|
|
+ AUTH_TYPE = LoginType.EMAIL_IDENTITY
|
|
|
+
|
|
|
+ def __init__(self, hs):
|
|
|
+ UserInteractiveAuthChecker.__init__(self, hs)
|
|
|
+ _BaseThreepidAuthChecker.__init__(self, hs)
|
|
|
+
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ return self._check_threepid("email", authdict)
|
|
|
+
|
|
|
+
|
|
|
+class MsisdnAuthChecker(UserInteractiveAuthChecker, _BaseThreepidAuthChecker):
|
|
|
+ AUTH_TYPE = LoginType.MSISDN
|
|
|
+
|
|
|
+ def __init__(self, hs):
|
|
|
+ UserInteractiveAuthChecker.__init__(self, hs)
|
|
|
+ _BaseThreepidAuthChecker.__init__(self, hs)
|
|
|
+
|
|
|
+ def check_auth(self, authdict, clientip):
|
|
|
+ return self._check_threepid("msisdn", authdict)
|
|
|
+
|
|
|
+
|
|
|
+INTERACTIVE_AUTH_CHECKERS = [
|
|
|
+ DummyAuthChecker,
|
|
|
+ TermsAuthChecker,
|
|
|
+ RecaptchaAuthChecker,
|
|
|
+ EmailIdentityAuthChecker,
|
|
|
+ MsisdnAuthChecker,
|
|
|
+]
|
|
|
+"""A list of UserInteractiveAuthChecker classes"""
|