summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-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
-rw-r--r--webclient/CAPTCHA_SETUP46
-rw-r--r--webclient/README7
-rw-r--r--webclient/components/matrix/matrix-service.js25
-rw-r--r--webclient/index.html4
-rw-r--r--webclient/login/register-controller.js48
-rw-r--r--webclient/login/register.html6
13 files changed, 309 insertions, 21 deletions
diff --git a/.gitignore b/.gitignore
index d2b93ef61f..dfe8dfedbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,6 @@ graph/*.svg
 graph/*.png
 graph/*.dot
 
+webclient/config.js
+
 uploads
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,
diff --git a/webclient/CAPTCHA_SETUP b/webclient/CAPTCHA_SETUP
new file mode 100644
index 0000000000..ebc8a5f3b0
--- /dev/null
+++ b/webclient/CAPTCHA_SETUP
@@ -0,0 +1,46 @@
+Captcha can be enabled for this web client / home server. This file explains how to do that.
+The captcha mechanism used is Google's ReCaptcha. This requires API keys from Google.
+
+Getting keys
+------------
+Requires a public/private key pair from:
+
+https://developers.google.com/recaptcha/
+
+
+Setting Private ReCaptcha Key
+-----------------------------
+The private key is a config option on the home server config. If it is not 
+visible, you can generate it via --generate-config. Set the following value:
+
+  recaptcha_private_key: YOUR_PRIVATE_KEY
+  
+In addition, you MUST enable captchas via:
+
+  enable_registration_captcha: true
+
+Setting Public ReCaptcha Key
+----------------------------
+The web client will look for the global variable webClientConfig for config 
+options. You should put your ReCaptcha public key there like so:
+
+webClientConfig = {
+    useCaptcha: true,
+    recaptcha_public_key: "YOUR_PUBLIC_KEY"
+}
+
+This should be put in webclient/config.js which is already .gitignored, rather 
+than in the web client source files. You MUST set useCaptcha to true else a
+ReCaptcha widget will not be generated.
+
+Configuring IP used for auth
+----------------------------
+The ReCaptcha API requires that the IP address of the user who solved the
+captcha is sent. If the client is connecting through a proxy or load balancer,
+it may be required to use the X-Forwarded-For (XFF) header instead of the origin
+IP address. This can be configured as an option on the home server like so:
+
+  captcha_ip_origin_is_x_forwarded: true
+
+
+
diff --git a/webclient/README b/webclient/README
index 0f893b1712..13224c3d07 100644
--- a/webclient/README
+++ b/webclient/README
@@ -1,12 +1,13 @@
 Basic Usage
 -----------
 
-The Synapse web client needs to be hosted by a basic HTTP server.
-
-You can use the Python simple HTTP server::
+The web client should automatically run when running the home server. Alternatively, you can run
+it stand-alone:
 
     $ python -m SimpleHTTPServer
 
 Then, open this URL in a WEB browser::
 
     http://127.0.0.1:8000/
+
+
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 55cbd4bc14..b7e03657f0 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -84,15 +84,32 @@ angular.module('matrixService', [])
         prefix: prefixPath,
 
         // Register an user
-        register: function(user_name, password, threepidCreds) {
+        register: function(user_name, password, threepidCreds, useCaptcha) {         
             // The REST path spec
             var path = "/register";
-
-            return doRequest("POST", path, undefined, {
+            
+            var data = {
                  user_id: user_name,
                  password: password,
                  threepidCreds: threepidCreds
-            });
+            };
+            
+            if (useCaptcha) {
+                // Not all home servers will require captcha on signup, but if this flag is checked,
+                // send captcha information.
+                // TODO: Might be nice to make this a bit more flexible..
+                var challengeToken = Recaptcha.get_challenge();
+                var captchaEntry = Recaptcha.get_response();
+                var captchaType = "m.login.recaptcha";
+                
+                data.captcha = {
+                    type: captchaType,
+                    challenge: challengeToken,
+                    response: captchaEntry
+                };
+            }   
+
+            return doRequest("POST", path, undefined, data);
         },
 
         // Create a room
diff --git a/webclient/index.html b/webclient/index.html
index a7ed9aa15f..baaaa8cef8 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -10,12 +10,14 @@
    
     <meta name="viewport" content="width=device-width">
    
-    <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script> 
+    <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script>
+    <script type="text/javascript" src="http://www.google.com/recaptcha/api/js/recaptcha_ajax.js"></script> 
     <script src="js/angular.min.js"></script>
     <script src="js/angular-route.min.js"></script>
     <script src="js/angular-sanitize.min.js"></script>
     <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
     <script src="app.js"></script>
+    <script src="config.js"></script>
     <script src="app-controller.js"></script>
     <script src="app-directive.js"></script>
     <script src="app-filter.js"></script>
diff --git a/webclient/login/register-controller.js b/webclient/login/register-controller.js
index 5a14964248..b3c0c21335 100644
--- a/webclient/login/register-controller.js
+++ b/webclient/login/register-controller.js
@@ -19,6 +19,12 @@ angular.module('RegisterController', ['matrixService'])
                                     function($scope, $rootScope, $location, matrixService, eventStreamService) {
     'use strict';
     
+    var config = window.webClientConfig;
+    var useCaptcha = true;
+    if (config !== undefined) {
+        useCaptcha = config.useCaptcha;
+    }
+    
     // FIXME: factor out duplication with login-controller.js
     
     // Assume that this is hosted on the home server, in which case the URL
@@ -87,9 +93,12 @@ angular.module('RegisterController', ['matrixService'])
     };
 
     $scope.registerWithMxidAndPassword = function(mxid, password, threepidCreds) {
-        matrixService.register(mxid, password, threepidCreds).then(
+        matrixService.register(mxid, password, threepidCreds, useCaptcha).then(
             function(response) {
                 $scope.feedback = "Success";
+                if (useCaptcha) {
+                    Recaptcha.destroy();
+                }
                 // Update the current config 
                 var config = matrixService.config();
                 angular.extend(config, {
@@ -116,11 +125,21 @@ angular.module('RegisterController', ['matrixService'])
             },
             function(error) {
                 console.trace("Registration error: "+error);
+                if (useCaptcha) {
+                    Recaptcha.reload();
+                }
                 if (error.data) {
                     if (error.data.errcode === "M_USER_IN_USE") {
                         $scope.feedback = "Username already taken.";
                         $scope.reenter_username = true;
                     }
+                    else if (error.data.errcode == "M_CAPTCHA_INVALID") {
+                        $scope.feedback = "Failed captcha.";
+                    }
+                    else if (error.data.errcode == "M_CAPTCHA_NEEDED") {
+                        $scope.feedback = "Captcha is required on this home " +
+                                          "server.";
+                    }
                 }
                 else if (error.status === 0) {
                     $scope.feedback = "Unable to talk to the server.";
@@ -142,6 +161,33 @@ angular.module('RegisterController', ['matrixService'])
             }
         );
     };
+	
+	var setupCaptcha = function() {
+	    console.log("Setting up ReCaptcha")
+        var config = window.webClientConfig;
+        var public_key = undefined;
+        if (config === undefined) {
+            console.error("Couldn't find webClientConfig. Cannot get public key for captcha.");
+        }
+        else {
+            public_key = webClientConfig.recaptcha_public_key;
+            if (public_key === undefined) {
+                console.error("No public key defined for captcha!")
+            }
+        }
+	    Recaptcha.create(public_key,
+	    "regcaptcha",
+	    {
+	      theme: "red",
+	      callback: Recaptcha.focus_response_field
+	    });
+	};
 
+	$scope.init = function() {
+        if (useCaptcha) {
+	        setupCaptcha();
+        }
+	};
+    
 }]);
 
diff --git a/webclient/login/register.html b/webclient/login/register.html
index 06a6526b70..a27f9ad4e8 100644
--- a/webclient/login/register.html
+++ b/webclient/login/register.html
@@ -12,7 +12,6 @@
                 
                 <div style="text-align: center">
                     <br/>
-
                     <input ng-show="!wait_3pid_code" id="email" size="32" type="text" ng-focus="true" ng-model="account.email" placeholder="Email address (optional)"/>
                     <div ng-show="!wait_3pid_code" class="smallPrint">Specifying an email address lets other users find you on Matrix more easily,<br/>
                         and will give you a way to reset your password in the future</div>
@@ -26,7 +25,10 @@
                     <input ng-show="!wait_3pid_code" id="displayName" size="32" type="text" ng-model="account.displayName" placeholder="Display name (e.g. Bob Obson)"/>
                     <br ng-show="!wait_3pid_code" />
                     <br ng-show="!wait_3pid_code" />
-                    
+
+
+                    <div id="regcaptcha" ng-init="init()" />
+
                     <button ng-show="!wait_3pid_code" ng-click="register()" ng-disabled="!account.desired_user_id || !account.homeserver || !account.pwd1 || !account.pwd2 || account.pwd1 !== account.pwd2">Sign up</button>
 
                     <div ng-show="wait_3pid_code">