Added captcha support on both the HS and web client.

Merge branch 'captcha' of github.com:matrix-org/synapse into develop
This commit is contained in:
Kegan Dougal 2014-09-05 23:32:51 -07:00
commit 1a298aad9c
13 changed files with 309 additions and 21 deletions

2
.gitignore vendored
View file

@ -24,4 +24,6 @@ graph/*.svg
graph/*.png graph/*.png
graph/*.dot graph/*.dot
webclient/config.js
uploads uploads

View file

@ -29,6 +29,8 @@ class Codes(object):
NOT_FOUND = "M_NOT_FOUND" NOT_FOUND = "M_NOT_FOUND"
UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN" UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN"
LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED" LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED"
CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
class CodeMessageException(Exception): class CodeMessageException(Exception):
@ -101,6 +103,19 @@ class StoreError(SynapseError):
pass 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): class LimitExceededError(SynapseError):
"""A client has sent too many requests and is being throttled. """A client has sent too many requests and is being throttled.
""" """

42
synapse/config/captcha.py Normal file
View file

@ -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."
)

View file

@ -19,9 +19,10 @@ from .logger import LoggingConfig
from .database import DatabaseConfig from .database import DatabaseConfig
from .ratelimiting import RatelimitConfig from .ratelimiting import RatelimitConfig
from .repository import ContentRepositoryConfig from .repository import ContentRepositoryConfig
from .captcha import CaptchaConfig
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig): RatelimitConfig, ContentRepositoryConfig, CaptchaConfig):
pass pass
if __name__=='__main__': if __name__=='__main__':

View file

@ -17,7 +17,9 @@
from twisted.internet import defer from twisted.internet import defer
from synapse.types import UserID from synapse.types import UserID
from synapse.api.errors import SynapseError, RegistrationError from synapse.api.errors import (
SynapseError, RegistrationError, InvalidCaptchaError
)
from ._base import BaseHandler from ._base import BaseHandler
import synapse.util.stringutils as stringutils import synapse.util.stringutils as stringutils
from synapse.http.client import PlainHttpClient from synapse.http.client import PlainHttpClient
@ -38,7 +40,8 @@ class RegistrationHandler(BaseHandler):
self.distributor.declare("registered_user") self.distributor.declare("registered_user")
@defer.inlineCallbacks @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. """Registers a new client on the server.
Args: Args:
@ -51,10 +54,26 @@ class RegistrationHandler(BaseHandler):
Raises: Raises:
RegistrationError if there was a problem registering. 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: if threepidCreds:
for c in 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: try:
threepid = yield self._threepid_from_creds(c) threepid = yield self._threepid_from_creds(c)
except: except:
@ -63,7 +82,8 @@ class RegistrationHandler(BaseHandler):
if not threepid: if not threepid:
raise RegistrationError(400, "Couldn't validate 3pid") 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 password_hash = None
if password: if password:
@ -131,7 +151,8 @@ class RegistrationHandler(BaseHandler):
# XXX: make this configurable! # XXX: make this configurable!
trustedIdServers = [ 'matrix.org:8090' ] trustedIdServers = [ 'matrix.org:8090' ]
if not creds['idServer'] in trustedIdServers: 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) defer.returnValue(None)
data = yield httpCli.get_json( data = yield httpCli.get_json(
creds['idServer'], creds['idServer'],
@ -149,9 +170,44 @@ class RegistrationHandler(BaseHandler):
data = yield httpCli.post_urlencoded_get_json( data = yield httpCli.post_urlencoded_get_json(
creds['idServer'], creds['idServer'],
"/_matrix/identity/api/v1/3pid/bind", "/_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.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)

View file

@ -16,7 +16,7 @@
from twisted.internet import defer, reactor from twisted.internet import defer, reactor
from twisted.internet.error import DNSLookupError 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 twisted.web.http_headers import Headers
from synapse.http.endpoint import matrix_endpoint from synapse.http.endpoint import matrix_endpoint
@ -188,6 +188,32 @@ class TwistedHttpClient(HttpClient):
body = yield readBody(response) body = yield readBody(response)
defer.returnValue(json.loads(body)) 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 @defer.inlineCallbacks
def _create_request(self, destination, method, path_bytes, param_bytes=b"", def _create_request(self, destination, method, path_bytes, param_bytes=b"",

View file

@ -16,7 +16,7 @@
"""This module contains REST servlets to do with registration: /register""" """This module contains REST servlets to do with registration: /register"""
from twisted.internet import defer 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 from base import RestServlet, client_path_pattern
import json import json
@ -50,12 +50,44 @@ class RegisterRestServlet(RestServlet):
threepidCreds = None threepidCreds = None
if 'threepidCreds' in register_json: if 'threepidCreds' in register_json:
threepidCreds = register_json['threepidCreds'] 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 handler = self.handlers.registration_handler
(user_id, token) = yield handler.register( (user_id, token) = yield handler.register(
localpart=desired_user_id, localpart=desired_user_id,
password=password, password=password,
threepidCreds=threepidCreds) threepidCreds=threepidCreds,
captcha_info=captcha)
result = { result = {
"user_id": user_id, "user_id": user_id,

46
webclient/CAPTCHA_SETUP Normal file
View file

@ -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

View file

@ -1,12 +1,13 @@
Basic Usage Basic Usage
----------- -----------
The Synapse web client needs to be hosted by a basic HTTP server. The web client should automatically run when running the home server. Alternatively, you can run
it stand-alone:
You can use the Python simple HTTP server::
$ python -m SimpleHTTPServer $ python -m SimpleHTTPServer
Then, open this URL in a WEB browser:: Then, open this URL in a WEB browser::
http://127.0.0.1:8000/ http://127.0.0.1:8000/

View file

@ -84,15 +84,32 @@ angular.module('matrixService', [])
prefix: prefixPath, prefix: prefixPath,
// Register an user // Register an user
register: function(user_name, password, threepidCreds) { register: function(user_name, password, threepidCreds, useCaptcha) {
// The REST path spec // The REST path spec
var path = "/register"; var path = "/register";
return doRequest("POST", path, undefined, { var data = {
user_id: user_name, user_id: user_name,
password: password, password: password,
threepidCreds: threepidCreds 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 // Create a room

View file

@ -10,12 +10,14 @@
<meta name="viewport" content="width=device-width"> <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.min.js"></script>
<script src="js/angular-route.min.js"></script> <script src="js/angular-route.min.js"></script>
<script src="js/angular-sanitize.min.js"></script> <script src="js/angular-sanitize.min.js"></script>
<script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script> <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="config.js"></script>
<script src="app-controller.js"></script> <script src="app-controller.js"></script>
<script src="app-directive.js"></script> <script src="app-directive.js"></script>
<script src="app-filter.js"></script> <script src="app-filter.js"></script>

View file

@ -19,6 +19,12 @@ angular.module('RegisterController', ['matrixService'])
function($scope, $rootScope, $location, matrixService, eventStreamService) { function($scope, $rootScope, $location, matrixService, eventStreamService) {
'use strict'; 'use strict';
var config = window.webClientConfig;
var useCaptcha = true;
if (config !== undefined) {
useCaptcha = config.useCaptcha;
}
// FIXME: factor out duplication with login-controller.js // FIXME: factor out duplication with login-controller.js
// Assume that this is hosted on the home server, in which case the URL // 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) { $scope.registerWithMxidAndPassword = function(mxid, password, threepidCreds) {
matrixService.register(mxid, password, threepidCreds).then( matrixService.register(mxid, password, threepidCreds, useCaptcha).then(
function(response) { function(response) {
$scope.feedback = "Success"; $scope.feedback = "Success";
if (useCaptcha) {
Recaptcha.destroy();
}
// Update the current config // Update the current config
var config = matrixService.config(); var config = matrixService.config();
angular.extend(config, { angular.extend(config, {
@ -116,11 +125,21 @@ angular.module('RegisterController', ['matrixService'])
}, },
function(error) { function(error) {
console.trace("Registration error: "+error); console.trace("Registration error: "+error);
if (useCaptcha) {
Recaptcha.reload();
}
if (error.data) { if (error.data) {
if (error.data.errcode === "M_USER_IN_USE") { if (error.data.errcode === "M_USER_IN_USE") {
$scope.feedback = "Username already taken."; $scope.feedback = "Username already taken.";
$scope.reenter_username = true; $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) { else if (error.status === 0) {
$scope.feedback = "Unable to talk to the server."; $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();
}
};
}]); }]);

View file

@ -12,7 +12,6 @@
<div style="text-align: center"> <div style="text-align: center">
<br/> <br/>
<input ng-show="!wait_3pid_code" id="email" size="32" type="text" ng-focus="true" ng-model="account.email" placeholder="Email address (optional)"/> <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/> <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> 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)"/> <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" />
<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> <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"> <div ng-show="wait_3pid_code">