Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/applicants/browser.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/applicants/browser.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/applicants/browser.py	(revision 18083)
@@ -50,5 +50,5 @@
 from waeup.kofa.applicants.applicant import search
 from waeup.kofa.applicants.workflow import (
-    INITIALIZED, STARTED, PAID, SUBMITTED, 
+    INITIALIZED, STARTED, PAID, SUBMITTED,
     ADMITTED, NOT_ADMITTED, CREATED, PROCESSED)
 from waeup.kofa.browser import (
@@ -843,5 +843,5 @@
     def render(self):
         return
-        
+
 class BalancePaymentAddFormPage(KofaAddFormPage):
     """ Page to add an online payment which can balance s previous session
@@ -1078,5 +1078,5 @@
     manage_applications = True
     pnav = 3
-    
+
     @property
     def display_actions(self):
@@ -1212,5 +1212,5 @@
         if self.upload_success is False:  # False is not None!
             # Error during image upload. Ignore other values.
-            return 
+            return
         changed_fields = self.applyData(self.context, **data)
         # Turn list of lists into single list
@@ -1578,6 +1578,8 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(
+            self.request, 'applicant_register')
+        self.captcha_code = self.captcha.display(
+            self.captcha_result.error_code, 'applicant_register')
         return
 
@@ -1707,6 +1709,8 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(
+            self.request, 'check_appl_status')
+        self.captcha_code = self.captcha.display(
+            self.captcha_result.error_code, 'check_appl_status')
         if SUBMIT:
             if not self.captcha_result.is_valid:
@@ -1792,6 +1796,8 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(
+            self.request, 'check_trans_state')
+        self.captcha_code = self.captcha.display(
+            self.captcha_result.error_code, 'check_trans_state')
         if SUBMIT:
             self.results = []
@@ -2087,5 +2093,5 @@
             args = {'mandate_id':mandate.mandate_id}
             # Check if report exists.
-            # (1) If mandate has been used to create a report, 
+            # (1) If mandate has been used to create a report,
             # redirect to the pdf file.
             if mandate.params.get('redirect_path2'):
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/captcha.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/captcha.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/captcha.py	(revision 18083)
@@ -21,5 +21,7 @@
 """
 import grok
+import decimal
 import json
+import logging
 import urllib
 import urllib2
@@ -33,5 +35,5 @@
     ICaptchaRequest, ICaptchaResponse, ICaptcha, ICaptchaConfig,
     ICaptchaManager)
-from waeup.kofa.interfaces import IUniversity
+from waeup.kofa.interfaces import IUniversity, IReCaptchaConfig
 
 #
@@ -103,8 +105,8 @@
     grok.implements(ICaptcha)
 
-    def verify(self, request):
+    def verify(self, request, action="submit"):
         return CaptchaResponse(True, None)
 
-    def display(self, error_code=None):
+    def display(self, error_code=None, action="submit"):
         return u''
 
@@ -139,5 +141,5 @@
     chal_field = 'challenge'
 
-    def verify(self, request):
+    def verify(self, request, action='submit'):
         """Verify that a solution sent equals the challenge.
         """
@@ -149,5 +151,5 @@
         return CaptchaResponse(is_valid=False)
 
-    def display(self, error_code=None):
+    def display(self, error_code=None, action='submit'):
         """Display challenge and input field for solution as HTML.
         """
@@ -164,5 +166,5 @@
 
 ##
-## ReCaptcha (v2)
+## ReCaptcha (v3)
 ##
 class ReCaptcha(StaticCaptcha):
@@ -170,5 +172,5 @@
 
     This is the Kofa implementation to support captchas as provided by
-    http://www.google.com/recaptcha (v2).
+    http://www.google.com/recaptcha (v3).
 
     ReCaptcha is widely used and adopted in web applications. See the
@@ -226,5 +228,4 @@
         - Google will track all activity of our clients
     """
-
     grok.implements(ICaptcha)
 
@@ -240,5 +241,30 @@
     VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'
 
-    def verify(self, request):
+    @property
+    def sitekey(self):
+        return self.get_config()['public_key']
+
+    @property
+    def logger(self):
+        # if there is no site, we are most probably in a testing env
+        return getattr(grok.getSite(), "logger", logging.getLogger("dummy"))
+
+    @property
+    def ob_class(self):
+        return self.__implemented__.__name__.replace('waeup.kofa.', '')
+
+    def get_config(self):
+        if not getattr(self, "config", None):
+            self.config = queryUtility(IReCaptchaConfig, default={
+                'public_key': self.PUBLIC_KEY,
+                'private_key': self.PRIVATE_KEY,
+                'hostname': u'localhost',
+                'min_score': decimal.Decimal("0.5")
+                })
+        self.logger.debug(u'%s : getting ReCaptchaConfig: %s' % (
+            self.ob_class, self.config))
+        return self.config
+
+    def verify(self, request, action='submit'):
         """Grab challenge/solution from HTTP request and verify it.
 
@@ -249,17 +275,34 @@
         Returns a :class:`CaptchaResponse` indicating that the
         verification failed or succeeded.
-        """
+
+        Possible error codes (official Google codes):
+            missing-input-secret    The secret parameter is missing.
+            invalid-input-secret    The secret parameter is invalid or malformed.
+            missing-input-response  The response parameter is missing.
+            invalid-input-response  The response parameter is invalid or malformed.
+            bad-request     The request is invalid or malformed.
+            timeout-or-duplicate    The response is no longer valid: either is too old or has been used previously.
+        We also have our own ones:
+            invalid-action          The passed action does not match the one we expected
+            insufficient-score      The returned score is too low; talking to a bot
+            invalid-hostname        The captcha was solved on a different domain.
+        """
+        remote_ip = request.get('HTTP_X_FORWARDED_FOR', '127.0.0.1')
         form = getattr(request, 'form', {})
         token = form.get(self.token_field, None)
+        oc = self.ob_class
+        self.logger.debug(u'%s: get token from form: %s' % (oc, token))
         if not token:
             # on first-time display, we won't get a token
             return CaptchaResponse(is_valid=False)
+        conf = self.get_config()
         params = urllib.urlencode(
             {
-                'secret': self.PRIVATE_KEY,
+                'secret': conf['private_key'],
                 'response': token,
-                'remoteip': '127.0.0.1',
+                'remoteip': remote_ip,
                 })
-        request = urllib2.Request(
+        self.logger.debug(u'%s: send validation request to Google' % oc)
+        v_request = urllib2.Request(
             url = self.VERIFY_URL,
             data = params,
@@ -269,13 +312,38 @@
                 }
             )
-        conn = urllib2.urlopen(request)
-        ret_vals = json.loads(conn.read())
-        conn.close()
+        try:
+            conn = urllib2.urlopen(v_request)
+            ret_vals = json.loads(conn.read())
+        except:
+            conn.close()
+            return CaptchaResponse(is_valid=False, error_code="could-not-reach-verification-server")
+        self.logger.debug(u'%s: got answer from Google: %s' % (oc, ret_vals))
+        err_codes = ret_vals.get('error-codes', [])
         if ret_vals.get('success', False) is True:
-            return CaptchaResponse(is_valid=True)
+            r_action = ret_vals.get('action', None)
+            score = ret_vals.get('score', decimal.Decimal("0.0"))
+            hostname = ret_vals.get('hostname', '')
+            if r_action != action:
+                err_codes.append('invalid-action')
+                self.logger.warn(
+                    u'%s: invalid action code: %s (expected: %s)' %
+                    (oc, r_action, action))
+            elif score < conf['min_score']:
+                err_codes.append('insufficent-score')
+                self.logger.debug(u'%s: insufficient score: %s' % (oc, score))
+            elif not hostname.endswith(conf['hostname']):
+                err_codes.append('invalid-hostname')
+                self.logger.warn(
+                    u'%s: cought captcha from illegal hostname: %s' % (
+                        oc, hostname))
+            else:
+                self.logger.debug(u'%s: captcha accepted' % oc)
+                return CaptchaResponse(is_valid=True)
+        self.logger.info(u'%s: captcha invalid/refused, form-data: %s' % (
+            oc, form))
         return CaptchaResponse(
-            is_valid=False, error_code="%s" % ret_vals['error-codes'])
-
-    def display(self, error_code=None):
+            is_valid=False, error_code="%s" % err_codes)
+
+    def display(self, error_code=None, action='submit'):
         """Display captcha widget snippet.
 
@@ -290,17 +358,47 @@
         form).
         """
-        error_param = ''
+        html = u''
+        # TODO: display errors as warnings
         if error_code:
-            error_param = '&error=%s' % error_code
-
-        html = (
-            u'<script type="text/javascript" '
-            u'src="%(ApiServer)s" async defer>'
+            html = u"<p class='warning'>Captcha-Error: Are you a bot?</p><!-- %s -->" % error_code
+        html += (
+            # Main event handler, triggered when a submit button is clicked.
+            # Requests the captcha token for the form and also copies name and
+            # value of the clicked button to a hidden field `fakebtn` defined
+            # below.
+            u'<script class="kofa-script-recaptcha-v3" '
+            u' src="https://www.google.com/recaptcha/api.js?render=%(sitekey)s">'
+            u'</script>\n'
+            u'<script class="kofa-script-recaptcha-v3"> '
+            u'  function onClickRecaptcha(e) {'
+            u'    e.preventDefault();'
+            u'    grecaptcha.ready(function() {'
+            u'      grecaptcha.execute("%(sitekey)s", {action: "%(action)s"}).then('
+            u'        function(token) {'
+            u'          document.getElementById("g-recaptcha-token").value = token;'
+            u'          var fakebtn = document.getElementById("g-recaptcha-fakebtn");'
+            u'          fakebtn.name = e.target.name;'
+            u'          fakebtn.value = e.target.value;'
+            u'          document.querySelector("form").submit();});});}'
+            u'</script>\n'
+        ) % {
+            'sitekey': self.sitekey,
+            'action': action,
+        }
+        html += (
+            # Form fields we use to (1) embed the captcha token and (2) a field
+            # to embed the name and value of the submit button/input clicked.
+            u'<input id="g-recaptcha-token" type="hidden" name="%(token_field)s"/>\n'
+            u'<input id="g-recaptcha-fakebtn" type="hidden"/>\n'
+            ) % {'token_field': self.token_field}
+        html += (
+            # install event handler `onClickRecaptcha` defined above, but only
+            # after document (HTML) loaded.
+            u'<script> window.addEventListener("load", function () {'
+            u'  if (typeof onClickRecaptcha === "function") {'
+            u'document.querySelector("*[type=\'submit\']").addEventListener('
+            u'"click", onClickRecaptcha);}})'
             u'</script>'
-            u'<div class="g-recaptcha" data-sitekey="%(SiteKey)s"></div>' % {
-                'ApiServer': "https://www.google.com/recaptcha/api.js",
-                'SiteKey': self.PUBLIC_KEY,
-                }
-        )
+            )
         return html
 
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/interfaces.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/interfaces.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/interfaces.py	(revision 18083)
@@ -90,5 +90,5 @@
 class ICaptcha(Interface):
 
-    def verify(request):
+    def verify(request, action='submit'):
         """Verify data entered in an HTTP request.
 
@@ -97,4 +97,8 @@
         not.
 
+        `action` is a simple string identifying the kind of operation
+        performed in the form. ``login`` or ``enquiry`` for instance.
+        Only used by recaptcha.
+
         If the solution could not be verified (this might also happen
         because of technical reasons), the response might contain an
@@ -102,5 +106,5 @@
         """
 
-    def display(error_code=None):
+    def display(error_code=None, action='submit'):
         """Returns a piece of HTML code that displays the captcha.
 
@@ -111,4 +115,6 @@
         therefore should not contain a ``<form>`` nor any submit
         buttons.
+
+        See `verify` for infos about the `action` parameter.
         """
 
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/pages.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/pages.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/pages.py	(revision 18083)
@@ -367,6 +367,7 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(self.request, 'login')
+        self.captcha_code = self.captcha.display(
+            self.captcha_result.error_code, 'login')
         self.camefrom = camefrom
         # Prefill form with URL params
@@ -456,5 +457,5 @@
                 return
             # Display appropriate flash message if credentials are correct
-            # but student has been deactivated or a temporary or parents 
+            # but student has been deactivated or a temporary or parents
             # password has been set.
             login = self.request.form['form.login']
@@ -622,6 +623,7 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(self.request, 'enquiry')
+        self.captcha_code = self.captcha.display(
+            self.captcha_result.error_code, 'enquiry')
         return
 
@@ -2076,5 +2078,5 @@
             if ena:
                 self.flash(ena, type='danger')
-                return 
+                return
             job_id = self.context.start_export_job(
                 exporter, self.request.principal.id)
@@ -2860,6 +2862,7 @@
         # Handle captcha
         self.captcha = getUtility(ICaptchaManager).getCaptcha()
-        self.captcha_result = self.captcha.verify(self.request)
-        self.captcha_code = self.captcha.display(self.captcha_result.error_code)
+        self.captcha_result = self.captcha.verify(self.request, 'reset_pwd')
+        self.captcha_code = self.captcha.display(
+                self.captcha_result.error_code, 'reset_pwd')
         # Unset default values maybe set by another person who used this form.
         self.form_fields.get('identifier').field.default = None
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/tests/test_captcha.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/tests/test_captcha.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/browser/tests/test_captcha.py	(revision 18083)
@@ -171,7 +171,8 @@
         result = captcha.display()
         self.assertMatches(
-            '<script type="text/javascript" '
-            'src="..." async defer></script>'
-            '<div class="g-recaptcha" data-sitekey="..."></div>',
+            '<script class="kofa-script-recaptcha-v3" ...</script>...'
+            '<input id="g-recaptcha-token" .../>...'
+            '<input id="g-recaptcha-fakebtn" .../>...'
+            '<script>...</script>',
             result)
         return
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/interfaces.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/interfaces.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/interfaces.py	(revision 18083)
@@ -16,4 +16,5 @@
 ## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 ##
+import decimal
 import os
 import re
@@ -1418,4 +1419,34 @@
         )
 
+
+class IReCaptchaConfig(Interface):
+    """The configuration values needed by Google recaptcha.
+    """
+    public_key = schema.TextLine(
+        title = u"Public site key",
+        description = u"Public sitekey, provided by Google",
+        required = True,
+        )
+    private_key = schema.TextLine(
+        title = u"Private site key",
+        description = u"Private sitekey, provided by Google",
+        required = True,
+        )
+    hostname = schema.TextLine(
+        title = u"Hostname",
+        description = u"Hostname we use when we require captchas"
+                      u"i.e. our own domain",
+        required = True,
+        default = u"localhost",
+        )
+    min_score = schema.Decimal(
+        title = u"Minimum score",
+        description = u"Minimum score required to qualify as non-bot "
+                      u"(range: 0.0 to 1.0)",
+        required = True,
+        default = decimal.Decimal("0.5")
+    )
+
+
 #
 # Asynchronous job handling and related
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/meta.zcml
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/meta.zcml	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/meta.zcml	(revision 18083)
@@ -17,4 +17,10 @@
       />
 
+  <meta:directive
+      namespace="http://namespaces.waeup.org/kofa"
+      name="recaptcha"
+      schema=".zcml.IReCaptchaConfig"
+      handler=".zcml.recaptcha_conf"
+      />
 
 </configure>
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/utils/logger.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/utils/logger.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/utils/logger.py	(revision 18083)
@@ -86,5 +86,5 @@
 
 #: Default logging level (`logging.INFO`)
-LEVEL = logging.INFO
+LEVEL = logging.DEBUG
 
 class ILogger(Interface):
Index: main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/zcml.py
===================================================================
--- main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/zcml.py	(revision 18082)
+++ main/waeup.kofa/branches/uli-recaptcha/src/waeup/kofa/zcml.py	(revision 18083)
@@ -17,5 +17,5 @@
 ##
 from zope.component.zcml import handler
-from waeup.kofa.interfaces import IDataCenterConfig
+from waeup.kofa.interfaces import IDataCenterConfig, IReCaptchaConfig
 
 def data_center_conf(context, path):
@@ -49,2 +49,48 @@
                 {'path':path}, IDataCenterConfig, '')
         )
+
+
+def recaptcha_conf(context, public_key, private_key, hostname, min_score):
+    """Handler for ZCML ``recaptcha`` directive.
+
+    Registers a global utility under IReCaptchaConfig and containing a
+    dictionary with entries: `enabled`, `public_key`, `private__key` and `url`.
+
+    The directive can be put into site.zcml like this:
+
+    - Add to the header:
+        ``xmlns:kofa="http://namespaces.waeup.org/kofa"``
+
+    - Then, after including waeup.kofa:
+        ``<kofa:recaptcha
+             public_key="<YOUR_PUBKEY_HERE>"
+             private_key="<YOUR_PRIVKEY_HERE>"
+             hostname="mysite.com"
+             min_score="0.7"
+             />``
+
+    In a running instance (where some directive like above was
+    processed during startup), one can then ask for the
+    IReCaptchaConfig utility:
+
+      >>> from waeup.kofa.interfaces import IReCaptchaConfig
+      >>> from zope.component import getUtility
+      >>> getUtility(IRecaptchaConfig)
+      {'enabled': False,
+              'public_key': '<GOOGLE_SITEKEY',
+              'private_key': 'GOOGLE_PRIVATE_SITE_KEY',
+              'hostname': 'mydomain.com'
+              'min_score': Decimal(0.7)}
+
+    """
+    context.action(
+        discriminator = ('utility', IReCaptchaConfig, ''),
+        callable = handler,
+        args = ('registerUtility',
+                {
+                    'public_key': public_key,
+                    'private_key': private_key,
+                    'hostname': hostname,
+                    'min_score': min_score
+                    }, IReCaptchaConfig, '')
+        )
