Changeset 18083


Ignore:
Timestamp:
7 Jun 2025, 02:06:59 (35 hours ago)
Author:
uli
Message:

Support Google Recaptcha v3.

Location:
main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa
Files:
9 edited

Legend:

Unmodified
Added
Removed
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/applicants/browser.py

    r18021 r18083  
    5050from waeup.kofa.applicants.applicant import search
    5151from waeup.kofa.applicants.workflow import (
    52     INITIALIZED, STARTED, PAID, SUBMITTED, 
     52    INITIALIZED, STARTED, PAID, SUBMITTED,
    5353    ADMITTED, NOT_ADMITTED, CREATED, PROCESSED)
    5454from waeup.kofa.browser import (
     
    843843    def render(self):
    844844        return
    845        
     845
    846846class BalancePaymentAddFormPage(KofaAddFormPage):
    847847    """ Page to add an online payment which can balance s previous session
     
    10781078    manage_applications = True
    10791079    pnav = 3
    1080    
     1080
    10811081    @property
    10821082    def display_actions(self):
     
    12121212        if self.upload_success is False:  # False is not None!
    12131213            # Error during image upload. Ignore other values.
    1214             return 
     1214            return
    12151215        changed_fields = self.applyData(self.context, **data)
    12161216        # Turn list of lists into single list
     
    15781578        # Handle captcha
    15791579        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    1580         self.captcha_result = self.captcha.verify(self.request)
    1581         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     1580        self.captcha_result = self.captcha.verify(
     1581            self.request, 'applicant_register')
     1582        self.captcha_code = self.captcha.display(
     1583            self.captcha_result.error_code, 'applicant_register')
    15821584        return
    15831585
     
    17071709        # Handle captcha
    17081710        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    1709         self.captcha_result = self.captcha.verify(self.request)
    1710         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     1711        self.captcha_result = self.captcha.verify(
     1712            self.request, 'check_appl_status')
     1713        self.captcha_code = self.captcha.display(
     1714            self.captcha_result.error_code, 'check_appl_status')
    17111715        if SUBMIT:
    17121716            if not self.captcha_result.is_valid:
     
    17921796        # Handle captcha
    17931797        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    1794         self.captcha_result = self.captcha.verify(self.request)
    1795         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     1798        self.captcha_result = self.captcha.verify(
     1799            self.request, 'check_trans_state')
     1800        self.captcha_code = self.captcha.display(
     1801            self.captcha_result.error_code, 'check_trans_state')
    17961802        if SUBMIT:
    17971803            self.results = []
     
    20872093            args = {'mandate_id':mandate.mandate_id}
    20882094            # Check if report exists.
    2089             # (1) If mandate has been used to create a report, 
     2095            # (1) If mandate has been used to create a report,
    20902096            # redirect to the pdf file.
    20912097            if mandate.params.get('redirect_path2'):
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/captcha.py

    r15012 r18083  
    2121"""
    2222import grok
     23import decimal
    2324import json
     25import logging
    2426import urllib
    2527import urllib2
     
    3335    ICaptchaRequest, ICaptchaResponse, ICaptcha, ICaptchaConfig,
    3436    ICaptchaManager)
    35 from waeup.kofa.interfaces import IUniversity
     37from waeup.kofa.interfaces import IUniversity, IReCaptchaConfig
    3638
    3739#
     
    103105    grok.implements(ICaptcha)
    104106
    105     def verify(self, request):
     107    def verify(self, request, action="submit"):
    106108        return CaptchaResponse(True, None)
    107109
    108     def display(self, error_code=None):
     110    def display(self, error_code=None, action="submit"):
    109111        return u''
    110112
     
    139141    chal_field = 'challenge'
    140142
    141     def verify(self, request):
     143    def verify(self, request, action='submit'):
    142144        """Verify that a solution sent equals the challenge.
    143145        """
     
    149151        return CaptchaResponse(is_valid=False)
    150152
    151     def display(self, error_code=None):
     153    def display(self, error_code=None, action='submit'):
    152154        """Display challenge and input field for solution as HTML.
    153155        """
     
    164166
    165167##
    166 ## ReCaptcha (v2)
     168## ReCaptcha (v3)
    167169##
    168170class ReCaptcha(StaticCaptcha):
     
    170172
    171173    This is the Kofa implementation to support captchas as provided by
    172     http://www.google.com/recaptcha (v2).
     174    http://www.google.com/recaptcha (v3).
    173175
    174176    ReCaptcha is widely used and adopted in web applications. See the
     
    226228        - Google will track all activity of our clients
    227229    """
    228 
    229230    grok.implements(ICaptcha)
    230231
     
    240241    VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'
    241242
    242     def verify(self, request):
     243    @property
     244    def sitekey(self):
     245        return self.get_config()['public_key']
     246
     247    @property
     248    def logger(self):
     249        # if there is no site, we are most probably in a testing env
     250        return getattr(grok.getSite(), "logger", logging.getLogger("dummy"))
     251
     252    @property
     253    def ob_class(self):
     254        return self.__implemented__.__name__.replace('waeup.kofa.', '')
     255
     256    def get_config(self):
     257        if not getattr(self, "config", None):
     258            self.config = queryUtility(IReCaptchaConfig, default={
     259                'public_key': self.PUBLIC_KEY,
     260                'private_key': self.PRIVATE_KEY,
     261                'hostname': u'localhost',
     262                'min_score': decimal.Decimal("0.5")
     263                })
     264        self.logger.debug(u'%s : getting ReCaptchaConfig: %s' % (
     265            self.ob_class, self.config))
     266        return self.config
     267
     268    def verify(self, request, action='submit'):
    243269        """Grab challenge/solution from HTTP request and verify it.
    244270
     
    249275        Returns a :class:`CaptchaResponse` indicating that the
    250276        verification failed or succeeded.
    251         """
     277
     278        Possible error codes (official Google codes):
     279            missing-input-secret    The secret parameter is missing.
     280            invalid-input-secret    The secret parameter is invalid or malformed.
     281            missing-input-response  The response parameter is missing.
     282            invalid-input-response  The response parameter is invalid or malformed.
     283            bad-request     The request is invalid or malformed.
     284            timeout-or-duplicate    The response is no longer valid: either is too old or has been used previously.
     285        We also have our own ones:
     286            invalid-action          The passed action does not match the one we expected
     287            insufficient-score      The returned score is too low; talking to a bot
     288            invalid-hostname        The captcha was solved on a different domain.
     289        """
     290        remote_ip = request.get('HTTP_X_FORWARDED_FOR', '127.0.0.1')
    252291        form = getattr(request, 'form', {})
    253292        token = form.get(self.token_field, None)
     293        oc = self.ob_class
     294        self.logger.debug(u'%s: get token from form: %s' % (oc, token))
    254295        if not token:
    255296            # on first-time display, we won't get a token
    256297            return CaptchaResponse(is_valid=False)
     298        conf = self.get_config()
    257299        params = urllib.urlencode(
    258300            {
    259                 'secret': self.PRIVATE_KEY,
     301                'secret': conf['private_key'],
    260302                'response': token,
    261                 'remoteip': '127.0.0.1',
     303                'remoteip': remote_ip,
    262304                })
    263         request = urllib2.Request(
     305        self.logger.debug(u'%s: send validation request to Google' % oc)
     306        v_request = urllib2.Request(
    264307            url = self.VERIFY_URL,
    265308            data = params,
     
    269312                }
    270313            )
    271         conn = urllib2.urlopen(request)
    272         ret_vals = json.loads(conn.read())
    273         conn.close()
     314        try:
     315            conn = urllib2.urlopen(v_request)
     316            ret_vals = json.loads(conn.read())
     317        except:
     318            conn.close()
     319            return CaptchaResponse(is_valid=False, error_code="could-not-reach-verification-server")
     320        self.logger.debug(u'%s: got answer from Google: %s' % (oc, ret_vals))
     321        err_codes = ret_vals.get('error-codes', [])
    274322        if ret_vals.get('success', False) is True:
    275             return CaptchaResponse(is_valid=True)
     323            r_action = ret_vals.get('action', None)
     324            score = ret_vals.get('score', decimal.Decimal("0.0"))
     325            hostname = ret_vals.get('hostname', '')
     326            if r_action != action:
     327                err_codes.append('invalid-action')
     328                self.logger.warn(
     329                    u'%s: invalid action code: %s (expected: %s)' %
     330                    (oc, r_action, action))
     331            elif score < conf['min_score']:
     332                err_codes.append('insufficent-score')
     333                self.logger.debug(u'%s: insufficient score: %s' % (oc, score))
     334            elif not hostname.endswith(conf['hostname']):
     335                err_codes.append('invalid-hostname')
     336                self.logger.warn(
     337                    u'%s: cought captcha from illegal hostname: %s' % (
     338                        oc, hostname))
     339            else:
     340                self.logger.debug(u'%s: captcha accepted' % oc)
     341                return CaptchaResponse(is_valid=True)
     342        self.logger.info(u'%s: captcha invalid/refused, form-data: %s' % (
     343            oc, form))
    276344        return CaptchaResponse(
    277             is_valid=False, error_code="%s" % ret_vals['error-codes'])
    278 
    279     def display(self, error_code=None):
     345            is_valid=False, error_code="%s" % err_codes)
     346
     347    def display(self, error_code=None, action='submit'):
    280348        """Display captcha widget snippet.
    281349
     
    290358        form).
    291359        """
    292         error_param = ''
     360        html = u''
     361        # TODO: display errors as warnings
    293362        if error_code:
    294             error_param = '&error=%s' % error_code
    295 
    296         html = (
    297             u'<script type="text/javascript" '
    298             u'src="%(ApiServer)s" async defer>'
     363            html = u"<p class='warning'>Captcha-Error: Are you a bot?</p><!-- %s -->" % error_code
     364        html += (
     365            # Main event handler, triggered when a submit button is clicked.
     366            # Requests the captcha token for the form and also copies name and
     367            # value of the clicked button to a hidden field `fakebtn` defined
     368            # below.
     369            u'<script class="kofa-script-recaptcha-v3" '
     370            u' src="https://www.google.com/recaptcha/api.js?render=%(sitekey)s">'
     371            u'</script>\n'
     372            u'<script class="kofa-script-recaptcha-v3"> '
     373            u'  function onClickRecaptcha(e) {'
     374            u'    e.preventDefault();'
     375            u'    grecaptcha.ready(function() {'
     376            u'      grecaptcha.execute("%(sitekey)s", {action: "%(action)s"}).then('
     377            u'        function(token) {'
     378            u'          document.getElementById("g-recaptcha-token").value = token;'
     379            u'          var fakebtn = document.getElementById("g-recaptcha-fakebtn");'
     380            u'          fakebtn.name = e.target.name;'
     381            u'          fakebtn.value = e.target.value;'
     382            u'          document.querySelector("form").submit();});});}'
     383            u'</script>\n'
     384        ) % {
     385            'sitekey': self.sitekey,
     386            'action': action,
     387        }
     388        html += (
     389            # Form fields we use to (1) embed the captcha token and (2) a field
     390            # to embed the name and value of the submit button/input clicked.
     391            u'<input id="g-recaptcha-token" type="hidden" name="%(token_field)s"/>\n'
     392            u'<input id="g-recaptcha-fakebtn" type="hidden"/>\n'
     393            ) % {'token_field': self.token_field}
     394        html += (
     395            # install event handler `onClickRecaptcha` defined above, but only
     396            # after document (HTML) loaded.
     397            u'<script> window.addEventListener("load", function () {'
     398            u'  if (typeof onClickRecaptcha === "function") {'
     399            u'document.querySelector("*[type=\'submit\']").addEventListener('
     400            u'"click", onClickRecaptcha);}})'
    299401            u'</script>'
    300             u'<div class="g-recaptcha" data-sitekey="%(SiteKey)s"></div>' % {
    301                 'ApiServer': "https://www.google.com/recaptcha/api.js",
    302                 'SiteKey': self.PUBLIC_KEY,
    303                 }
    304         )
     402            )
    305403        return html
    306404
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/interfaces.py

    r13089 r18083  
    9090class ICaptcha(Interface):
    9191
    92     def verify(request):
     92    def verify(request, action='submit'):
    9393        """Verify data entered in an HTTP request.
    9494
     
    9797        not.
    9898
     99        `action` is a simple string identifying the kind of operation
     100        performed in the form. ``login`` or ``enquiry`` for instance.
     101        Only used by recaptcha.
     102
    99103        If the solution could not be verified (this might also happen
    100104        because of technical reasons), the response might contain an
     
    102106        """
    103107
    104     def display(error_code=None):
     108    def display(error_code=None, action='submit'):
    105109        """Returns a piece of HTML code that displays the captcha.
    106110
     
    111115        therefore should not contain a ``<form>`` nor any submit
    112116        buttons.
     117
     118        See `verify` for infos about the `action` parameter.
    113119        """
    114120
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/pages.py

    r18074 r18083  
    367367        # Handle captcha
    368368        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    369         self.captcha_result = self.captcha.verify(self.request)
    370         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     369        self.captcha_result = self.captcha.verify(self.request, 'login')
     370        self.captcha_code = self.captcha.display(
     371            self.captcha_result.error_code, 'login')
    371372        self.camefrom = camefrom
    372373        # Prefill form with URL params
     
    456457                return
    457458            # Display appropriate flash message if credentials are correct
    458             # but student has been deactivated or a temporary or parents 
     459            # but student has been deactivated or a temporary or parents
    459460            # password has been set.
    460461            login = self.request.form['form.login']
     
    622623        # Handle captcha
    623624        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    624         self.captcha_result = self.captcha.verify(self.request)
    625         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     625        self.captcha_result = self.captcha.verify(self.request, 'enquiry')
     626        self.captcha_code = self.captcha.display(
     627            self.captcha_result.error_code, 'enquiry')
    626628        return
    627629
     
    20762078            if ena:
    20772079                self.flash(ena, type='danger')
    2078                 return 
     2080                return
    20792081            job_id = self.context.start_export_job(
    20802082                exporter, self.request.principal.id)
     
    28602862        # Handle captcha
    28612863        self.captcha = getUtility(ICaptchaManager).getCaptcha()
    2862         self.captcha_result = self.captcha.verify(self.request)
    2863         self.captcha_code = self.captcha.display(self.captcha_result.error_code)
     2864        self.captcha_result = self.captcha.verify(self.request, 'reset_pwd')
     2865        self.captcha_code = self.captcha.display(
     2866                self.captcha_result.error_code, 'reset_pwd')
    28642867        # Unset default values maybe set by another person who used this form.
    28652868        self.form_fields.get('identifier').field.default = None
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/tests/test_captcha.py

    r15012 r18083  
    171171        result = captcha.display()
    172172        self.assertMatches(
    173             '<script type="text/javascript" '
    174             'src="..." async defer></script>'
    175             '<div class="g-recaptcha" data-sitekey="..."></div>',
     173            '<script class="kofa-script-recaptcha-v3" ...</script>...'
     174            '<input id="g-recaptcha-token" .../>...'
     175            '<input id="g-recaptcha-fakebtn" .../>...'
     176            '<script>...</script>',
    176177            result)
    177178        return
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/interfaces.py

    r17787 r18083  
    1616## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
    1717##
     18import decimal
    1819import os
    1920import re
     
    14181419        )
    14191420
     1421
     1422class IReCaptchaConfig(Interface):
     1423    """The configuration values needed by Google recaptcha.
     1424    """
     1425    public_key = schema.TextLine(
     1426        title = u"Public site key",
     1427        description = u"Public sitekey, provided by Google",
     1428        required = True,
     1429        )
     1430    private_key = schema.TextLine(
     1431        title = u"Private site key",
     1432        description = u"Private sitekey, provided by Google",
     1433        required = True,
     1434        )
     1435    hostname = schema.TextLine(
     1436        title = u"Hostname",
     1437        description = u"Hostname we use when we require captchas"
     1438                      u"i.e. our own domain",
     1439        required = True,
     1440        default = u"localhost",
     1441        )
     1442    min_score = schema.Decimal(
     1443        title = u"Minimum score",
     1444        description = u"Minimum score required to qualify as non-bot "
     1445                      u"(range: 0.0 to 1.0)",
     1446        required = True,
     1447        default = decimal.Decimal("0.5")
     1448    )
     1449
     1450
    14201451#
    14211452# Asynchronous job handling and related
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/meta.zcml

    r7811 r18083  
    1717      />
    1818
     19  <meta:directive
     20      namespace="http://namespaces.waeup.org/kofa"
     21      name="recaptcha"
     22      schema=".zcml.IReCaptchaConfig"
     23      handler=".zcml.recaptcha_conf"
     24      />
    1925
    2026</configure>
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/utils/logger.py

    r13776 r18083  
    8686
    8787#: Default logging level (`logging.INFO`)
    88 LEVEL = logging.INFO
     88LEVEL = logging.DEBUG
    8989
    9090class ILogger(Interface):
  • main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/zcml.py

    r8057 r18083  
    1717##
    1818from zope.component.zcml import handler
    19 from waeup.kofa.interfaces import IDataCenterConfig
     19from waeup.kofa.interfaces import IDataCenterConfig, IReCaptchaConfig
    2020
    2121def data_center_conf(context, path):
     
    4949                {'path':path}, IDataCenterConfig, '')
    5050        )
     51
     52
     53def recaptcha_conf(context, public_key, private_key, hostname, min_score):
     54    """Handler for ZCML ``recaptcha`` directive.
     55
     56    Registers a global utility under IReCaptchaConfig and containing a
     57    dictionary with entries: `enabled`, `public_key`, `private__key` and `url`.
     58
     59    The directive can be put into site.zcml like this:
     60
     61    - Add to the header:
     62        ``xmlns:kofa="http://namespaces.waeup.org/kofa"``
     63
     64    - Then, after including waeup.kofa:
     65        ``<kofa:recaptcha
     66             public_key="<YOUR_PUBKEY_HERE>"
     67             private_key="<YOUR_PRIVKEY_HERE>"
     68             hostname="mysite.com"
     69             min_score="0.7"
     70             />``
     71
     72    In a running instance (where some directive like above was
     73    processed during startup), one can then ask for the
     74    IReCaptchaConfig utility:
     75
     76      >>> from waeup.kofa.interfaces import IReCaptchaConfig
     77      >>> from zope.component import getUtility
     78      >>> getUtility(IRecaptchaConfig)
     79      {'enabled': False,
     80              'public_key': '<GOOGLE_SITEKEY',
     81              'private_key': 'GOOGLE_PRIVATE_SITE_KEY',
     82              'hostname': 'mydomain.com'
     83              'min_score': Decimal(0.7)}
     84
     85    """
     86    context.action(
     87        discriminator = ('utility', IReCaptchaConfig, ''),
     88        callable = handler,
     89        args = ('registerUtility',
     90                {
     91                    'public_key': public_key,
     92                    'private_key': private_key,
     93                    'hostname': hostname,
     94                    'min_score': min_score
     95                    }, IReCaptchaConfig, '')
     96        )
Note: See TracChangeset for help on using the changeset viewer.