← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/turnip/cgit-openid into lp:turnip

 

Colin Watson has proposed merging lp:~cjwatson/turnip/cgit-openid into lp:turnip.

Commit message:
Add OpenID authentication support to make it possible to browse private repositories.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/turnip/cgit-openid/+merge/259948

Add OpenID authentication support to make it possible to browse private repositories.

This required quite a bit of careful restructuring.  I thought it best to split up the cgit resource into several pieces to make things more manageable.  Further work will be required to make this work with Python 3 (a python-openid3 library does exist on PyPI, but it'll need the same diff as found in launchpad-dependencies or some other solution to the same problem; and I haven't tested the other new code I'm using with Python 3).
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/turnip/cgit-openid into lp:turnip.
=== modified file 'config.yaml'
--- config.yaml	2015-04-02 12:26:26 +0000
+++ config.yaml	2015-05-22 15:44:58 +0000
@@ -12,4 +12,7 @@
 virtinfo_endpoint: http://xmlrpc-private.launchpad.dev:8087/git
 cgit_exec_path: /usr/lib/cgit/cgit.cgi
 cgit_data_path: /usr/share/cgit
+cgit_secret_path: null
+openid_provider_root: https://testopenid.dev/
 site_name: git.launchpad.dev
+main_site_root: https://launchpad.dev/

=== modified file 'httpserver.tac'
--- httpserver.tac	2015-04-26 21:14:39 +0000
+++ httpserver.tac	2015-05-22 15:44:58 +0000
@@ -19,13 +19,7 @@
 
     config = TurnipConfig()
     smarthttp_site = server.Site(
-        SmartHTTPFrontendResource(b'localhost',
-                                  config.get('pack_virt_port'),
-                                  config.get('virtinfo_endpoint'),
-                                  config.get('repo_store'),
-                                  cgit_exec_path=config.get('cgit_exec_path'),
-                                  cgit_data_path=config.get('cgit_data_path'),
-                                  site_name=config.get('site_name')))
+        SmartHTTPFrontendResource(b'localhost', config))
     return internet.TCPServer(config.get('smart_http_port'), smarthttp_site)
 
 

=== modified file 'requirements.txt'
--- requirements.txt	2015-04-28 21:36:37 +0000
+++ requirements.txt	2015-05-22 15:44:58 +0000
@@ -1,13 +1,19 @@
 contextlib2==0.4.0
 cornice==0.19.0
 lazr.sshserver==0.1.1
+Paste==2.0.2
 PasteDeploy==1.5.2
 pyasn1==0.1.7
 pycrypto==2.6.1
 pyramid==1.5.4
+# XXX: deryck 2012-08-10
+# See lp:~deryck/python-openid/python-openid-fix1034376 which
+# reapplied a patch from wgrant to get codehosting going again.
+python-openid==2.2.5-fix1034376
 PyYAML==3.11
 repoze.lru==0.6
 simplejson==3.6.5
+six==1.9.0
 translationstring==1.3
 Twisted==15.0.0
 venusian==1.0

=== modified file 'turnip/pack/http.py'
--- turnip/pack/http.py	2015-04-26 22:53:26 +0000
+++ turnip/pack/http.py	2015-05-22 15:44:58 +0000
@@ -6,16 +6,32 @@
 
 from cStringIO import StringIO
 import os.path
+import pickle
 import tempfile
 import textwrap
+try:
+    from urllib.parse import urlencode
+except ImportError:
+    from urllib import urlencode
 import sys
 import zlib
 
+from openid.consumer import consumer
+from openid.extensions.sreg import (
+    SRegRequest,
+    SRegResponse,
+    )
+from paste.auth.cookie import (
+    AuthCookieSigner,
+    decode as decode_cookie,
+    encode as encode_cookie,
+    )
 from twisted.internet import (
     defer,
     protocol,
     reactor,
     )
+from twisted.python.urlpath import URLPath
 from twisted.web import (
     http,
     resource,
@@ -338,23 +354,175 @@
 class CGitScriptResource(twcgi.CGIScript):
     """HTTP resource to run cgit."""
 
-    def __init__(self, root):
+    def __init__(self, root, repo_url, repo_path, trailing):
         twcgi.CGIScript.__init__(self, root.cgit_exec_path)
         self.root = root
+        self.repo_url = repo_url
+        self.repo_path = repo_path
+        self.trailing = trailing
         self.cgit_config = None
 
-    def _error(self, request, message, code=http.INTERNAL_SERVER_ERROR):
-        request.setResponseCode(code)
-        request.setHeader(b'Content-Type', b'text/plain')
-        request.write(message)
-        request.finish()
-
     def _finished(self, ignored):
         if self.cgit_config is not None:
             self.cgit_config.close()
 
-    def _translatePathCallback(self, translated, env, request,
-                               *args, **kwargs):
+    def runProcess(self, env, request, *args, **kwargs):
+        request.notifyFinish().addBoth(self._finished)
+        self.cgit_config = tempfile.NamedTemporaryFile(
+            mode='w+', prefix='turnip-cgit-')
+        os.chmod(self.cgit_config.name, 0o644)
+        fmt = {'repo_url': self.repo_url, 'repo_path': self.repo_path}
+        if self.root.site_name is not None:
+            prefixes = " ".join(
+                "{}://{}".format(scheme, self.root.site_name)
+                for scheme in ("git", "git+ssh", "https"))
+            print("clone-prefix={}".format(prefixes), file=self.cgit_config)
+        print(textwrap.dedent("""\
+            css=/static/cgit.css
+            enable-http-clone=0
+            enable-index-owner=0
+            logo=/static/launchpad-logo.png
+
+            repo.url={repo_url}
+            repo.path={repo_path}
+            """).format(**fmt), file=self.cgit_config)
+        self.cgit_config.flush()
+        env["CGIT_CONFIG"] = self.cgit_config.name
+        env["PATH_INFO"] = "/%s%s" % (self.repo_url, self.trailing)
+        env["SCRIPT_NAME"] = "/"
+        twcgi.CGIScript.runProcess(self, env, request, *args, **kwargs)
+
+
+class BaseHTTPAuthResource(resource.Resource):
+    """Base HTTP resource for OpenID authentication handling."""
+
+    session_var = 'turnip.session'
+    cookie_name = 'TURNIP_COOKIE'
+    anonymous_id = '+launchpad-anonymous'
+
+    def __init__(self, root):
+        resource.Resource.__init__(self)
+        self.root = root
+        if root.cgit_secret is not None:
+            self.signer = AuthCookieSigner(root.cgit_secret)
+        else:
+            self.signer = None
+
+    def _getSession(self, request):
+        if self.signer is not None:
+            cookie = request.getCookie(self.cookie_name)
+            if cookie is not None:
+                content = self.signer.auth(cookie)
+                if content:
+                    return pickle.loads(decode_cookie(content))
+        return {}
+
+    def _putSession(self, request, session):
+        if self.signer is not None:
+            content = self.signer.sign(encode_cookie(pickle.dumps(session)))
+            cookie = '%s=%s; Path=/; secure;' % (self.cookie_name, content)
+            request.setHeader(b'Set-Cookie', cookie.encode('UTF-8'))
+
+    def _error(self, request, message, code=http.INTERNAL_SERVER_ERROR):
+        request.setResponseCode(code)
+        request.setHeader(b'Content-Type', b'text/plain')
+        request.write(message)
+        request.finish()
+
+    def _makeConsumer(self, session):
+        """Build an OpenID `Consumer` object with standard arguments."""
+        # Multiple instances need to share a store or not use one at all (in
+        # which case they will use check_authentication).  Using no store is
+        # easier, and check_authentication is cheap.
+        return consumer.Consumer(session, None)
+
+
+class HTTPAuthLoginResource(BaseHTTPAuthResource):
+    """HTTP resource to complete OpenID authentication."""
+
+    def render_GET(self, request):
+        """Complete the OpenID authentication process.
+
+        Here we handle the result of the OpenID process.  If the process
+        succeeded, we record the identity URL and username in the session
+        and redirect the user to the page they were trying to view that
+        triggered the login attempt.  In the various failure cases we return
+        a 401 Unauthorized response with a brief explanation of what went
+        wrong.
+        """
+        # XXX cjwatson 2015-05-21: Bring logging into sync with the similar
+        # method in launchpad/lib/launchpad_loggerhead/app.py.
+        session = self._getSession(request)
+        response = self._makeConsumer(session).complete(
+            request.args, request.args['openid.return_to'])
+        if response.status == consumer.SUCCESS:
+            sreg_info = SRegResponse.fromSuccessResponse(response)
+            if not sreg_info:
+                message = (
+                    "You don't have a Launchpad account.  Check that you're "
+                    "logged in as the right user, or log into Launchpad and "
+                    "try again.")
+                self._error(request, message, http.UNAUTHORIZED)
+            else:
+                session['identity_url'] = response.identity_url
+                session['user'] = sreg_info['nickname']
+                self._putSession(request, session)
+                request.redirect(request.args['back_to'])
+                request.finish()
+        elif response.status == consumer.FAILURE:
+            self._error(request, response.message, http.UNAUTHORIZED)
+        elif response.status == consumer.CANCEL:
+            self._error(
+                request, 'Authentication cancelled.', http.UNAUTHORIZED)
+        else:
+            self._error(request, 'Unknown OpenID response.', http.UNAUTHORIZED)
+
+
+class HTTPAuthLogoutResource(BaseHTTPAuthResource):
+    """HTTP resource to log out of OpenID authentication."""
+
+    def render_GET(self, request):
+        """Log out of turnip.
+
+        Clear the cookie and redirect to `next_to`.
+        """
+        self._putSession(request, {})
+        next_url = request.args.get('next_to')
+        if next_url is None:
+            next_url = self.root.main_site_root
+        request.redirect(next_url)
+        return b''
+
+
+class HTTPAuthRootResource(BaseHTTPAuthResource):
+    """HTTP resource to translate a path and authenticate if necessary.
+
+    Requests that require further authentication are denied or sent through
+    OpenID redirection, as appropriate.  Properly-authenticated requests are
+    passed on to cgit.
+    """
+
+    def _beginLogin(self, request, session):
+        """Start the process of authenticating with OpenID.
+
+        We redirect the user to Launchpad to identify themselves.  Launchpad
+        will then redirect them to our +login page with enough information
+        that we can then redirect them again to the page they were looking
+        at, with a cookie that gives us the identity URL and username.
+        """
+        openid_request = self._makeConsumer(session).begin(
+            self.root.openid_provider_root)
+        openid_request.addExtension(SRegRequest(required=['nickname']))
+        urlpath = URLPath.fromRequest(request)
+        back_to = str(urlpath)
+        base_url = str(urlpath.click('/'))
+        target = openid_request.redirectURL(
+            base_url,
+            base_url + '+login/?' + urlencode({'back_to': back_to}))
+        request.redirect(target.encode('UTF-8'))
+        request.finish()
+
+    def _translatePathCallback(self, translated, request):
         if 'path' not in translated:
             self._error(
                 request, b'translatePath response did not include path')
@@ -384,75 +552,69 @@
                 return
             repo_url = repo_url[:-len(trailing)]
         repo_url = repo_url.strip('/')
-        request.notifyFinish().addBoth(self._finished)
-        self.cgit_config = tempfile.NamedTemporaryFile(
-            mode='w+', prefix='turnip-cgit-')
-        os.chmod(self.cgit_config.name, 0o644)
-        fmt = {'repo_url': repo_url, 'repo_path': repo_path}
-        if self.root.site_name is not None:
-            prefixes = " ".join(
-                "{}://{}".format(scheme, self.root.site_name)
-                for scheme in ("git", "git+ssh", "https"))
-            print("clone-prefix={}".format(prefixes), file=self.cgit_config)
-        print(textwrap.dedent("""\
-            css=/static/cgit.css
-            enable-http-clone=0
-            enable-index-owner=0
-            logo=/static/launchpad-logo.png
-
-            repo.url={repo_url}
-            repo.path={repo_path}
-            """).format(**fmt), file=self.cgit_config)
-        self.cgit_config.flush()
-        env["CGIT_CONFIG"] = self.cgit_config.name
-        env["PATH_INFO"] = "/%s%s" % (repo_url, trailing)
-        env["SCRIPT_NAME"] = "/"
-        twcgi.CGIScript.runProcess(self, env, request, *args, **kwargs)
-
-    def _translatePathErrback(self, failure, request):
+        cgit_resource = CGitScriptResource(
+            self.root, repo_url, repo_path, trailing)
+        request.render(cgit_resource)
+
+    def _translatePathErrback(self, failure, request, session):
         e = failure.value
+        message = e.faultString
         if e.faultCode in (1, 290):
             error_code = http.NOT_FOUND
         elif e.faultCode in (2, 310):
             error_code = http.FORBIDDEN
         elif e.faultCode in (3, 410):
-            # XXX cjwatson 2015-03-30: should be UNAUTHORIZED, but we
-            # don't implement that yet
-            error_code = http.FORBIDDEN
+            if 'user' in session:
+                error_code = http.UNAUTHORIZED
+                message = 'You are logged in as %s.' % session['user']
+            else:
+                self._beginLogin(request, session)
+                return
         else:
             error_code = http.INTERNAL_SERVER_ERROR
-        self._error(request, e.faultString, code=error_code)
+        self._error(request, message, code=error_code)
 
-    def runProcess(self, env, request, *args, **kwargs):
+    def render_GET(self, request):
+        session = self._getSession(request)
+        identity_url = session.get('identity_url', self.anonymous_id)
         proxy = xmlrpc.Proxy(self.root.virtinfo_endpoint, allowNone=True)
-        # XXX cjwatson 2015-03-30: authentication
         d = proxy.callRemote(
-            b'translatePath', request.path, b'read', None, False)
-        d.addCallback(
-            self._translatePathCallback, env, request, *args, **kwargs)
-        d.addErrback(self._translatePathErrback, request)
+            b'translatePath', request.path, b'read', identity_url, True)
+        d.addCallback(self._translatePathCallback, request)
+        d.addErrback(self._translatePathErrback, request, session)
         return server.NOT_DONE_YET
 
 
+class HTTPAuthResource(resource.Resource):
+    """Container for the various HTTP authentication resources."""
+
+    def __init__(self, root):
+        resource.Resource.__init__(self)
+        self.putChild('', HTTPAuthRootResource(root))
+        self.putChild('+login', HTTPAuthLoginResource(root))
+        self.putChild('+logout', HTTPAuthLogoutResource(root))
+
+
 class SmartHTTPFrontendResource(resource.Resource):
     """HTTP resource to translate Git smart HTTP requests to pack protocol."""
 
     allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
 
-    def __init__(self, backend_host, backend_port, virtinfo_endpoint,
-                 repo_store, cgit_exec_path=None, cgit_data_path=None,
-                 site_name=None):
+    def __init__(self, backend_host, config):
         resource.Resource.__init__(self)
         self.backend_host = backend_host
-        self.backend_port = backend_port
-        self.virtinfo_endpoint = virtinfo_endpoint
+        self.backend_port = config.get("pack_virt_port")
+        self.virtinfo_endpoint = config.get("virtinfo_endpoint")
         # XXX cjwatson 2015-03-30: Knowing about the store path here
         # violates turnip's layering and may cause scaling problems later,
         # but for now cgit needs direct filesystem access.
-        self.repo_store = repo_store
-        self.cgit_exec_path = cgit_exec_path
-        self.site_name = site_name
+        self.repo_store = config.get("repo_store")
+        self.cgit_exec_path = config.get("cgit_exec_path")
+        self.openid_provider_root = config.get("openid_provider_root")
+        self.site_name = config.get("site_name")
+        self.main_site_root = config.get("main_site_root")
         self.putChild('', SmartHTTPRootResource())
+        cgit_data_path = config.get("cgit_data_path")
         if cgit_data_path is not None:
             static_resource = DirectoryWithoutListings(
                 cgit_data_path, defaultType='text/plain')
@@ -462,6 +624,12 @@
             self.putChild('static', static_resource)
             favicon = os.path.join(top, 'images', 'launchpad.png')
             self.putChild('favicon.ico', static.File(favicon))
+        cgit_secret_path = config.get("cgit_secret_path")
+        if cgit_secret_path:
+            with open(cgit_secret_path, 'rb') as cgit_secret_file:
+                self.cgit_secret = cgit_secret_file.read()
+        else:
+            self.cgit_secret = None
 
     @staticmethod
     def _isGitRequest(request):
@@ -489,7 +657,7 @@
             if service in self.allowed_services:
                 return SmartHTTPCommandResource(self, service, path)
         elif self.cgit_exec_path is not None:
-            return CGitScriptResource(self)
+            return HTTPAuthResource(self)
         return resource.NoResource(b'No such resource')
 
     def connectToBackend(self, client_factory):

=== modified file 'turnip/pack/tests/test_functional.py'
--- turnip/pack/tests/test_functional.py	2015-04-26 16:08:24 +0000
+++ turnip/pack/tests/test_functional.py	2015-05-22 15:44:58 +0000
@@ -343,7 +343,11 @@
         # virtinfo servers started by the mixin.
         frontend_site = server.Site(
             SmartHTTPFrontendResource(
-                b'localhost', self.virt_port, self.virtinfo_url, self.root))
+                b'localhost', {
+                    "pack_virt_port": self.virt_port,
+                    "virtinfo_endpoint": self.virtinfo_url,
+                    "repo_store": self.root,
+                    }))
         self.frontend_listener = reactor.listenTCP(0, frontend_site)
         self.port = self.frontend_listener.getHost().port
 

=== modified file 'turnipserver.py'
--- turnipserver.py	2015-04-07 05:47:48 +0000
+++ turnipserver.py	2015-05-22 15:44:58 +0000
@@ -31,9 +31,6 @@
 PACK_BACKEND_PORT = config.get('pack_backend_port')
 REPO_STORE = config.get('repo_store')
 VIRTINFO_ENDPOINT = config.get('virtinfo_endpoint')
-CGIT_EXEC_PATH = config.get('cgit_exec_path')
-CGIT_DATA_PATH = config.get('cgit_data_path')
-SITE_NAME = config.get('site_name')
 
 # turnipserver.py is preserved for convenience in development, services
 # in production are run in separate processes.
@@ -58,11 +55,7 @@
 reactor.listenTCP(config.get('pack_frontend_port'),
                   PackFrontendFactory('localhost',
                                       PACK_VIRT_PORT))
-smarthttp_site = server.Site(
-    SmartHTTPFrontendResource(
-        b'localhost', PACK_VIRT_PORT, VIRTINFO_ENDPOINT, REPO_STORE,
-        cgit_exec_path=CGIT_EXEC_PATH, cgit_data_path=CGIT_DATA_PATH,
-        site_name=SITE_NAME))
+smarthttp_site = server.Site(SmartHTTPFrontendResource(b'localhost', config))
 reactor.listenTCP(config.get('smart_http_port'), smarthttp_site)
 smartssh_service = SmartSSHService(
     b'localhost', PACK_VIRT_PORT, config.get('authentication_endpoint'),


Follow ups