Merge ~cjwatson/launchpad:login-interstitial into launchpad:master

Proposed by Colin Watson
Status: Needs review
Proposed branch: ~cjwatson/launchpad:login-interstitial
Merge into: launchpad:master
Diff against target: 1078 lines (+534/-110)
10 files modified
lib/lp/app/browser/configure.zcml (+12/-0)
lib/lp/app/browser/launchpad.py (+3/-1)
lib/lp/services/webapp/login.py (+208/-41)
lib/lp/services/webapp/templates/login-new-account.pt (+36/-0)
lib/lp/services/webapp/templates/login-reactivate-account.pt (+43/-0)
lib/lp/services/webapp/tests/test_login.py (+153/-25)
lib/lp/testopenid/browser/server.py (+37/-25)
lib/lp/testopenid/interfaces/server.py (+7/-2)
lib/lp/testopenid/stories/logging-in.txt (+1/-0)
utilities/make-lp-user (+34/-16)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+373748@code.launchpad.net

Commit message

Add interstitial pages when creating or reactivating an account

Description of the change

These provide an opportunity to present the user with the terms of service and privacy policy and require that they explicitly accept them, as well as making it harder to reactivate an account by accident.

To support testing this locally, I extended make-lp-user to be able to create placeholder accounts, and adjusted testopenid so that it can authenticate as an inactive account by explicitly supplying the username.

This is essentially the same as https://code.launchpad.net/~cjwatson/launchpad/login-interstitial/+merge/346908, converted to git and rebased on master.

To post a comment you must log in.

Unmerged commits

55f540c... by Colin Watson

Add interstitial pages when creating or reactivating an account.

These provide an opportunity to present the user with the terms of service
and privacy policy and require that they explicitly accept them, as well as
making it harder to reactivate an account by accident.

To support testing this locally, I extended make-lp-user to be able to
create placeholder accounts, and adjusted testopenid so that it can
authenticate as an inactive account by explicitly supplying the username.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
index 6573fb2..b908bba 100644
--- a/lib/lp/app/browser/configure.zcml
+++ b/lib/lp/app/browser/configure.zcml
@@ -250,6 +250,18 @@
250 permission="zope.Public"250 permission="zope.Public"
251 name="+openid-callback"251 name="+openid-callback"
252 />252 />
253 <browser:page
254 for="lp.services.webapp.interfaces.ILaunchpadApplication"
255 class="lp.services.webapp.login.NewAccountView"
256 permission="zope.Public"
257 name="+new-account"
258 />
259 <browser:page
260 for="lp.services.webapp.interfaces.ILaunchpadApplication"
261 class="lp.services.webapp.login.ReactivateAccountView"
262 permission="zope.Public"
263 name="+reactivate-account"
264 />
253265
254 <browser:page266 <browser:page
255 for="*"267 for="*"
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 5d2d6d6..f480b4a 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -618,7 +618,9 @@ class LoginStatus:
618 @property618 @property
619 def login_shown(self):619 def login_shown(self):
620 return (self.user is None and620 return (self.user is None and
621 '+login' not in self.request['PATH_INFO'])621 '+login' not in self.request['PATH_INFO'] and
622 '+new-account' not in self.request['PATH_INFO'] and
623 '+reactivate-account' not in self.request['PATH_INFO'])
622624
623 @property625 @property
624 def logged_in(self):626 def logged_in(self):
diff --git a/lib/lp/services/webapp/login.py b/lib/lp/services/webapp/login.py
index 8dc179f..5f7d4ff 100644
--- a/lib/lp/services/webapp/login.py
+++ b/lib/lp/services/webapp/login.py
@@ -45,16 +45,27 @@ from zope.session.interfaces import (
45 )45 )
4646
47from lp import _47from lp import _
48from lp.app.browser.launchpadform import (
49 action,
50 LaunchpadFormView,
51 )
48from lp.registry.interfaces.person import (52from lp.registry.interfaces.person import (
53 IPerson,
49 IPersonSet,54 IPersonSet,
50 PersonCreationRationale,55 PersonCreationRationale,
51 TeamEmailAddressError,56 TeamEmailAddressError,
52 )57 )
53from lp.services.config import config58from lp.services.config import config
59from lp.services.database.interfaces import IStore
54from lp.services.database.policy import MasterDatabasePolicy60from lp.services.database.policy import MasterDatabasePolicy
55from lp.services.identity.interfaces.account import AccountSuspendedError61from lp.services.identity.interfaces.account import (
62 AccountStatus,
63 AccountSuspendedError,
64 )
65from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
56from lp.services.openid.extensions import macaroon66from lp.services.openid.extensions import macaroon
57from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore67from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
68from lp.services.openid.model.openididentifier import OpenIdIdentifier
58from lp.services.propertycache import cachedproperty69from lp.services.propertycache import cachedproperty
59from lp.services.timeline.requesttimeline import get_request_timeline70from lp.services.timeline.requesttimeline import get_request_timeline
60from lp.services.webapp import canonical_url71from lp.services.webapp import canonical_url
@@ -271,12 +282,8 @@ class OpenIDLogin(LaunchpadView):
271 [six.ensure_binary(value) for value in value_list])282 [six.ensure_binary(value) for value in value_list])
272283
273284
274class OpenIDCallbackView(OpenIDLogin):285class FinishLoginMixin:
275 """The OpenID callback page for logging into Launchpad.286 """A mixin for views that can finish the login process."""
276
277 This is the page the OpenID provider will send the user's browser to,
278 after the user has authenticated on the provider.
279 """
280287
281 suspended_account_template = ViewPageTemplateFile(288 suspended_account_template = ViewPageTemplateFile(
282 'templates/login-suspended-account.pt')289 'templates/login-suspended-account.pt')
@@ -284,9 +291,6 @@ class OpenIDCallbackView(OpenIDLogin):
284 team_email_address_template = ViewPageTemplateFile(291 team_email_address_template = ViewPageTemplateFile(
285 'templates/login-team-email-address.pt')292 'templates/login-team-email-address.pt')
286293
287 discharge_macaroon_template = ViewPageTemplateFile(
288 'templates/login-discharge-macaroon.pt')
289
290 def _gather_params(self, request):294 def _gather_params(self, request):
291 params = dict(request.form)295 params = dict(request.form)
292 for key, value in request.query_string_params.iteritems():296 for key, value in request.query_string_params.iteritems():
@@ -297,6 +301,31 @@ class OpenIDCallbackView(OpenIDLogin):
297301
298 return params302 return params
299303
304 def login(self, person, when=None):
305 loginsource = getUtility(IPlacelessLoginSource)
306 # We don't have a logged in principal, so we must remove the security
307 # proxy of the account's preferred email.
308 email = removeSecurityProxy(person.preferredemail).email
309 logInPrincipal(
310 self.request, loginsource.getPrincipalByLogin(email), email, when)
311
312 def _redirect(self):
313 target = self.params.get('starting_url')
314 if target is None:
315 target = self.request.getApplicationURL()
316 self.request.response.redirect(target, temporary_if_possible=True)
317
318
319class OpenIDCallbackView(FinishLoginMixin, OpenIDLogin):
320 """The OpenID callback page for logging into Launchpad.
321
322 This is the page the OpenID provider will send the user's browser to,
323 after the user has authenticated on the provider.
324 """
325
326 discharge_macaroon_template = ViewPageTemplateFile(
327 'templates/login-discharge-macaroon.pt')
328
300 def _get_requested_url(self, request):329 def _get_requested_url(self, request):
301 requested_url = request.getURL()330 requested_url = request.getURL()
302 query_string = request.get('QUERY_STRING')331 query_string = request.get('QUERY_STRING')
@@ -317,13 +346,28 @@ class OpenIDCallbackView(OpenIDLogin):
317 timeline_action.finish()346 timeline_action.finish()
318 self.discharge_macaroon_raw = None347 self.discharge_macaroon_raw = None
319348
320 def login(self, person, when=None):349 def loginInactive(self, when=None):
321 loginsource = getUtility(IPlacelessLoginSource)350 """Log an inactive person in.
322 # We don't have a logged in principal, so we must remove the security351
323 # proxy of the account's preferred email.352 This isn't a normal login, which we can't do while the person is
324 email = removeSecurityProxy(person.preferredemail).email353 inactive. Instead, we store a few details about the OpenID response
325 logInPrincipal(354 in a separate part of the session database, which lets us render an
326 self.request, loginsource.getPrincipalByLogin(email), email, when)355 appropriate interstitial page and then activate the account properly
356 after the form on that page is submitted.
357 """
358 # Force a fresh session, per bug #828638.
359 client_id_manager = getUtility(IClientIdManager)
360 new_client_id = client_id_manager.generateUniqueId()
361 client_id_manager.setRequestId(self.request, new_client_id)
362 session = ISession(self.request)
363 authdata = session['launchpad.pendinguser']
364 authdata['identifier'] = self._getOpenIDIdentifier()
365 email_address, full_name = self._getEmailAddressAndFullName()
366 authdata['email'] = email_address
367 authdata['fullname'] = full_name
368 if when is None:
369 when = datetime.utcnow()
370 authdata['logintime'] = when
327371
328 @cachedproperty372 @cachedproperty
329 def sreg_response(self):373 def sreg_response(self):
@@ -334,6 +378,10 @@ class OpenIDCallbackView(OpenIDLogin):
334 return macaroon.MacaroonResponse.fromSuccessResponse(378 return macaroon.MacaroonResponse.fromSuccessResponse(
335 self.openid_response)379 self.openid_response)
336380
381 def _getOpenIDIdentifier(self):
382 identifier = self.openid_response.identity_url.split('/')[-1]
383 return identifier.decode('ascii')
384
337 def _getEmailAddressAndFullName(self):385 def _getEmailAddressAndFullName(self):
338 # Here we assume the OP sent us the user's email address and386 # Here we assume the OP sent us the user's email address and
339 # full name in the response. Note we can only do that because387 # full name in the response. Note we can only do that because
@@ -352,6 +400,49 @@ class OpenIDCallbackView(OpenIDLogin):
352 "No email address or full name found in sreg response.")400 "No email address or full name found in sreg response.")
353 return email_address, full_name401 return email_address, full_name
354402
403 def _maybeRedirectToInterstitial(self, openid_identifier, email_address):
404 """Redirect to an interstitial page in some cases.
405
406 If there is no existing account for this OpenID identifier or email
407 address, or if the existing account is in certain inactive states,
408 then instead of logging in straight away we redirect to an
409 interstitial page to confirm what the user wants to do.
410 """
411 redirect_view_names = {
412 AccountStatus.DEACTIVATED: '+reactivate-account',
413 AccountStatus.NOACCOUNT: '+new-account',
414 AccountStatus.PLACEHOLDER: '+new-account',
415 }
416 identifier = IStore(OpenIdIdentifier).find(
417 OpenIdIdentifier, identifier=openid_identifier).one()
418 if identifier is not None:
419 person = IPerson(identifier.account, None)
420 else:
421 email = getUtility(IEmailAddressSet).getByEmail(email_address)
422 person = email.person if email is not None else None
423
424 if (person is None or
425 (not person.is_team and
426 (not person.account or
427 person.account.status in redirect_view_names))):
428 self.loginInactive()
429 trust_root = allvhosts.configs['mainsite'].rooturl
430 url = urlappend(
431 trust_root,
432 redirect_view_names[
433 person.account.status if person
434 else AccountStatus.NOACCOUNT])
435 params = {}
436 target = self.params.get('starting_url')
437 if target is not None:
438 params['starting_url'] = target
439 if params:
440 url += "?%s" % urllib.urlencode(params)
441 self.request.response.redirect(url, temporary_if_possible=True)
442 return True
443 else:
444 return False
445
355 def processPositiveAssertion(self):446 def processPositiveAssertion(self):
356 """Process an OpenID response containing a positive assertion.447 """Process an OpenID response containing a positive assertion.
357448
@@ -365,25 +456,9 @@ class OpenIDCallbackView(OpenIDLogin):
365 DB writes, to ensure subsequent requests use the master DB and see456 DB writes, to ensure subsequent requests use the master DB and see
366 the changes we just did.457 the changes we just did.
367 """458 """
368 identifier = self.openid_response.identity_url.split('/')[-1]459 identifier = self._getOpenIDIdentifier()
369 identifier = identifier.decode('ascii')
370 should_update_last_write = False
371 # Force the use of the master database to make sure a lagged slave
372 # doesn't fool us into creating a Person/Account when one already
373 # exists.
374 person_set = getUtility(IPersonSet)
375 email_address, full_name = self._getEmailAddressAndFullName()460 email_address, full_name = self._getEmailAddressAndFullName()
376 try:461 should_update_last_write = False
377 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
378 identifier, email_address, full_name,
379 comment='when logging in to Launchpad.',
380 creation_rationale=(
381 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
382 should_update_last_write = db_updated
383 except AccountSuspendedError:
384 return self.suspended_account_template()
385 except TeamEmailAddressError:
386 return self.team_email_address_template()
387462
388 if self.params.get('discharge_macaroon_field'):463 if self.params.get('discharge_macaroon_field'):
389 if self.macaroon_response.discharge_macaroon_raw is None:464 if self.macaroon_response.discharge_macaroon_raw is None:
@@ -392,7 +467,30 @@ class OpenIDCallbackView(OpenIDLogin):
392 self.discharge_macaroon_raw = (467 self.discharge_macaroon_raw = (
393 self.macaroon_response.discharge_macaroon_raw)468 self.macaroon_response.discharge_macaroon_raw)
394469
470 # Force the use of the master database to make sure a lagged slave
471 # doesn't fool us into creating a Person/Account when one already
472 # exists.
395 with MasterDatabasePolicy():473 with MasterDatabasePolicy():
474 if self._maybeRedirectToInterstitial(identifier, email_address):
475 return None
476
477 # XXX cjwatson 2018-05-25: We should never create a Person at
478 # this point; any situation that would result in that should
479 # result in a redirection to an interstitial page. Guaranteeing
480 # that will require a bit more refactoring, though.
481 person_set = getUtility(IPersonSet)
482 try:
483 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
484 identifier, email_address, full_name,
485 comment='when logging in to Launchpad.',
486 creation_rationale=(
487 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
488 should_update_last_write = db_updated
489 except AccountSuspendedError:
490 return self.suspended_account_template()
491 except TeamEmailAddressError:
492 return self.team_email_address_template()
493
396 self.login(person)494 self.login(person)
397495
398 if self.params.get('discharge_macaroon_field'):496 if self.params.get('discharge_macaroon_field'):
@@ -439,12 +537,6 @@ class OpenIDCallbackView(OpenIDLogin):
439 transaction.commit()537 transaction.commit()
440 return retval538 return retval
441539
442 def _redirect(self):
443 target = self.params.get('starting_url')
444 if target is None:
445 target = self.request.getApplicationURL()
446 self.request.response.redirect(target, temporary_if_possible=True)
447
448540
449class OpenIDLoginErrorView(LaunchpadView):541class OpenIDLoginErrorView(LaunchpadView):
450542
@@ -467,6 +559,81 @@ class OpenIDLoginErrorView(LaunchpadView):
467 self.login_error = "Unknown error: %s" % openid_response559 self.login_error = "Unknown error: %s" % openid_response
468560
469561
562class FinishLoginInterstitialView(FinishLoginMixin, LaunchpadFormView):
563
564 class schema(Interface):
565 pass
566
567 def initialize(self):
568 self.params = self._gather_params(self.request)
569 super(FinishLoginInterstitialView, self).initialize()
570
571 def _accept(self):
572 session = ISession(self.request)
573 authdata = session['launchpad.pendinguser']
574 try:
575 identifier = authdata['identifier']
576 email_address = authdata['email']
577 full_name = authdata['fullname']
578 except KeyError:
579 return OpenIDLoginErrorView(
580 self.context, self.request,
581 login_error=(
582 "Your session expired. Please try logging in again."))
583 should_update_last_write = False
584
585 # Force the use of the master database to make sure a lagged slave
586 # doesn't fool us into creating a Person/Account when one already
587 # exists.
588 with MasterDatabasePolicy():
589 person_set = getUtility(IPersonSet)
590 try:
591 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
592 identifier, email_address, full_name,
593 comment=(
594 'when logging in to Launchpad, after accepting '
595 'terms.'),
596 creation_rationale=(
597 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
598 should_update_last_write = db_updated
599 except AccountSuspendedError:
600 return self.suspended_account_template()
601 except TeamEmailAddressError:
602 return self.team_email_address_template()
603
604 self.login(person)
605
606 if should_update_last_write:
607 # This is a GET request but we changed the database, so update
608 # session_data['last_write'] to make sure further requests use
609 # the master DB and thus see the changes we've just made.
610 session_data = ISession(self.request)['lp.dbpolicy']
611 session_data['last_write'] = datetime.utcnow()
612 self._redirect()
613 # No need to return anything as we redirect above.
614 return None
615
616
617class NewAccountView(FinishLoginInterstitialView):
618
619 page_title = label = 'Welcome to Launchpad!'
620 template = ViewPageTemplateFile('templates/login-new-account.pt')
621
622 @action('Accept terms and create account', name='accept')
623 def accept(self, action, data):
624 return self._accept()
625
626
627class ReactivateAccountView(FinishLoginInterstitialView):
628
629 page_title = label = 'Welcome back to Launchpad!'
630 template = ViewPageTemplateFile('templates/login-reactivate-account.pt')
631
632 @action('Accept terms and reactivate account', name='accept')
633 def accept(self, action, data):
634 return self._accept()
635
636
470class AlreadyLoggedInView(LaunchpadView):637class AlreadyLoggedInView(LaunchpadView):
471638
472 page_title = 'Already logged in'639 page_title = 'Already logged in'
diff --git a/lib/lp/services/webapp/templates/login-new-account.pt b/lib/lp/services/webapp/templates/login-new-account.pt
473new file mode 100644640new file mode 100644
index 0000000..6e5887f
--- /dev/null
+++ b/lib/lp/services/webapp/templates/login-new-account.pt
@@ -0,0 +1,36 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8
9 <body>
10 <div class="top-portlet" metal:fill-slot="main">
11 <div metal:use-macro="context/@@launchpad_form/form">
12 <div metal:fill-slot="extra_info">
13 <p>
14 Please read and accept the
15 <a href="/legal">Launchpad terms of service</a> and the
16 <a href="https://www.ubuntu.com/legal/dataprivacy">data privacy
17 policy</a> before continuing. If you accept these terms,
18 Launchpad will create an account for you.
19 </p>
20
21 <p>
22 You may also
23 <a tal:attributes="href view/params/starting_url|string:/">return
24 to Launchpad without creating an account</a>.
25 </p>
26
27 <input
28 type="hidden"
29 name="starting_url"
30 tal:condition="view/params/starting_url|nothing"
31 tal:attributes="value view/params/starting_url" />
32 </div>
33 </div>
34 </div>
35 </body>
36</html>
diff --git a/lib/lp/services/webapp/templates/login-reactivate-account.pt b/lib/lp/services/webapp/templates/login-reactivate-account.pt
0new file mode 10064437new file mode 100644
index 0000000..3b02d0e
--- /dev/null
+++ b/lib/lp/services/webapp/templates/login-reactivate-account.pt
@@ -0,0 +1,43 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8
9 <body>
10 <div class="top-portlet" metal:fill-slot="main">
11 <div metal:use-macro="context/@@launchpad_form/form">
12 <div metal:fill-slot="extra_info">
13 <p>
14 Please read and accept the
15 <a href="/legal">Launchpad terms of service</a> and the
16 <a href="https://www.ubuntu.com/legal/dataprivacy">data privacy
17 policy</a> before continuing. If you accept these terms,
18 Launchpad will reactivate your account.
19 </p>
20
21 <p>
22 Reactivating your account will not restore any information that
23 was deleted when you deactivated it. You may start receiving
24 email notifications again related to information that was not
25 deleted, such as changes to any bugs you reported.
26 </p>
27
28 <p>
29 You may also
30 <a tal:attributes="href view/params/starting_url|string:/">return
31 to Launchpad without reactivating your account</a>.
32 </p>
33
34 <input
35 type="hidden"
36 name="starting_url"
37 tal:condition="view/params/starting_url|nothing"
38 tal:attributes="value view/params/starting_url" />
39 </div>
40 </div>
41 </div>
42 </body>
43</html>
diff --git a/lib/lp/services/webapp/tests/test_login.py b/lib/lp/services/webapp/tests/test_login.py
index 1a7dfad..28f9815 100644
--- a/lib/lp/services/webapp/tests/test_login.py
+++ b/lib/lp/services/webapp/tests/test_login.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
3"""Test harness for running the new-login.txt tests."""3"""Test harness for running the new-login.txt tests."""
44
@@ -64,8 +64,10 @@ from lp.services.openid.model.openididentifier import OpenIdIdentifier
64from lp.services.timeline.requesttimeline import get_request_timeline64from lp.services.timeline.requesttimeline import get_request_timeline
65from lp.services.webapp.interfaces import ILaunchpadApplication65from lp.services.webapp.interfaces import ILaunchpadApplication
66from lp.services.webapp.login import (66from lp.services.webapp.login import (
67 NewAccountView,
67 OpenIDCallbackView,68 OpenIDCallbackView,
68 OpenIDLogin,69 OpenIDLogin,
70 ReactivateAccountView,
69 )71 )
70from lp.services.webapp.servers import LaunchpadTestRequest72from lp.services.webapp.servers import LaunchpadTestRequest
71from lp.testing import (73from lp.testing import (
@@ -106,11 +108,11 @@ class FakeOpenIDResponse:
106 self.discharge_macaroon_raw = discharge_macaroon_raw108 self.discharge_macaroon_raw = discharge_macaroon_raw
107109
108110
109class StubbedOpenIDCallbackView(OpenIDCallbackView):111class StubLoginMixin:
110 login_called = False112 login_called = False
111113
112 def login(self, account):114 def login(self, account):
113 super(StubbedOpenIDCallbackView, self).login(account)115 super(StubLoginMixin, self).login(account)
114 self.login_called = True116 self.login_called = True
115 current_policy = getUtility(IStoreSelector).get_current()117 current_policy = getUtility(IStoreSelector).get_current()
116 if not isinstance(current_policy, MasterDatabasePolicy):118 if not isinstance(current_policy, MasterDatabasePolicy):
@@ -118,6 +120,18 @@ class StubbedOpenIDCallbackView(OpenIDCallbackView):
118 "Not using the master store: %s" % current_policy)120 "Not using the master store: %s" % current_policy)
119121
120122
123class StubbedOpenIDCallbackView(StubLoginMixin, OpenIDCallbackView):
124 pass
125
126
127class StubbedNewAccountView(StubLoginMixin, NewAccountView):
128 pass
129
130
131class StubbedReactivateAccountView(StubLoginMixin, ReactivateAccountView):
132 pass
133
134
121class FakeConsumer:135class FakeConsumer:
122 """An OpenID consumer that stashes away arguments for test inspection."""136 """An OpenID consumer that stashes away arguments for test inspection."""
123137
@@ -212,24 +226,27 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
212 openid_response, view_class=view_class)226 openid_response, view_class=view_class)
213227
214 def _createAndRenderView(self, response,228 def _createAndRenderView(self, response,
215 view_class=StubbedOpenIDCallbackView, form=None):229 view_class=StubbedOpenIDCallbackView, form=None,
230 method='GET', **kwargs):
216 if form is None:231 if form is None:
217 form = {'starting_url': 'http://launchpad.test/after-login'}232 form = {'starting_url': 'http://launchpad.test/after-login'}
218 request = LaunchpadTestRequest(form=form, environ={'PATH_INFO': '/'})233 request = LaunchpadTestRequest(
234 form=form, environ={'PATH_INFO': '/'}, method=method, **kwargs)
219 # The layer we use sets up an interaction (by calling login()), but we235 # The layer we use sets up an interaction (by calling login()), but we
220 # want to use our own request in the interaction, so we logout() and236 # want to use our own request in the interaction, so we logout() and
221 # setup a newInteraction() using our request.237 # setup a newInteraction() using our request.
222 logout()238 logout()
223 newInteraction(request)239 newInteraction(request)
224 view = view_class(object(), request)240 view = view_class(object(), request)
225 view.initialize()
226 view.openid_response = response
227 # Monkey-patch getByOpenIDIdentifier() to make sure the view uses the241 # Monkey-patch getByOpenIDIdentifier() to make sure the view uses the
228 # master DB. This mimics the problem we're trying to avoid, where242 # master DB. This mimics the problem we're trying to avoid, where
229 # getByOpenIDIdentifier() doesn't find a newly created account because243 # getByOpenIDIdentifier() doesn't find a newly created account because
230 # it looks in the slave database.244 # it looks in the slave database.
231 with IAccountSet_getByOpenIDIdentifier_monkey_patched():245 with IAccountSet_getByOpenIDIdentifier_monkey_patched():
232 html = view.render()246 view.initialize()
247 if response is not None:
248 view.openid_response = response
249 html = view.render() if method == 'GET' else None
233 return view, html250 return view, html
234251
235 def test_full_fledged_account(self):252 def test_full_fledged_account(self):
@@ -328,9 +345,7 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
328345
329 def test_unseen_identity(self):346 def test_unseen_identity(self):
330 # When we get a positive assertion about an identity URL we've never347 # When we get a positive assertion about an identity URL we've never
331 # seen, we automatically register an account with that identity348 # seen, we redirect to a confirmation page.
332 # because someone who registered on login.lp.net or login.u.c should
333 # be able to login here without any further steps.
334 identifier = u'4w7kmzU'349 identifier = u'4w7kmzU'
335 account_set = getUtility(IAccountSet)350 account_set = getUtility(IAccountSet)
336 self.assertRaises(351 self.assertRaises(
@@ -340,15 +355,47 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
340 email='non-existent@example.com', full_name='Foo User')355 email='non-existent@example.com', full_name='Foo User')
341 with SRegResponse_fromSuccessResponse_stubbed():356 with SRegResponse_fromSuccessResponse_stubbed():
342 view, html = self._createAndRenderView(openid_response)357 view, html = self._createAndRenderView(openid_response)
358 self.assertFalse(view.login_called)
359 response = view.request.response
360 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
361 self.assertEqual(
362 'http://launchpad.test/+new-account?' + urllib.urlencode(
363 {'starting_url': view.request.form['starting_url']}),
364 response.getHeader('Location'))
365 self.assertRaises(
366 LookupError, account_set.getByOpenIDIdentifier, identifier)
367 self.assertThat(
368 ISession(view.request)['launchpad.pendinguser'],
369 ContainsDict({
370 'identifier': Equals(identifier),
371 'email': Equals('non-existent@example.com'),
372 'fullname': Equals('Foo User'),
373 }))
374
375 # If the user accepts, we automatically register an account with
376 # that identity, since its existence on SSO is good enough for us.
377 cookie = response.getCookie('launchpad_tests')['value']
378 view, _ = self._createAndRenderView(
379 None, view_class=StubbedNewAccountView,
380 form={
381 'starting_url': view.request.form['starting_url'],
382 'field.actions.accept': 'Accept terms and create account',
383 },
384 method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie)
343 self.assertTrue(view.login_called)385 self.assertTrue(view.login_called)
386 response = view.request.response
387 self.assertEqual(httplib.SEE_OTHER, response.getStatus())
388 self.assertEqual(
389 view.request.form['starting_url'], response.getHeader('Location'))
344 account = account_set.getByOpenIDIdentifier(identifier)390 account = account_set.getByOpenIDIdentifier(identifier)
345 self.assertIsNot(None, account)391 self.assertIsNotNone(account)
346 self.assertEqual(AccountStatus.ACTIVE, account.status)392 self.assertEqual(AccountStatus.ACTIVE, account.status)
347 person = IPerson(account, None)393 person = IPerson(account, None)
348 self.assertIsNot(None, person)394 self.assertIsNotNone(person)
349 self.assertEqual('Foo User', person.displayname)395 self.assertEqual('Foo User', person.displayname)
350 self.assertEqual('non-existent@example.com',396 self.assertEqual(
351 removeSecurityProxy(person.preferredemail).email)397 'non-existent@example.com',
398 removeSecurityProxy(person.preferredemail).email)
352399
353 # We also update the last_write flag in the session, to make sure400 # We also update the last_write flag in the session, to make sure
354 # further requests use the master DB and thus see the newly created401 # further requests use the master DB and thus see the newly created
@@ -363,8 +410,7 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
363 email = 'test@example.com'410 email = 'test@example.com'
364 person = self.factory.makePerson(411 person = self.factory.makePerson(
365 displayname='Test account', email=email,412 displayname='Test account', email=email,
366 account_status=AccountStatus.DEACTIVATED,413 account_status=AccountStatus.NOACCOUNT)
367 email_address_status=EmailAddressStatus.NEW)
368 account = person.account414 account = person.account
369 account_set = getUtility(IAccountSet)415 account_set = getUtility(IAccountSet)
370 self.assertRaises(416 self.assertRaises(
@@ -374,7 +420,38 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
374 email=email, full_name='Foo User')420 email=email, full_name='Foo User')
375 with SRegResponse_fromSuccessResponse_stubbed():421 with SRegResponse_fromSuccessResponse_stubbed():
376 view, html = self._createAndRenderView(openid_response)422 view, html = self._createAndRenderView(openid_response)
423 self.assertFalse(view.login_called)
424 response = view.request.response
425 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
426 self.assertEqual(
427 'http://launchpad.test/+new-account?' + urllib.urlencode(
428 {'starting_url': view.request.form['starting_url']}),
429 response.getHeader('Location'))
430 self.assertRaises(
431 LookupError, account_set.getByOpenIDIdentifier, identifier)
432 self.assertEqual(AccountStatus.NOACCOUNT, account.status)
433 self.assertThat(
434 ISession(view.request)['launchpad.pendinguser'],
435 ContainsDict({
436 'identifier': Equals(identifier),
437 'email': Equals('test@example.com'),
438 'fullname': Equals('Foo User'),
439 }))
440
441 # Accept the terms and proceed.
442 cookie = response.getCookie('launchpad_tests')['value']
443 view, _ = self._createAndRenderView(
444 None, view_class=StubbedNewAccountView,
445 form={
446 'starting_url': view.request.form['starting_url'],
447 'field.actions.accept': 'Accept terms and create account',
448 },
449 method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie)
377 self.assertTrue(view.login_called)450 self.assertTrue(view.login_called)
451 response = view.request.response
452 self.assertEqual(httplib.SEE_OTHER, response.getStatus())
453 self.assertEqual(
454 view.request.form['starting_url'], response.getHeader('Location'))
378455
379 # The existing accounts had a new openid_identifier added, the456 # The existing accounts had a new openid_identifier added, the
380 # account was reactivated and its preferred email was set, but457 # account was reactivated and its preferred email was set, but
@@ -386,7 +463,7 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
386 self.assertEqual(463 self.assertEqual(
387 email, removeSecurityProxy(person.preferredemail).email)464 email, removeSecurityProxy(person.preferredemail).email)
388 person = IPerson(account, None)465 person = IPerson(account, None)
389 self.assertIsNot(None, person)466 self.assertIsNotNone(person)
390 self.assertEqual('Test account', person.displayname)467 self.assertEqual('Test account', person.displayname)
391468
392 # We also update the last_write flag in the session, to make sure469 # We also update the last_write flag in the session, to make sure
@@ -396,7 +473,7 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
396473
397 def test_deactivated_account(self):474 def test_deactivated_account(self):
398 # The user has the account's password and is trying to login, so we'll475 # The user has the account's password and is trying to login, so we'll
399 # just re-activate their account.476 # redirect them to a confirmation page.
400 email = 'foo@example.com'477 email = 'foo@example.com'
401 person = self.factory.makePerson(478 person = self.factory.makePerson(
402 displayname='Test account', email=email,479 displayname='Test account', email=email,
@@ -409,11 +486,37 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
409 status=SUCCESS, email=email, full_name=person.displayname)486 status=SUCCESS, email=email, full_name=person.displayname)
410 with SRegResponse_fromSuccessResponse_stubbed():487 with SRegResponse_fromSuccessResponse_stubbed():
411 view, html = self._createAndRenderView(openid_response)488 view, html = self._createAndRenderView(openid_response)
412 self.assertTrue(view.login_called)489 self.assertFalse(view.login_called)
413 response = view.request.response490 response = view.request.response
414 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())491 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
415 self.assertEqual(view.request.form['starting_url'],492 self.assertEqual(
416 response.getHeader('Location'))493 'http://launchpad.test/+reactivate-account?' + urllib.urlencode(
494 {'starting_url': view.request.form['starting_url']}),
495 response.getHeader('Location'))
496 self.assertEqual(AccountStatus.DEACTIVATED, person.account.status)
497 self.assertIsNone(person.preferredemail)
498 self.assertThat(
499 ISession(view.request)['launchpad.pendinguser'],
500 ContainsDict({
501 'identifier': Equals(openid_identifier),
502 'email': Equals(email),
503 'fullname': Equals('Test account'),
504 }))
505
506 # If the user confirms the reactivation, we do it.
507 cookie = response.getCookie('launchpad_tests')['value']
508 view, _ = self._createAndRenderView(
509 None, view_class=StubbedReactivateAccountView,
510 form={
511 'starting_url': view.request.form['starting_url'],
512 'field.actions.accept': 'Accept terms and reactivate account',
513 },
514 method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie)
515 self.assertTrue(view.login_called)
516 response = view.request.response
517 self.assertEqual(httplib.SEE_OTHER, response.getStatus())
518 self.assertEqual(
519 view.request.form['starting_url'], response.getHeader('Location'))
417 self.assertEqual(AccountStatus.ACTIVE, person.account.status)520 self.assertEqual(AccountStatus.ACTIVE, person.account.status)
418 self.assertEqual(email, person.preferredemail.email)521 self.assertEqual(email, person.preferredemail.email)
419 # We also update the last_write flag in the session, to make sure522 # We also update the last_write flag in the session, to make sure
@@ -423,12 +526,11 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
423526
424 def test_never_used_account(self):527 def test_never_used_account(self):
425 # The account was created by one of our scripts but was never528 # The account was created by one of our scripts but was never
426 # activated, so we just activate it.529 # activated, so we redirect to a confirmation page.
427 email = 'foo@example.com'530 email = 'foo@example.com'
428 person = self.factory.makePerson(531 person = self.factory.makePerson(
429 displayname='Test account', email=email,532 displayname='Test account', email=email,
430 account_status=AccountStatus.DEACTIVATED,533 account_status=AccountStatus.NOACCOUNT)
431 email_address_status=EmailAddressStatus.NEW)
432 openid_identifier = IStore(OpenIdIdentifier).find(534 openid_identifier = IStore(OpenIdIdentifier).find(
433 OpenIdIdentifier.identifier,535 OpenIdIdentifier.identifier,
434 OpenIdIdentifier.account_id == person.account.id).order_by(536 OpenIdIdentifier.account_id == person.account.id).order_by(
@@ -439,6 +541,32 @@ class TestOpenIDCallbackView(TestCaseWithFactory):
439 status=SUCCESS, email=email, full_name=person.displayname)541 status=SUCCESS, email=email, full_name=person.displayname)
440 with SRegResponse_fromSuccessResponse_stubbed():542 with SRegResponse_fromSuccessResponse_stubbed():
441 view, html = self._createAndRenderView(openid_response)543 view, html = self._createAndRenderView(openid_response)
544 self.assertFalse(view.login_called)
545 response = view.request.response
546 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
547 self.assertEqual(
548 'http://launchpad.test/+new-account?' + urllib.urlencode(
549 {'starting_url': view.request.form['starting_url']}),
550 response.getHeader('Location'))
551 self.assertEqual(AccountStatus.NOACCOUNT, person.account.status)
552 self.assertIsNone(person.preferredemail)
553 self.assertThat(
554 ISession(view.request)['launchpad.pendinguser'],
555 ContainsDict({
556 'identifier': Equals(openid_identifier),
557 'email': Equals(email),
558 'fullname': Equals('Test account'),
559 }))
560
561 # If the user confirms the activation, we do it.
562 cookie = response.getCookie('launchpad_tests')['value']
563 view, _ = self._createAndRenderView(
564 None, view_class=StubbedNewAccountView,
565 form={
566 'starting_url': view.request.form['starting_url'],
567 'field.actions.accept': 'Accept terms and create account',
568 },
569 method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie)
442 self.assertTrue(view.login_called)570 self.assertTrue(view.login_called)
443 self.assertEqual(AccountStatus.ACTIVE, person.account.status)571 self.assertEqual(AccountStatus.ACTIVE, person.account.status)
444 self.assertEqual(email, person.preferredemail.email)572 self.assertEqual(email, person.preferredemail.email)
diff --git a/lib/lp/testopenid/browser/server.py b/lib/lp/testopenid/browser/server.py
index 18d4434..2e1305c 100644
--- a/lib/lp/testopenid/browser/server.py
+++ b/lib/lp/testopenid/browser/server.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2011 Canonical Ltd. This software is licensed under the1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Test OpenID server."""4"""Test OpenID server."""
@@ -30,7 +30,10 @@ from z3c.ptcompat import ViewPageTemplateFile
30from zope.authentication.interfaces import IUnauthenticatedPrincipal30from zope.authentication.interfaces import IUnauthenticatedPrincipal
31from zope.component import getUtility31from zope.component import getUtility
32from zope.interface import implementer32from zope.interface import implementer
33from zope.security.proxy import isinstance as zisinstance33from zope.security.proxy import (
34 isinstance as zisinstance,
35 removeSecurityProxy,
36 )
34from zope.session.interfaces import ISession37from zope.session.interfaces import ISession
3538
36from lp import _39from lp import _
@@ -39,11 +42,11 @@ from lp.app.browser.launchpadform import (
39 LaunchpadFormView,42 LaunchpadFormView,
40 )43 )
41from lp.app.errors import UnexpectedFormData44from lp.app.errors import UnexpectedFormData
42from lp.registry.interfaces.person import IPerson45from lp.registry.interfaces.person import (
43from lp.services.identity.interfaces.account import (46 IPerson,
44 AccountStatus,47 IPersonSet,
45 IAccountSet,
46 )48 )
49from lp.services.identity.interfaces.account import IAccountSet
47from lp.services.openid.browser.openiddiscovery import (50from lp.services.openid.browser.openiddiscovery import (
48 XRDSContentNegotiationMixin,51 XRDSContentNegotiationMixin,
49 )52 )
@@ -52,13 +55,9 @@ from lp.services.propertycache import (
52 get_property_cache,55 get_property_cache,
53 )56 )
54from lp.services.webapp import LaunchpadView57from lp.services.webapp import LaunchpadView
55from lp.services.webapp.interfaces import (58from lp.services.webapp.interfaces import ICanonicalUrlData
56 ICanonicalUrlData,
57 IPlacelessLoginSource,
58 )
59from lp.services.webapp.login import (59from lp.services.webapp.login import (
60 allowUnauthenticatedSession,60 allowUnauthenticatedSession,
61 logInPrincipal,
62 logoutPerson,61 logoutPerson,
63 )62 )
64from lp.services.webapp.publisher import (63from lp.services.webapp.publisher import (
@@ -105,7 +104,7 @@ class TestOpenIDApplicationNavigation(Navigation):
105 account = getUtility(IAccountSet).getByOpenIDIdentifier(name)104 account = getUtility(IAccountSet).getByOpenIDIdentifier(name)
106 except LookupError:105 except LookupError:
107 account = None106 account = None
108 if account is None or account.status != AccountStatus.ACTIVE:107 if account is None:
109 return None108 return None
110 return ITestOpenIDPersistentIdentity(account)109 return ITestOpenIDPersistentIdentity(account)
111110
@@ -206,7 +205,7 @@ class OpenIDMixin:
206 response.setHeader(header, value)205 response.setHeader(header, value)
207 return webresponse.body206 return webresponse.body
208207
209 def createPositiveResponse(self):208 def createPositiveResponse(self, email):
210 """Create a positive assertion OpenIDResponse.209 """Create a positive assertion OpenIDResponse.
211210
212 This method should be called to create the response to211 This method should be called to create the response to
@@ -233,7 +232,7 @@ class OpenIDMixin:
233 person = IPerson(self.account)232 person = IPerson(self.account)
234 sreg_fields = dict(233 sreg_fields = dict(
235 nickname=person.name,234 nickname=person.name,
236 email=person.preferredemail.email,235 email=email,
237 fullname=self.account.displayname)236 fullname=self.account.displayname)
238 sreg_request = SRegRequest.fromOpenIDRequest(self.openid_request)237 sreg_request = SRegRequest.fromOpenIDRequest(self.openid_request)
239 sreg_response = SRegResponse.extractResponse(238 sreg_response = SRegResponse.extractResponse(
@@ -306,21 +305,34 @@ class TestOpenIDLoginView(OpenIDMixin, LaunchpadFormView):
306305
307 def validate(self, data):306 def validate(self, data):
308 """Check that the email address is valid for login."""307 """Check that the email address is valid for login."""
309 loginsource = getUtility(IPlacelessLoginSource)308 if data.get('username'):
310 principal = loginsource.getPrincipalByLogin(data['email'])309 person = getUtility(IPersonSet).getByName(data['username'])
311 if principal is None:310 if person is None:
312 self.addError(311 self.setFieldError('username', _("Unknown username."))
313 _("Unknown email address."))312 elif person.preferredemail is not None:
313 email = removeSecurityProxy(person.preferredemail).email
314 if email != data['email']:
315 self.setFieldError(
316 'email',
317 _("Email address for user '%s' is '%s', not '%s'.") %
318 (data['username'], email, data['email']))
319 elif getUtility(IPersonSet).getByEmail(data['email']) is None:
320 self.setFieldError('email', _("Unknown email address."))
314321
315 @action('Continue', name='continue')322 @action('Continue', name='continue')
316 def continue_action(self, action, data):323 def continue_action(self, action, data):
317 email = data['email']324 email = data['email']
318 principal = getUtility(IPlacelessLoginSource).getPrincipalByLogin(325 username = data.get('username')
319 email)326 if username is not None:
320 logInPrincipal(self.request, principal, email)327 person = getUtility(IPersonSet).getByName(username)
321 # Update the attribute holding the cached user.328 else:
322 self._account = principal.account329 person = getUtility(IPersonSet).getByEmail(email)
323 return self.renderOpenIDResponse(self.createPositiveResponse())330 # Update the attribute holding the cached user. This fakes a login;
331 # we don't do a true login here, because we can get away without it
332 # and it allows testing the case of logging in as a user whose
333 # account status is not ACTIVE.
334 self._account = person.account
335 return self.renderOpenIDResponse(self.createPositiveResponse(email))
324336
325337
326class PersistentIdentityView(338class PersistentIdentityView(
diff --git a/lib/lp/testopenid/interfaces/server.py b/lib/lp/testopenid/interfaces/server.py
index 434fee3..92bf847 100644
--- a/lib/lp/testopenid/interfaces/server.py
+++ b/lib/lp/testopenid/interfaces/server.py
@@ -1,4 +1,4 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -23,7 +23,12 @@ class ITestOpenIDApplication(ILaunchpadApplication):
2323
2424
25class ITestOpenIDLoginForm(Interface):25class ITestOpenIDLoginForm(Interface):
26 email = TextLine(title=u'What is your email address?', required=True)26 email = TextLine(title=u'Your email address', required=True)
27 username = TextLine(
28 title=u'Your username', required=False,
29 description=(
30 u'This is only required if you are logging into a placeholder '
31 u'account for the first time.'))
2732
2833
29class ITestOpenIDPersistentIdentity(IOpenIDPersistentIdentity):34class ITestOpenIDPersistentIdentity(IOpenIDPersistentIdentity):
diff --git a/lib/lp/testopenid/stories/logging-in.txt b/lib/lp/testopenid/stories/logging-in.txt
index 43ce1f1..7a9a8ed 100644
--- a/lib/lp/testopenid/stories/logging-in.txt
+++ b/lib/lp/testopenid/stories/logging-in.txt
@@ -42,6 +42,7 @@ If the email address isn't registered, an error is shown:
42 >>> for tag in find_tags_by_class(browser.contents, 'error'):42 >>> for tag in find_tags_by_class(browser.contents, 'error'):
43 ... print extract_text(tag)43 ... print extract_text(tag)
44 There is 1 error.44 There is 1 error.
45 Your email address:
45 Unknown email address.46 Unknown email address.
4647
47If the email address matches an account, the user is logged in and48If the email address matches an account, the user is logged in and
diff --git a/utilities/make-lp-user b/utilities/make-lp-user
index 0f28da4..6cbea4c 100755
--- a/utilities/make-lp-user
+++ b/utilities/make-lp-user
@@ -1,6 +1,6 @@
1#!/usr/bin/python -S1#!/usr/bin/python -S
2#2#
3# Copyright 2009-2010 Canonical Ltd. This software is licensed under the3# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6"""Create a user for testing the local Launchpad.6"""Create a user for testing the local Launchpad.
@@ -29,6 +29,8 @@ Please note that this script is for testing purposes only. Do NOT use it in
29production environments.29production environments.
30"""30"""
3131
32from __future__ import absolute_import, print_function
33
32import _pythonpath34import _pythonpath
3335
34from optparse import OptionParser36from optparse import OptionParser
@@ -50,6 +52,7 @@ from lp.services.gpg.interfaces import (
50 GPGKeyAlgorithm,52 GPGKeyAlgorithm,
51 IGPGHandler,53 IGPGHandler,
52 )54 )
55from lp.services.identity.interfaces.account import AccountStatus
53from lp.services.scripts import execute_zcml_for_scripts56from lp.services.scripts import execute_zcml_for_scripts
54from lp.services.timeout import set_default_timeout_function57from lp.services.timeout import set_default_timeout_function
55from lp.testing.factory import LaunchpadObjectFactory58from lp.testing.factory import LaunchpadObjectFactory
@@ -59,14 +62,23 @@ factory = LaunchpadObjectFactory()
5962
60set_default_timeout_function(lambda: 100)63set_default_timeout_function(lambda: 100)
6164
65
62def make_person(username, email):66def make_person(username, email):
63 """Create and return a person with the given username.67 """Create and return a person with the given username.
6468
65 The email address for the user will be <username>@example.com.69 The email address for the user will be <username>@example.com.
66 """70 """
67 person = factory.makePerson(name=username, email=email)71 person = factory.makePerson(name=username, email=email)
68 print "username: %s" % (username,)72 print("username: %s" % (username,))
69 print "email: %s" % (email,)73 print("email: %s" % (email,))
74 return person
75
76
77def make_placeholder_person(username):
78 """Create and return a placeholder person with the given username."""
79 person = factory.makePerson(
80 name=username, account_status=AccountStatus.PLACEHOLDER)
81 print("username: %s" % (username,))
70 return person82 return person
7183
7284
@@ -83,15 +95,15 @@ def add_person_to_teams(person, team_names):
83 for team_name in team_names:95 for team_name in team_names:
84 team = person_set.getByName(team_name)96 team = person_set.getByName(team_name)
85 if team is None:97 if team is None:
86 print "ERROR: %s not found." % (team_name,)98 print("ERROR: %s not found." % (team_name,))
87 continue99 continue
88 if not team.is_team:100 if not team.is_team:
89 print "ERROR: %s is not a team." % (team_name,)101 print("ERROR: %s is not a team." % (team_name,))
90 continue102 continue
91 team.addMember(103 team.addMember(
92 person, person, status=TeamMembershipStatus.APPROVED)104 person, person, status=TeamMembershipStatus.APPROVED)
93 teams_joined.append(team_name)105 teams_joined.append(team_name)
94 print "teams: %s" % ' '.join(teams_joined)106 print("teams: %s" % ' '.join(teams_joined))
95107
96108
97def add_ssh_public_keys(person):109def add_ssh_public_keys(person):
@@ -112,10 +124,10 @@ def add_ssh_public_keys(person):
112 except (OSError, IOError):124 except (OSError, IOError):
113 continue125 continue
114 key_set.new(person, public_key)126 key_set.new(person, public_key)
115 print 'Registered SSH key: %s' % (filename,)127 print('Registered SSH key: %s' % (filename,))
116 break128 break
117 else:129 else:
118 print 'No SSH key files found in %s' % ssh_dir130 print('No SSH key files found in %s' % ssh_dir)
119131
120132
121def parse_fingerprints(gpg_output):133def parse_fingerprints(gpg_output):
@@ -144,7 +156,7 @@ def run_native_gpg(arguments):
144 command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)156 command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
145 stdout, stderr = pipe.communicate()157 stdout, stderr = pipe.communicate()
146 if stderr != '':158 if stderr != '':
147 print stderr159 print(stderr)
148 if pipe.returncode != 0:160 if pipe.returncode != 0:
149 raise Exception('GPG error during "%s"' % ' '.join(command_line))161 raise Exception('GPG error during "%s"' % ' '.join(command_line))
150162
@@ -179,7 +191,7 @@ def attach_gpg_keys(email, person):
179191
180 fingerprints = parse_fingerprints(output)192 fingerprints = parse_fingerprints(output)
181 if len(fingerprints) == 0:193 if len(fingerprints) == 0:
182 print "No GPG key fingerprints found!"194 print("No GPG key fingerprints found!")
183 for fingerprint in fingerprints:195 for fingerprint in fingerprints:
184 add_gpg_key(person, fingerprint)196 add_gpg_key(person, fingerprint)
185197
@@ -194,10 +206,13 @@ def parse_args(arguments):
194 parser.add_option(206 parser.add_option(
195 '-e', '--email', action='store', dest='email', default=None,207 '-e', '--email', action='store', dest='email', default=None,
196 help="Email address; set to use real GPG key for this address.")208 help="Email address; set to use real GPG key for this address.")
209 parser.add_option(
210 '--placeholder', action='store_true', default=False,
211 help="Create a placeholder account rather than a full user account.")
197212
198 options, args = parser.parse_args(arguments)213 options, args = parser.parse_args(arguments)
199 if len(args) == 0:214 if len(args) == 0:
200 print __doc__215 print(__doc__)
201 sys.exit(2)216 sys.exit(2)
202217
203 options.username = args[0]218 options.username = args[0]
@@ -217,12 +232,15 @@ def main(arguments):
217 execute_zcml_for_scripts()232 execute_zcml_for_scripts()
218 transaction.begin()233 transaction.begin()
219234
220 person = make_person(options.username, email)235 if options.placeholder:
221 add_person_to_teams(person, options.teams)236 make_placeholder_person(options.username)
222 add_ssh_public_keys(person)237 else:
238 person = make_person(options.username, email)
239 add_person_to_teams(person, options.teams)
240 add_ssh_public_keys(person)
223241
224 if options.email is not None:242 if options.email is not None:
225 attach_gpg_keys(options.email, person)243 attach_gpg_keys(options.email, person)
226244
227 transaction.commit()245 transaction.commit()
228246

Subscribers

People subscribed via source and target branches

to status/vote changes: