Merge lp:~cjwatson/launchpad/login-interstitial into lp:launchpad
- login-interstitial
- Merge into devel
Status: | Rejected |
---|---|
Rejected by: | Colin Watson |
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | 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.
Colin Watson (cjwatson) wrote : | # |
Unmerged revisions
- 18668. 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
1 | === modified file 'lib/lp/app/browser/configure.zcml' | |||
2 | --- lib/lp/app/browser/configure.zcml 2017-09-01 12:57:34 +0000 | |||
3 | +++ lib/lp/app/browser/configure.zcml 2018-05-26 07:32:51 +0000 | |||
4 | @@ -1,4 +1,4 @@ | |||
6 | 1 | <!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the | 1 | <!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
7 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | 2 | GNU Affero General Public License version 3 (see the file LICENSE). |
8 | 3 | --> | 3 | --> |
9 | 4 | 4 | ||
10 | @@ -250,6 +250,18 @@ | |||
11 | 250 | permission="zope.Public" | 250 | permission="zope.Public" |
12 | 251 | name="+openid-callback" | 251 | name="+openid-callback" |
13 | 252 | /> | 252 | /> |
14 | 253 | <browser:page | ||
15 | 254 | for="lp.services.webapp.interfaces.ILaunchpadApplication" | ||
16 | 255 | class="lp.services.webapp.login.NewAccountView" | ||
17 | 256 | permission="zope.Public" | ||
18 | 257 | name="+new-account" | ||
19 | 258 | /> | ||
20 | 259 | <browser:page | ||
21 | 260 | for="lp.services.webapp.interfaces.ILaunchpadApplication" | ||
22 | 261 | class="lp.services.webapp.login.ReactivateAccountView" | ||
23 | 262 | permission="zope.Public" | ||
24 | 263 | name="+reactivate-account" | ||
25 | 264 | /> | ||
26 | 253 | 265 | ||
27 | 254 | <browser:page | 266 | <browser:page |
28 | 255 | for="*" | 267 | for="*" |
29 | 256 | 268 | ||
30 | === modified file 'lib/lp/app/browser/launchpad.py' | |||
31 | --- lib/lp/app/browser/launchpad.py 2016-06-22 21:04:30 +0000 | |||
32 | +++ lib/lp/app/browser/launchpad.py 2018-05-26 07:32:51 +0000 | |||
33 | @@ -1,4 +1,4 @@ | |||
35 | 1 | # Copyright 2009-2016 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
36 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
37 | 3 | 3 | ||
38 | 4 | """Browser code for the launchpad application.""" | 4 | """Browser code for the launchpad application.""" |
39 | @@ -620,7 +620,9 @@ | |||
40 | 620 | @property | 620 | @property |
41 | 621 | def login_shown(self): | 621 | def login_shown(self): |
42 | 622 | return (self.user is None and | 622 | return (self.user is None and |
44 | 623 | '+login' not in self.request['PATH_INFO']) | 623 | '+login' not in self.request['PATH_INFO'] and |
45 | 624 | '+new-account' not in self.request['PATH_INFO'] and | ||
46 | 625 | '+reactivate-account' not in self.request['PATH_INFO']) | ||
47 | 624 | 626 | ||
48 | 625 | @property | 627 | @property |
49 | 626 | def logged_in(self): | 628 | def logged_in(self): |
50 | 627 | 629 | ||
51 | === modified file 'lib/lp/services/webapp/login.py' | |||
52 | --- lib/lp/services/webapp/login.py 2017-01-14 15:16:36 +0000 | |||
53 | +++ lib/lp/services/webapp/login.py 2018-05-26 07:32:51 +0000 | |||
54 | @@ -1,4 +1,4 @@ | |||
56 | 1 | # Copyright 2009-2017 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
57 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
58 | 3 | """Stuff to do with logging in and logging out.""" | 3 | """Stuff to do with logging in and logging out.""" |
59 | 4 | 4 | ||
60 | @@ -44,16 +44,27 @@ | |||
61 | 44 | ) | 44 | ) |
62 | 45 | 45 | ||
63 | 46 | from lp import _ | 46 | from lp import _ |
64 | 47 | from lp.app.browser.launchpadform import ( | ||
65 | 48 | action, | ||
66 | 49 | LaunchpadFormView, | ||
67 | 50 | ) | ||
68 | 47 | from lp.registry.interfaces.person import ( | 51 | from lp.registry.interfaces.person import ( |
69 | 52 | IPerson, | ||
70 | 48 | IPersonSet, | 53 | IPersonSet, |
71 | 49 | PersonCreationRationale, | 54 | PersonCreationRationale, |
72 | 50 | TeamEmailAddressError, | 55 | TeamEmailAddressError, |
73 | 51 | ) | 56 | ) |
74 | 52 | from lp.services.config import config | 57 | from lp.services.config import config |
75 | 58 | from lp.services.database.interfaces import IStore | ||
76 | 53 | from lp.services.database.policy import MasterDatabasePolicy | 59 | from lp.services.database.policy import MasterDatabasePolicy |
78 | 54 | from lp.services.identity.interfaces.account import AccountSuspendedError | 60 | from lp.services.identity.interfaces.account import ( |
79 | 61 | AccountStatus, | ||
80 | 62 | AccountSuspendedError, | ||
81 | 63 | ) | ||
82 | 64 | from lp.services.identity.interfaces.emailaddress import IEmailAddressSet | ||
83 | 55 | from lp.services.openid.extensions import macaroon | 65 | from lp.services.openid.extensions import macaroon |
84 | 56 | from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore | 66 | from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore |
85 | 67 | from lp.services.openid.model.openididentifier import OpenIdIdentifier | ||
86 | 57 | from lp.services.propertycache import cachedproperty | 68 | from lp.services.propertycache import cachedproperty |
87 | 58 | from lp.services.timeline.requesttimeline import get_request_timeline | 69 | from lp.services.timeline.requesttimeline import get_request_timeline |
88 | 59 | from lp.services.webapp import canonical_url | 70 | from lp.services.webapp import canonical_url |
89 | @@ -275,12 +286,8 @@ | |||
90 | 275 | [encode_utf8(value) for value in value_list]) | 286 | [encode_utf8(value) for value in value_list]) |
91 | 276 | 287 | ||
92 | 277 | 288 | ||
99 | 278 | class OpenIDCallbackView(OpenIDLogin): | 289 | class FinishLoginMixin: |
100 | 279 | """The OpenID callback page for logging into Launchpad. | 290 | """A mixin for views that can finish the login process.""" |
95 | 280 | |||
96 | 281 | This is the page the OpenID provider will send the user's browser to, | ||
97 | 282 | after the user has authenticated on the provider. | ||
98 | 283 | """ | ||
101 | 284 | 291 | ||
102 | 285 | suspended_account_template = ViewPageTemplateFile( | 292 | suspended_account_template = ViewPageTemplateFile( |
103 | 286 | 'templates/login-suspended-account.pt') | 293 | 'templates/login-suspended-account.pt') |
104 | @@ -288,9 +295,6 @@ | |||
105 | 288 | team_email_address_template = ViewPageTemplateFile( | 295 | team_email_address_template = ViewPageTemplateFile( |
106 | 289 | 'templates/login-team-email-address.pt') | 296 | 'templates/login-team-email-address.pt') |
107 | 290 | 297 | ||
108 | 291 | discharge_macaroon_template = ViewPageTemplateFile( | ||
109 | 292 | 'templates/login-discharge-macaroon.pt') | ||
110 | 293 | |||
111 | 294 | def _gather_params(self, request): | 298 | def _gather_params(self, request): |
112 | 295 | params = dict(request.form) | 299 | params = dict(request.form) |
113 | 296 | for key, value in request.query_string_params.iteritems(): | 300 | for key, value in request.query_string_params.iteritems(): |
114 | @@ -301,6 +305,31 @@ | |||
115 | 301 | 305 | ||
116 | 302 | return params | 306 | return params |
117 | 303 | 307 | ||
118 | 308 | def login(self, person, when=None): | ||
119 | 309 | loginsource = getUtility(IPlacelessLoginSource) | ||
120 | 310 | # We don't have a logged in principal, so we must remove the security | ||
121 | 311 | # proxy of the account's preferred email. | ||
122 | 312 | email = removeSecurityProxy(person.preferredemail).email | ||
123 | 313 | logInPrincipal( | ||
124 | 314 | self.request, loginsource.getPrincipalByLogin(email), email, when) | ||
125 | 315 | |||
126 | 316 | def _redirect(self): | ||
127 | 317 | target = self.params.get('starting_url') | ||
128 | 318 | if target is None: | ||
129 | 319 | target = self.request.getApplicationURL() | ||
130 | 320 | self.request.response.redirect(target, temporary_if_possible=True) | ||
131 | 321 | |||
132 | 322 | |||
133 | 323 | class OpenIDCallbackView(FinishLoginMixin, OpenIDLogin): | ||
134 | 324 | """The OpenID callback page for logging into Launchpad. | ||
135 | 325 | |||
136 | 326 | This is the page the OpenID provider will send the user's browser to, | ||
137 | 327 | after the user has authenticated on the provider. | ||
138 | 328 | """ | ||
139 | 329 | |||
140 | 330 | discharge_macaroon_template = ViewPageTemplateFile( | ||
141 | 331 | 'templates/login-discharge-macaroon.pt') | ||
142 | 332 | |||
143 | 304 | def _get_requested_url(self, request): | 333 | def _get_requested_url(self, request): |
144 | 305 | requested_url = request.getURL() | 334 | requested_url = request.getURL() |
145 | 306 | query_string = request.get('QUERY_STRING') | 335 | query_string = request.get('QUERY_STRING') |
146 | @@ -321,13 +350,28 @@ | |||
147 | 321 | timeline_action.finish() | 350 | timeline_action.finish() |
148 | 322 | self.discharge_macaroon_raw = None | 351 | self.discharge_macaroon_raw = None |
149 | 323 | 352 | ||
157 | 324 | def login(self, person, when=None): | 353 | def loginInactive(self, when=None): |
158 | 325 | loginsource = getUtility(IPlacelessLoginSource) | 354 | """Log an inactive person in. |
159 | 326 | # We don't have a logged in principal, so we must remove the security | 355 | |
160 | 327 | # proxy of the account's preferred email. | 356 | This isn't a normal login, which we can't do while the person is |
161 | 328 | email = removeSecurityProxy(person.preferredemail).email | 357 | inactive. Instead, we store a few details about the OpenID response |
162 | 329 | logInPrincipal( | 358 | in a separate part of the session database, which lets us render an |
163 | 330 | self.request, loginsource.getPrincipalByLogin(email), email, when) | 359 | appropriate interstitial page and then activate the account properly |
164 | 360 | after the form on that page is submitted. | ||
165 | 361 | """ | ||
166 | 362 | # Force a fresh session, per bug #828638. | ||
167 | 363 | client_id_manager = getUtility(IClientIdManager) | ||
168 | 364 | new_client_id = client_id_manager.generateUniqueId() | ||
169 | 365 | client_id_manager.setRequestId(self.request, new_client_id) | ||
170 | 366 | session = ISession(self.request) | ||
171 | 367 | authdata = session['launchpad.pendinguser'] | ||
172 | 368 | authdata['identifier'] = self._getOpenIDIdentifier() | ||
173 | 369 | email_address, full_name = self._getEmailAddressAndFullName() | ||
174 | 370 | authdata['email'] = email_address | ||
175 | 371 | authdata['fullname'] = full_name | ||
176 | 372 | if when is None: | ||
177 | 373 | when = datetime.utcnow() | ||
178 | 374 | authdata['logintime'] = when | ||
179 | 331 | 375 | ||
180 | 332 | @cachedproperty | 376 | @cachedproperty |
181 | 333 | def sreg_response(self): | 377 | def sreg_response(self): |
182 | @@ -338,6 +382,10 @@ | |||
183 | 338 | return macaroon.MacaroonResponse.fromSuccessResponse( | 382 | return macaroon.MacaroonResponse.fromSuccessResponse( |
184 | 339 | self.openid_response) | 383 | self.openid_response) |
185 | 340 | 384 | ||
186 | 385 | def _getOpenIDIdentifier(self): | ||
187 | 386 | identifier = self.openid_response.identity_url.split('/')[-1] | ||
188 | 387 | return identifier.decode('ascii') | ||
189 | 388 | |||
190 | 341 | def _getEmailAddressAndFullName(self): | 389 | def _getEmailAddressAndFullName(self): |
191 | 342 | # Here we assume the OP sent us the user's email address and | 390 | # Here we assume the OP sent us the user's email address and |
192 | 343 | # full name in the response. Note we can only do that because | 391 | # full name in the response. Note we can only do that because |
193 | @@ -356,6 +404,49 @@ | |||
194 | 356 | "No email address or full name found in sreg response.") | 404 | "No email address or full name found in sreg response.") |
195 | 357 | return email_address, full_name | 405 | return email_address, full_name |
196 | 358 | 406 | ||
197 | 407 | def _maybeRedirectToInterstitial(self, openid_identifier, email_address): | ||
198 | 408 | """Redirect to an interstitial page in some cases. | ||
199 | 409 | |||
200 | 410 | If there is no existing account for this OpenID identifier or email | ||
201 | 411 | address, or if the existing account is in certain inactive states, | ||
202 | 412 | then instead of logging in straight away we redirect to an | ||
203 | 413 | interstitial page to confirm what the user wants to do. | ||
204 | 414 | """ | ||
205 | 415 | redirect_view_names = { | ||
206 | 416 | AccountStatus.DEACTIVATED: '+reactivate-account', | ||
207 | 417 | AccountStatus.NOACCOUNT: '+new-account', | ||
208 | 418 | AccountStatus.PLACEHOLDER: '+new-account', | ||
209 | 419 | } | ||
210 | 420 | identifier = IStore(OpenIdIdentifier).find( | ||
211 | 421 | OpenIdIdentifier, identifier=openid_identifier).one() | ||
212 | 422 | if identifier is not None: | ||
213 | 423 | person = IPerson(identifier.account, None) | ||
214 | 424 | else: | ||
215 | 425 | email = getUtility(IEmailAddressSet).getByEmail(email_address) | ||
216 | 426 | person = email.person if email is not None else None | ||
217 | 427 | |||
218 | 428 | if (person is None or | ||
219 | 429 | (not person.is_team and | ||
220 | 430 | (not person.account or | ||
221 | 431 | person.account.status in redirect_view_names))): | ||
222 | 432 | self.loginInactive() | ||
223 | 433 | trust_root = allvhosts.configs['mainsite'].rooturl | ||
224 | 434 | url = urlappend( | ||
225 | 435 | trust_root, | ||
226 | 436 | redirect_view_names[ | ||
227 | 437 | person.account.status if person | ||
228 | 438 | else AccountStatus.NOACCOUNT]) | ||
229 | 439 | params = {} | ||
230 | 440 | target = self.params.get('starting_url') | ||
231 | 441 | if target is not None: | ||
232 | 442 | params['starting_url'] = target | ||
233 | 443 | if params: | ||
234 | 444 | url += "?%s" % urllib.urlencode(params) | ||
235 | 445 | self.request.response.redirect(url, temporary_if_possible=True) | ||
236 | 446 | return True | ||
237 | 447 | else: | ||
238 | 448 | return False | ||
239 | 449 | |||
240 | 359 | def processPositiveAssertion(self): | 450 | def processPositiveAssertion(self): |
241 | 360 | """Process an OpenID response containing a positive assertion. | 451 | """Process an OpenID response containing a positive assertion. |
242 | 361 | 452 | ||
243 | @@ -369,25 +460,9 @@ | |||
244 | 369 | DB writes, to ensure subsequent requests use the master DB and see | 460 | DB writes, to ensure subsequent requests use the master DB and see |
245 | 370 | the changes we just did. | 461 | the changes we just did. |
246 | 371 | """ | 462 | """ |
249 | 372 | identifier = self.openid_response.identity_url.split('/')[-1] | 463 | identifier = self._getOpenIDIdentifier() |
250 | 373 | identifier = identifier.decode('ascii') | 464 | email_address, full_name = self._getEmailAddressAndFullName() |
251 | 374 | should_update_last_write = False | 465 | should_update_last_write = False |
252 | 375 | # Force the use of the master database to make sure a lagged slave | ||
253 | 376 | # doesn't fool us into creating a Person/Account when one already | ||
254 | 377 | # exists. | ||
255 | 378 | person_set = getUtility(IPersonSet) | ||
256 | 379 | email_address, full_name = self._getEmailAddressAndFullName() | ||
257 | 380 | try: | ||
258 | 381 | person, db_updated = person_set.getOrCreateByOpenIDIdentifier( | ||
259 | 382 | identifier, email_address, full_name, | ||
260 | 383 | comment='when logging in to Launchpad.', | ||
261 | 384 | creation_rationale=( | ||
262 | 385 | PersonCreationRationale.OWNER_CREATED_LAUNCHPAD)) | ||
263 | 386 | should_update_last_write = db_updated | ||
264 | 387 | except AccountSuspendedError: | ||
265 | 388 | return self.suspended_account_template() | ||
266 | 389 | except TeamEmailAddressError: | ||
267 | 390 | return self.team_email_address_template() | ||
268 | 391 | 466 | ||
269 | 392 | if self.params.get('discharge_macaroon_field'): | 467 | if self.params.get('discharge_macaroon_field'): |
270 | 393 | if self.macaroon_response.discharge_macaroon_raw is None: | 468 | if self.macaroon_response.discharge_macaroon_raw is None: |
271 | @@ -396,7 +471,30 @@ | |||
272 | 396 | self.discharge_macaroon_raw = ( | 471 | self.discharge_macaroon_raw = ( |
273 | 397 | self.macaroon_response.discharge_macaroon_raw) | 472 | self.macaroon_response.discharge_macaroon_raw) |
274 | 398 | 473 | ||
275 | 474 | # Force the use of the master database to make sure a lagged slave | ||
276 | 475 | # doesn't fool us into creating a Person/Account when one already | ||
277 | 476 | # exists. | ||
278 | 399 | with MasterDatabasePolicy(): | 477 | with MasterDatabasePolicy(): |
279 | 478 | if self._maybeRedirectToInterstitial(identifier, email_address): | ||
280 | 479 | return None | ||
281 | 480 | |||
282 | 481 | # XXX cjwatson 2018-05-25: We should never create a Person at | ||
283 | 482 | # this point; any situation that would result in that should | ||
284 | 483 | # result in a redirection to an interstitial page. Guaranteeing | ||
285 | 484 | # that will require a bit more refactoring, though. | ||
286 | 485 | person_set = getUtility(IPersonSet) | ||
287 | 486 | try: | ||
288 | 487 | person, db_updated = person_set.getOrCreateByOpenIDIdentifier( | ||
289 | 488 | identifier, email_address, full_name, | ||
290 | 489 | comment='when logging in to Launchpad.', | ||
291 | 490 | creation_rationale=( | ||
292 | 491 | PersonCreationRationale.OWNER_CREATED_LAUNCHPAD)) | ||
293 | 492 | should_update_last_write = db_updated | ||
294 | 493 | except AccountSuspendedError: | ||
295 | 494 | return self.suspended_account_template() | ||
296 | 495 | except TeamEmailAddressError: | ||
297 | 496 | return self.team_email_address_template() | ||
298 | 497 | |||
299 | 400 | self.login(person) | 498 | self.login(person) |
300 | 401 | 499 | ||
301 | 402 | if self.params.get('discharge_macaroon_field'): | 500 | if self.params.get('discharge_macaroon_field'): |
302 | @@ -443,12 +541,6 @@ | |||
303 | 443 | transaction.commit() | 541 | transaction.commit() |
304 | 444 | return retval | 542 | return retval |
305 | 445 | 543 | ||
306 | 446 | def _redirect(self): | ||
307 | 447 | target = self.params.get('starting_url') | ||
308 | 448 | if target is None: | ||
309 | 449 | target = self.request.getApplicationURL() | ||
310 | 450 | self.request.response.redirect(target, temporary_if_possible=True) | ||
311 | 451 | |||
312 | 452 | 544 | ||
313 | 453 | class OpenIDLoginErrorView(LaunchpadView): | 545 | class OpenIDLoginErrorView(LaunchpadView): |
314 | 454 | 546 | ||
315 | @@ -471,6 +563,81 @@ | |||
316 | 471 | self.login_error = "Unknown error: %s" % openid_response | 563 | self.login_error = "Unknown error: %s" % openid_response |
317 | 472 | 564 | ||
318 | 473 | 565 | ||
319 | 566 | class FinishLoginInterstitialView(FinishLoginMixin, LaunchpadFormView): | ||
320 | 567 | |||
321 | 568 | class schema(Interface): | ||
322 | 569 | pass | ||
323 | 570 | |||
324 | 571 | def initialize(self): | ||
325 | 572 | self.params = self._gather_params(self.request) | ||
326 | 573 | super(FinishLoginInterstitialView, self).initialize() | ||
327 | 574 | |||
328 | 575 | def _accept(self): | ||
329 | 576 | session = ISession(self.request) | ||
330 | 577 | authdata = session['launchpad.pendinguser'] | ||
331 | 578 | try: | ||
332 | 579 | identifier = authdata['identifier'] | ||
333 | 580 | email_address = authdata['email'] | ||
334 | 581 | full_name = authdata['fullname'] | ||
335 | 582 | except KeyError: | ||
336 | 583 | return OpenIDLoginErrorView( | ||
337 | 584 | self.context, self.request, | ||
338 | 585 | login_error=( | ||
339 | 586 | "Your session expired. Please try logging in again.")) | ||
340 | 587 | should_update_last_write = False | ||
341 | 588 | |||
342 | 589 | # Force the use of the master database to make sure a lagged slave | ||
343 | 590 | # doesn't fool us into creating a Person/Account when one already | ||
344 | 591 | # exists. | ||
345 | 592 | with MasterDatabasePolicy(): | ||
346 | 593 | person_set = getUtility(IPersonSet) | ||
347 | 594 | try: | ||
348 | 595 | person, db_updated = person_set.getOrCreateByOpenIDIdentifier( | ||
349 | 596 | identifier, email_address, full_name, | ||
350 | 597 | comment=( | ||
351 | 598 | 'when logging in to Launchpad, after accepting ' | ||
352 | 599 | 'terms.'), | ||
353 | 600 | creation_rationale=( | ||
354 | 601 | PersonCreationRationale.OWNER_CREATED_LAUNCHPAD)) | ||
355 | 602 | should_update_last_write = db_updated | ||
356 | 603 | except AccountSuspendedError: | ||
357 | 604 | return self.suspended_account_template() | ||
358 | 605 | except TeamEmailAddressError: | ||
359 | 606 | return self.team_email_address_template() | ||
360 | 607 | |||
361 | 608 | self.login(person) | ||
362 | 609 | |||
363 | 610 | if should_update_last_write: | ||
364 | 611 | # This is a GET request but we changed the database, so update | ||
365 | 612 | # session_data['last_write'] to make sure further requests use | ||
366 | 613 | # the master DB and thus see the changes we've just made. | ||
367 | 614 | session_data = ISession(self.request)['lp.dbpolicy'] | ||
368 | 615 | session_data['last_write'] = datetime.utcnow() | ||
369 | 616 | self._redirect() | ||
370 | 617 | # No need to return anything as we redirect above. | ||
371 | 618 | return None | ||
372 | 619 | |||
373 | 620 | |||
374 | 621 | class NewAccountView(FinishLoginInterstitialView): | ||
375 | 622 | |||
376 | 623 | page_title = label = 'Welcome to Launchpad!' | ||
377 | 624 | template = ViewPageTemplateFile('templates/login-new-account.pt') | ||
378 | 625 | |||
379 | 626 | @action('Accept terms and create account', name='accept') | ||
380 | 627 | def accept(self, action, data): | ||
381 | 628 | return self._accept() | ||
382 | 629 | |||
383 | 630 | |||
384 | 631 | class ReactivateAccountView(FinishLoginInterstitialView): | ||
385 | 632 | |||
386 | 633 | page_title = label = 'Welcome back to Launchpad!' | ||
387 | 634 | template = ViewPageTemplateFile('templates/login-reactivate-account.pt') | ||
388 | 635 | |||
389 | 636 | @action('Accept terms and reactivate account', name='accept') | ||
390 | 637 | def accept(self, action, data): | ||
391 | 638 | return self._accept() | ||
392 | 639 | |||
393 | 640 | |||
394 | 474 | class AlreadyLoggedInView(LaunchpadView): | 641 | class AlreadyLoggedInView(LaunchpadView): |
395 | 475 | 642 | ||
396 | 476 | page_title = 'Already logged in' | 643 | page_title = 'Already logged in' |
397 | 477 | 644 | ||
398 | === added file 'lib/lp/services/webapp/templates/login-new-account.pt' | |||
399 | --- lib/lp/services/webapp/templates/login-new-account.pt 1970-01-01 00:00:00 +0000 | |||
400 | +++ lib/lp/services/webapp/templates/login-new-account.pt 2018-05-26 07:32:51 +0000 | |||
401 | @@ -0,0 +1,36 @@ | |||
402 | 1 | <html | ||
403 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
404 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
405 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
406 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
407 | 6 | metal:use-macro="view/macro:page/main_only" | ||
408 | 7 | i18n:domain="launchpad"> | ||
409 | 8 | |||
410 | 9 | <body> | ||
411 | 10 | <div class="top-portlet" metal:fill-slot="main"> | ||
412 | 11 | <div metal:use-macro="context/@@launchpad_form/form"> | ||
413 | 12 | <div metal:fill-slot="extra_info"> | ||
414 | 13 | <p> | ||
415 | 14 | Please read and accept the | ||
416 | 15 | <a href="/legal">Launchpad terms of service</a> and the | ||
417 | 16 | <a href="https://www.ubuntu.com/legal/dataprivacy">data privacy | ||
418 | 17 | policy</a> before continuing. If you accept these terms, | ||
419 | 18 | Launchpad will create an account for you. | ||
420 | 19 | </p> | ||
421 | 20 | |||
422 | 21 | <p> | ||
423 | 22 | You may also | ||
424 | 23 | <a tal:attributes="href view/params/starting_url|string:/">return | ||
425 | 24 | to Launchpad without creating an account</a>. | ||
426 | 25 | </p> | ||
427 | 26 | |||
428 | 27 | <input | ||
429 | 28 | type="hidden" | ||
430 | 29 | name="starting_url" | ||
431 | 30 | tal:condition="view/params/starting_url|nothing" | ||
432 | 31 | tal:attributes="value view/params/starting_url" /> | ||
433 | 32 | </div> | ||
434 | 33 | </div> | ||
435 | 34 | </div> | ||
436 | 35 | </body> | ||
437 | 36 | </html> | ||
438 | 0 | 37 | ||
439 | === added file 'lib/lp/services/webapp/templates/login-reactivate-account.pt' | |||
440 | --- lib/lp/services/webapp/templates/login-reactivate-account.pt 1970-01-01 00:00:00 +0000 | |||
441 | +++ lib/lp/services/webapp/templates/login-reactivate-account.pt 2018-05-26 07:32:51 +0000 | |||
442 | @@ -0,0 +1,43 @@ | |||
443 | 1 | <html | ||
444 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
445 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
446 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
447 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
448 | 6 | metal:use-macro="view/macro:page/main_only" | ||
449 | 7 | i18n:domain="launchpad"> | ||
450 | 8 | |||
451 | 9 | <body> | ||
452 | 10 | <div class="top-portlet" metal:fill-slot="main"> | ||
453 | 11 | <div metal:use-macro="context/@@launchpad_form/form"> | ||
454 | 12 | <div metal:fill-slot="extra_info"> | ||
455 | 13 | <p> | ||
456 | 14 | Please read and accept the | ||
457 | 15 | <a href="/legal">Launchpad terms of service</a> and the | ||
458 | 16 | <a href="https://www.ubuntu.com/legal/dataprivacy">data privacy | ||
459 | 17 | policy</a> before continuing. If you accept these terms, | ||
460 | 18 | Launchpad will reactivate your account. | ||
461 | 19 | </p> | ||
462 | 20 | |||
463 | 21 | <p> | ||
464 | 22 | Reactivating your account will not restore any information that | ||
465 | 23 | was deleted when you deactivated it. You may start receiving | ||
466 | 24 | email notifications again related to information that was not | ||
467 | 25 | deleted, such as changes to any bugs you reported. | ||
468 | 26 | </p> | ||
469 | 27 | |||
470 | 28 | <p> | ||
471 | 29 | You may also | ||
472 | 30 | <a tal:attributes="href view/params/starting_url|string:/">return | ||
473 | 31 | to Launchpad without reactivating your account</a>. | ||
474 | 32 | </p> | ||
475 | 33 | |||
476 | 34 | <input | ||
477 | 35 | type="hidden" | ||
478 | 36 | name="starting_url" | ||
479 | 37 | tal:condition="view/params/starting_url|nothing" | ||
480 | 38 | tal:attributes="value view/params/starting_url" /> | ||
481 | 39 | </div> | ||
482 | 40 | </div> | ||
483 | 41 | </div> | ||
484 | 42 | </body> | ||
485 | 43 | </html> | ||
486 | 0 | 44 | ||
487 | === modified file 'lib/lp/services/webapp/tests/test_login.py' | |||
488 | --- lib/lp/services/webapp/tests/test_login.py 2018-01-02 16:10:26 +0000 | |||
489 | +++ lib/lp/services/webapp/tests/test_login.py 2018-05-26 07:32:51 +0000 | |||
490 | @@ -1,4 +1,4 @@ | |||
492 | 1 | # Copyright 2009-2016 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
493 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
494 | 3 | """Test harness for running the new-login.txt tests.""" | 3 | """Test harness for running the new-login.txt tests.""" |
495 | 4 | 4 | ||
496 | @@ -64,8 +64,10 @@ | |||
497 | 64 | from lp.services.timeline.requesttimeline import get_request_timeline | 64 | from lp.services.timeline.requesttimeline import get_request_timeline |
498 | 65 | from lp.services.webapp.interfaces import ILaunchpadApplication | 65 | from lp.services.webapp.interfaces import ILaunchpadApplication |
499 | 66 | from lp.services.webapp.login import ( | 66 | from lp.services.webapp.login import ( |
500 | 67 | NewAccountView, | ||
501 | 67 | OpenIDCallbackView, | 68 | OpenIDCallbackView, |
502 | 68 | OpenIDLogin, | 69 | OpenIDLogin, |
503 | 70 | ReactivateAccountView, | ||
504 | 69 | ) | 71 | ) |
505 | 70 | from lp.services.webapp.servers import LaunchpadTestRequest | 72 | from lp.services.webapp.servers import LaunchpadTestRequest |
506 | 71 | from lp.testing import ( | 73 | from lp.testing import ( |
507 | @@ -106,11 +108,11 @@ | |||
508 | 106 | self.discharge_macaroon_raw = discharge_macaroon_raw | 108 | self.discharge_macaroon_raw = discharge_macaroon_raw |
509 | 107 | 109 | ||
510 | 108 | 110 | ||
512 | 109 | class StubbedOpenIDCallbackView(OpenIDCallbackView): | 111 | class StubLoginMixin: |
513 | 110 | login_called = False | 112 | login_called = False |
514 | 111 | 113 | ||
515 | 112 | def login(self, account): | 114 | def login(self, account): |
517 | 113 | super(StubbedOpenIDCallbackView, self).login(account) | 115 | super(StubLoginMixin, self).login(account) |
518 | 114 | self.login_called = True | 116 | self.login_called = True |
519 | 115 | current_policy = getUtility(IStoreSelector).get_current() | 117 | current_policy = getUtility(IStoreSelector).get_current() |
520 | 116 | if not isinstance(current_policy, MasterDatabasePolicy): | 118 | if not isinstance(current_policy, MasterDatabasePolicy): |
521 | @@ -118,6 +120,18 @@ | |||
522 | 118 | "Not using the master store: %s" % current_policy) | 120 | "Not using the master store: %s" % current_policy) |
523 | 119 | 121 | ||
524 | 120 | 122 | ||
525 | 123 | class StubbedOpenIDCallbackView(StubLoginMixin, OpenIDCallbackView): | ||
526 | 124 | pass | ||
527 | 125 | |||
528 | 126 | |||
529 | 127 | class StubbedNewAccountView(StubLoginMixin, NewAccountView): | ||
530 | 128 | pass | ||
531 | 129 | |||
532 | 130 | |||
533 | 131 | class StubbedReactivateAccountView(StubLoginMixin, ReactivateAccountView): | ||
534 | 132 | pass | ||
535 | 133 | |||
536 | 134 | |||
537 | 121 | class FakeConsumer: | 135 | class FakeConsumer: |
538 | 122 | """An OpenID consumer that stashes away arguments for test inspection.""" | 136 | """An OpenID consumer that stashes away arguments for test inspection.""" |
539 | 123 | 137 | ||
540 | @@ -212,24 +226,27 @@ | |||
541 | 212 | openid_response, view_class=view_class) | 226 | openid_response, view_class=view_class) |
542 | 213 | 227 | ||
543 | 214 | def _createAndRenderView(self, response, | 228 | def _createAndRenderView(self, response, |
545 | 215 | view_class=StubbedOpenIDCallbackView, form=None): | 229 | view_class=StubbedOpenIDCallbackView, form=None, |
546 | 230 | method='GET', **kwargs): | ||
547 | 216 | if form is None: | 231 | if form is None: |
548 | 217 | form = {'starting_url': 'http://launchpad.dev/after-login'} | 232 | form = {'starting_url': 'http://launchpad.dev/after-login'} |
550 | 218 | request = LaunchpadTestRequest(form=form, environ={'PATH_INFO': '/'}) | 233 | request = LaunchpadTestRequest( |
551 | 234 | form=form, environ={'PATH_INFO': '/'}, method=method, **kwargs) | ||
552 | 219 | # The layer we use sets up an interaction (by calling login()), but we | 235 | # The layer we use sets up an interaction (by calling login()), but we |
553 | 220 | # want to use our own request in the interaction, so we logout() and | 236 | # want to use our own request in the interaction, so we logout() and |
554 | 221 | # setup a newInteraction() using our request. | 237 | # setup a newInteraction() using our request. |
555 | 222 | logout() | 238 | logout() |
556 | 223 | newInteraction(request) | 239 | newInteraction(request) |
557 | 224 | view = view_class(object(), request) | 240 | view = view_class(object(), request) |
558 | 225 | view.initialize() | ||
559 | 226 | view.openid_response = response | ||
560 | 227 | # Monkey-patch getByOpenIDIdentifier() to make sure the view uses the | 241 | # Monkey-patch getByOpenIDIdentifier() to make sure the view uses the |
561 | 228 | # master DB. This mimics the problem we're trying to avoid, where | 242 | # master DB. This mimics the problem we're trying to avoid, where |
562 | 229 | # getByOpenIDIdentifier() doesn't find a newly created account because | 243 | # getByOpenIDIdentifier() doesn't find a newly created account because |
563 | 230 | # it looks in the slave database. | 244 | # it looks in the slave database. |
564 | 231 | with IAccountSet_getByOpenIDIdentifier_monkey_patched(): | 245 | with IAccountSet_getByOpenIDIdentifier_monkey_patched(): |
566 | 232 | html = view.render() | 246 | view.initialize() |
567 | 247 | if response is not None: | ||
568 | 248 | view.openid_response = response | ||
569 | 249 | html = view.render() if method == 'GET' else None | ||
570 | 233 | return view, html | 250 | return view, html |
571 | 234 | 251 | ||
572 | 235 | def test_full_fledged_account(self): | 252 | def test_full_fledged_account(self): |
573 | @@ -328,9 +345,7 @@ | |||
574 | 328 | 345 | ||
575 | 329 | def test_unseen_identity(self): | 346 | def test_unseen_identity(self): |
576 | 330 | # When we get a positive assertion about an identity URL we've never | 347 | # When we get a positive assertion about an identity URL we've never |
580 | 331 | # seen, we automatically register an account with that identity | 348 | # seen, we redirect to a confirmation page. |
578 | 332 | # because someone who registered on login.lp.net or login.u.c should | ||
579 | 333 | # be able to login here without any further steps. | ||
581 | 334 | identifier = u'4w7kmzU' | 349 | identifier = u'4w7kmzU' |
582 | 335 | account_set = getUtility(IAccountSet) | 350 | account_set = getUtility(IAccountSet) |
583 | 336 | self.assertRaises( | 351 | self.assertRaises( |
584 | @@ -340,15 +355,47 @@ | |||
585 | 340 | email='non-existent@example.com', full_name='Foo User') | 355 | email='non-existent@example.com', full_name='Foo User') |
586 | 341 | with SRegResponse_fromSuccessResponse_stubbed(): | 356 | with SRegResponse_fromSuccessResponse_stubbed(): |
587 | 342 | view, html = self._createAndRenderView(openid_response) | 357 | view, html = self._createAndRenderView(openid_response) |
588 | 358 | self.assertFalse(view.login_called) | ||
589 | 359 | response = view.request.response | ||
590 | 360 | self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) | ||
591 | 361 | self.assertEqual( | ||
592 | 362 | 'http://launchpad.dev/+new-account?' + urllib.urlencode( | ||
593 | 363 | {'starting_url': view.request.form['starting_url']}), | ||
594 | 364 | response.getHeader('Location')) | ||
595 | 365 | self.assertRaises( | ||
596 | 366 | LookupError, account_set.getByOpenIDIdentifier, identifier) | ||
597 | 367 | self.assertThat( | ||
598 | 368 | ISession(view.request)['launchpad.pendinguser'], | ||
599 | 369 | ContainsDict({ | ||
600 | 370 | 'identifier': Equals(identifier), | ||
601 | 371 | 'email': Equals('non-existent@example.com'), | ||
602 | 372 | 'fullname': Equals('Foo User'), | ||
603 | 373 | })) | ||
604 | 374 | |||
605 | 375 | # If the user accepts, we automatically register an account with | ||
606 | 376 | # that identity, since its existence on SSO is good enough for us. | ||
607 | 377 | cookie = response.getCookie('launchpad_tests')['value'] | ||
608 | 378 | view, _ = self._createAndRenderView( | ||
609 | 379 | None, view_class=StubbedNewAccountView, | ||
610 | 380 | form={ | ||
611 | 381 | 'starting_url': view.request.form['starting_url'], | ||
612 | 382 | 'field.actions.accept': 'Accept terms and create account', | ||
613 | 383 | }, | ||
614 | 384 | method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie) | ||
615 | 343 | self.assertTrue(view.login_called) | 385 | self.assertTrue(view.login_called) |
616 | 386 | response = view.request.response | ||
617 | 387 | self.assertEqual(httplib.SEE_OTHER, response.getStatus()) | ||
618 | 388 | self.assertEqual( | ||
619 | 389 | view.request.form['starting_url'], response.getHeader('Location')) | ||
620 | 344 | account = account_set.getByOpenIDIdentifier(identifier) | 390 | account = account_set.getByOpenIDIdentifier(identifier) |
622 | 345 | self.assertIsNot(None, account) | 391 | self.assertIsNotNone(account) |
623 | 346 | self.assertEqual(AccountStatus.ACTIVE, account.status) | 392 | self.assertEqual(AccountStatus.ACTIVE, account.status) |
624 | 347 | person = IPerson(account, None) | 393 | person = IPerson(account, None) |
626 | 348 | self.assertIsNot(None, person) | 394 | self.assertIsNotNone(person) |
627 | 349 | self.assertEqual('Foo User', person.displayname) | 395 | self.assertEqual('Foo User', person.displayname) |
630 | 350 | self.assertEqual('non-existent@example.com', | 396 | self.assertEqual( |
631 | 351 | removeSecurityProxy(person.preferredemail).email) | 397 | 'non-existent@example.com', |
632 | 398 | removeSecurityProxy(person.preferredemail).email) | ||
633 | 352 | 399 | ||
634 | 353 | # We also update the last_write flag in the session, to make sure | 400 | # We also update the last_write flag in the session, to make sure |
635 | 354 | # further requests use the master DB and thus see the newly created | 401 | # further requests use the master DB and thus see the newly created |
636 | @@ -363,8 +410,7 @@ | |||
637 | 363 | email = 'test@example.com' | 410 | email = 'test@example.com' |
638 | 364 | person = self.factory.makePerson( | 411 | person = self.factory.makePerson( |
639 | 365 | displayname='Test account', email=email, | 412 | displayname='Test account', email=email, |
642 | 366 | account_status=AccountStatus.DEACTIVATED, | 413 | account_status=AccountStatus.NOACCOUNT) |
641 | 367 | email_address_status=EmailAddressStatus.NEW) | ||
643 | 368 | account = person.account | 414 | account = person.account |
644 | 369 | account_set = getUtility(IAccountSet) | 415 | account_set = getUtility(IAccountSet) |
645 | 370 | self.assertRaises( | 416 | self.assertRaises( |
646 | @@ -374,7 +420,38 @@ | |||
647 | 374 | email=email, full_name='Foo User') | 420 | email=email, full_name='Foo User') |
648 | 375 | with SRegResponse_fromSuccessResponse_stubbed(): | 421 | with SRegResponse_fromSuccessResponse_stubbed(): |
649 | 376 | view, html = self._createAndRenderView(openid_response) | 422 | view, html = self._createAndRenderView(openid_response) |
650 | 423 | self.assertFalse(view.login_called) | ||
651 | 424 | response = view.request.response | ||
652 | 425 | self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) | ||
653 | 426 | self.assertEqual( | ||
654 | 427 | 'http://launchpad.dev/+new-account?' + urllib.urlencode( | ||
655 | 428 | {'starting_url': view.request.form['starting_url']}), | ||
656 | 429 | response.getHeader('Location')) | ||
657 | 430 | self.assertRaises( | ||
658 | 431 | LookupError, account_set.getByOpenIDIdentifier, identifier) | ||
659 | 432 | self.assertEqual(AccountStatus.NOACCOUNT, account.status) | ||
660 | 433 | self.assertThat( | ||
661 | 434 | ISession(view.request)['launchpad.pendinguser'], | ||
662 | 435 | ContainsDict({ | ||
663 | 436 | 'identifier': Equals(identifier), | ||
664 | 437 | 'email': Equals('test@example.com'), | ||
665 | 438 | 'fullname': Equals('Foo User'), | ||
666 | 439 | })) | ||
667 | 440 | |||
668 | 441 | # Accept the terms and proceed. | ||
669 | 442 | cookie = response.getCookie('launchpad_tests')['value'] | ||
670 | 443 | view, _ = self._createAndRenderView( | ||
671 | 444 | None, view_class=StubbedNewAccountView, | ||
672 | 445 | form={ | ||
673 | 446 | 'starting_url': view.request.form['starting_url'], | ||
674 | 447 | 'field.actions.accept': 'Accept terms and create account', | ||
675 | 448 | }, | ||
676 | 449 | method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie) | ||
677 | 377 | self.assertTrue(view.login_called) | 450 | self.assertTrue(view.login_called) |
678 | 451 | response = view.request.response | ||
679 | 452 | self.assertEqual(httplib.SEE_OTHER, response.getStatus()) | ||
680 | 453 | self.assertEqual( | ||
681 | 454 | view.request.form['starting_url'], response.getHeader('Location')) | ||
682 | 378 | 455 | ||
683 | 379 | # The existing accounts had a new openid_identifier added, the | 456 | # The existing accounts had a new openid_identifier added, the |
684 | 380 | # account was reactivated and its preferred email was set, but | 457 | # account was reactivated and its preferred email was set, but |
685 | @@ -386,7 +463,7 @@ | |||
686 | 386 | self.assertEqual( | 463 | self.assertEqual( |
687 | 387 | email, removeSecurityProxy(person.preferredemail).email) | 464 | email, removeSecurityProxy(person.preferredemail).email) |
688 | 388 | person = IPerson(account, None) | 465 | person = IPerson(account, None) |
690 | 389 | self.assertIsNot(None, person) | 466 | self.assertIsNotNone(person) |
691 | 390 | self.assertEqual('Test account', person.displayname) | 467 | self.assertEqual('Test account', person.displayname) |
692 | 391 | 468 | ||
693 | 392 | # We also update the last_write flag in the session, to make sure | 469 | # We also update the last_write flag in the session, to make sure |
694 | @@ -396,7 +473,7 @@ | |||
695 | 396 | 473 | ||
696 | 397 | def test_deactivated_account(self): | 474 | def test_deactivated_account(self): |
697 | 398 | # The user has the account's password and is trying to login, so we'll | 475 | # The user has the account's password and is trying to login, so we'll |
699 | 399 | # just re-activate their account. | 476 | # redirect them to a confirmation page. |
700 | 400 | email = 'foo@example.com' | 477 | email = 'foo@example.com' |
701 | 401 | person = self.factory.makePerson( | 478 | person = self.factory.makePerson( |
702 | 402 | displayname='Test account', email=email, | 479 | displayname='Test account', email=email, |
703 | @@ -409,11 +486,37 @@ | |||
704 | 409 | status=SUCCESS, email=email, full_name=person.displayname) | 486 | status=SUCCESS, email=email, full_name=person.displayname) |
705 | 410 | with SRegResponse_fromSuccessResponse_stubbed(): | 487 | with SRegResponse_fromSuccessResponse_stubbed(): |
706 | 411 | view, html = self._createAndRenderView(openid_response) | 488 | view, html = self._createAndRenderView(openid_response) |
707 | 489 | self.assertFalse(view.login_called) | ||
708 | 490 | response = view.request.response | ||
709 | 491 | self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) | ||
710 | 492 | self.assertEqual( | ||
711 | 493 | 'http://launchpad.dev/+reactivate-account?' + urllib.urlencode( | ||
712 | 494 | {'starting_url': view.request.form['starting_url']}), | ||
713 | 495 | response.getHeader('Location')) | ||
714 | 496 | self.assertEqual(AccountStatus.DEACTIVATED, person.account.status) | ||
715 | 497 | self.assertIsNone(person.preferredemail) | ||
716 | 498 | self.assertThat( | ||
717 | 499 | ISession(view.request)['launchpad.pendinguser'], | ||
718 | 500 | ContainsDict({ | ||
719 | 501 | 'identifier': Equals(openid_identifier), | ||
720 | 502 | 'email': Equals(email), | ||
721 | 503 | 'fullname': Equals('Test account'), | ||
722 | 504 | })) | ||
723 | 505 | |||
724 | 506 | # If the user confirms the reactivation, we do it. | ||
725 | 507 | cookie = response.getCookie('launchpad_tests')['value'] | ||
726 | 508 | view, _ = self._createAndRenderView( | ||
727 | 509 | None, view_class=StubbedReactivateAccountView, | ||
728 | 510 | form={ | ||
729 | 511 | 'starting_url': view.request.form['starting_url'], | ||
730 | 512 | 'field.actions.accept': 'Accept terms and reactivate account', | ||
731 | 513 | }, | ||
732 | 514 | method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie) | ||
733 | 412 | self.assertTrue(view.login_called) | 515 | self.assertTrue(view.login_called) |
734 | 413 | response = view.request.response | 516 | response = view.request.response |
738 | 414 | self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) | 517 | self.assertEqual(httplib.SEE_OTHER, response.getStatus()) |
739 | 415 | self.assertEqual(view.request.form['starting_url'], | 518 | self.assertEqual( |
740 | 416 | response.getHeader('Location')) | 519 | view.request.form['starting_url'], response.getHeader('Location')) |
741 | 417 | self.assertEqual(AccountStatus.ACTIVE, person.account.status) | 520 | self.assertEqual(AccountStatus.ACTIVE, person.account.status) |
742 | 418 | self.assertEqual(email, person.preferredemail.email) | 521 | self.assertEqual(email, person.preferredemail.email) |
743 | 419 | # We also update the last_write flag in the session, to make sure | 522 | # We also update the last_write flag in the session, to make sure |
744 | @@ -423,12 +526,11 @@ | |||
745 | 423 | 526 | ||
746 | 424 | def test_never_used_account(self): | 527 | def test_never_used_account(self): |
747 | 425 | # The account was created by one of our scripts but was never | 528 | # The account was created by one of our scripts but was never |
749 | 426 | # activated, so we just activate it. | 529 | # activated, so we redirect to a confirmation page. |
750 | 427 | email = 'foo@example.com' | 530 | email = 'foo@example.com' |
751 | 428 | person = self.factory.makePerson( | 531 | person = self.factory.makePerson( |
752 | 429 | displayname='Test account', email=email, | 532 | displayname='Test account', email=email, |
755 | 430 | account_status=AccountStatus.DEACTIVATED, | 533 | account_status=AccountStatus.NOACCOUNT) |
754 | 431 | email_address_status=EmailAddressStatus.NEW) | ||
756 | 432 | openid_identifier = IStore(OpenIdIdentifier).find( | 534 | openid_identifier = IStore(OpenIdIdentifier).find( |
757 | 433 | OpenIdIdentifier.identifier, | 535 | OpenIdIdentifier.identifier, |
758 | 434 | OpenIdIdentifier.account_id == person.account.id).order_by( | 536 | OpenIdIdentifier.account_id == person.account.id).order_by( |
759 | @@ -439,6 +541,32 @@ | |||
760 | 439 | status=SUCCESS, email=email, full_name=person.displayname) | 541 | status=SUCCESS, email=email, full_name=person.displayname) |
761 | 440 | with SRegResponse_fromSuccessResponse_stubbed(): | 542 | with SRegResponse_fromSuccessResponse_stubbed(): |
762 | 441 | view, html = self._createAndRenderView(openid_response) | 543 | view, html = self._createAndRenderView(openid_response) |
763 | 544 | self.assertFalse(view.login_called) | ||
764 | 545 | response = view.request.response | ||
765 | 546 | self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) | ||
766 | 547 | self.assertEqual( | ||
767 | 548 | 'http://launchpad.dev/+new-account?' + urllib.urlencode( | ||
768 | 549 | {'starting_url': view.request.form['starting_url']}), | ||
769 | 550 | response.getHeader('Location')) | ||
770 | 551 | self.assertEqual(AccountStatus.NOACCOUNT, person.account.status) | ||
771 | 552 | self.assertIsNone(person.preferredemail) | ||
772 | 553 | self.assertThat( | ||
773 | 554 | ISession(view.request)['launchpad.pendinguser'], | ||
774 | 555 | ContainsDict({ | ||
775 | 556 | 'identifier': Equals(openid_identifier), | ||
776 | 557 | 'email': Equals(email), | ||
777 | 558 | 'fullname': Equals('Test account'), | ||
778 | 559 | })) | ||
779 | 560 | |||
780 | 561 | # If the user confirms the activation, we do it. | ||
781 | 562 | cookie = response.getCookie('launchpad_tests')['value'] | ||
782 | 563 | view, _ = self._createAndRenderView( | ||
783 | 564 | None, view_class=StubbedNewAccountView, | ||
784 | 565 | form={ | ||
785 | 566 | 'starting_url': view.request.form['starting_url'], | ||
786 | 567 | 'field.actions.accept': 'Accept terms and create account', | ||
787 | 568 | }, | ||
788 | 569 | method='POST', HTTP_COOKIE='launchpad_tests=%s' % cookie) | ||
789 | 442 | self.assertTrue(view.login_called) | 570 | self.assertTrue(view.login_called) |
790 | 443 | self.assertEqual(AccountStatus.ACTIVE, person.account.status) | 571 | self.assertEqual(AccountStatus.ACTIVE, person.account.status) |
791 | 444 | self.assertEqual(email, person.preferredemail.email) | 572 | self.assertEqual(email, person.preferredemail.email) |
792 | 445 | 573 | ||
793 | === modified file 'lib/lp/testopenid/browser/server.py' | |||
794 | --- lib/lp/testopenid/browser/server.py 2015-07-08 16:05:11 +0000 | |||
795 | +++ lib/lp/testopenid/browser/server.py 2018-05-26 07:32:51 +0000 | |||
796 | @@ -1,4 +1,4 @@ | |||
798 | 1 | # Copyright 2010-2011 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2010-2018 Canonical Ltd. This software is licensed under the |
799 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
800 | 3 | 3 | ||
801 | 4 | """Test OpenID server.""" | 4 | """Test OpenID server.""" |
802 | @@ -30,7 +30,10 @@ | |||
803 | 30 | from zope.authentication.interfaces import IUnauthenticatedPrincipal | 30 | from zope.authentication.interfaces import IUnauthenticatedPrincipal |
804 | 31 | from zope.component import getUtility | 31 | from zope.component import getUtility |
805 | 32 | from zope.interface import implementer | 32 | from zope.interface import implementer |
807 | 33 | from zope.security.proxy import isinstance as zisinstance | 33 | from zope.security.proxy import ( |
808 | 34 | isinstance as zisinstance, | ||
809 | 35 | removeSecurityProxy, | ||
810 | 36 | ) | ||
811 | 34 | from zope.session.interfaces import ISession | 37 | from zope.session.interfaces import ISession |
812 | 35 | 38 | ||
813 | 36 | from lp import _ | 39 | from lp import _ |
814 | @@ -39,11 +42,11 @@ | |||
815 | 39 | LaunchpadFormView, | 42 | LaunchpadFormView, |
816 | 40 | ) | 43 | ) |
817 | 41 | from lp.app.errors import UnexpectedFormData | 44 | from lp.app.errors import UnexpectedFormData |
822 | 42 | from lp.registry.interfaces.person import IPerson | 45 | from lp.registry.interfaces.person import ( |
823 | 43 | from lp.services.identity.interfaces.account import ( | 46 | IPerson, |
824 | 44 | AccountStatus, | 47 | IPersonSet, |
821 | 45 | IAccountSet, | ||
825 | 46 | ) | 48 | ) |
826 | 49 | from lp.services.identity.interfaces.account import IAccountSet | ||
827 | 47 | from lp.services.openid.browser.openiddiscovery import ( | 50 | from lp.services.openid.browser.openiddiscovery import ( |
828 | 48 | XRDSContentNegotiationMixin, | 51 | XRDSContentNegotiationMixin, |
829 | 49 | ) | 52 | ) |
830 | @@ -52,13 +55,9 @@ | |||
831 | 52 | get_property_cache, | 55 | get_property_cache, |
832 | 53 | ) | 56 | ) |
833 | 54 | from lp.services.webapp import LaunchpadView | 57 | from lp.services.webapp import LaunchpadView |
838 | 55 | from lp.services.webapp.interfaces import ( | 58 | from lp.services.webapp.interfaces import ICanonicalUrlData |
835 | 56 | ICanonicalUrlData, | ||
836 | 57 | IPlacelessLoginSource, | ||
837 | 58 | ) | ||
839 | 59 | from lp.services.webapp.login import ( | 59 | from lp.services.webapp.login import ( |
840 | 60 | allowUnauthenticatedSession, | 60 | allowUnauthenticatedSession, |
841 | 61 | logInPrincipal, | ||
842 | 62 | logoutPerson, | 61 | logoutPerson, |
843 | 63 | ) | 62 | ) |
844 | 64 | from lp.services.webapp.publisher import ( | 63 | from lp.services.webapp.publisher import ( |
845 | @@ -105,7 +104,7 @@ | |||
846 | 105 | account = getUtility(IAccountSet).getByOpenIDIdentifier(name) | 104 | account = getUtility(IAccountSet).getByOpenIDIdentifier(name) |
847 | 106 | except LookupError: | 105 | except LookupError: |
848 | 107 | account = None | 106 | account = None |
850 | 108 | if account is None or account.status != AccountStatus.ACTIVE: | 107 | if account is None: |
851 | 109 | return None | 108 | return None |
852 | 110 | return ITestOpenIDPersistentIdentity(account) | 109 | return ITestOpenIDPersistentIdentity(account) |
853 | 111 | 110 | ||
854 | @@ -206,7 +205,7 @@ | |||
855 | 206 | response.setHeader(header, value) | 205 | response.setHeader(header, value) |
856 | 207 | return webresponse.body | 206 | return webresponse.body |
857 | 208 | 207 | ||
859 | 209 | def createPositiveResponse(self): | 208 | def createPositiveResponse(self, email): |
860 | 210 | """Create a positive assertion OpenIDResponse. | 209 | """Create a positive assertion OpenIDResponse. |
861 | 211 | 210 | ||
862 | 212 | This method should be called to create the response to | 211 | This method should be called to create the response to |
863 | @@ -233,7 +232,7 @@ | |||
864 | 233 | person = IPerson(self.account) | 232 | person = IPerson(self.account) |
865 | 234 | sreg_fields = dict( | 233 | sreg_fields = dict( |
866 | 235 | nickname=person.name, | 234 | nickname=person.name, |
868 | 236 | email=person.preferredemail.email, | 235 | email=email, |
869 | 237 | fullname=self.account.displayname) | 236 | fullname=self.account.displayname) |
870 | 238 | sreg_request = SRegRequest.fromOpenIDRequest(self.openid_request) | 237 | sreg_request = SRegRequest.fromOpenIDRequest(self.openid_request) |
871 | 239 | sreg_response = SRegResponse.extractResponse( | 238 | sreg_response = SRegResponse.extractResponse( |
872 | @@ -306,21 +305,34 @@ | |||
873 | 306 | 305 | ||
874 | 307 | def validate(self, data): | 306 | def validate(self, data): |
875 | 308 | """Check that the email address is valid for login.""" | 307 | """Check that the email address is valid for login.""" |
881 | 309 | loginsource = getUtility(IPlacelessLoginSource) | 308 | if data.get('username'): |
882 | 310 | principal = loginsource.getPrincipalByLogin(data['email']) | 309 | person = getUtility(IPersonSet).getByName(data['username']) |
883 | 311 | if principal is None: | 310 | if person is None: |
884 | 312 | self.addError( | 311 | self.setFieldError('username', _("Unknown username.")) |
885 | 313 | _("Unknown email address.")) | 312 | elif person.preferredemail is not None: |
886 | 313 | email = removeSecurityProxy(person.preferredemail).email | ||
887 | 314 | if email != data['email']: | ||
888 | 315 | self.setFieldError( | ||
889 | 316 | 'email', | ||
890 | 317 | _("Email address for user '%s' is '%s', not '%s'.") % | ||
891 | 318 | (data['username'], email, data['email'])) | ||
892 | 319 | elif getUtility(IPersonSet).getByEmail(data['email']) is None: | ||
893 | 320 | self.setFieldError('email', _("Unknown email address.")) | ||
894 | 314 | 321 | ||
895 | 315 | @action('Continue', name='continue') | 322 | @action('Continue', name='continue') |
896 | 316 | def continue_action(self, action, data): | 323 | def continue_action(self, action, data): |
897 | 317 | email = data['email'] | 324 | email = data['email'] |
904 | 318 | principal = getUtility(IPlacelessLoginSource).getPrincipalByLogin( | 325 | username = data.get('username') |
905 | 319 | email) | 326 | if username is not None: |
906 | 320 | logInPrincipal(self.request, principal, email) | 327 | person = getUtility(IPersonSet).getByName(username) |
907 | 321 | # Update the attribute holding the cached user. | 328 | else: |
908 | 322 | self._account = principal.account | 329 | person = getUtility(IPersonSet).getByEmail(email) |
909 | 323 | return self.renderOpenIDResponse(self.createPositiveResponse()) | 330 | # Update the attribute holding the cached user. This fakes a login; |
910 | 331 | # we don't do a true login here, because we can get away without it | ||
911 | 332 | # and it allows testing the case of logging in as a user whose | ||
912 | 333 | # account status is not ACTIVE. | ||
913 | 334 | self._account = person.account | ||
914 | 335 | return self.renderOpenIDResponse(self.createPositiveResponse(email)) | ||
915 | 324 | 336 | ||
916 | 325 | 337 | ||
917 | 326 | class PersistentIdentityView( | 338 | class PersistentIdentityView( |
918 | 327 | 339 | ||
919 | === modified file 'lib/lp/testopenid/interfaces/server.py' | |||
920 | --- lib/lp/testopenid/interfaces/server.py 2015-07-21 09:04:01 +0000 | |||
921 | +++ lib/lp/testopenid/interfaces/server.py 2018-05-26 07:32:51 +0000 | |||
922 | @@ -1,4 +1,4 @@ | |||
924 | 1 | # Copyright 2010 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2010-2018 Canonical Ltd. This software is licensed under the |
925 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
926 | 3 | 3 | ||
927 | 4 | __metaclass__ = type | 4 | __metaclass__ = type |
928 | @@ -23,7 +23,12 @@ | |||
929 | 23 | 23 | ||
930 | 24 | 24 | ||
931 | 25 | class ITestOpenIDLoginForm(Interface): | 25 | class ITestOpenIDLoginForm(Interface): |
933 | 26 | email = TextLine(title=u'What is your email address?', required=True) | 26 | email = TextLine(title=u'Your email address', required=True) |
934 | 27 | username = TextLine( | ||
935 | 28 | title=u'Your username', required=False, | ||
936 | 29 | description=( | ||
937 | 30 | u'This is only required if you are logging into a placeholder ' | ||
938 | 31 | u'account for the first time.')) | ||
939 | 27 | 32 | ||
940 | 28 | 33 | ||
941 | 29 | class ITestOpenIDPersistentIdentity(IOpenIDPersistentIdentity): | 34 | class ITestOpenIDPersistentIdentity(IOpenIDPersistentIdentity): |
942 | 30 | 35 | ||
943 | === modified file 'lib/lp/testopenid/stories/logging-in.txt' | |||
944 | --- lib/lp/testopenid/stories/logging-in.txt 2012-01-14 12:47:20 +0000 | |||
945 | +++ lib/lp/testopenid/stories/logging-in.txt 2018-05-26 07:32:51 +0000 | |||
946 | @@ -42,6 +42,7 @@ | |||
947 | 42 | >>> for tag in find_tags_by_class(browser.contents, 'error'): | 42 | >>> for tag in find_tags_by_class(browser.contents, 'error'): |
948 | 43 | ... print extract_text(tag) | 43 | ... print extract_text(tag) |
949 | 44 | There is 1 error. | 44 | There is 1 error. |
950 | 45 | Your email address: | ||
951 | 45 | Unknown email address. | 46 | Unknown email address. |
952 | 46 | 47 | ||
953 | 47 | If the email address matches an account, the user is logged in and | 48 | If the email address matches an account, the user is logged in and |
954 | 48 | 49 | ||
955 | === modified file 'utilities/make-lp-user' | |||
956 | --- utilities/make-lp-user 2015-05-07 09:29:30 +0000 | |||
957 | +++ utilities/make-lp-user 2018-05-26 07:32:51 +0000 | |||
958 | @@ -1,6 +1,6 @@ | |||
959 | 1 | #!/usr/bin/python -S | 1 | #!/usr/bin/python -S |
960 | 2 | # | 2 | # |
962 | 3 | # Copyright 2009-2010 Canonical Ltd. This software is licensed under the | 3 | # Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
963 | 4 | # GNU Affero General Public License version 3 (see the file LICENSE). | 4 | # GNU Affero General Public License version 3 (see the file LICENSE). |
964 | 5 | 5 | ||
965 | 6 | """Create a user for testing the local Launchpad. | 6 | """Create a user for testing the local Launchpad. |
966 | @@ -29,6 +29,8 @@ | |||
967 | 29 | production environments. | 29 | production environments. |
968 | 30 | """ | 30 | """ |
969 | 31 | 31 | ||
970 | 32 | from __future__ import absolute_import, print_function | ||
971 | 33 | |||
972 | 32 | import _pythonpath | 34 | import _pythonpath |
973 | 33 | 35 | ||
974 | 34 | from optparse import OptionParser | 36 | from optparse import OptionParser |
975 | @@ -49,6 +51,7 @@ | |||
976 | 49 | GPGKeyAlgorithm, | 51 | GPGKeyAlgorithm, |
977 | 50 | IGPGHandler, | 52 | IGPGHandler, |
978 | 51 | ) | 53 | ) |
979 | 54 | from lp.services.identity.interfaces.account import AccountStatus | ||
980 | 52 | from lp.services.scripts import execute_zcml_for_scripts | 55 | from lp.services.scripts import execute_zcml_for_scripts |
981 | 53 | from lp.services.timeout import set_default_timeout_function | 56 | from lp.services.timeout import set_default_timeout_function |
982 | 54 | from lp.testing.factory import LaunchpadObjectFactory | 57 | from lp.testing.factory import LaunchpadObjectFactory |
983 | @@ -58,14 +61,23 @@ | |||
984 | 58 | 61 | ||
985 | 59 | set_default_timeout_function(lambda: 100) | 62 | set_default_timeout_function(lambda: 100) |
986 | 60 | 63 | ||
987 | 64 | |||
988 | 61 | def make_person(username, email): | 65 | def make_person(username, email): |
989 | 62 | """Create and return a person with the given username. | 66 | """Create and return a person with the given username. |
990 | 63 | 67 | ||
991 | 64 | The email address for the user will be <username>@example.com. | 68 | The email address for the user will be <username>@example.com. |
992 | 65 | """ | 69 | """ |
993 | 66 | person = factory.makePerson(name=username, email=email) | 70 | person = factory.makePerson(name=username, email=email) |
996 | 67 | print "username: %s" % (username,) | 71 | print("username: %s" % (username,)) |
997 | 68 | print "email: %s" % (email,) | 72 | print("email: %s" % (email,)) |
998 | 73 | return person | ||
999 | 74 | |||
1000 | 75 | |||
1001 | 76 | def make_placeholder_person(username): | ||
1002 | 77 | """Create and return a placeholder person with the given username.""" | ||
1003 | 78 | person = factory.makePerson( | ||
1004 | 79 | name=username, account_status=AccountStatus.PLACEHOLDER) | ||
1005 | 80 | print("username: %s" % (username,)) | ||
1006 | 69 | return person | 81 | return person |
1007 | 70 | 82 | ||
1008 | 71 | 83 | ||
1009 | @@ -82,15 +94,15 @@ | |||
1010 | 82 | for team_name in team_names: | 94 | for team_name in team_names: |
1011 | 83 | team = person_set.getByName(team_name) | 95 | team = person_set.getByName(team_name) |
1012 | 84 | if team is None: | 96 | if team is None: |
1014 | 85 | print "ERROR: %s not found." % (team_name,) | 97 | print("ERROR: %s not found." % (team_name,)) |
1015 | 86 | continue | 98 | continue |
1016 | 87 | if not team.is_team: | 99 | if not team.is_team: |
1018 | 88 | print "ERROR: %s is not a team." % (team_name,) | 100 | print("ERROR: %s is not a team." % (team_name,)) |
1019 | 89 | continue | 101 | continue |
1020 | 90 | team.addMember( | 102 | team.addMember( |
1021 | 91 | person, person, status=TeamMembershipStatus.APPROVED) | 103 | person, person, status=TeamMembershipStatus.APPROVED) |
1022 | 92 | teams_joined.append(team_name) | 104 | teams_joined.append(team_name) |
1024 | 93 | print "teams: %s" % ' '.join(teams_joined) | 105 | print("teams: %s" % ' '.join(teams_joined)) |
1025 | 94 | 106 | ||
1026 | 95 | 107 | ||
1027 | 96 | def add_ssh_public_keys(person): | 108 | def add_ssh_public_keys(person): |
1028 | @@ -111,10 +123,10 @@ | |||
1029 | 111 | except (OSError, IOError): | 123 | except (OSError, IOError): |
1030 | 112 | continue | 124 | continue |
1031 | 113 | key_set.new(person, public_key) | 125 | key_set.new(person, public_key) |
1033 | 114 | print 'Registered SSH key: %s' % (filename,) | 126 | print('Registered SSH key: %s' % (filename,)) |
1034 | 115 | break | 127 | break |
1035 | 116 | else: | 128 | else: |
1037 | 117 | print 'No SSH key files found in %s' % ssh_dir | 129 | print('No SSH key files found in %s' % ssh_dir) |
1038 | 118 | 130 | ||
1039 | 119 | 131 | ||
1040 | 120 | def parse_fingerprints(gpg_output): | 132 | def parse_fingerprints(gpg_output): |
1041 | @@ -143,7 +155,7 @@ | |||
1042 | 143 | command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | 155 | command_line, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
1043 | 144 | stdout, stderr = pipe.communicate() | 156 | stdout, stderr = pipe.communicate() |
1044 | 145 | if stderr != '': | 157 | if stderr != '': |
1046 | 146 | print stderr | 158 | print(stderr) |
1047 | 147 | if pipe.returncode != 0: | 159 | if pipe.returncode != 0: |
1048 | 148 | raise Exception('GPG error during "%s"' % ' '.join(command_line)) | 160 | raise Exception('GPG error during "%s"' % ' '.join(command_line)) |
1049 | 149 | 161 | ||
1050 | @@ -179,7 +191,7 @@ | |||
1051 | 179 | 191 | ||
1052 | 180 | fingerprints = parse_fingerprints(output) | 192 | fingerprints = parse_fingerprints(output) |
1053 | 181 | if len(fingerprints) == 0: | 193 | if len(fingerprints) == 0: |
1055 | 182 | print "No GPG key fingerprints found!" | 194 | print("No GPG key fingerprints found!") |
1056 | 183 | for fingerprint in fingerprints: | 195 | for fingerprint in fingerprints: |
1057 | 184 | add_gpg_key(person, fingerprint) | 196 | add_gpg_key(person, fingerprint) |
1058 | 185 | 197 | ||
1059 | @@ -194,10 +206,13 @@ | |||
1060 | 194 | parser.add_option( | 206 | parser.add_option( |
1061 | 195 | '-e', '--email', action='store', dest='email', default=None, | 207 | '-e', '--email', action='store', dest='email', default=None, |
1062 | 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.") |
1063 | 209 | parser.add_option( | ||
1064 | 210 | '--placeholder', action='store_true', default=False, | ||
1065 | 211 | help="Create a placeholder account rather than a full user account.") | ||
1066 | 197 | 212 | ||
1067 | 198 | options, args = parser.parse_args(arguments) | 213 | options, args = parser.parse_args(arguments) |
1068 | 199 | if len(args) == 0: | 214 | if len(args) == 0: |
1070 | 200 | print __doc__ | 215 | print(__doc__) |
1071 | 201 | sys.exit(2) | 216 | sys.exit(2) |
1072 | 202 | 217 | ||
1073 | 203 | options.username = args[0] | 218 | options.username = args[0] |
1074 | @@ -217,12 +232,15 @@ | |||
1075 | 217 | execute_zcml_for_scripts() | 232 | execute_zcml_for_scripts() |
1076 | 218 | transaction.begin() | 233 | transaction.begin() |
1077 | 219 | 234 | ||
1081 | 220 | person = make_person(options.username, email) | 235 | if options.placeholder: |
1082 | 221 | add_person_to_teams(person, options.teams) | 236 | make_placeholder_person(options.username) |
1083 | 222 | add_ssh_public_keys(person) | 237 | else: |
1084 | 238 | person = make_person(options.username, email) | ||
1085 | 239 | add_person_to_teams(person, options.teams) | ||
1086 | 240 | add_ssh_public_keys(person) | ||
1087 | 223 | 241 | ||
1090 | 224 | if options.email is not None: | 242 | if options.email is not None: |
1091 | 225 | attach_gpg_keys(options.email, person) | 243 | attach_gpg_keys(options.email, person) |
1092 | 226 | 244 | ||
1093 | 227 | transaction.commit() | 245 | transaction.commit() |
1094 | 228 | 246 |
Superseded by https:/ /code.launchpad .net/~cjwatson/ launchpad/ +git/launchpad/ +merge/ 373748.