# -*- coding: utf-8 -*
r"""
Notebook Registration Challenges
This module includes support for challenge-response tests posed to
users registering for new Sage notebook accounts. These \ **C**\
ompletely \ **A**\ utomated \ **P**\ ublic \ **T**\ uring tests to
tell \ **C**\ omputers and \ **H**\ umans \ **A**\ part, or CAPTCHAs,
may be simple math questions, requests for special registration codes,
or reCAPTCHAs_.
.. _reCAPTCHAs: http://recaptcha.net/
AUTHORS:
- reCAPTCHA_ is written by Ben Maurer and maintained by Josh
Bronson. It is licensed under a MIT/X11 license. The reCAPTCHA
challenge implemented in :class:`reCAPTCHAChallenge` is adapted from
`this Python API`_, which is also available here_.
.. _reCAPTCHA: http://recaptcha.net/
.. _this Python API: http://pypi.python.org/pypi/recaptcha-client
.. _here: http://code.google.com/p/recaptcha
"""
import os
import random
import re
from six.moves.urllib.parse import urlencode
from six.moves.urllib.request import urlopen, Request
from sagenb.notebook.template import template
from flask_babel import gettext, lazy_gettext
_ = lazy_gettext
[docs]class ChallengeResponse(object):
"""
A simple challenge response class that indicates whether a
response is empty, correct, or incorrect, and, if it's incorrect,
includes an optional error code.
"""
def __init__(self, is_valid, error_code = None):
"""
Instantiates a challenge response.
INPUT:
- ``is_valid`` - a boolean or None; whether there response is
valid
- ``error_code`` - a string (default: None); an optional error
code if ``is_valid`` is False
TESTS::
sage: from sagenb.notebook.challenge import ChallengeResponse
sage: resp = ChallengeResponse(False, 'Wrong! Please try again.')
sage: resp.is_valid
False
sage: resp.error_code
'Wrong! Please try again.'
"""
self.is_valid = is_valid
self.error_code = error_code
[docs]class AbstractChallenge(object):
"""
An abstract class with a suggested common interface for specific
challenge-response schemes.
"""
def __init__(self, conf, **kwargs):
"""
Instantiates an abstract challenge.
INPUT:
- ``conf`` - a :class:`ServerConfiguration`; a notebook server
configuration instance
- ``kwargs`` - a dictionary of keyword arguments
TESTS::
sage: from sagenb.notebook.challenge import AbstractChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = AbstractChallenge(nb.conf())
"""
pass
[docs] def html(self, **kwargs):
"""
Returns HTML for the challenge, e.g., to insert into a new
account registration page.
INPUT:
- ``kwargs`` - a dictionary of keywords arguments
OUTPUT:
- a string; HTML form representation of the challenge,
including a field for the response, supporting hidden
fields, JavaScript code, etc.
TESTS::
sage: from sagenb.notebook.challenge import AbstractChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = AbstractChallenge(nb.conf())
sage: chal.html()
Traceback (most recent call last):
...
NotImplementedError
"""
raise NotImplementedError
[docs] def is_valid_response(self, **kwargs):
"""
Returns the status of a challenge response.
INPUT:
- ``kwargs`` - a dictionary of keyword arguments
OUTPUT:
- a :class:`ChallengeResponse` instance
TESTS::
sage: from sagenb.notebook.challenge import AbstractChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = AbstractChallenge(nb.conf())
sage: chal.is_valid_response()
Traceback (most recent call last):
...
NotImplementedError
"""
raise NotImplementedError
# HTML template for :class:`SimpleChallenge`.
SIMPLE_TEMPLATE = u"""<p>%(question)s</p>
<input type="text" id="simple_response_field" name="simple_response_field" class="entry" tabindex="5" />
<input type="hidden" value="%(untranslated_question)s" id="simple_challenge_field" name="simple_challenge_field" class="entry" />
"""
old_tr = _
_ = lambda s: s
# A set of sample questions for :class:`SimpleChallenge`.
QUESTIONS = {
_('Is pi > e?') : _('y|yes'),
_('What is 3 times 8?') : _('24|twenty-four'),
_('What is 2 plus 3?') : _('5|five'),
_('How many bits are in one byte?') : _('8|eight'),
_('What is the largest prime factor of 15?') : _('5|five'),
# 'What is the smallest perfect number?' : r'6|six',
# 'What is our class registration code?' : r'XYZ123',
# 'What is the smallest integer expressible as the sum of two positive cubes in two distinct ways?' : r'1729',
# 'How many permutations of ABCD agree with it in no position? For example, BDCA matches ABCD only in position 3.' : r'9|nine',
}
# QUESTIONS is now dict of str->str
#let's make answers lazy translated:
for key in QUESTIONS: QUESTIONS[key] = old_tr(QUESTIONS[key])
_ = old_tr
del old_tr
[docs]def agree(response, answer):
"""
Returns whether a challenge response agrees with the answer.
INPUT:
- ``response`` - a string; the user's response to a posed challenge
- ``answer`` - a string; the challenge's right answer as a regular
expression
OUTPUT:
- a boolean; whether the response agrees with the answer
TESTS::
sage: from sagenb.notebook.challenge import agree
sage: agree('0', r'0|zero')
True
sage: agree('eighty', r'8|eight')
False
"""
response = re.sub(r'\s+', ' ', response.strip())
m = re.search(r'^(' + answer + r')$', response, re.IGNORECASE)
if m:
return True
else:
return False
[docs]class SimpleChallenge(AbstractChallenge):
"""
A simple question and answer challenge.
"""
[docs] def html(self, **kwargs):
"""
Returns a HTML form posing a randomly chosen question.
INPUT:
- ``kwargs`` - a dictionary of keyword arguments
OUTPUT:
- a string; the HTML form
TESTS::
sage: from sagenb.notebook.challenge import SimpleChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = SimpleChallenge(nb.conf())
sage: chal.html() # random
'...What is the largest prime factor of 1001?...'
"""
question = random.choice([q for q in QUESTIONS])
return SIMPLE_TEMPLATE % { 'question' : gettext(question),
'untranslated_question': question }
[docs] def is_valid_response(self, req_args = {}, **kwargs):
"""
Returns the status of a user's answer to the challenge
question.
INPUT:
- ``req_args`` - a string:list dictionary; the arguments of
the remote client's HTTP POST request
- ``kwargs`` - a dictionary of extra keyword arguments
OUTPUT:
- a :class:`ChallengeResponse` instance
TESTS::
sage: from sagenb.notebook.challenge import SimpleChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = SimpleChallenge(nb.conf())
sage: req = {}
sage: chal.is_valid_response(req).is_valid
sage: chal.is_valid_response(req).error_code
''
sage: from sagenb.notebook.challenge import QUESTIONS
sage: ques, ans = sorted(QUESTIONS.items())[0]
sage: ans = ans.split('|')[0]
sage: print(ques)
How many bits are in one byte?
sage: print(ans)
8
sage: req['simple_response_field'] = ans
sage: chal.is_valid_response(req).is_valid
False
sage: chal.is_valid_response(req).error_code
''
sage: req['simple_challenge_field'] = ques
sage: chal.is_valid_response(req).is_valid
True
sage: chal.is_valid_response(req).error_code
''
"""
response_field = req_args.get('simple_response_field', None)
if not (response_field and len(response_field)):
return ChallengeResponse(None, '')
challenge_field = req_args.get('simple_challenge_field', None)
if not (challenge_field and len(challenge_field)):
return ChallengeResponse(False, '')
if agree(response_field, gettext(QUESTIONS[challenge_field])):
return ChallengeResponse(True, '')
else:
return ChallengeResponse(False, '')
RECAPTCHA_SERVER = "http://api.recaptcha.net"
RECAPTCHA_SSL_SERVER = "https://api-secure.recaptcha.net"
RECAPTCHA_VERIFY_SERVER = "api-verify.recaptcha.net"
[docs]class reCAPTCHAChallenge(AbstractChallenge):
"""
A reCAPTCHA_ challenge adapted from `this Python API`_, also
hosted here_, written by Ben Maurer and maintained by Josh
Bronson.
.. _reCAPTCHA: http://recaptcha.net/
.. _this Python API: http://pypi.python.org/pypi/recaptcha-client
.. _here: http://code.google.com/p/recaptcha
"""
def __init__(self, conf, remote_ip = '', is_secure = False, lang = 'en',
**kwargs):
"""
Instantiates a reCAPTCHA challenge.
INPUT:
- ``conf`` - a :class:`ServerConfiguration`; an instance of the
notebook server's configuration
- ``remote_ip`` - a string (default: ''); the user's IP
address, **required** by reCAPTCHA
- ``is_secure`` - a boolean (default: False); whether the
user's connection is secure, e.g., over SSL
- ``lang`` - a string (default 'en'); the language used for
the reCAPTCHA interface. As of October 2009, the
pre-defined choices are 'en', 'nl', 'fr', 'de', 'pt', 'ru',
'es', and 'tr'
- ``kwargs`` - a dictionary of extra keyword arguments
ATTRIBUTES:
- ``public_key`` - a string; a **site-specific** public
key obtained at the `reCAPTCHA site`_.
- ``private_key`` - a string; a **site-specific** private
key obtained at the `reCAPTCHA site`_.
.. _reCAPTCHA site: http://recaptcha.net/whyrecaptcha.html
Currently, the keys are read from ``conf``'s
``recaptcha_public_key`` and ``recaptcha_private_key``
settings.
TESTS::
sage: from sagenb.notebook.challenge import reCAPTCHAChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = reCAPTCHAChallenge(nb.conf(), remote_ip = 'localhost')
"""
self.remote_ip = remote_ip
if is_secure:
self.api_server = RECAPTCHA_SSL_SERVER
else:
self.api_server = RECAPTCHA_SERVER
self.lang = lang
self.public_key = conf['recaptcha_public_key']
self.private_key = conf['recaptcha_private_key']
[docs] def html(self, error_code = None, **kwargs):
"""
Returns HTML and JavaScript for a reCAPTCHA challenge and
response field.
INPUT:
- ``error_code`` - a string (default: None); an optional error
code to embed in the HTML, giving feedback about the user's
*previous* response
- ``kwargs`` - a dictionary of extra keyword arguments
OUTPUT:
- a string; HTML and JavaScript to render the reCAPTCHA
challenge
TESTS::
sage: from sagenb.flask_version import base # random output -- depends on warnings issued by other sage packages
sage: app = base.create_app(tmp_dir(ext='.sagenb'))
sage: ctx = app.app_context()
sage: ctx.push()
sage: nb = base.notebook
sage: from sagenb.notebook.challenge import reCAPTCHAChallenge
sage: chal = reCAPTCHAChallenge(nb.conf(), remote_ip = 'localhost')
sage: chal.html()
u'...recaptcha...'
sage: chal.html('incorrect-captcha-sol')
u'...incorrect-captcha-sol...'
"""
error_param = ''
if error_code:
error_param = '&error=%s' % error_code
template_dict = { 'api_server' : self.api_server,
'public_key' : self.public_key,
'error_param' : error_param,
'lang' : self.lang }
return template(os.path.join('html', 'recaptcha.html'),
**template_dict)
[docs] def is_valid_response(self, req_args = {}, **kwargs):
"""
Submits a reCAPTCHA request for verification and returns its
status.
INPUT:
- ``req_args`` - a dictionary; the arguments of the responding
user's HTTP POST request
- ``kwargs`` - a dictionary of extra keyword arguments
OUTPUT:
- a :class:`ChallengeResponse` instance; whether the user's
response is empty, accepted, or rejected, with an optional
error string
TESTS::
sage: from sagenb.notebook.challenge import reCAPTCHAChallenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: chal = reCAPTCHAChallenge(nb.conf(), remote_ip = 'localhost')
sage: req = {}
sage: chal.is_valid_response(req).is_valid
sage: chal.is_valid_response(req).error_code
''
sage: req['recaptcha_response_field'] = ['subplotTimes']
sage: chal.is_valid_response(req).is_valid
False
sage: chal.is_valid_response(req).error_code
'incorrect-captcha-sol'
sage: req['simple_challenge_field'] = ['VBORw0KGgoANSUhEUgAAAB']
sage: chal.is_valid_response(req).is_valid # random
False
sage: chal.is_valid_response(req).error_code # random
'incorrect-captcha-sol'
"""
response_field = req_args.get('recaptcha_response_field', [None])[0]
if not (response_field and len(response_field)):
return ChallengeResponse(None, '')
challenge_field = req_args.get('recaptcha_challenge_field', [None])[0]
if not (challenge_field and len(challenge_field)):
return ChallengeResponse(False, 'incorrect-captcha-sol')
def encode_if_necessary(s):
if isinstance(s, unicode):
return s.encode('utf-8')
return s
params = urlencode({
'privatekey': encode_if_necessary(self.private_key),
'remoteip' : encode_if_necessary(self.remote_ip),
'challenge': encode_if_necessary(challenge_field),
'response' : encode_if_necessary(response_field)
})
request = Request(
url = "http://%s/verify" % RECAPTCHA_VERIFY_SERVER,
data = params,
headers = {
"Content-type": "application/x-www-form-urlencoded",
"User-agent": "reCAPTCHA Python"
}
)
httpresp = urlopen(request)
return_values = httpresp.read().splitlines();
httpresp.close();
return_code = return_values[0]
if (return_code == "true"):
return ChallengeResponse(True)
else:
return ChallengeResponse(False, return_values[1])
[docs]class ChallengeDispatcher(object):
"""
A simple dispatcher class that provides access to a specific
challenge.
"""
def __init__(self, conf, **kwargs):
"""
Uses the server's configuration to select and set up a
challenge.
INPUT:
- ``conf`` - a :class:`ServerConfiguration`; a server
configuration instance
- ``kwargs`` - a dictionary of keyword arguments
ATTRIBUTES:
- ``type`` - a string; the type of challenge to set up
Currently, ``type`` is read from ``conf``'s ``challenge_type``
setting.
TESTS::
sage: from sagenb.notebook.challenge import ChallengeDispatcher
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: disp = ChallengeDispatcher(nb.conf())
sage: disp.type # random
'recaptcha'
"""
self.type = conf['challenge_type']
if self.type == 'recaptcha':
# Very simple test for public and private reCAPTCHA keys.
if conf['recaptcha_public_key'] and conf['recaptcha_private_key']:
self.challenge = reCAPTCHAChallenge(conf, **kwargs)
else:
self.challenge = NotConfiguredChallenge(conf, **kwargs)
elif self.type == 'simple':
self.challenge = SimpleChallenge(conf, **kwargs)
else:
self.challenge = NotConfiguredChallenge(conf, **kwargs)
def __call__(self):
"""
Returns a previously set up challenge.
OUTPUT:
- an instantiated subclass of :class:`AbstractChallenge`.
TESTS::
sage: from sagenb.notebook.challenge import ChallengeDispatcher
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: nb.conf()['challenge_type'] = 'simple'
sage: disp = ChallengeDispatcher(nb.conf())
sage: disp().html() # random
'<p>...'
sage: nb.conf()['challenge_type'] = 'mistake'
sage: disp = ChallengeDispatcher(nb.conf())
sage: print(disp().html())
Please ask the server administrator to configure a challenge!
"""
return self.challenge
[docs]def challenge(conf, **kwargs):
"""
Wraps an instance of :class:`ChallengeDispatcher` and returns an
instance of a specific challenge.
INPUT:
- ``conf`` - a :class:`ServerConfiguration`; a server configuration
instance
- ``kwargs`` - a dictionary of keyword arguments
OUTPUT:
- an instantiated subclass of :class:`AbstractChallenge`
TESTS::
sage: from sagenb.notebook.challenge import challenge
sage: tmp = tmp_dir(ext='.sagenb')
sage: import sagenb.notebook.notebook as n
sage: nb = n.Notebook(tmp)
sage: nb.conf()['challenge_type'] = 'simple'
sage: chal = challenge(nb.conf())
sage: chal.html() # random
'<p>...'
"""
return ChallengeDispatcher(conf, **kwargs)()