summary refs log tree commit diff
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
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
-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">