# 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

    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

    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

        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

        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

    class NotConfiguredChallenge(AbstractChallenge):
        """
        A fallback challenge used when an administrator has not configured
        a specific method.
        """
        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!")

        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

    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

    class SimpleChallenge(AbstractChallenge):
        """
        A simple question and answer challenge.
        """
        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 }

        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"

    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']

        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)

        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])

    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

    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)()