[PATCH cube signedrequest] Support new protocol version for signed requests coming from a browser

Laurent Wouters lwouters at cenotelie.fr
Wed Jun 19 15:25:21 CEST 2019


# HG changeset patch
# User Laurent Wouters <lwouters at cenotelie.fr>
# Date 1560950479 -7200
#      Wed Jun 19 15:21:19 2019 +0200
# Node ID 48171b698f460abe8a9da2acb5091c8219c432ff
# Parent  9cf3d9ab91e3fd3b3dda51991e73859cbce2abda
Support new protocol version for signed requests coming from a browser

The current protocol for signed request requires the use of the Date HTTP
header. Although this works fine for clients that have full control over the
HTTP headers they send, this is not working in the context of web browser where
the Date HTTP headers are forbidden to be programmatically set (and therefore
used in any meaningful way)
https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name

To avoid this issue, this changeset introduces a new protocol version that use
custom HTTP headers (X-Cubicweb-Foo) for non-standard, or otherwise forbidden
HTTP headers. Instead of a date, this version of the protocol relies on the
client generating a cryptographically-secured nonce that is passed in a header
and included in the signature computation.

diff -r 9cf3d9ab91e3 -r 48171b698f46 cubicweb_signedrequest/pconfig.py
--- a/cubicweb_signedrequest/pconfig.py	Wed Mar 06 15:09:48 2019 +0000
+++ b/cubicweb_signedrequest/pconfig.py	Wed Jun 19 15:21:19 2019 +0200
@@ -20,10 +20,12 @@
 from zope.interface import implementer
 from pyramid.authentication import IAuthenticationPolicy
 
-from cubicweb import AuthenticationError
-from cubicweb_signedrequest.tools import (hash_content, build_string_to_sign,
-                                          authenticate_user,
-                                          get_credentials_from_headers)
+from cubicweb_signedrequest.tools import (
+    hash_content,
+    get_protocol_version_from_headers,
+    check_request_v1,
+    check_request_v2
+)
 
 logger = logging.getLogger(__name__)
 
@@ -50,31 +52,20 @@
     Authentication header.
 
     """
-    headers_to_sign = ('Content-MD5', 'Content-Type', 'Date')
 
     def unauthenticated_userid(self, request):
         return None
 
     def authenticated_userid(self, request):
         logger.debug('authentication by %s', self.__class__.__name__)
-        try:
-            credentials = get_credentials_from_headers(
-                request, request.body_hash)
-        except AuthenticationError:
-            credentials = None
-        if credentials is None:
+        version = get_protocol_version_from_headers(request)
+        if version == "1":
+            return check_request_v1(request)
+        elif version == "2":
+            return check_request_v2(request)
+        else:
+            logger.error('unrecognized protocol version: %s' % version)
             return
-        try:
-            userid, signature = credentials.split(':', 1)
-        except ValueError:
-            logger.warning('authentication failure: invalid credentials')
-            return
-        repo = request.registry['cubicweb.repository']
-        signed_content = build_string_to_sign(request)
-        with repo.internal_cnx() as cnx:
-            user_eid = authenticate_user(
-                cnx, userid, signed_content, signature)
-        return user_eid
 
     def effective_principals(self, request):
         return ()
diff -r 9cf3d9ab91e3 -r 48171b698f46 cubicweb_signedrequest/tools.py
--- a/cubicweb_signedrequest/tools.py	Wed Mar 06 15:09:48 2019 +0000
+++ b/cubicweb_signedrequest/tools.py	Wed Jun 19 15:21:19 2019 +0200
@@ -64,7 +64,20 @@
     return md5.hexdigest()
 
 
-def get_credentials_from_headers(request, content_md5):
+def get_protocol_version_from_headers(request):
+    """
+    Get the version of the protocol requested by the client
+    :param request: The current request
+    :return: The version of the protocol, defaults to '1' in case none
+                is specified by the client
+    """
+    header = request.get_header('X-Cubicweb-SignedRequest-Version', None)
+    if header is None:
+        return '1'
+    return header
+
+
+def get_credentials_from_headers(request):
     """Parse the request headers to retrieve the authentication credentials
 
     Returns the parsed credentials as a string '<tokenid>:<signature>' where
@@ -80,18 +93,26 @@
     try:
         method, credentials = header.split(None, 1)
     except ValueError:
-        log.debug("SIGNED REQUEST: couldn't determine method from "
-                  "Authorization header")
+        log.debug("SIGNED REQUEST: couldn't determine method from Authorization header")
         return
     if method != 'Cubicweb':
         log.debug('SIGNED REQUEST: method is not Cubicweb')
         return
-    if request.http_method() != 'GET':
-        if content_md5 != request.get_header('Content-MD5'):
-            log.error('SIGNED REQUEST: wrong md5, %s != %s' % (
-                content_md5,
-                request.get_header('Content-MD5')))
-            raise AuthenticationError()
+    try:
+        _id, _signature = credentials.split(':', 1)
+        log.debug('SIGNED REQUEST: encoding info for %s' % id)
+        return credentials
+    except ValueError:
+        log.exception('HTTP REST authenticator failed')
+        raise AuthenticationError()
+
+
+def check_request_header_date(request):
+    """
+    Check that the Date HTTP header is correct
+    :param request: The current request
+    :return: Nothing
+    """
     date_header = request.get_header('Date')
     if date_header is None:
         raise AuthenticationError()
@@ -104,13 +125,96 @@
     if delta > timedelta(0, 300):
         log.error('SIGNED REQUEST: date delta error')
         raise AuthenticationError()
+
+
+def check_request_header_nonce(request):
+    """
+    Check that the nonce used for a request is sufficiently complex
+    :param request: The current request
+    :return: Nothing
+    """
+    nonce = request.get_header('X-Cubicweb-Nonce')
+    if nonce is None:
+        raise AuthenticationError()
+    if len(nonce) < 128:
+        log.error('SIGNED REQUEST: nonce is too short')
+        raise AuthenticationError()
+
+
+def check_request_header_hash(request, header_name):
+    """
+    Check that the computed hash of the request body is the same as the
+    one specified by the client in an HTTP header
+    :param request: The current request
+    :param header_name: The name of the header supposed to contain the hash
+    :return: Nothing
+    """
+    if request.http_method() != 'GET':
+        content_hash = request.body_hash
+        if content_hash != request.get_header(header_name):
+            log.error('SIGNED REQUEST: wrong hash, %s != %s' % (
+                content_hash,
+                request.get_header(header_name)))
+            raise AuthenticationError()
+
+
+def check_request_v1(request):
+    """
+    Verify the request according to the version 1 of the protocol
+    :param request: The current request
+    :return: The EID of the authenticated user, if successful
+    """
     try:
-        id, signature = credentials.split(':', 1)
-        log.debug('SIGNED REQUEST: encoding info for %s' % id)
-        return credentials
+        credentials = get_credentials_from_headers(request)
+    except AuthenticationError:
+        credentials = None
+    if credentials is None:
+        return
+    try:
+        userid, signature = credentials.split(':', 1)
     except ValueError:
-        log.exception('HTTP REST authenticator failed')
-        raise AuthenticationError()
+        log.warning('authentication failure: invalid credentials')
+        return
+    check_request_header_date(request)
+    check_request_header_hash(request, 'Content-MD5')
+    repo = request.registry['cubicweb.repository']
+    signed_content = build_string_to_sign(request, None, (
+        'Content-MD5',
+        'Content-Type',
+        'Date'))
+    with repo.internal_cnx() as cnx:
+        user_eid = authenticate_user(cnx, userid, signed_content, signature)
+    return user_eid
+
+
+def check_request_v2(request):
+    """
+    Verify the request according to the version 2 of the protocol
+    :param request: The current request
+    :return: The EID of the authenticated user, if successful
+    """
+    try:
+        credentials = get_credentials_from_headers(request)
+    except AuthenticationError:
+        credentials = None
+    if credentials is None:
+        return
+    try:
+        userid, signature = credentials.split(':', 1)
+    except ValueError:
+        log.warning('authentication failure: invalid credentials')
+        return
+    check_request_header_nonce(request)
+    check_request_header_hash(request, 'X-Cubicweb-Content-Hash')
+    repo = request.registry['cubicweb.repository']
+    signed_content = build_string_to_sign(request, None, (
+        'X-Cubicweb-Content-Hash',
+        'Content-Type',
+        'X-Cubicweb-Nonce'
+    ))
+    with repo.internal_cnx() as cnx:
+        user_eid = authenticate_user(cnx, userid, signed_content, signature)
+    return user_eid
 
 
 def build_string_to_sign(request, url=None, headers=None):



More information about the cubicweb-devel mailing list