← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~leonardr/launchpad/publish-tokens-2 into lp:launchpad

 

Leonard Richardson has proposed merging lp:~leonardr/launchpad/publish-tokens-2 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


This branch publishes OAuth access tokens through the web service. This meant adding traversal rules an canonical URL data for OAuthAccessToken objects.

A user can only view their own access tokens, and only by using a client that's been given the GRANT_PERMISSIONS access level. The old rules apply when accessing access tokens through the website: a user can always access their own tokens, and admins can access other peoples'.

Because I changed the rules for permission checks on OAuth access tokens to check the current request, I've added removeSecurityProxy calls in test helper classes like oauth_token_for_user and tests in which there is no current request. Please check this code to make sure I haven't opened a security hole.

This branch takes advantage of a new version of lazr.restful so that the read-write 'permission' field of IOAuthAccessToken can be published as read-only in the web service. This code is tested in lazr.restful, but I can add a Launchpad test as well.
-- 
https://code.launchpad.net/~leonardr/launchpad/publish-tokens-2/+merge/34123
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~leonardr/launchpad/publish-tokens-2 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/database/oauth.py'
--- lib/canonical/launchpad/database/oauth.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/database/oauth.py	2010-08-30 19:49:45 +0000
@@ -46,6 +46,7 @@
     )
 from canonical.launchpad.webapp.interfaces import (
     AccessLevel,
+    ICanonicalUrlData,
     IStoreSelector,
     MAIN_STORE,
     MASTER_FLAVOR,
@@ -136,7 +137,17 @@
 
 class OAuthAccessToken(OAuthBase):
     """See `IOAuthAccessToken`."""
-    implements(IOAuthAccessToken)
+    implements(IOAuthAccessToken, ICanonicalUrlData)
+
+    @property
+    def inside(self):
+        return self.person
+
+    @property
+    def path(self):
+        return "+oauth-access-token/" + self.key
+
+    rootsite = 'api'
 
     consumer = ForeignKey(
         dbName='consumer', foreignKey='OAuthConsumer', notNull=True)

=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
--- lib/canonical/launchpad/doc/oauth.txt	2010-04-16 15:06:55 +0000
+++ lib/canonical/launchpad/doc/oauth.txt	2010-08-30 19:49:45 +0000
@@ -186,6 +186,13 @@
     True
     >>> request_token.permission
     <DBItem OAuthPermission.WRITE_PUBLIC...
+
+Because an access token can only be viewed or edited by its owner or
+an admin, we need to log in at this point.
+
+    >>> token_owner = request_token.person
+    >>> login_person(token_owner)
+
     >>> access_token = request_token.createAccessToken()
     >>> verifyObject(IOAuthAccessToken, access_token)
     True
@@ -245,14 +252,30 @@
     >>> consumer.getAccessToken(access_token.key) == access_token
     True
 
-An access token can only be changed by the person associated with it.
-
-    >>> access_token.permission = OAuthPermission.WRITE_PUBLIC
-    Traceback (most recent call last):
-    ...
-    Unauthorized:...
-    >>> login_person(access_token.person)
-    >>> access_token.permission = AccessLevel.WRITE_PUBLIC
+In general, one user cannot edit another user's access token.
+
+    >>> login('no-priv@xxxxxxxxxxxxx')
+    >>> access_token.permission
+    Traceback (most recent call last):
+    ...
+    Unauthorized:...
+
+    >>> access_token.permission = AccessLevel.WRITE_PUBLIC
+    Traceback (most recent call last):
+    ...
+    Unauthorized:...
+
+An admin can view and edit someone else's access token.
+
+    >>> login("foo.bar@xxxxxxxxxxxxx")
+    >>> print access_token.permission.name
+    WRITE_PUBLIC
+    >>> access_token.permission = AccessLevel.WRITE_PUBLIC
+
+And a user can edit their own access tokens.
+
+    >>> login_person(token_owner)
+    >>> access_token.permission = AccessLevel.WRITE_PRIVATE
 
 From any given person it's possible to retrieve his non-expired access
 tokens.
@@ -305,6 +328,7 @@
 
 - We can use a nonce.
 
+    >>> login_person(token_owner)
     >>> import time
     >>> now = time.time() - 1
     >>> nonce1 = access_token.checkNonceAndTimestamp('boo', now)

=== modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py'
--- lib/canonical/launchpad/interfaces/_schema_circular_imports.py	2010-08-27 11:19:54 +0000
+++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py	2010-08-30 19:49:45 +0000
@@ -32,6 +32,7 @@
     IMessage,
     IUserToUserEmail,
     )
+from canonical.launchpad.interfaces.oauth import IOAuthToken
 from lp.blueprints.interfaces.specification import ISpecification
 from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch
 from lp.bugs.interfaces.bug import (
@@ -196,6 +197,8 @@
 patch_choice_parameter_type(
     IHasBugs, 'searchTasks', 'hardware_bus', HWBus)
 
+IOAuthToken['person'].schema = IPerson
+
 IPreviewDiff['branch_merge_proposal'].schema = IBranchMergeProposal
 
 patch_reference_property(IPersonPublic, 'archive', IArchive)

=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
--- lib/canonical/launchpad/interfaces/oauth.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/interfaces/oauth.py	2010-08-30 19:49:45 +0000
@@ -34,12 +34,14 @@
     TextLine,
     )
 
+from lazr.restful.declarations import (
+    export_as_webservice_entry, exported)
+
 from canonical.launchpad import _
 from canonical.launchpad.webapp.interfaces import (
     AccessLevel,
     OAuthPermission,
     )
-from lp.registry.interfaces.person import IPerson
 
 # The challenge included in responses with a 401 status.
 OAUTH_REALM = 'https://api.launchpad.net'
@@ -128,23 +130,24 @@
         schema=IOAuthConsumer, title=_('The consumer.'),
         description=_("The consumer which will access Launchpad on the "
                       "user's behalf."))
-    person = Object(
-        schema=IPerson, title=_('Person'), required=False, readonly=False,
-        description=_('The user on whose behalf the consumer is accessing.'))
-    date_created = Datetime(
-        title=_('Date created'), required=True, readonly=True)
-    date_expires = Datetime(
+    person = exported(Object(
+        schema=Interface, title=_('Person'), required=False, readonly=False,
+        description=_('The user on whose behalf the consumer is accessing.')),
+                      readonly=True)
+    date_created = exported(Datetime(
+        title=_('Date created'), required=True, readonly=True))
+    date_expires = exported(Datetime(
         title=_('Date expires'), required=False, readonly=False,
         description=_('From this date onwards this token can not be used '
-                      'by the consumer to access protected resources.'))
-    key = TextLine(
+                      'by the consumer to access protected resources.')))
+    key = exported(TextLine(
         title=_('Key'), required=True, readonly=True,
         description=_('The key used to identify this token.  It is included '
-                      'by the consumer in each request.'))
-    secret = TextLine(
+                      'by the consumer in each request.')))
+    secret = exported(TextLine(
         title=_('Secret'), required=True, readonly=True,
         description=_('The secret associated with this token.  It is used '
-                      'by the consumer to sign its requests.'))
+                      'by the consumer to sign its requests.')))
     product = Choice(title=_('Project'), required=False, vocabulary='Product')
     project = Choice(
         title=_('Project'), required=False, vocabulary='ProjectGroup')
@@ -161,12 +164,14 @@
     It's created automatically once a user logs in and grants access to a
     consumer.  The consumer then exchanges an `IOAuthRequestToken` for it.
     """
+    export_as_webservice_entry()
 
-    permission = Choice(
+    permission = exported(Choice(
         title=_('Access level'), required=True, readonly=False,
         vocabulary=AccessLevel,
         description=_('The level of access given to the application acting '
-                      'on your behalf.'))
+                      'on your behalf.')),
+                          readonly=True)
 
     def checkNonceAndTimestamp(nonce, timestamp):
         """Verify the nonce and timestamp.

=== added file 'lib/canonical/launchpad/pagetests/webservice/oauth.txt'
--- lib/canonical/launchpad/pagetests/webservice/oauth.txt	1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/pagetests/webservice/oauth.txt	2010-08-30 19:49:45 +0000
@@ -0,0 +1,65 @@
+**********************
+OAuth token management
+**********************
+
+A properly authorized web service client can examine and manage the
+OAuth access tokens used to manage access to the web service itself.
+
+    >>> launchpad = launchpadlib_for(
+    ...     'Token management test', 'salgado', 'GRANT_PERMISSIONS')
+
+The person 'salgado' has three associated tokens: two created as part
+of sample data, and one ("Grant Permissions") created in the
+launchpadlib_for() call immediately above.
+
+    >>> my_tokens = launchpad.me.oauth_access_tokens
+    >>> sorted([token.permission for token in my_tokens])
+    [u'Change Anything', u'Grant Permissions', u'Read Non-Private Data']
+
+You can change certain details of an existing OAuth access
+token. Let's set a token's expiration date to a date in the far
+future.
+
+    >>> from datetime import date
+    >>> token = [token for token in my_tokens
+    ...          if token.permission == "Read Non-Private Data"][0]
+    >>> old_date_expires = token.date_expires
+    >>> new_date_expires = str(date(9000, 1, 1))
+    >>> token.date_expires = new_date_expires
+    >>> token.lp_save()
+
+Now let's change it back.
+
+    >>> token.date_expires = old_date_expires
+    >>> token.lp_save()
+
+You can't see another person's OAuth tokens.
+
+    >>> someone_else = launchpadlib_for(
+    ...     'Token management test', 'name12', 'GRANT_PERMISSIONS')
+
+    >>> [x for x in someone_else.people['salgado'].oauth_access_tokens]
+    []
+
+If you haven't given your client the GRANT_PERMISSIONS access level,
+you can't see your own OAuth tokens.
+
+    >>> low_access = launchpadlib_for(
+    ...     'Token management test', 'salgado', 'WRITE_PRIVATE')
+    >>> [x for x in low_access.me.oauth_access_tokens]
+    []
+
+Hacking the URL from an underprivileged account (such as the
+'webservice' object, which has WRITE_PRIVATE) won't get you
+anything.
+
+    >>> url = '/~salgado/+oauth-access-token/salgado-read-nonprivate'
+    >>> print webservice.get(url)
+    HTTP/1.1 401 Unauthorized
+    ...
+
+    >>> import simplejson
+    >>> body = simplejson.dumps(dict(date_expires=new_date_expires))
+    >>> print webservice.patch(url, 'application/json', body)
+    HTTP/1.1 401 Unauthorized
+    ...

=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py	2010-08-27 23:01:42 +0000
+++ lib/canonical/launchpad/security.py	2010-08-30 19:49:45 +0000
@@ -33,12 +33,15 @@
 from canonical.launchpad.interfaces.oauth import (
     IOAuthAccessToken,
     IOAuthRequestToken,
+    IOAuthSignedRequest,
     )
 from canonical.launchpad.webapp.authorization import check_permission
 from canonical.launchpad.webapp.interfaces import (
+    AccessLevel,
     IAuthorization,
     ILaunchpadRoot,
     )
+from canonical.lazr.utils import get_current_browser_request
 from lp.answers.interfaces.faq import IFAQ
 from lp.answers.interfaces.faqtarget import IFAQTarget
 from lp.answers.interfaces.question import IQuestion
@@ -373,15 +376,42 @@
         return user.in_admin or user.in_registry_experts
 
 
-class EditOAuthAccessToken(AuthorizationBase):
-    permission = 'launchpad.Edit'
-    usedfor = IOAuthAccessToken
+class OAuthToken(AuthorizationBase):
+    """Special rules for access to OAuth tokens.
+
+    Through the website, a person can always access their own OAuth
+    tokens, and an admin can access someone else's OAuth tokens.
+
+    To minimize the risk of privilege escalation attacks, much greater
+    restrictions apply through the web service. A person can only
+    access their own OAuth tokens, and they must be using a client
+    authorized at the GRANT_PERMISSIONS access level.
+    """
 
     def checkAuthenticated(self, user):
-        return self.obj.person == user.person or user.in_admin
-
-
-class EditOAuthRequestToken(EditOAuthAccessToken):
+        request = get_current_browser_request()
+        if IOAuthSignedRequest.providedBy(request):
+            # This is a web service request.
+            if self.obj.person != user.person:
+                return False
+            return (request.principal.access_level ==
+                    AccessLevel.GRANT_PERMISSIONS)
+        else:
+            # This is a website request.
+            return self.obj.person == user.person or user.in_admin
+
+
+class ViewOAuthAccessToken(OAuthToken):
+    permission = 'launchpad.View'
+    usedfor = IOAuthAccessToken
+
+
+class EditOAuthAccessToken(OAuthToken):
+    permission = 'launchpad.Edit'
+    usedfor = IOAuthAccessToken
+
+
+class EditOAuthRequestToken(OAuthToken):
     permission = 'launchpad.Edit'
     usedfor = IOAuthRequestToken
 

=== modified file 'lib/canonical/launchpad/testing/pages.py'
--- lib/canonical/launchpad/testing/pages.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/testing/pages.py	2010-08-30 19:49:45 +0000
@@ -153,10 +153,16 @@
                     self.consumer = OAuthConsumer(oauth_consumer_key, '')
                     self.access_token = OAuthToken(oauth_access_key, '')
                 else:
-                    # The client wants to make an authorized request
-                    # using a recognized consumer key.
-                    self.access_token = self.consumer.getAccessToken(
-                        oauth_access_key)
+                    # The client wants to make authorized requests
+                    # using a recognized consumer key. The security
+                    # policy for IOAuthAccessToken assumes the request
+                    # is being processed on the server side and has
+                    # already been signed with some access token.
+                    #
+                    # Here, the requests haven't even gone out yet,
+                    # so it's okay to strip the security proxy.
+                    self.access_token = removeSecurityProxy(
+                        self.consumer.getAccessToken(oauth_access_key))
             logout()
         else:
             self.consumer = None
@@ -694,7 +700,11 @@
         consumer = oacs.new(consumer_key)
     request_token = consumer.newRequestToken()
     request_token.review(person, permission, context)
-    access_token = request_token.createAccessToken()
+    # The security proxy assumes that an OAuth token has been
+    # associated with the current HTTP request. Since we're using an
+    # OAuth token to construct the HTTP requests in the first place,
+    # we need to strip the security proxy.
+    access_token = removeSecurityProxy(request_token.createAccessToken())
     logout()
     return LaunchpadWebServiceCaller(consumer_key, access_token.key)
 

=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
--- lib/canonical/launchpad/webapp/authorization.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/authorization.py	2010-08-30 19:49:45 +0000
@@ -61,7 +61,8 @@
         lp_permission = getUtility(ILaunchpadPermission, permission)
         if lp_permission.access_level == "write":
             required_access_level = [
-                AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]
+                AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
+                AccessLevel.GRANT_PERMISSIONS]
             if access_level not in required_access_level:
                 return False
         elif lp_permission.access_level == "read":

=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py	2010-08-28 03:23:19 +0000
+++ lib/canonical/launchpad/webapp/servers.py	2010-08-30 19:49:45 +0000
@@ -1276,6 +1276,12 @@
         token = consumer.getAccessToken(token_key)
         if token is None:
             raise Unauthorized('Unknown access token (%s).' % token_key)
+
+        # The security policy for IOAuthAccessToken assumes that the
+        # access level of the request has already been
+        # identified. Since identifying the access level is what we're
+        # trying to do here, we need to strip the security policy.
+        token = removeSecurityProxy(token)
         nonce = form.get('oauth_nonce')
         timestamp = form.get('oauth_timestamp')
         try:

=== modified file 'lib/canonical/launchpad/zcml/oauth.zcml'
--- lib/canonical/launchpad/zcml/oauth.zcml	2009-07-13 18:15:02 +0000
+++ lib/canonical/launchpad/zcml/oauth.zcml	2010-08-30 19:49:45 +0000
@@ -39,7 +39,10 @@
   </securedutility>
 
   <class class="canonical.launchpad.database.OAuthAccessToken">
-      <allow interface="canonical.launchpad.interfaces.IOAuthAccessToken"/>
+      <allow interface="canonical.launchpad.webapp.interfaces.ICanonicalUrlData" />
+      <require
+          permission="launchpad.View"
+          interface="canonical.launchpad.interfaces.IOAuthAccessToken"/>
       <require
           permission="launchpad.Edit"
           set_schema="canonical.launchpad.interfaces.IOAuthAccessToken"/>

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2010-08-30 06:38:53 +0000
+++ lib/lp/registry/browser/person.py	2010-08-30 19:49:45 +0000
@@ -488,6 +488,15 @@
             return None
         return email
 
+    @stepthrough('+oauth-access-token')
+    def traverse_access_token(self, key):
+        """Traverse to an OAuth access token on the webservice layer."""
+        matches = [token for token in self.context.oauth_access_tokens
+                   if token.key == key]
+        if len(matches) > 0:
+            return matches[0]
+        return None
+
     @stepthrough('+wikiname')
     def traverse_wikiname(self, id):
         """Traverse to this person's WikiNames on the webservice layer."""

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2010-08-22 03:09:51 +0000
+++ lib/lp/registry/interfaces/person.py	2010-08-30 19:49:45 +0000
@@ -103,6 +103,7 @@
     IHasMugshot,
     IPrivacy,
     )
+from canonical.launchpad.interfaces.oauth import IOAuthAccessToken
 from canonical.launchpad.interfaces.validation import validate_new_team_email
 from canonical.launchpad.validators import LaunchpadValidationError
 from canonical.launchpad.validators.email import email_validator
@@ -596,7 +597,9 @@
     # apart from here.
     registrant = Attribute('The user who created this profile.')
 
-    oauth_access_tokens = Attribute(_("Non-expired access tokens"))
+    oauth_access_tokens = exported(CollectionField(
+        title=u"Non-expired access tokens",
+        value_type=Reference(schema=IOAuthAccessToken)))
 
     oauth_request_tokens = Attribute(_("Non-expired request tokens"))
 

=== modified file 'lib/lp/registry/stories/webservice/xx-person.txt'
--- lib/lp/registry/stories/webservice/xx-person.txt	2010-07-13 15:29:08 +0000
+++ lib/lp/registry/stories/webservice/xx-person.txt	2010-08-30 19:49:45 +0000
@@ -37,6 +37,7 @@
     memberships_details_collection_link: u'http://.../~salgado/memberships_details'
     mugshot_link: u'http://.../~salgado/mugshot'
     name: u'salgado'
+    oauth_access_tokens_collection_link: u'http://.../~salgado/oauth-access-tokens'
     open_membership_invitations_collection_link: u'http://.../~salgado/open_membership_invitations'
     participants_collection_link: u'http://.../~salgado/participants'
     ppas_collection_link: u'http://.../~salgado/ppas'
@@ -86,6 +87,7 @@
     memberships_details_collection_link: u'http://.../~ubuntu-team/memberships_details'
     mugshot_link: u'http://.../~ubuntu-team/mugshot'
     name: u'ubuntu-team'
+    oauth_access_tokens_collection_link: u'http://.../~ubuntu-team/oauth-access-tokens'
     open_membership_invitations_collection_link: u'http://.../~ubuntu-team/open_membership_invitations'
     participants_collection_link: u'http://.../~ubuntu-team/participants'
     ppas_collection_link: u'http://.../~ubuntu-team/ppas'

=== modified file 'lib/lp/testing/_webservice.py'
--- lib/lp/testing/_webservice.py	2010-08-20 20:31:18 +0000
+++ lib/lp/testing/_webservice.py	2010-08-30 19:49:45 +0000
@@ -24,6 +24,7 @@
 from zope.app.publication.interfaces import IEndRequestEvent
 from zope.app.testing import ztapi
 from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 import zope.testing.cleanup
 
 from canonical.launchpad.interfaces import (
@@ -72,19 +73,23 @@
         # We didn't have to create the consumer. Maybe this user
         # already has an access token for this
         # consumer+person+permission?
-        existing_token = [token for token in person.oauth_access_tokens
-                          if (token.consumer == consumer
-                              and token.permission == permission
-                              and token.context == context)]
-        if len(existing_token) >= 1:
-            return existing_token[0]
+        for token in person.oauth_access_tokens:
+            # The security proxy on OAuthAccessToken assumes an access
+            # token has already been associated with the current
+            # request. Since there is no current request, it doesn't
+            # make sense to run those security checks.
+            token = removeSecurityProxy(token)
+            if (token.consumer == consumer
+                and token.permission == permission
+                and token.context == context):
+                return token
 
     # There is no existing access token for this
     # consumer+person+permission+context. Create one and review it.
     request_token = consumer.newRequestToken()
     request_token.review(person, permission, context)
     access_token = request_token.createAccessToken()
-    return access_token
+    return removeSecurityProxy(access_token)
 
 
 def launchpadlib_credentials_for(
@@ -109,8 +114,7 @@
     access_token = oauth_access_token_for(
         consumer_name, person, permission, context)
     logout()
-    launchpadlib_token = AccessToken(
-        access_token.key, access_token.secret)
+    launchpadlib_token = AccessToken(access_token.key, access_token.secret)
     return Credentials(consumer_name=consumer_name,
                        access_token=launchpadlib_token)
 

=== modified file 'versions.cfg'
--- versions.cfg	2010-08-27 02:21:01 +0000
+++ versions.cfg	2010-08-30 19:49:45 +0000
@@ -31,7 +31,7 @@
 lazr.delegates = 1.2.0
 lazr.enum = 1.1.2
 lazr.lifecycle = 1.1
-lazr.restful = 0.11.3
+lazr.restful = 0.12.0
 lazr.restfulclient = 0.10.0
 lazr.smtptest = 1.1
 lazr.testing = 0.1.1