summary refs log tree commit diff
path: root/synapse
diff options
context:
space:
mode:
authorKegan Dougal <kegan@matrix.org>2014-09-05 23:32:51 -0700
committerKegan Dougal <kegan@matrix.org>2014-09-05 23:32:51 -0700
commit1a298aad9c3bbc1f0c80c40a1518c29c511f9fb4 (patch)
tree8c27ca9b59021a5f9211cfb2c167ceaf083987d2 /synapse
parentFix generation of event ids so that they are consistent between local and rem... (diff)
parentAdded instructions for setting up captcha in an obviously named file. (diff)
downloadsynapse-1a298aad9c3bbc1f0c80c40a1518c29c511f9fb4.tar.xz
Added captcha support on both the HS and web client.
Merge branch 'captcha' of github.com:matrix-org/synapse into develop
Diffstat (limited to 'synapse')
-rw-r--r--synapse/api/errors.py15
-rw-r--r--synapse/config/captcha.py42
-rw-r--r--synapse/config/homeserver.py3
-rw-r--r--synapse/handlers/register.py68
-rw-r--r--synapse/http/client.py28
-rw-r--r--synapse/rest/register.py36
6 files changed, 182 insertions, 10 deletions
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index 84afe4fa37..88175602c4 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -29,6 +29,8 @@ class Codes(object):
     NOT_FOUND = "M_NOT_FOUND"
     UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN"
     LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED"
+    CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
+    CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
 
 
 class CodeMessageException(Exception):
@@ -101,6 +103,19 @@ class StoreError(SynapseError):
     pass
 
 
+class InvalidCaptchaError(SynapseError):
+    def __init__(self, code=400, msg="Invalid captcha.", error_url=None,
+                 errcode=Codes.CAPTCHA_INVALID):
+        super(InvalidCaptchaError, self).__init__(code, msg, errcode)
+        self.error_url = error_url
+
+    def error_dict(self):
+        return cs_error(
+            self.msg,
+            self.errcode,
+            error_url=self.error_url,
+        )
+
 class LimitExceededError(SynapseError):
     """A client has sent too many requests and is being throttled.
     """
diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py
new file mode 100644
index 0000000000..a97a5bab1e
--- /dev/null
+++ b/synapse/config/captcha.py
@@ -0,0 +1,42 @@
+# Copyright 2014 OpenMarket Ltd
+#
+# 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.
+
+from ._base import Config
+
+class CaptchaConfig(Config):
+
+    def __init__(self, args):
+        super(CaptchaConfig, self).__init__(args)
+        self.recaptcha_private_key = args.recaptcha_private_key
+        self.enable_registration_captcha = args.enable_registration_captcha
+        self.captcha_ip_origin_is_x_forwarded = args.captcha_ip_origin_is_x_forwarded
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(CaptchaConfig, cls).add_arguments(parser)
+        group = parser.add_argument_group("recaptcha")
+        group.add_argument(
+            "--recaptcha-private-key", type=str, default="YOUR_PRIVATE_KEY",
+            help="The matching private key for the web client's public key."
+        )
+        group.add_argument(
+            "--enable-registration-captcha", type=bool, default=False,
+            help="Enables ReCaptcha checks when registering, preventing signup "+
+            "unless a captcha is answered. Requires a valid ReCaptcha public/private key."
+        )
+        group.add_argument(
+            "--captcha_ip_origin_is_x_forwarded", type=bool, default=False,
+            help="When checking captchas, use the X-Forwarded-For (XFF) header as the client IP "+
+            "and not the actual client IP."
+        )
\ No newline at end of file
diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index 76e2cdeddd..e16f2c733b 100644
--- a/synapse/config/homeserver.py
+++ b/synapse/config/homeserver.py
@@ -19,9 +19,10 @@ from .logger import LoggingConfig
 from .database import DatabaseConfig
 from .ratelimiting import RatelimitConfig
 from .repository import ContentRepositoryConfig
+from .captcha import CaptchaConfig
 
 class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
-                       RatelimitConfig, ContentRepositoryConfig):
+                       RatelimitConfig, ContentRepositoryConfig, CaptchaConfig):
     pass
 
 if __name__=='__main__':
diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py
index bee052274f..0b841d6d3a 100644
--- a/synapse/handlers/register.py
+++ b/synapse/handlers/register.py
@@ -17,7 +17,9 @@
 from twisted.internet import defer
 
 from synapse.types import UserID
-from synapse.api.errors import SynapseError, RegistrationError
+from synapse.api.errors import (
+    SynapseError, RegistrationError, InvalidCaptchaError
+)
 from ._base import BaseHandler
 import synapse.util.stringutils as stringutils
 from synapse.http.client import PlainHttpClient
@@ -38,7 +40,8 @@ class RegistrationHandler(BaseHandler):
         self.distributor.declare("registered_user")
 
     @defer.inlineCallbacks
-    def register(self, localpart=None, password=None, threepidCreds=None):
+    def register(self, localpart=None, password=None, threepidCreds=None, 
+                 captcha_info={}):
         """Registers a new client on the server.
 
         Args:
@@ -51,10 +54,26 @@ class RegistrationHandler(BaseHandler):
         Raises:
             RegistrationError if there was a problem registering.
         """
+        if captcha_info:
+            captcha_response = yield self._validate_captcha(
+                captcha_info["ip"], 
+                captcha_info["private_key"],
+                captcha_info["challenge"],
+                captcha_info["response"]
+            )
+            if not captcha_response["valid"]:
+                logger.info("Invalid captcha entered from %s. Error: %s", 
+                            captcha_info["ip"], captcha_response["error_url"])
+                raise InvalidCaptchaError(
+                    error_url=captcha_response["error_url"]
+                )
+            else:
+                logger.info("Valid captcha entered from %s", captcha_info["ip"])
 
         if threepidCreds:
             for c in threepidCreds:
-                logger.info("validating theeepidcred sid %s on id server %s", c['sid'], c['idServer'])
+                logger.info("validating theeepidcred sid %s on id server %s",
+                            c['sid'], c['idServer'])
                 try:
                     threepid = yield self._threepid_from_creds(c)
                 except:
@@ -63,7 +82,8 @@ class RegistrationHandler(BaseHandler):
                     
                 if not threepid:
                     raise RegistrationError(400, "Couldn't validate 3pid")
-                logger.info("got threepid medium %s address %s", threepid['medium'], threepid['address'])
+                logger.info("got threepid medium %s address %s", 
+                            threepid['medium'], threepid['address'])
 
         password_hash = None
         if password:
@@ -131,7 +151,8 @@ class RegistrationHandler(BaseHandler):
         # XXX: make this configurable!
         trustedIdServers = [ 'matrix.org:8090' ]
         if not creds['idServer'] in trustedIdServers:
-            logger.warn('%s is not a trusted ID server: rejecting 3pid credentials', creds['idServer'])
+            logger.warn('%s is not a trusted ID server: rejecting 3pid '+
+                        'credentials', creds['idServer'])
             defer.returnValue(None)
         data = yield httpCli.get_json(
             creds['idServer'],
@@ -149,9 +170,44 @@ class RegistrationHandler(BaseHandler):
         data = yield httpCli.post_urlencoded_get_json(
             creds['idServer'],
             "/_matrix/identity/api/v1/3pid/bind",
-            { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'], 'mxid':mxid }
+            { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'], 
+            'mxid':mxid }
         )
         defer.returnValue(data)
         
+    @defer.inlineCallbacks
+    def _validate_captcha(self, ip_addr, private_key, challenge, response):
+        """Validates the captcha provided.
+        
+        Returns:
+            dict: Containing 'valid'(bool) and 'error_url'(str) if invalid.
+        
+        """
+        response = yield self._submit_captcha(ip_addr, private_key, challenge, 
+                                              response)
+        # parse Google's response. Lovely format..
+        lines = response.split('\n')
+        json = {
+            "valid": lines[0] == 'true',
+            "error_url": "http://www.google.com/recaptcha/api/challenge?"+
+                         "error=%s" % lines[1]
+        }
+        defer.returnValue(json)
+        
+    @defer.inlineCallbacks
+    def _submit_captcha(self, ip_addr, private_key, challenge, response):
+        client = PlainHttpClient(self.hs)
+        data = yield client.post_urlencoded_get_raw(
+            "www.google.com:80",
+            "/recaptcha/api/verify",
+            accept_partial=True,  # twisted dislikes google's response, no content length.
+            args={ 
+                'privatekey': private_key, 
+                'remoteip': ip_addr,
+                'challenge': challenge,
+                'response': response
+            }
+        )
+        defer.returnValue(data)
         
 
diff --git a/synapse/http/client.py b/synapse/http/client.py
index ebf1aa47c4..ece6318e00 100644
--- a/synapse/http/client.py
+++ b/synapse/http/client.py
@@ -16,7 +16,7 @@
 
 from twisted.internet import defer, reactor
 from twisted.internet.error import DNSLookupError
-from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer
+from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer, PartialDownloadError
 from twisted.web.http_headers import Headers
 
 from synapse.http.endpoint import matrix_endpoint
@@ -188,6 +188,32 @@ class TwistedHttpClient(HttpClient):
         body = yield readBody(response)
 
         defer.returnValue(json.loads(body))
+        
+    # XXX FIXME : I'm so sorry.
+    @defer.inlineCallbacks
+    def post_urlencoded_get_raw(self, destination, path, accept_partial=False, args={}):
+        if destination in _destination_mappings:
+            destination = _destination_mappings[destination]
+
+        query_bytes = urllib.urlencode(args, True)
+
+        response = yield self._create_request(
+            destination.encode("ascii"),
+            "POST",
+            path.encode("ascii"),
+            producer=FileBodyProducer(StringIO(urllib.urlencode(args))),
+            headers_dict={"Content-Type": ["application/x-www-form-urlencoded"]}
+        )
+
+        try:
+            body = yield readBody(response)
+            defer.returnValue(body)
+        except PartialDownloadError as e:
+            if accept_partial:
+                defer.returnValue(e.response)
+            else:
+                raise e
+        
 
     @defer.inlineCallbacks
     def _create_request(self, destination, method, path_bytes, param_bytes=b"",
diff --git a/synapse/rest/register.py b/synapse/rest/register.py
index b8de3b250d..48d3c6eca0 100644
--- a/synapse/rest/register.py
+++ b/synapse/rest/register.py
@@ -16,7 +16,7 @@
 """This module contains REST servlets to do with registration: /register"""
 from twisted.internet import defer
 
-from synapse.api.errors import SynapseError
+from synapse.api.errors import SynapseError, Codes
 from base import RestServlet, client_path_pattern
 
 import json
@@ -50,12 +50,44 @@ class RegisterRestServlet(RestServlet):
         threepidCreds = None
         if 'threepidCreds' in register_json:
             threepidCreds = register_json['threepidCreds']
+            
+        captcha = {}
+        if self.hs.config.enable_registration_captcha:
+            challenge = None
+            user_response = None
+            try:
+                captcha_type = register_json["captcha"]["type"]
+                if captcha_type != "m.login.recaptcha":
+                    raise SynapseError(400, "Sorry, only m.login.recaptcha " +
+                                       "requests are supported.")
+                challenge = register_json["captcha"]["challenge"]
+                user_response = register_json["captcha"]["response"]
+            except KeyError:
+                raise SynapseError(400, "Captcha response is required",
+                                   errcode=Codes.CAPTCHA_NEEDED)
+            
+            # TODO determine the source IP : May be an X-Forwarding-For header depending on config
+            ip_addr = request.getClientIP()
+            if self.hs.config.captcha_ip_origin_is_x_forwarded:
+                # use the header
+                if request.requestHeaders.hasHeader("X-Forwarded-For"):
+                    ip_addr = request.requestHeaders.getRawHeaders(
+                        "X-Forwarded-For")[0]
+            
+            captcha = {
+                "ip": ip_addr,
+                "private_key": self.hs.config.recaptcha_private_key,
+                "challenge": challenge,
+                "response": user_response
+            }
+            
 
         handler = self.handlers.registration_handler
         (user_id, token) = yield handler.register(
             localpart=desired_user_id,
             password=password,
-            threepidCreds=threepidCreds)
+            threepidCreds=threepidCreds,
+            captcha_info=captcha)
 
         result = {
             "user_id": user_id,