diff options
author | Kegan Dougal <kegan@matrix.org> | 2014-09-05 23:32:51 -0700 |
---|---|---|
committer | Kegan Dougal <kegan@matrix.org> | 2014-09-05 23:32:51 -0700 |
commit | 1a298aad9c3bbc1f0c80c40a1518c29c511f9fb4 (patch) | |
tree | 8c27ca9b59021a5f9211cfb2c167ceaf083987d2 /synapse | |
parent | Fix generation of event ids so that they are consistent between local and rem... (diff) | |
parent | Added instructions for setting up captcha in an obviously named file. (diff) | |
download | synapse-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.py | 15 | ||||
-rw-r--r-- | synapse/config/captcha.py | 42 | ||||
-rw-r--r-- | synapse/config/homeserver.py | 3 | ||||
-rw-r--r-- | synapse/handlers/register.py | 68 | ||||
-rw-r--r-- | synapse/http/client.py | 28 | ||||
-rw-r--r-- | synapse/rest/register.py | 36 |
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, |