Source code for sagenb.notebook.challenge

# -*- 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
[docs]class NotConfiguredChallenge(AbstractChallenge): """ A fallback challenge used when an administrator has not configured a specific method. """
[docs] def html(self, **kwargs): """ Returns a suggestion to inform the Notebook server's administrator about the misconfigured challenge. INPUT: - ``conf`` - a :class:`ServerConfiguration`; an instance of the server's configuration - ``kwargs`` - a dictionary of keyword arguments OUTPUT: - a string TESTS:: sage: from sagenb.notebook.challenge import NotConfiguredChallenge sage: tmp = tmp_dir(ext='.sagenb') sage: import sagenb.notebook.notebook as n sage: nb = n.Notebook(tmp) sage: chal = NotConfiguredChallenge(nb.conf()) sage: print(chal.html()) Please ask the server administrator to configure a challenge! """ return _("Please ask the server administrator to configure a challenge!")
[docs] def is_valid_response(self, **kwargs): """ Always reports a failed response, for the sake of security. INPUT: - ``kwargs`` - a dictionary of keyword arguments OUTPUT: - a :class:`ChallengeResponse` instance TESTS:: sage: from sagenb.notebook.challenge import NotConfiguredChallenge sage: tmp = tmp_dir(ext='.sagenb') sage: import sagenb.notebook.notebook as n sage: nb = n.Notebook(tmp) sage: chal = NotConfiguredChallenge(nb.conf()) sage: chal.is_valid_response().is_valid False sage: chal.is_valid_response().error_code '' """ return ChallengeResponse(False, '')
# 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)()