← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/sca-api into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/sca-api into lp:launchpad.

Commit message:
Implement PersonSet.getOrCreateSoftwareCenterCustomer and export it on the webservice. The xmlrpc-private method is deprecated.

Requested reviews:
  Michael Nelson (michael.nelson)
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/sca-api/+merge/252420

Implement PersonSet.getOrCreateSoftwareCenterCustomer and export it on the webservice.

It will replace the xmlrpc-private method of the same name with something that's a bit less insecure. SCA will no longer be able to compromise existing active accounts by attaching new OpenID identifiers or email addresses to them. It retains the ability to:

 - Look up an existing active account by OpenID identifier
 - Activate an unactivated account by OpenID identifier and with the given email address
 - Create a new account with the given OpenID identifier and email address
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/sca-api into lp:launchpad.
=== modified file 'lib/lp/registry/browser/tests/test_person_webservice.py'
--- lib/lp/registry/browser/tests/test_person_webservice.py	2015-01-07 00:35:53 +0000
+++ lib/lp/registry/browser/tests/test_person_webservice.py	2015-03-10 11:46:53 +0000
@@ -1,8 +1,9 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
+from storm.store import Store
 from testtools.matchers import Equals
 from zope.component import getUtility
 from zope.security.management import endInteraction
@@ -14,6 +15,7 @@
     )
 from lp.registry.interfaces.teammembership import ITeamMembershipSet
 from lp.services.identity.interfaces.account import AccountStatus
+from lp.services.openid.model.openididentifier import OpenIdIdentifier
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
     admin_logged_in,
@@ -282,3 +284,61 @@
                 'identifier=http://login1.dev/%%2Bid/%s'
                 % person_openid,
                 api_version='devel').jsonBody()['name'])
+
+    def getOrCreateSoftwareCenterCustomer(self, user):
+        webservice = webservice_for_person(
+            user, permission=OAuthPermission.WRITE_PRIVATE)
+        response = webservice.named_post(
+            '/people', 'getOrCreateSoftwareCenterCustomer',
+            openid_identifier='somebody',
+            email_address='somebody@xxxxxxxxxxx', display_name='Somebody',
+            api_version='devel')
+        return response
+
+    def test_getOrCreateSoftwareCenterCustomer(self):
+        # Software Center Agent (SCA) can get or create people by OpenID
+        # identifier.
+        with admin_logged_in():
+            sca = getUtility(IPersonSet).getByName('software-center-agent')
+        response = self.getOrCreateSoftwareCenterCustomer(sca)
+        self.assertEqual('Somebody', response.jsonBody()['display_name'])
+        with admin_logged_in():
+            person = getUtility(IPersonSet).getByEmail('somebody@xxxxxxxxxxx')
+            self.assertEqual('Somebody', person.displayname)
+            self.assertEqual(
+                ['somebody'],
+                [oid.identifier for oid in person.account.openid_identifiers])
+            self.assertEqual(
+                'somebody@xxxxxxxxxxx', person.preferredemail.email)
+
+    def test_getOrCreateSoftwareCenterCustomer_is_restricted(self):
+        # The method may only be invoked by the ~software-center-agent
+        # celebrity user, as it is security-sensitive.
+        with admin_logged_in():
+            random = self.factory.makePerson()
+        response = self.getOrCreateSoftwareCenterCustomer(random)
+        self.assertEqual(401, response.status)
+
+    def test_getOrCreateSoftwareCenterCustomer_rejects_email_conflicts(self):
+        # An unknown OpenID identifier with a known email address causes
+        # the request to fail with 409 Conflict, as we'd otherwise end
+        # up linking the OpenID identifier to an existing account.
+        with admin_logged_in():
+            self.factory.makePerson(email='somebody@xxxxxxxxxxx')
+            sca = getUtility(IPersonSet).getByName('software-center-agent')
+        response = self.getOrCreateSoftwareCenterCustomer(sca)
+        self.assertEqual(409, response.status)
+
+    def test_getOrCreateSoftwareCenterCustomer_rejects_suspended(self):
+        # Suspended accounts are not returned.
+        with admin_logged_in():
+            existing = self.factory.makePerson(
+                email='somebody@xxxxxxxxxxx',
+                account_status=AccountStatus.SUSPENDED)
+            oid = OpenIdIdentifier()
+            oid.account = existing.account
+            oid.identifier = u'somebody'
+            Store.of(existing).add(oid)
+            sca = getUtility(IPersonSet).getByName('software-center-agent')
+        response = self.getOrCreateSoftwareCenterCustomer(sca)
+        self.assertEqual(400, response.status)

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2015-02-26 11:34:47 +0000
+++ lib/lp/registry/interfaces/person.py	2015-03-10 11:46:53 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Person interfaces."""
@@ -2173,6 +2173,30 @@
             identifier has been suspended.
         """
 
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        openid_identifier=TextLine(
+            title=_("OpenID identifier suffix"), required=True),
+        email_address=TextLine(title=_("Email address"), required=True),
+        display_name=TextLine(title=_("Display name"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def getOrCreateSoftwareCenterCustomer(user, openid_identifier,
+                                          email_address, display_name):
+        """Restricted person creation API for Software Center Agent.
+
+        This method can only be called by Software Center Agent. It gets
+        a person by OpenID identifier or creates a new Launchpad person
+        from the OpenID identifier, email address and display name.
+
+        :param user: the `IPerson` performing the operation. Only the
+            software-center-agent celebrity is allowed.
+        :param openid_identifier: OpenID identifier suffix for the user.
+            This is *not* the full URL, just the unique suffix portion.
+        :param email_address: the email address of the user.
+        :param full_name: the full name of the user.
+        """
+
     @call_with(teamowner=REQUEST_USER)
     @rename_parameters_as(
         displayname='display_name', teamdescription='team_description',

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2015-03-04 19:05:47 +0000
+++ lib/lp/registry/model/person.py	2015-03-10 11:46:53 +0000
@@ -268,6 +268,7 @@
     INACTIVE_ACCOUNT_STATUSES,
     )
 from lp.services.identity.interfaces.emailaddress import (
+    EmailAddressAlreadyTaken,
     EmailAddressStatus,
     IEmailAddress,
     IEmailAddressSet,
@@ -3317,8 +3318,26 @@
         return IPerson(account)
 
     def getOrCreateByOpenIDIdentifier(self, openid_identifier, email_address,
-                                      full_name, creation_rationale, comment):
+                                      full_name, creation_rationale, comment,
+                                      trust_email=True):
         """See `IPersonSet`."""
+        # trust_email is an internal flag used by
+        # getOrCreateSoftwareCenterCustomer. We don't want SCA to be
+        # able to use the API to associate arbitrary OpenID identifiers
+        # and email addresses when that could compromise existing
+        # accounts.
+        #
+        # To that end, if trust_email is not set then the given
+        # email address will only be used to create an account. It will
+        # never be used to look up an account, nor will it be added to
+        # an existing account. This causes two additional cases to be
+        # rejected: unknown OpenID identifier but known email address,
+        # and deactivated account.
+        #
+        # Exempting account creation and activation from this rule opens
+        # us to a potential account fixation attack, but the risk is
+        # minimal.
+
         assert email_address is not None and full_name is not None, (
             "Both email address and full name are required to create an "
             "account.")
@@ -3341,13 +3360,19 @@
                 # We don't know about the OpenID identifier yet, so try
                 # to match a person by email address, or as a last
                 # resort create a new one.
-                if email is not None:
-                    person = email.person
-                else:
+                if email is None:
                     person_set = getUtility(IPersonSet)
                     person, email = person_set.createPersonAndEmail(
                         email_address, creation_rationale, comment=comment,
                         displayname=full_name)
+                elif trust_email:
+                    person = email.person
+                else:
+                    # The email address originated from a source that's
+                    # not completely trustworth (eg. SCA), so we can't
+                    # use it to link the OpenID identifier to an
+                    # existing person.
+                    raise EmailAddressAlreadyTaken()
 
                 # It's possible that the email address is owned by a
                 # team. Reject the login attempt, and wait for the user
@@ -3373,10 +3398,20 @@
             person = IPerson(identifier.account, None)
             assert person is not None, ('Received a personless account.')
 
-            if person.account.status == AccountStatus.SUSPENDED:
+            status = person.account.status
+            if status == AccountStatus.ACTIVE:
+                # Account is active, so nothing to do.
+                pass
+            elif status == AccountStatus.SUSPENDED:
                 raise AccountSuspendedError(
                     "The account matching the identifier is suspended.")
-
+            elif not trust_email and status != AccountStatus.NOACCOUNT:
+                # If the email address is not completely trustworthy
+                # (ie. it comes from SCA) and the account has already
+                # been used, then we don't want to proceed as we might
+                # end up adding a malicious OpenID identifier to an
+                # existing account.
+                raise AccountSuspendedError()
             elif person.account.status in [AccountStatus.DEACTIVATED,
                                            AccountStatus.NOACCOUNT]:
                 removeSecurityProxy(person.account).reactivate(comment)
@@ -3386,11 +3421,24 @@
                 removeSecurityProxy(person).setPreferredEmail(email)
                 db_updated = True
             else:
-                # Account is active, so nothing to do.
-                pass
+                raise AssertionError(
+                    "Unhandled account status: %r" % person.account.status)
 
             return person, db_updated
 
+    def getOrCreateSoftwareCenterCustomer(self, user, openid_identifier,
+                                          email_address, display_name):
+        """See `IPersonSet`."""
+        if user != getUtility(ILaunchpadCelebrities).software_center_agent:
+            raise Unauthorized()
+        person, _ = getUtility(
+            IPersonSet).getOrCreateByOpenIDIdentifier(
+                openid_identifier, email_address, display_name,
+                PersonCreationRationale.SOFTWARE_CENTER_PURCHASE,
+                "when purchasing an application via Software Center.",
+                trust_email=False)
+        return person
+
     def newTeam(self, teamowner, name, displayname, teamdescription=None,
                 membership_policy=TeamMembershipPolicy.MODERATED,
                 defaultmembershipperiod=None, defaultrenewalperiod=None,

=== modified file 'lib/lp/registry/tests/test_personset.py'
--- lib/lp/registry/tests/test_personset.py	2015-01-07 00:35:53 +0000
+++ lib/lp/registry/tests/test_personset.py	2015-03-10 11:46:53 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for PersonSet."""
@@ -9,6 +9,7 @@
 from testtools.matchers import LessThan
 import transaction
 from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
 from lp.code.tests.helpers import remove_all_sample_data_branches
@@ -41,13 +42,17 @@
 from lp.services.identity.interfaces.emailaddress import (
     EmailAddressAlreadyTaken,
     EmailAddressStatus,
+    IEmailAddressSet,
     InvalidEmailAddress,
     )
 from lp.services.identity.model.account import Account
 from lp.services.identity.model.emailaddress import EmailAddress
 from lp.services.openid.model.openididentifier import OpenIdIdentifier
+from lp.services.webapp.interfaces import ILaunchBag
 from lp.testing import (
+    admin_logged_in,
     ANONYMOUS,
+    anonymous_logged_in,
     login,
     logout,
     person_logged_in,
@@ -590,3 +595,135 @@
             u'other-openid-identifier' in [
                 identifier.identifier for identifier in removeSecurityProxy(
                     person.account).openid_identifiers])
+
+
+class TestPersonSetGetOrCreateSoftwareCenterCustomer(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestPersonSetGetOrCreateSoftwareCenterCustomer, self).setUp()
+        self.sca = getUtility(IPersonSet).getByName('software-center-agent')
+
+    def makeOpenIdIdentifier(self, account, identifier):
+        openid_identifier = OpenIdIdentifier()
+        openid_identifier.identifier = identifier
+        openid_identifier.account = account
+        return IStore(OpenIdIdentifier).add(openid_identifier)
+
+    def test_restricted_to_sca(self):
+        # Only the software-center-agent celebrity can invoke this
+        # privileged method.
+        def do_it():
+            return getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
+                getUtility(ILaunchBag).user, u'somebody',
+                'somebody@xxxxxxxxxxx', 'Example')
+        random = self.factory.makePerson()
+        admin = self.factory.makePerson(
+            member_of=[getUtility(IPersonSet).getByName('admins')])
+
+        # Anonymous, random or admin users can't invoke the method.
+        with anonymous_logged_in():
+            self.assertRaises(Unauthorized, do_it)
+        with person_logged_in(random):
+            self.assertRaises(Unauthorized, do_it)
+        with person_logged_in(admin):
+            self.assertRaises(Unauthorized, do_it)
+
+        with person_logged_in(self.sca):
+            person = do_it()
+        self.assertIsInstance(person, Person)
+
+    def test_finds_by_openid(self):
+        # A Person with the requested OpenID identifier is returned.
+        somebody = self.factory.makePerson()
+        self.makeOpenIdIdentifier(somebody.account, u'somebody')
+        with person_logged_in(self.sca):
+            got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
+                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
+        self.assertEqual(somebody, got)
+
+        # The email address doesn't get linked, as that could change how
+        # future logins work.
+        self.assertIs(
+            None,
+            getUtility(IEmailAddressSet).getByEmail('somebody@xxxxxxxxxxx'))
+
+    def test_creates_new(self):
+        # If an unknown OpenID identifier and email address are
+        # provided, a new account is created and returned.
+        with person_logged_in(self.sca):
+            made = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
+                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
+        with admin_logged_in():
+            self.assertEqual('Example', made.displayname)
+            self.assertEqual('somebody@xxxxxxxxxxx', made.preferredemail.email)
+
+        # The email address is linked, since it can't compromise an
+        # account that is being created just for it.
+        email = getUtility(IEmailAddressSet).getByEmail('somebody@xxxxxxxxxxx')
+        self.assertEqual(made, email.person)
+
+    def test_activates_unactivated(self):
+        # An unactivated account should be treated just like a new
+        # account -- it gets activated with the given email address.
+        somebody = self.factory.makePerson(
+            email='existing@xxxxxxxxxxx',
+            account_status=AccountStatus.NOACCOUNT)
+        self.makeOpenIdIdentifier(somebody.account, u'somebody')
+        self.assertEqual(AccountStatus.NOACCOUNT, somebody.account.status)
+        with person_logged_in(self.sca):
+            got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
+                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
+        self.assertEqual(somebody, got)
+        with admin_logged_in():
+            self.assertEqual(AccountStatus.ACTIVE, somebody.account.status)
+            self.assertEqual(
+                'somebody@xxxxxxxxxxx', somebody.preferredemail.email)
+
+    def test_fails_if_email_is_already_registered(self):
+        # Only the OpenID identifier is used to look up an account. If
+        # the OpenID identifier isn't already registered by the email
+        # address is, the request is rejected to avoid potentially
+        # adding an unwanted OpenID identifier to the address' account.
+        #
+        # The user must log into Launchpad directly first to register
+        # their OpenID identifier.
+        other = self.factory.makePerson(email='other@xxxxxxxxxxx')
+        with person_logged_in(self.sca):
+            self.assertRaises(
+                EmailAddressAlreadyTaken,
+                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
+                self.sca, u'somebody', 'other@xxxxxxxxxxx', 'Example')
+
+        # The email address stays with the old owner.
+        email = getUtility(IEmailAddressSet).getByEmail('other@xxxxxxxxxxx')
+        self.assertEqual(other, email.person)
+
+    def test_fails_if_account_is_suspended(self):
+        # Suspended accounts cannot be returned.
+        somebody = self.factory.makePerson()
+        self.makeOpenIdIdentifier(somebody.account, u'somebody')
+        with admin_logged_in():
+            somebody.setAccountStatus(
+                AccountStatus.SUSPENDED, None, "Go away!")
+        with person_logged_in(self.sca):
+            self.assertRaises(
+                AccountSuspendedError,
+                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
+                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')
+
+    def test_fails_if_account_is_deactivated(self):
+        # We don't want to reactivate explicitly deactivated accounts,
+        # nor do we want to potentially compromise them with a bad email
+        # address.
+        somebody = self.factory.makePerson()
+        self.makeOpenIdIdentifier(somebody.account, u'somebody')
+        with admin_logged_in():
+            somebody.setAccountStatus(
+                AccountStatus.DEACTIVATED, None, "Goodbye cruel world.")
+        with person_logged_in(self.sca):
+            self.assertRaises(
+                AccountSuspendedError,
+                getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
+                self.sca, u'somebody', 'somebody@xxxxxxxxxxx', 'Example')

=== modified file 'lib/lp/services/identity/interfaces/account.py'
--- lib/lp/services/identity/interfaces/account.py	2015-01-07 00:35:53 +0000
+++ lib/lp/services/identity/interfaces/account.py	2015-03-10 11:46:53 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Account interfaces."""
@@ -18,11 +18,13 @@
     'INACTIVE_ACCOUNT_STATUSES',
     ]
 
+import httplib
 
 from lazr.enum import (
     DBEnumeratedType,
     DBItem,
     )
+from lazr.restful.declarations import error_status
 from zope.interface import (
     Attribute,
     Interface,
@@ -40,6 +42,7 @@
 from lp.services.fields import StrippedTextLine
 
 
+@error_status(httplib.BAD_REQUEST)
 class AccountSuspendedError(Exception):
     """The account being accessed has been suspended."""
 

=== modified file 'lib/lp/services/identity/interfaces/emailaddress.py'
--- lib/lp/services/identity/interfaces/emailaddress.py	2013-01-07 02:40:55 +0000
+++ lib/lp/services/identity/interfaces/emailaddress.py	2015-03-10 11:46:53 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """EmailAddress interfaces."""
@@ -12,11 +12,14 @@
     'InvalidEmailAddress',
     'VALID_EMAIL_STATUSES']
 
+import httplib
+
 from lazr.enum import (
     DBEnumeratedType,
     DBItem,
     )
 from lazr.restful.declarations import (
+    error_status,
     export_as_webservice_entry,
     exported,
     )
@@ -36,6 +39,7 @@
     """The email address is not valid."""
 
 
+@error_status(httplib.CONFLICT)
 class EmailAddressAlreadyTaken(Exception):
     """The email address is already registered in Launchpad."""