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

Proposed by Colin Watson on 2018-05-26
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad/login-interstitial
Merge into: lp:launchpad
Diff against target: 1093 lines (+537/-113)
10 files modified
lib/lp/app/browser/configure.zcml (+13/-1)
lib/lp/app/browser/launchpad.py (+4/-2)
lib/lp/services/webapp/login.py (+209/-42)
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)
To merge this branch: bzr merge lp:~cjwatson/launchpad/login-interstitial
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2018-05-26 Pending
Review via email: mp+346908@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.

To post a comment you must log in.

Unmerged revisions

18668. By Colin Watson on 2018-05-25

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
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml 2017-09-01 12:57:34 +0000
+++ lib/lp/app/browser/configure.zcml 2018-05-26 07:32:51 +0000
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2015 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-->3-->
44
@@ -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="*"
256268
=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py 2016-06-22 21:04:30 +0000
+++ lib/lp/app/browser/launchpad.py 2018-05-26 07:32:51 +0000
@@ -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).
33
4"""Browser code for the launchpad application."""4"""Browser code for the launchpad application."""
@@ -620,7 +620,9 @@
620 @property620 @property
621 def login_shown(self):621 def login_shown(self):
622 return (self.user is None and622 return (self.user is None and
623 '+login' not in self.request['PATH_INFO'])623 '+login' not in self.request['PATH_INFO'] and
624 '+new-account' not in self.request['PATH_INFO'] and
625 '+reactivate-account' not in self.request['PATH_INFO'])
624626
625 @property627 @property
626 def logged_in(self):628 def logged_in(self):
627629
=== modified file 'lib/lp/services/webapp/login.py'
--- lib/lp/services/webapp/login.py 2017-01-14 15:16:36 +0000
+++ lib/lp/services/webapp/login.py 2018-05-26 07:32:51 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2017 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"""Stuff to do with logging in and logging out."""3"""Stuff to do with logging in and logging out."""
44
@@ -44,16 +44,27 @@
44 )44 )
4545
46from lp import _46from lp import _
47from lp.app.browser.launchpadform import (
48 action,
49 LaunchpadFormView,
50 )
47from lp.registry.interfaces.person import (51from lp.registry.interfaces.person import (
52 IPerson,
48 IPersonSet,53 IPersonSet,
49 PersonCreationRationale,54 PersonCreationRationale,
50 TeamEmailAddressError,55 TeamEmailAddressError,
51 )56 )
52from lp.services.config import config57from lp.services.config import config
58from lp.services.database.interfaces import IStore
53from lp.services.database.policy import MasterDatabasePolicy59from lp.services.database.policy import MasterDatabasePolicy
54from lp.services.identity.interfaces.account import AccountSuspendedError60from lp.services.identity.interfaces.account import (
61 AccountStatus,
62 AccountSuspendedError,
63 )
64from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
55from lp.services.openid.extensions import macaroon65from lp.services.openid.extensions import macaroon
56from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore66from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
67from lp.services.openid.model.openididentifier import OpenIdIdentifier
57from lp.services.propertycache import cachedproperty68from lp.services.propertycache import cachedproperty
58from lp.services.timeline.requesttimeline import get_request_timeline69from lp.services.timeline.requesttimeline import get_request_timeline
59from lp.services.webapp import canonical_url70from lp.services.webapp import canonical_url
@@ -275,12 +286,8 @@
275 [encode_utf8(value) for value in value_list])286 [encode_utf8(value) for value in value_list])
276287
277288
278class OpenIDCallbackView(OpenIDLogin):289class FinishLoginMixin:
279 """The OpenID callback page for logging into Launchpad.290 """A mixin for views that can finish the login process."""
280
281 This is the page the OpenID provider will send the user's browser to,
282 after the user has authenticated on the provider.
283 """
284291
285 suspended_account_template = ViewPageTemplateFile(292 suspended_account_template = ViewPageTemplateFile(
286 'templates/login-suspended-account.pt')293 'templates/login-suspended-account.pt')
@@ -288,9 +295,6 @@
288 team_email_address_template = ViewPageTemplateFile(295 team_email_address_template = ViewPageTemplateFile(
289 'templates/login-team-email-address.pt')296 'templates/login-team-email-address.pt')
290297
291 discharge_macaroon_template = ViewPageTemplateFile(
292 'templates/login-discharge-macaroon.pt')
293
294 def _gather_params(self, request):298 def _gather_params(self, request):
295 params = dict(request.form)299 params = dict(request.form)
296 for key, value in request.query_string_params.iteritems():300 for key, value in request.query_string_params.iteritems():
@@ -301,6 +305,31 @@
301305
302 return params306 return params
303307
308 def login(self, person, when=None):
309 loginsource = getUtility(IPlacelessLoginSource)
310 # We don't have a logged in principal, so we must remove the security
311 # proxy of the account's preferred email.
312 email = removeSecurityProxy(person.preferredemail).email
313 logInPrincipal(
314 self.request, loginsource.getPrincipalByLogin(email), email, when)
315
316 def _redirect(self):
317 target = self.params.get('starting_url')
318 if target is None:
319 target = self.request.getApplicationURL()
320 self.request.response.redirect(target, temporary_if_possible=True)
321
322
323class OpenIDCallbackView(FinishLoginMixin, OpenIDLogin):
324 """The OpenID callback page for logging into Launchpad.
325
326 This is the page the OpenID provider will send the user's browser to,
327 after the user has authenticated on the provider.
328 """
329
330 discharge_macaroon_template = ViewPageTemplateFile(
331 'templates/login-discharge-macaroon.pt')
332
304 def _get_requested_url(self, request):333 def _get_requested_url(self, request):
305 requested_url = request.getURL()334 requested_url = request.getURL()
306 query_string = request.get('QUERY_STRING')335 query_string = request.get('QUERY_STRING')
@@ -321,13 +350,28 @@
321 timeline_action.finish()350 timeline_action.finish()
322 self.discharge_macaroon_raw = None351 self.discharge_macaroon_raw = None
323352
324 def login(self, person, when=None):353 def loginInactive(self, when=None):
325 loginsource = getUtility(IPlacelessLoginSource)354 """Log an inactive person in.
326 # We don't have a logged in principal, so we must remove the security355
327 # proxy of the account's preferred email.356 This isn't a normal login, which we can't do while the person is
328 email = removeSecurityProxy(person.preferredemail).email357 inactive. Instead, we store a few details about the OpenID response
329 logInPrincipal(358 in a separate part of the session database, which lets us render an
330 self.request, loginsource.getPrincipalByLogin(email), email, when)359 appropriate interstitial page and then activate the account properly
360 after the form on that page is submitted.
361 """
362 # Force a fresh session, per bug #828638.
363 client_id_manager = getUtility(IClientIdManager)
364 new_client_id = client_id_manager.generateUniqueId()
365 client_id_manager.setRequestId(self.request, new_client_id)
366 session = ISession(self.request)
367 authdata = session['launchpad.pendinguser']
368 authdata['identifier'] = self._getOpenIDIdentifier()
369 email_address, full_name = self._getEmailAddressAndFullName()
370 authdata['email'] = email_address
371 authdata['fullname'] = full_name
372 if when is None:
373 when = datetime.utcnow()
374 authdata['logintime'] = when
331375
332 @cachedproperty376 @cachedproperty
333 def sreg_response(self):377 def sreg_response(self):
@@ -338,6 +382,10 @@
338 return macaroon.MacaroonResponse.fromSuccessResponse(382 return macaroon.MacaroonResponse.fromSuccessResponse(
339 self.openid_response)383 self.openid_response)
340384
385 def _getOpenIDIdentifier(self):
386 identifier = self.openid_response.identity_url.split('/')[-1]
387 return identifier.decode('ascii')
388
341 def _getEmailAddressAndFullName(self):389 def _getEmailAddressAndFullName(self):
342 # Here we assume the OP sent us the user's email address and390 # Here we assume the OP sent us the user's email address and
343 # full name in the response. Note we can only do that because391 # full name in the response. Note we can only do that because
@@ -356,6 +404,49 @@
356 "No email address or full name found in sreg response.")404 "No email address or full name found in sreg response.")
357 return email_address, full_name405 return email_address, full_name
358406
407 def _maybeRedirectToInterstitial(self, openid_identifier, email_address):
408 """Redirect to an interstitial page in some cases.
409
410 If there is no existing account for this OpenID identifier or email
411 address, or if the existing account is in certain inactive states,
412 then instead of logging in straight away we redirect to an
413 interstitial page to confirm what the user wants to do.
414 """
415 redirect_view_names = {
416 AccountStatus.DEACTIVATED: '+reactivate-account',
417 AccountStatus.NOACCOUNT: '+new-account',
418 AccountStatus.PLACEHOLDER: '+new-account',
419 }
420 identifier = IStore(OpenIdIdentifier).find(
421 OpenIdIdentifier, identifier=openid_identifier).one()
422 if identifier is not None:
423 person = IPerson(identifier.account, None)
424 else:
425 email = getUtility(IEmailAddressSet).getByEmail(email_address)
426 person = email.person if email is not None else None
427
428 if (person is None or
429 (not person.is_team and
430 (not person.account or
431 person.account.status in redirect_view_names))):
432 self.loginInactive()
433 trust_root = allvhosts.configs['mainsite'].rooturl
434 url = urlappend(
435 trust_root,
436 redirect_view_names[
437 person.account.status if person
438 else AccountStatus.NOACCOUNT])
439 params = {}
440 target = self.params.get('starting_url')
441 if target is not None:
442 params['starting_url'] = target
443 if params:
444 url += "?%s" % urllib.urlencode(params)
445 self.request.response.redirect(url, temporary_if_possible=True)
446 return True
447 else:
448 return False
449
359 def processPositiveAssertion(self):450 def processPositiveAssertion(self):
360 """Process an OpenID response containing a positive assertion.451 """Process an OpenID response containing a positive assertion.
361452
@@ -369,25 +460,9 @@
369 DB writes, to ensure subsequent requests use the master DB and see460 DB writes, to ensure subsequent requests use the master DB and see
370 the changes we just did.461 the changes we just did.
371 """462 """
372 identifier = self.openid_response.identity_url.split('/')[-1]463 identifier = self._getOpenIDIdentifier()
373 identifier = identifier.decode('ascii')464 email_address, full_name = self._getEmailAddressAndFullName()
374 should_update_last_write = False465 should_update_last_write = False
375 # Force the use of the master database to make sure a lagged slave
376 # doesn't fool us into creating a Person/Account when one already
377 # exists.
378 person_set = getUtility(IPersonSet)
379 email_address, full_name = self._getEmailAddressAndFullName()
380 try:
381 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
382 identifier, email_address, full_name,
383 comment='when logging in to Launchpad.',
384 creation_rationale=(
385 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
386 should_update_last_write = db_updated
387 except AccountSuspendedError:
388 return self.suspended_account_template()
389 except TeamEmailAddressError:
390 return self.team_email_address_template()
391466
392 if self.params.get('discharge_macaroon_field'):467 if self.params.get('discharge_macaroon_field'):
393 if self.macaroon_response.discharge_macaroon_raw is None:468 if self.macaroon_response.discharge_macaroon_raw is None:
@@ -396,7 +471,30 @@
396 self.discharge_macaroon_raw = (471 self.discharge_macaroon_raw = (
397 self.macaroon_response.discharge_macaroon_raw)472 self.macaroon_response.discharge_macaroon_raw)
398473
474 # Force the use of the master database to make sure a lagged slave
475 # doesn't fool us into creating a Person/Account when one already
476 # exists.
399 with MasterDatabasePolicy():477 with MasterDatabasePolicy():
478 if self._maybeRedirectToInterstitial(identifier, email_address):
479 return None
480
481 # XXX cjwatson 2018-05-25: We should never create a Person at
482 # this point; any situation that would result in that should
483 # result in a redirection to an interstitial page. Guaranteeing
484 # that will require a bit more refactoring, though.
485 person_set = getUtility(IPersonSet)
486 try:
487 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
488 identifier, email_address, full_name,
489 comment='when logging in to Launchpad.',
490 creation_rationale=(
491 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
492 should_update_last_write = db_updated
493 except AccountSuspendedError:
494 return self.suspended_account_template()
495 except TeamEmailAddressError:
496 return self.team_email_address_template()
497
400 self.login(person)498 self.login(person)
401499
402 if self.params.get('discharge_macaroon_field'):500 if self.params.get('discharge_macaroon_field'):
@@ -443,12 +541,6 @@
443 transaction.commit()541 transaction.commit()
444 return retval542 return retval
445543
446 def _redirect(self):
447 target = self.params.get('starting_url')
448 if target is None:
449 target = self.request.getApplicationURL()
450 self.request.response.redirect(target, temporary_if_possible=True)
451
452544
453class OpenIDLoginErrorView(LaunchpadView):545class OpenIDLoginErrorView(LaunchpadView):
454546
@@ -471,6 +563,81 @@
471 self.login_error = "Unknown error: %s" % openid_response563 self.login_error = "Unknown error: %s" % openid_response
472564
473565
566class FinishLoginInterstitialView(FinishLoginMixin, LaunchpadFormView):
567
568 class schema(Interface):
569 pass
570
571 def initialize(self):
572 self.params = self._gather_params(self.request)
573 super(FinishLoginInterstitialView, self).initialize()
574
575 def _accept(self):
576 session = ISession(self.request)
577 authdata = session['launchpad.pendinguser']
578 try:
579 identifier = authdata['identifier']
580 email_address = authdata['email']
581 full_name = authdata['fullname']
582 except KeyError:
583 return OpenIDLoginErrorView(
584 self.context, self.request,
585 login_error=(
586 "Your session expired. Please try logging in again."))
587 should_update_last_write = False
588
589 # Force the use of the master database to make sure a lagged slave
590 # doesn't fool us into creating a Person/Account when one already
591 # exists.
592 with MasterDatabasePolicy():
593 person_set = getUtility(IPersonSet)
594 try:
595 person, db_updated = person_set.getOrCreateByOpenIDIdentifier(
596 identifier, email_address, full_name,
597 comment=(
598 'when logging in to Launchpad, after accepting '
599 'terms.'),
600 creation_rationale=(
601 PersonCreationRationale.OWNER_CREATED_LAUNCHPAD))
602 should_update_last_write = db_updated
603 except AccountSuspendedError:
604 return self.suspended_account_template()
605 except TeamEmailAddressError:
606 return self.team_email_address_template()
607
608 self.login(person)
609
610 if should_update_last_write:
611 # This is a GET request but we changed the database, so update
612 # session_data['last_write'] to make sure further requests use
613 # the master DB and thus see the changes we've just made.
614 session_data = ISession(self.request)['lp.dbpolicy']
615 session_data['last_write'] = datetime.utcnow()
616 self._redirect()
617 # No need to return anything as we redirect above.
618 return None
619
620
621class NewAccountView(FinishLoginInterstitialView):
622
623 page_title = label = 'Welcome to Launchpad!'
624 template = ViewPageTemplateFile('templates/login-new-account.pt')
625
626 @action('Accept terms and create account', name='accept')
627 def accept(self, action, data):
628 return self._accept()
629
630
631class ReactivateAccountView(FinishLoginInterstitialView):
632
633 page_title = label = 'Welcome back to Launchpad!'
634 template = ViewPageTemplateFile('templates/login-reactivate-account.pt')
635
636 @action('Accept terms and reactivate account', name='accept')
637 def accept(self, action, data):
638 return self._accept()
639
640
474class AlreadyLoggedInView(LaunchpadView):641class AlreadyLoggedInView(LaunchpadView):
475642
476 page_title = 'Already logged in'643 page_title = 'Already logged in'
477644
=== added file 'lib/lp/services/webapp/templates/login-new-account.pt'
--- lib/lp/services/webapp/templates/login-new-account.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webapp/templates/login-new-account.pt 2018-05-26 07:32:51 +0000
@@ -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>
037
=== added file 'lib/lp/services/webapp/templates/login-reactivate-account.pt'
--- lib/lp/services/webapp/templates/login-reactivate-account.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webapp/templates/login-reactivate-account.pt 2018-05-26 07:32:51 +0000
@@ -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>
044
=== modified file 'lib/lp/services/webapp/tests/test_login.py'
--- lib/lp/services/webapp/tests/test_login.py 2018-01-02 16:10:26 +0000
+++ lib/lp/services/webapp/tests/test_login.py 2018-05-26 07:32:51 +0000
@@ -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 @@
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 @@
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 @@
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 @@
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.dev/after-login'}232 form = {'starting_url': 'http://launchpad.dev/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 @@
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 @@
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.dev/+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 @@
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 @@
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.dev/+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 @@
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 @@
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 @@
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)
489 self.assertFalse(view.login_called)
490 response = view.request.response
491 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
492 self.assertEqual(
493 'http://launchpad.dev/+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)
412 self.assertTrue(view.login_called)515 self.assertTrue(view.login_called)
413 response = view.request.response516 response = view.request.response
414 self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())517 self.assertEqual(httplib.SEE_OTHER, response.getStatus())
415 self.assertEqual(view.request.form['starting_url'],518 self.assertEqual(
416 response.getHeader('Location'))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 @@
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 @@
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.dev/+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)
445573
=== modified file 'lib/lp/testopenid/browser/server.py'
--- lib/lp/testopenid/browser/server.py 2015-07-08 16:05:11 +0000
+++ lib/lp/testopenid/browser/server.py 2018-05-26 07:32:51 +0000
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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(
327339
=== modified file 'lib/lp/testopenid/interfaces/server.py'
--- lib/lp/testopenid/interfaces/server.py 2015-07-21 09:04:01 +0000
+++ lib/lp/testopenid/interfaces/server.py 2018-05-26 07:32:51 +0000
@@ -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 @@
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):
3035
=== modified file 'lib/lp/testopenid/stories/logging-in.txt'
--- lib/lp/testopenid/stories/logging-in.txt 2012-01-14 12:47:20 +0000
+++ lib/lp/testopenid/stories/logging-in.txt 2018-05-26 07:32:51 +0000
@@ -42,6 +42,7 @@
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
4849
=== modified file 'utilities/make-lp-user'
--- utilities/make-lp-user 2015-05-07 09:29:30 +0000
+++ utilities/make-lp-user 2018-05-26 07:32:51 +0000
@@ -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 @@
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
@@ -49,6 +51,7 @@
49 GPGKeyAlgorithm,51 GPGKeyAlgorithm,
50 IGPGHandler,52 IGPGHandler,
51 )53 )
54from lp.services.identity.interfaces.account import AccountStatus
52from lp.services.scripts import execute_zcml_for_scripts55from lp.services.scripts import execute_zcml_for_scripts
53from lp.services.timeout import set_default_timeout_function56from lp.services.timeout import set_default_timeout_function
54from lp.testing.factory import LaunchpadObjectFactory57from lp.testing.factory import LaunchpadObjectFactory
@@ -58,14 +61,23 @@
5861
59set_default_timeout_function(lambda: 100)62set_default_timeout_function(lambda: 100)
6063
64
61def make_person(username, email):65def make_person(username, email):
62 """Create and return a person with the given username.66 """Create and return a person with the given username.
6367
64 The email address for the user will be <username>@example.com.68 The email address for the user will be <username>@example.com.
65 """69 """
66 person = factory.makePerson(name=username, email=email)70 person = factory.makePerson(name=username, email=email)
67 print "username: %s" % (username,)71 print("username: %s" % (username,))
68 print "email: %s" % (email,)72 print("email: %s" % (email,))
73 return person
74
75
76def make_placeholder_person(username):
77 """Create and return a placeholder person with the given username."""
78 person = factory.makePerson(
79 name=username, account_status=AccountStatus.PLACEHOLDER)
80 print("username: %s" % (username,))
69 return person81 return person
7082
7183
@@ -82,15 +94,15 @@
82 for team_name in team_names:94 for team_name in team_names:
83 team = person_set.getByName(team_name)95 team = person_set.getByName(team_name)
84 if team is None:96 if team is None:
85 print "ERROR: %s not found." % (team_name,)97 print("ERROR: %s not found." % (team_name,))
86 continue98 continue
87 if not team.is_team:99 if not team.is_team:
88 print "ERROR: %s is not a team." % (team_name,)100 print("ERROR: %s is not a team." % (team_name,))
89 continue101 continue
90 team.addMember(102 team.addMember(
91 person, person, status=TeamMembershipStatus.APPROVED)103 person, person, status=TeamMembershipStatus.APPROVED)
92 teams_joined.append(team_name)104 teams_joined.append(team_name)
93 print "teams: %s" % ' '.join(teams_joined)105 print("teams: %s" % ' '.join(teams_joined))
94106
95107
96def add_ssh_public_keys(person):108def add_ssh_public_keys(person):
@@ -111,10 +123,10 @@
111 except (OSError, IOError):123 except (OSError, IOError):
112 continue124 continue
113 key_set.new(person, public_key)125 key_set.new(person, public_key)
114 print 'Registered SSH key: %s' % (filename,)126 print('Registered SSH key: %s' % (filename,))
115 break127 break
116 else:128 else:
117 print 'No SSH key files found in %s' % ssh_dir129 print('No SSH key files found in %s' % ssh_dir)
118130
119131
120def parse_fingerprints(gpg_output):132def parse_fingerprints(gpg_output):
@@ -143,7 +155,7 @@
143 command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)155 command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
144 stdout, stderr = pipe.communicate()156 stdout, stderr = pipe.communicate()
145 if stderr != '':157 if stderr != '':
146 print stderr158 print(stderr)
147 if pipe.returncode != 0:159 if pipe.returncode != 0:
148 raise Exception('GPG error during "%s"' % ' '.join(command_line))160 raise Exception('GPG error during "%s"' % ' '.join(command_line))
149161
@@ -179,7 +191,7 @@
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 @@
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 @@
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