← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:inline-zope.session into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:inline-zope.session into launchpad:master.

Commit message:
Inline relevant parts of zope.session

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/426587

`zope.session` has some unfortunate dependencies on things like `ZODB` which it would be helpful to be able to avoid.  It turns out that we don't actually use very much of its code, because we substitute a PostgreSQL-based session implementation for most of it; and for the parts we do use, we often have to work around its behaviour in inconvenient ways.

This branch inlines the relevant bits of `zope.session`, cut down heavily to only what Launchpad needs.  It's still unnecessarily complicated, but this is about as far as I can reduce it without causing behaviour changes, which ought to be in a separate branch.

`zope.session` is still present in the virtualenv after this change due to dependencies from `zope.app.appsetup` and `zope.app.wsgi`, but removing the direct dependency was the hardest part.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:inline-zope.session into launchpad:master.
diff --git a/lib/lp/services/database/policy.py b/lib/lp/services/database/policy.py
index 8d8f4e3..f1f1b41 100644
--- a/lib/lp/services/database/policy.py
+++ b/lib/lp/services/database/policy.py
@@ -30,10 +30,6 @@ from zope.interface import (
     alsoProvides,
     implementer,
     )
-from zope.session.interfaces import (
-    IClientIdManager,
-    ISession,
-    )
 
 from lp.services.config import (
     config,
@@ -51,6 +47,10 @@ from lp.services.database.interfaces import (
     STANDBY_FLAVOR,
     )
 from lp.services.database.sqlbase import StupidCache
+from lp.services.webapp.interfaces import (
+    IClientIdManager,
+    ISession,
+    )
 
 
 def _now():
diff --git a/lib/lp/services/webapp/authentication.py b/lib/lp/services/webapp/authentication.py
index 6e0ba21..0dfde45 100644
--- a/lib/lp/services/webapp/authentication.py
+++ b/lib/lp/services/webapp/authentication.py
@@ -21,7 +21,6 @@ from zope.interface import implementer
 from zope.principalregistry.principalregistry import UnauthenticatedPrincipal
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
-from zope.session.interfaces import ISession
 
 from lp.registry.interfaces.person import (
     IPerson,
@@ -37,6 +36,7 @@ from lp.services.webapp.interfaces import (
     ILaunchpadPrincipal,
     IPlacelessAuthUtility,
     IPlacelessLoginSource,
+    ISession,
     )
 
 
diff --git a/lib/lp/services/webapp/candid.py b/lib/lp/services/webapp/candid.py
index c24d035..60802ea 100644
--- a/lib/lp/services/webapp/candid.py
+++ b/lib/lp/services/webapp/candid.py
@@ -25,7 +25,6 @@ from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
 from requests import HTTPError
 from zope.browserpage import ViewPageTemplateFile
-from zope.session.interfaces import ISession
 
 from lp.services.config import config
 from lp.services.timeline.requesttimeline import get_request_timeline
@@ -33,6 +32,7 @@ from lp.services.timeout import (
     raise_for_status_redacted,
     urlfetch,
     )
+from lp.services.webapp.interfaces import ISession
 from lp.services.webapp.publisher import LaunchpadView
 from lp.services.webapp.url import urlappend
 from lp.services.webapp.vhosts import allvhosts
diff --git a/lib/lp/services/webapp/configure.zcml b/lib/lp/services/webapp/configure.zcml
index bb5ddc0..a1256b1 100644
--- a/lib/lp/services/webapp/configure.zcml
+++ b/lib/lp/services/webapp/configure.zcml
@@ -147,13 +147,26 @@
         />
 
     <!-- Session machinery. -->
+    <adapter
+        factory="lp.services.webapp.session.Session"
+        provides="lp.services.webapp.interfaces.ISession"
+        permission="zope.Public"
+        />
+
+    <class class="lp.services.webapp.session.LaunchpadCookieClientIdManager">
+      <require
+          interface="lp.services.webapp.interfaces.IClientIdManager"
+          permission="zope.Public"
+          />
+    </class>
+
     <utility
         component="lp.services.webapp.session.idmanager"
-        provides="zope.session.interfaces.IClientIdManager"
+        provides="lp.services.webapp.interfaces.IClientIdManager"
         />
     <utility
         component="lp.services.webapp.pgsession.data_container"
-        provides="zope.session.interfaces.ISessionDataContainer"
+        provides="lp.services.webapp.interfaces.ISessionDataContainer"
         />
 
     <!-- Default favicon -->
@@ -210,7 +223,7 @@
     <!-- LaunchpadBrowserResponse needs to be able to find the session -->
     <adapter
         for="lp.services.webapp.servers.LaunchpadBrowserResponse"
-        provides="zope.session.interfaces.ISession"
+        provides="lp.services.webapp.interfaces.ISession"
         factory="lp.services.webapp.servers.adaptResponseToSession"
         />
 
diff --git a/lib/lp/services/webapp/interfaces.py b/lib/lp/services/webapp/interfaces.py
index 88da6f2..37b489b 100644
--- a/lib/lp/services/webapp/interfaces.py
+++ b/lib/lp/services/webapp/interfaces.py
@@ -1,5 +1,17 @@
 # Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
+#
+# Portions from zope.session, which is:
+#
+# Copyright (c) 2004 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
 
 import logging
 
@@ -19,6 +31,11 @@ from zope.interface import (
     implementer,
     Interface,
     )
+from zope.interface.common.mapping import (
+    IMapping,
+    IReadMapping,
+    IWriteMapping,
+    )
 from zope.interface.interfaces import IObjectEvent
 from zope.publisher.interfaces.browser import IBrowserApplicationRequest
 from zope.schema import (
@@ -770,3 +787,100 @@ class StormRangeFactoryError(Exception):
     """Raised when a Storm result set cannot be used for slicing by a
     StormRangeFactory.
     """
+
+
+class IClientIdManager(Interface):
+    """Manages client identifiers."""
+
+    def getClientId(request):
+        """
+        Return the client id for the given request as a string.
+
+        If the request doesn't have an attached sessionId a new one will be
+        generated.
+
+        This will do whatever is possible to do the HTTP request to ensure
+        the session id will be preserved.  Depending on the specific method,
+        further action might be necessary on the part of the user.  See the
+        documentation for the specific implementation and its interfaces.
+        """
+
+
+class ISessionDataContainer(IReadMapping, IWriteMapping):
+    """
+    Stores data objects for sessions.
+
+    The object implementing this interface is responsible for expiring data
+    as it feels appropriate.
+
+    Usage::
+
+      session_data_container[client_id][product_id][key] = value
+
+    Note that this interface does not support the full mapping interface -
+    the keys need to remain secret so we can't give access to :meth:`keys`,
+    :meth:`values` etc.
+    """
+    resolution = Int(
+        title=_("Timeout resolution (in seconds)"),
+        description=_(
+            "Defines what the 'resolution' of item timeout is. "
+            "Setting this higher allows the transience machinery to "
+            "do fewer 'writes' at the expense of causing items to time "
+            "out later than the 'Data object timeout value' by  a factor "
+            "of (at most) this many seconds."
+        ),
+        default=10*60,
+        required=True,
+        min=0,
+    )
+
+    def __getitem__(self, product_id):
+        """Return an `ISessionPkgData`."""
+
+    def __setitem__(self, product_id, value):
+        """Store an `ISessionPkgData`."""
+
+
+class ISession(Interface):
+    """A user session, indexed by client (from the request) and product."""
+
+    def __getitem__(product_id):
+        """Return the `ISessionPkgData` for this product ID.
+
+        .. caution::
+               This method implicitly creates a new session for the user if
+               it does not exist yet.
+        """
+
+    def get(product_id, default=None):
+        """
+        Return the relevant `ISessionPkgData` for this product ID, or
+        *default* if not available.
+        """
+
+
+class ISessionData(IReadMapping, IWriteMapping):
+    """
+    Storage for a particular client ID's session data.
+
+    Contains 0 or more `ISessionPkgData` instances.
+    """
+
+    # Note that only IReadMapping and IWriteMapping are implemented.
+    # We cannot give access to the keys, as they need to remain secret.
+
+    def __getitem__(self, client_id):
+        """Return an `ISessionPkgData`."""
+
+    def __setitem__(self, client_id, session_pkg_data):
+        """Store an `ISessionPkgData`."""
+
+
+class ISessionPkgData(IMapping):
+    """
+    Storage for a particular client ID and product ID's session data.
+
+    Data is stored persistently and transactionally.  Data stored must
+    be persistent or picklable.
+    """
diff --git a/lib/lp/services/webapp/login.py b/lib/lp/services/webapp/login.py
index 0060ab7..6b1b5e1 100644
--- a/lib/lp/services/webapp/login.py
+++ b/lib/lp/services/webapp/login.py
@@ -35,10 +35,6 @@ from zope.interface import Interface
 from zope.publisher.browser import BrowserPage
 from zope.publisher.interfaces.http import IHTTPApplicationRequest
 from zope.security.proxy import removeSecurityProxy
-from zope.session.interfaces import (
-    IClientIdManager,
-    ISession,
-    )
 
 from lp import _
 from lp.registry.interfaces.person import (
@@ -60,9 +56,11 @@ from lp.services.webapp import canonical_url
 from lp.services.webapp.error import SystemErrorView
 from lp.services.webapp.interfaces import (
     CookieAuthLoggedInEvent,
+    IClientIdManager,
     ILaunchpadApplication,
     IPlacelessAuthUtility,
     IPlacelessLoginSource,
+    ISession,
     LoggedOutEvent,
     )
 from lp.services.webapp.publisher import LaunchpadView
diff --git a/lib/lp/services/webapp/notifications.py b/lib/lp/services/webapp/notifications.py
index 9e76431..e16e29e 100644
--- a/lib/lp/services/webapp/notifications.py
+++ b/lib/lp/services/webapp/notifications.py
@@ -15,7 +15,6 @@ browser window the request came from.
 from datetime import datetime
 
 from zope.interface import implementer
-from zope.session.interfaces import ISession
 
 from lp.services.config import config
 from lp.services.webapp.escaping import (
@@ -28,6 +27,7 @@ from lp.services.webapp.interfaces import (
     INotificationList,
     INotificationRequest,
     INotificationResponse,
+    ISession,
     )
 from lp.services.webapp.login import allowUnauthenticatedSession
 from lp.services.webapp.publisher import LaunchpadView
diff --git a/lib/lp/services/webapp/pgsession.py b/lib/lp/services/webapp/pgsession.py
index b8fc07b..794b53a 100644
--- a/lib/lp/services/webapp/pgsession.py
+++ b/lib/lp/services/webapp/pgsession.py
@@ -8,7 +8,6 @@ from datetime import datetime
 import hashlib
 import io
 import pickle
-import time
 
 from lazr.restful.utils import get_current_browser_request
 import six
@@ -16,7 +15,8 @@ from storm.zope.interfaces import IZStorm
 from zope.authentication.interfaces import IUnauthenticatedPrincipal
 from zope.component import getUtility
 from zope.interface import implementer
-from zope.session.interfaces import (
+
+from lp.services.webapp.interfaces import (
     IClientIdManager,
     ISessionData,
     ISessionDataContainer,
@@ -95,11 +95,11 @@ class PGSessionDataContainer(PGSessionBase):
     session_pkg_data_table_name = 'SessionPkgData'
 
     def __getitem__(self, client_id):
-        """See zope.session.interfaces.ISessionDataContainer"""
+        """See `ISessionDataContainer`."""
         return PGSessionData(self, client_id)
 
     def __setitem__(self, client_id, session_data):
-        """See zope.session.interfaces.ISessionDataContainer"""
+        """See `ISessionDataContainer`."""
         # The SessionData / SessionPkgData objects know how to store
         # themselves.
         pass
@@ -110,8 +110,6 @@ class PGSessionData(PGSessionBase):
 
     session_data_container = None
 
-    lastAccessTime = None
-
     _have_ensured_client_id = False
 
     def __init__(self, session_data_container, client_id):
@@ -119,7 +117,6 @@ class PGSessionData(PGSessionBase):
         self.client_id = six.ensure_text(client_id, 'ascii')
         self.hashed_client_id = (
             hashlib.sha256(self.client_id.encode()).hexdigest())
-        self.lastAccessTime = time.time()
 
         # Update the last access time in the db if it is out of date
         table_name = session_data_container.session_data_table_name
@@ -174,11 +171,11 @@ class PGSessionData(PGSessionBase):
         self._have_ensured_client_id = True
 
     def __getitem__(self, product_id):
-        """Return an ISessionPkgData"""
+        """Return an `ISessionPkgData`."""
         return PGSessionPkgData(self, product_id)
 
     def __setitem__(self, product_id, session_pkg_data):
-        """See zope.session.interfaces.ISessionData
+        """See `ISessionData`.
 
         This is a noop in the RDBMS implementation.
         """
diff --git a/lib/lp/services/webapp/servers.py b/lib/lp/services/webapp/servers.py
index 79888bd..ef3a8ae 100644
--- a/lib/lp/services/webapp/servers.py
+++ b/lib/lp/services/webapp/servers.py
@@ -52,7 +52,6 @@ from zope.security.proxy import (
     isinstance as zope_isinstance,
     removeSecurityProxy,
     )
-from zope.session.interfaces import ISession
 
 from lp.app import versioninfo
 from lp.app.errors import UnexpectedFormData
@@ -95,6 +94,7 @@ from lp.services.webapp.interfaces import (
     INotificationResponse,
     IPlacelessAuthUtility,
     IPlacelessLoginSource,
+    ISession,
     OAuthPermission,
     )
 from lp.services.webapp.notifications import (
diff --git a/lib/lp/services/webapp/session.py b/lib/lp/services/webapp/session.py
index 0e7eb3b..a5475f7 100644
--- a/lib/lp/services/webapp/session.py
+++ b/lib/lp/services/webapp/session.py
@@ -1,15 +1,46 @@
 # Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
+#
+# Portions from zope.session, which is:
+#
+# Copyright (c) 2004 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
 
 """Support for browser-cookie sessions."""
 
+import base64
+from email.utils import formatdate
+from hashlib import sha1
+import hmac
 from http.cookiejar import domain_match
+import random
+import time
+from time import process_time
 
 from lazr.uri import URI
-from zope.session.http import CookieClientIdManager
+from zope.component import (
+    adapter,
+    getUtility,
+    )
+from zope.interface import implementer
+from zope.publisher.interfaces import IRequest
+from zope.publisher.interfaces.http import IHTTPApplicationRequest
 
 from lp.services.config import config
 from lp.services.database.sqlbase import session_store
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp.interfaces import (
+    IClientIdManager,
+    ISession,
+    ISessionDataContainer,
+    )
 
 
 SECONDS = 1
@@ -19,6 +50,45 @@ DAYS = 24 * HOURS
 YEARS = 365 * DAYS
 
 
+transtable = bytes.maketrans(b'+/', b'-.')
+
+
+def encode_digest(s):
+    """Encode SHA digest for cookie."""
+    return base64.encodebytes(s)[:-2].translate(transtable)
+
+
+@implementer(ISession)
+@adapter(IRequest)
+class Session:
+    """Default implementation of `lp.services.webapp.interfaces.ISession`."""
+
+    def __init__(self, request):
+        self.client_id = getUtility(IClientIdManager).getClientId(request)
+
+    def get(self, product_id, default=None):
+        """Get session data."""
+        # The ISessionDataContainer contains two levels:
+        # ISessionDataContainer[client_id] == ISessionData
+        # ISessionDataContainer[client_id][product_id] == ISessionPkgData
+        sdc = getUtility(ISessionDataContainer)
+        try:
+            sd = sdc[self.client_id]
+        except KeyError:
+            return default
+
+        return sd.get(product_id, default)
+
+    def __getitem__(self, product_id):
+        """Get or create session data."""
+        sdc = getUtility(ISessionDataContainer)
+
+        # The ISessionDataContainer contains two levels:
+        # ISessionDataContainer[client_id] == ISessionData
+        # ISessionDataContainer[client_id][product_id] == ISessionPkgData
+        return sdc[self.client_id][product_id]
+
+
 def get_cookie_domain(request_domain):
     """Return a string suitable for use as the domain parameter of a cookie.
 
@@ -37,20 +107,15 @@ def get_cookie_domain(request_domain):
             return dotted_domain
     return None
 
+
 ANNOTATION_KEY = 'lp.services.webapp.session.sid'
 
 
-class LaunchpadCookieClientIdManager(CookieClientIdManager):
+@implementer(IClientIdManager)
+class LaunchpadCookieClientIdManager:
 
     def __init__(self):
-        CookieClientIdManager.__init__(self)
         self.namespace = config.launchpad_session.cookie
-        # Set the cookie life time to something big.
-        # It should be larger than our session expiry time.
-        self.cookieLifetime = 1 * YEARS
-        self._secret = None
-        # Forbid browsers from exposing it to JS.
-        self.httpOnly = True
 
     def getClientId(self, request):
         sid = self.getRequestId(request)
@@ -69,29 +134,64 @@ class LaunchpadCookieClientIdManager(CookieClientIdManager):
                 request.annotations[ANNOTATION_KEY] = sid
         return sid
 
-    def _get_secret(self):
+    def generateUniqueId(self):
+        """Generate a new, random, unique id."""
+        data = "%.20f%.20f%.20f" % (
+            random.random(), time.time(), process_time())
+        digest = sha1(data.encode()).digest()
+        s = encode_digest(digest)
+        # we store a HMAC of the random value together with it, which makes
+        # our session ids unforgeable.
+        mac = hmac.new(s, self.secret.encode(), digestmod=sha1).digest()
+        return (s + encode_digest(mac)).decode()
+
+    def getRequestId(self, request):
+        """Return the browser id encoded in request as a string.
+
+        Return `None` if an id is not set.
+        """
+        response_cookie = request.response.getCookie(self.namespace)
+        if response_cookie:
+            sid = response_cookie['value']
+        else:
+            request = IHTTPApplicationRequest(request)
+            sid = request.getCookies().get(self.namespace, None)
+
+        # If there is an id set on the response, use that but don't trust
+        # it.  We need to check the response in case there has already been
+        # a new session created during the course of this request.
+
+        if sid is None or len(sid) != 54:
+            return None
+        s, mac = sid[:27], sid[27:]
+
+        # HMAC is specified to work on byte strings only so make
+        # sure to feed it that by encoding
+        mac_with_my_secret = hmac.new(
+            s.encode(), self.secret.encode(), digestmod=sha1).digest()
+        mac_with_my_secret = encode_digest(mac_with_my_secret).decode()
+
+        if mac_with_my_secret != mac:
+            return None
+
+        return sid
+
+    @cachedproperty
+    def secret(self):
         # Because our CookieClientIdManager is not persistent, we need to
         # pull the secret from some other data store - failing to do this
         # would mean a new secret is generated every time the server is
         # restarted, invalidating all old session information.
         # Secret is looked up here rather than in __init__, because
         # we can't be sure the database connections are setup at that point.
-        if self._secret is None:
-            store = session_store()
-            result = store.execute("SELECT secret FROM secret")
-            self._secret = result.get_one()[0]
-        return self._secret
-
-    def _set_secret(self, value):
-        # Silently ignored so CookieClientIdManager's __init__ method
-        # doesn't die
-        pass
-
-    secret = property(_get_secret, _set_secret)
+        store = session_store()
+        result = store.execute("SELECT secret FROM secret")
+        return result.get_one()[0]
 
     def setRequestId(self, request, id):
-        """As per CookieClientIdManager.setRequestID, except
-        we force the domain key on the cookie to be set to allow our
+        """Set cookie with id on response.
+
+        We force the domain key on the cookie to be set to allow our
         session to be shared between virtual hosts where possible, and
         we set the secure key to stop the session key being sent to
         insecure URLs like the Librarian.
@@ -99,21 +199,39 @@ class LaunchpadCookieClientIdManager(CookieClientIdManager):
         We also log the referrer url on creation of a new
         requestid so we can track where first time users arrive from.
         """
-        CookieClientIdManager.setRequestId(self, request, id)
-
-        cookie = request.response.getCookie(self.namespace)
+        response = request.response
         uri = URI(request.getURL())
+        options = {}
 
-        # Set secure flag on cookie.
-        if uri.scheme != 'http':
-            cookie['secure'] = True
-        else:
-            cookie['secure'] = False
+        # Set the cookie lifetime to something big.  It should be larger
+        # than our session expiry time.
+        expires = formatdate(
+            time.time() + 1 * YEARS, localtime=False, usegmt=True)
+        options['expires'] = expires
 
         # Set domain attribute on cookie if vhosting requires it.
         cookie_domain = get_cookie_domain(uri.host)
         if cookie_domain is not None:
-            cookie['domain'] = cookie_domain
+            options['domain'] = cookie_domain
+
+        # Forbid browsers from exposing it to JS.
+        options['HttpOnly'] = True
+
+        # Set secure flag on cookie.
+        if uri.scheme != 'http':
+            options['secure'] = True
+        else:
+            options['secure'] = False
+
+        response.setCookie(
+            self.namespace, id,
+            path=request.getApplicationURL(path_only=True),
+            **options)
+
+        response.setHeader(
+            'Cache-Control', 'no-cache="Set-Cookie,Set-Cookie2"')
+        response.setHeader('Pragma', 'no-cache')
+        response.setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT')
 
 
 idmanager = LaunchpadCookieClientIdManager()
diff --git a/lib/lp/services/webapp/tests/test_candid.py b/lib/lp/services/webapp/tests/test_candid.py
index b06bd2f..ef7e36b 100644
--- a/lib/lp/services/webapp/tests/test_candid.py
+++ b/lib/lp/services/webapp/tests/test_candid.py
@@ -25,7 +25,6 @@ from testtools.matchers import (
     MatchesStructure,
     Not,
     )
-from zope.session.interfaces import ISession
 
 from lp.services.config import config
 from lp.services.webapp.candid import (
@@ -35,6 +34,7 @@ from lp.services.webapp.candid import (
     extract_candid_caveat,
     request_candid_discharge,
     )
+from lp.services.webapp.interfaces import ISession
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.testing import (
     login_person,
diff --git a/lib/lp/services/webapp/tests/test_dbpolicy.py b/lib/lp/services/webapp/tests/test_dbpolicy.py
index a2a8120..e097f2b 100644
--- a/lib/lp/services/webapp/tests/test_dbpolicy.py
+++ b/lib/lp/services/webapp/tests/test_dbpolicy.py
@@ -21,7 +21,6 @@ from zope.security.management import (
     endInteraction,
     newInteraction,
     )
-from zope.session.interfaces import ISession
 
 from lp.layers import (
     FeedsLayer,
@@ -49,6 +48,7 @@ from lp.services.database.policy import (
     StandbyDatabasePolicy,
     StandbyOnlyDatabasePolicy,
     )
+from lp.services.webapp.interfaces import ISession
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.testing import TestCase
 from lp.testing.fixture import PGBouncerFixture
diff --git a/lib/lp/services/webapp/tests/test_login.py b/lib/lp/services/webapp/tests/test_login.py
index 507c2ff..d738325 100644
--- a/lib/lp/services/webapp/tests/test_login.py
+++ b/lib/lp/services/webapp/tests/test_login.py
@@ -42,7 +42,6 @@ from testtools.matchers import (
 from zope.component import getUtility
 from zope.security.management import newInteraction
 from zope.security.proxy import removeSecurityProxy
-from zope.session.interfaces import ISession
 from zope.testbrowser.wsgi import Browser as TestBrowser
 
 from lp.registry.interfaces.person import IPerson
@@ -62,7 +61,10 @@ from lp.services.openid.extensions.macaroon import (
     )
 from lp.services.openid.model.openididentifier import OpenIdIdentifier
 from lp.services.timeline.requesttimeline import get_request_timeline
-from lp.services.webapp.interfaces import ILaunchpadApplication
+from lp.services.webapp.interfaces import (
+    ILaunchpadApplication,
+    ISession,
+    )
 from lp.services.webapp.login import (
     OpenIDCallbackView,
     OpenIDLogin,
diff --git a/lib/lp/services/webapp/tests/test_login_account.py b/lib/lp/services/webapp/tests/test_login_account.py
index 550a7ae..4dfbed8 100644
--- a/lib/lp/services/webapp/tests/test_login_account.py
+++ b/lib/lp/services/webapp/tests/test_login_account.py
@@ -7,7 +7,6 @@ from urllib.parse import parse_qs
 import lazr.uri
 from zope.component import getUtility
 from zope.event import notify
-from zope.session.interfaces import ISession
 
 from lp.services.config import config
 from lp.services.identity.interfaces.account import (
@@ -18,6 +17,7 @@ from lp.services.webapp.authentication import LaunchpadPrincipal
 from lp.services.webapp.interfaces import (
     CookieAuthLoggedInEvent,
     IPlacelessAuthUtility,
+    ISession,
     )
 from lp.services.webapp.login import (
     CookieLogoutPage,
diff --git a/lib/lp/services/webapp/tests/test_notifications.py b/lib/lp/services/webapp/tests/test_notifications.py
index 7ee572e..76ceca8 100644
--- a/lib/lp/services/webapp/tests/test_notifications.py
+++ b/lib/lp/services/webapp/tests/test_notifications.py
@@ -15,15 +15,13 @@ from zope.interface import implementer
 from zope.publisher.browser import TestRequest
 from zope.publisher.interfaces.browser import IBrowserRequest
 from zope.publisher.interfaces.http import IHTTPApplicationResponse
-from zope.session.interfaces import (
-    ISession,
-    ISessionData,
-    )
 
 from lp.services.webapp.escaping import structured
 from lp.services.webapp.interfaces import (
     INotificationRequest,
     INotificationResponse,
+    ISession,
+    ISessionData,
     )
 from lp.services.webapp.notifications import NotificationResponse
 
@@ -42,8 +40,6 @@ class MockSession(dict):
 @implementer(ISessionData)
 class MockSessionData(dict):
 
-    lastAccessTime = 0
-
     def __call__(self, whatever):
         return self
 
diff --git a/lib/lp/services/webapp/tests/test_pgsession.py b/lib/lp/services/webapp/tests/test_pgsession.py
index b982ccc..ac77ed9 100644
--- a/lib/lp/services/webapp/tests/test_pgsession.py
+++ b/lib/lp/services/webapp/tests/test_pgsession.py
@@ -11,11 +11,11 @@ from zope.security.management import (
     endInteraction,
     newInteraction,
     )
-from zope.session.interfaces import (
+
+from lp.services.webapp.interfaces import (
     ISessionData,
     ISessionDataContainer,
     )
-
 from lp.services.webapp.pgsession import (
     PGSessionData,
     PGSessionDataContainer,
diff --git a/lib/lp/services/webapp/tests/test_session.py b/lib/lp/services/webapp/tests/test_session.py
index 84e94cd..006136c 100644
--- a/lib/lp/services/webapp/tests/test_session.py
+++ b/lib/lp/services/webapp/tests/test_session.py
@@ -1,25 +1,34 @@
 # Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-import datetime
+from datetime import (
+    datetime,
+    timedelta,
+    )
+from email.utils import parsedate
 import hashlib
 import hmac
 
-from testtools import TestCase
-from testtools.matchers import Contains
-from zope.session.session import digestEncode
+from testtools.matchers import (
+    Contains,
+    ContainsDict,
+    Equals,
+    )
 
+from lp.services.propertycache import get_property_cache
 from lp.services.webapp.login import (
     isFreshLogin,
     OpenIDCallbackView,
     )
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.services.webapp.session import (
+    encode_digest,
     get_cookie_domain,
     LaunchpadCookieClientIdManager,
     )
 from lp.testing import (
     person_logged_in,
+    TestCase,
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
@@ -61,6 +70,23 @@ class GetCookieDomainTestCase(TestCase):
 
 class TestLaunchpadCookieClientIdManager(TestCase):
 
+    def test_generateUniqueId(self):
+        idmanager = LaunchpadCookieClientIdManager()
+        get_property_cache(idmanager).secret = 'secret'
+        self.assertNotEqual(
+            idmanager.generateUniqueId(), idmanager.generateUniqueId())
+
+    def test_expires(self):
+        request = LaunchpadTestRequest()
+        idmanager = LaunchpadCookieClientIdManager()
+        idmanager.setRequestId(request, 'some-id')
+        cookie = request.response.getCookie(idmanager.namespace)
+        expires = datetime(*parsedate(cookie['expires'])[:7])
+        self.assertLess(
+            expires - datetime.now() - timedelta(days=365),
+            # Allow some slack for slow tests.
+            timedelta(seconds=30))
+
     def test_httponly(self):
         # Authentication cookies are marked as httponly, so JavaScript
         # can't read them directly.
@@ -70,18 +96,31 @@ class TestLaunchpadCookieClientIdManager(TestCase):
             dict(request.response.getHeaders())['Set-Cookie'].lower(),
             Contains('; httponly;'))
 
+    def test_headers(self):
+        # When the cookie is set, cache headers are added to the response to
+        # try to prevent the cookie header from being cached:
+        request = LaunchpadTestRequest()
+        LaunchpadCookieClientIdManager().setRequestId(request, 'some-id')
+        self.assertThat(
+            dict(request.response.getHeaders()),
+            ContainsDict({
+                'Cache-Control': Equals('no-cache="Set-Cookie,Set-Cookie2"'),
+                'Pragma': Equals('no-cache'),
+                'Expires': Equals('Mon, 26 Jul 1997 05:00:00 GMT'),
+                }))
+
     def test_stable_client_id(self):
         # Changing the HMAC algorithm used for client IDs would invalidate
         # existing sessions, so be careful that that doesn't happen
         # accidentally across upgrades.
         request = LaunchpadTestRequest()
         idmanager = LaunchpadCookieClientIdManager()
-        idmanager._secret = 'secret'
+        get_property_cache(idmanager).secret = 'secret'
         data = b'random'
-        s = digestEncode(hashlib.sha1(data).digest())
+        s = encode_digest(hashlib.sha1(data).digest())
         mac = hmac.new(
             s, idmanager.secret.encode(), digestmod=hashlib.sha1).digest()
-        sid = (s + digestEncode(mac)).decode()
+        sid = (s + encode_digest(mac)).decode()
         idmanager.setRequestId(request, sid)
         # getRequestId will only return the previously-set ID if it was
         # generated using the correct secret with the correct algorithm.
@@ -114,6 +153,6 @@ class TestSessionRelatedFunctions(TestCaseWithFactory):
         """isFreshLogin should be False for users logged in over 2 minutes."""
         user = self.factory.makePerson()
         request = LaunchpadTestRequest()
-        when = datetime.datetime.utcnow() - datetime.timedelta(seconds=180)
+        when = datetime.utcnow() - timedelta(seconds=180)
         self.setupLoggedInRequest(user, request, when)
         self.assertFalse(isFreshLogin(request))
diff --git a/lib/lp/testing/pages.py b/lib/lp/testing/pages.py
index 3a4e58d..6e2d360 100644
--- a/lib/lp/testing/pages.py
+++ b/lib/lp/testing/pages.py
@@ -36,7 +36,6 @@ from zope.app.wsgi.testlayer import (
 from zope.component import getUtility
 from zope.security.management import setSecurityPolicy
 from zope.security.proxy import removeSecurityProxy
-from zope.session.interfaces import ISession
 from zope.testbrowser.browser import (
     BrowserStateError,
     isMatching,
@@ -65,7 +64,10 @@ from lp.services.oauth.interfaces import (
     )
 from lp.services.webapp import canonical_url
 from lp.services.webapp.authorization import LaunchpadPermissiveSecurityPolicy
-from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.interfaces import (
+    ISession,
+    OAuthPermission,
+    )
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.services.webapp.url import urlsplit
 from lp.testing import (
diff --git a/lib/lp/testing/yuixhr.py b/lib/lp/testing/yuixhr.py
index 5f83617..c19b84e 100644
--- a/lib/lp/testing/yuixhr.py
+++ b/lib/lp/testing/yuixhr.py
@@ -31,11 +31,11 @@ from zope.security.checker import (
     ProxyFactory,
     )
 from zope.security.proxy import removeSecurityProxy
-from zope.session.interfaces import IClientIdManager
 
 from lp.app import versioninfo
 from lp.services.config import config
 from lp.services.webapp.interfaces import (
+    IClientIdManager,
     IOpenLaunchBag,
     IPlacelessAuthUtility,
     )
diff --git a/lib/lp/testopenid/browser/server.py b/lib/lp/testopenid/browser/server.py
index 04d0982..8a7ad97 100644
--- a/lib/lp/testopenid/browser/server.py
+++ b/lib/lp/testopenid/browser/server.py
@@ -29,7 +29,6 @@ from zope.browserpage import ViewPageTemplateFile
 from zope.component import getUtility
 from zope.interface import implementer
 from zope.security.proxy import isinstance as zisinstance
-from zope.session.interfaces import ISession
 
 from lp import _
 from lp.app.browser.launchpadform import (
@@ -54,6 +53,7 @@ from lp.services.webapp import LaunchpadView
 from lp.services.webapp.interfaces import (
     ICanonicalUrlData,
     IPlacelessLoginSource,
+    ISession,
     )
 from lp.services.webapp.login import (
     allowUnauthenticatedSession,
diff --git a/requirements/launchpad.txt b/requirements/launchpad.txt
index 984e30f..71208ba 100644
--- a/requirements/launchpad.txt
+++ b/requirements/launchpad.txt
@@ -194,8 +194,6 @@ zope.app.publication==4.3.1
 zope.app.publisher==4.2.0
 zope.app.wsgi==4.3.0
 zope.publisher==6.0.2+lp1
-# lp:~launchpad-committers/zope.session:launchpad
-zope.session==4.3.0+lp1
 zope.testbrowser==5.5.1
 # lp:~launchpad-committers/zope.testrunner:launchpad
 zope.testrunner==5.3.0+lp1
diff --git a/setup.cfg b/setup.cfg
index 523364e..b4e029e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -149,7 +149,6 @@ install_requires =
     zope.security
     zope.securitypolicy
     zope.sendmail
-    zope.session
     zope.tal
     zope.tales
     zope.testbrowser
diff --git a/zcml/zopeapp.zcml b/zcml/zopeapp.zcml
index 3606c4e..9ae82dd 100644
--- a/zcml/zopeapp.zcml
+++ b/zcml/zopeapp.zcml
@@ -29,7 +29,6 @@
   <include package="zope.principalregistry" />
 
   <include package="zope.login" />
-  <include package="zope.session" />
 
   <include package="zope.app.publisher.xmlrpc" />