Merge lp:~bac/launchpad/bug-452491-captcha2-boogaloo into lp:launchpad

Proposed by Brad Crittenden
Status: Merged
Merged at revision: not available
Proposed branch: lp:~bac/launchpad/bug-452491-captcha2-boogaloo
Merge into: lp:launchpad
Diff against target: 451 lines
9 files modified
lib/canonical/launchpad/browser/tests/registration.py (+1/-1)
lib/canonical/launchpad/pagetests/standalone/xx-new-account-redirection-url.txt (+1/-1)
lib/canonical/launchpad/templates/launchpad-forgottenpassword.pt (+34/-7)
lib/canonical/launchpad/templates/launchpad-login.pt (+1/-0)
lib/canonical/launchpad/webapp/login.py (+48/-38)
lib/lp/registry/stories/foaf/xx-createaccount.txt (+2/-2)
lib/lp/registry/stories/foaf/xx-reg-with-existing-email.txt (+4/-4)
lib/lp/registry/stories/foaf/xx-resetpassword.txt (+34/-24)
lib/lp/testing/registration.py (+2/-2)
To merge this branch: bzr merge lp:~bac/launchpad/bug-452491-captcha2-boogaloo
Reviewer Review Type Date Requested Status
Martin Albisetti (community) ui Approve
Abel Deuring (community) Approve
Review via email: mp+13487@code.launchpad.net

Commit message

Add simple captcha to the +forgottenpassword page.

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

= Summary =

Bug 452491 requests a simple captcha be placed on the forgotten password page.

== Proposed fix ==

The captcha already existed on the registration page. It was quick work to refactor
the bits into a mixin class and let the forgotten password page use it. Sadly both
of those pages are done without using LaunchpadFormView, made instead with grout and
twine, so it took a little massaging to get things to work.

The test helper set_captcha_answer had to be made a little smarter to account for
form prefixes.

The reset password test needed some clean up too. There is a fair amount of drive by
stuff in here but it's easy to sort out, I hope.

I also changed the spacing on the error messages for this page and the registration
page to fix some overlap problems. Screenshots are at:
http://people.canonical.com/~bac/captcha/

== Pre-implementation notes ==

None

== Implementation details ==

As above.

== Tests ==

bin/test -vvm lp.registry -t foaf -t xx-new-account-redirection-url.txt

== Demo and Q/A ==

= Launchpad lint =

Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.

Linting changed files:
  lib/canonical/launchpad/templates/launchpad-login.pt
  lib/canonical/launchpad/templates/launchpad-forgottenpassword.pt
  lib/lp/registry/stories/foaf/xx-reg-with-existing-email.txt
  lib/lp/registry/stories/foaf/xx-createaccount.txt
  lib/canonical/launchpad/browser/tests/registration.py
  lib/canonical/launchpad/webapp/login.py
  lib/lp/testing/registration.py
  lib/canonical/launchpad/pagetests/standalone/xx-new-account-redirection-url.txt
  lib/lp/registry/stories/foaf/xx-resetpassword.txt

Revision history for this message
Abel Deuring (adeuring) wrote :

Hi Brad,

a nice branch r=me. I have only a minor cosmetic suggestion, see below.

Abel

> === modified file 'lib/lp/testing/registration.py'
> --- lib/lp/testing/registration.py 2009-10-08 20:22:25 +0000
> +++ lib/lp/testing/registration.py 2009-10-16 16:35:20 +0000
> @@ -24,9 +24,10 @@
> return ''
>
>
> -def set_captcha_answer(browser, answer=None):
> +def set_captcha_answer(browser, answer=None, prefix=''):
> """Given a browser, set the login captcha with the correct answer."""
> if answer is None:
> answer = get_captcha_answer(browser.contents)
> - browser.getControl(name='loginpage_captcha_submission').value = (
> + control_name = prefix + 'captcha_submission'
> + browser.getControl(name=control_name).value = (
> answer)

I think you can merge the last two lines in one ;)

review: Approve
Revision history for this message
Brad Crittenden (bac) wrote :

> Hi Brad,
>
> a nice branch r=me. I have only a minor cosmetic suggestion, see below.
>
> Abel
>
> > === modified file 'lib/lp/testing/registration.py'
> > --- lib/lp/testing/registration.py 2009-10-08 20:22:25 +0000
> > +++ lib/lp/testing/registration.py 2009-10-16 16:35:20 +0000
> > @@ -24,9 +24,10 @@
> > return ''
> >
> >
> > -def set_captcha_answer(browser, answer=None):
> > +def set_captcha_answer(browser, answer=None, prefix=''):
> > """Given a browser, set the login captcha with the correct answer."""
> > if answer is None:
> > answer = get_captcha_answer(browser.contents)
> > - browser.getControl(name='loginpage_captcha_submission').value = (
> > + control_name = prefix + 'captcha_submission'
> > + browser.getControl(name=control_name).value = (
> > answer)
>
> I think you can merge the last two lines in one ;)

Done

Revision history for this message
Martin Albisetti (beuno) wrote :

Bonus points if the forms look like everywhere else in Launchpad.

review: Approve (ui)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/tests/registration.py'
2--- lib/canonical/launchpad/browser/tests/registration.py 2009-10-08 19:52:06 +0000
3+++ lib/canonical/launchpad/browser/tests/registration.py 2009-10-16 19:59:17 +0000
4@@ -21,7 +21,7 @@
5 browser = setupBrowser()
6 browser.open('http://launchpad.dev/+login')
7 browser.getControl(name='loginpage_email', index=1).value = email
8- set_captcha_answer(browser)
9+ set_captcha_answer(browser, prefix='loginpage_')
10 browser.getControl('Register').click()
11 return browser
12
13
14=== modified file 'lib/canonical/launchpad/pagetests/standalone/xx-new-account-redirection-url.txt'
15--- lib/canonical/launchpad/pagetests/standalone/xx-new-account-redirection-url.txt 2009-10-08 19:52:06 +0000
16+++ lib/canonical/launchpad/pagetests/standalone/xx-new-account-redirection-url.txt 2009-10-16 19:59:17 +0000
17@@ -42,7 +42,7 @@
18 >>> from lp.testing.registration import set_captcha_answer
19 >>> anon_browser.getControl('E-mail address:', index=1).value = (
20 ... 'granny@canonical.com')
21- >>> set_captcha_answer(anon_browser)
22+ >>> set_captcha_answer(anon_browser, prefix='loginpage_')
23 >>> anon_browser.getControl('Register').click()
24 >>> print anon_browser.contents
25 <...
26
27=== modified file 'lib/canonical/launchpad/templates/launchpad-forgottenpassword.pt'
28--- lib/canonical/launchpad/templates/launchpad-forgottenpassword.pt 2009-07-17 17:59:07 +0000
29+++ lib/canonical/launchpad/templates/launchpad-forgottenpassword.pt 2009-10-16 19:59:17 +0000
30@@ -21,22 +21,49 @@
31
32 <div tal:condition="not: view/success">
33
34- <div class="error" tal:condition="view/errortext"
35- tal:content="structure view/errortext" />
36+ <tal:block condition="view/errortext">
37+ <h1>Your password reset request was unsuccessful</h1>
38+ <p class="error message"
39+ style="margin-top: 2em;"
40+ tal:content="structure view/errortext" />
41+ </tal:block>
42
43 <p>
44 Enter your e-mail address, and we will send you instructions
45- on how to reset your password.
46+ on how to reset your password. You'll also need to answer a simple
47+ question so we know that you're human.
48 </p>
49
50 <form name="forgottenpassword" method="POST">
51+ <input type="hidden"
52+ tal:attributes="name view/captcha_hash;
53+ value view/get_captcha_hash" />
54 <div class="field">
55- <label for="email">Email address:</label>
56- <input type="text" size="50" name="email" />
57+ <table>
58+ <tr>
59+ <th>
60+ <label for="email">Email address:</label>
61+ </th>
62+ <td>
63+ <input type="text" size="50" name="email" />
64+ </td>
65+ </tr>
66+ <tr>
67+ <th>
68+ <label>Random question:</label>
69+ </th>
70+ <td>
71+ <span id="problem" tal:content="view/captcha_problem" />
72+ <input type="text" size="5"
73+ id="captcha_submission" value=""
74+ tal:attributes="name view/captcha_submission"
75+ />
76+ </td>
77+ </tr>
78+ </table>
79 <div class="formHelp">
80 <p>
81- This should be the email address registered for your Launchpad
82- account.
83+ Enter the email address registered for your Launchpad account.
84 </p>
85 <p>
86 If you are claiming an existing account but don't know the email
87
88=== modified file 'lib/canonical/launchpad/templates/launchpad-login.pt'
89--- lib/canonical/launchpad/templates/launchpad-login.pt 2009-10-08 15:57:01 +0000
90+++ lib/canonical/launchpad/templates/launchpad-login.pt 2009-10-16 19:59:17 +0000
91@@ -164,6 +164,7 @@
92 <tal:block condition="view/registration_error">
93 <h1>Your registration was unsuccessful</h1>
94 <p class="error message"
95+ style="margin-top: 2em;"
96 tal:content="structure view/registration_error" />
97 </tal:block>
98 <!-- 5. might want to register: -->
99
100=== modified file 'lib/canonical/launchpad/webapp/login.py'
101--- lib/canonical/launchpad/webapp/login.py 2009-10-08 15:57:01 +0000
102+++ lib/canonical/launchpad/webapp/login.py 2009-10-16 19:59:17 +0000
103@@ -153,8 +153,44 @@
104 return getUtility(IPersonSet).getByName(
105 config.launchpad.restrict_to_team).title
106
107-
108-class LoginOrRegister:
109+class CaptchaMixin:
110+ """Mixin class to provide simple captcha capabilities."""
111+ def validateCaptcha(self):
112+ """Validate the submitted captcha value matches what we expect."""
113+ expected = self.request.form.get(self.captcha_hash)
114+ submitted = self.request.form.get(self.captcha_submission)
115+ if expected is not None and submitted is not None:
116+ return md5.new(submitted).hexdigest() == expected
117+ return False
118+
119+ @cachedproperty
120+ def captcha_answer(self):
121+ """Get the answer for the current captcha challenge.
122+
123+ With each failed attempt a new challenge will be given. Our answer
124+ space is acknowledged to be ridiculously small but is chosen in the
125+ interest of ease-of-use. We're not trying to create an iron-clad
126+ challenge but only a minimal obstacle to dumb bots.
127+ """
128+ return random.randint(10, 20)
129+
130+ @property
131+ def get_captcha_hash(self):
132+ """Get the captcha hash.
133+
134+ The hash is the value we put in the form for later comparison.
135+ """
136+ return md5.new(str(self.captcha_answer)).hexdigest()
137+
138+ @property
139+ def captcha_problem(self):
140+ """Create the captcha challenge."""
141+ op1 = random.randint(1, self.captcha_answer - 1)
142+ op2 = self.captcha_answer - op1
143+ return '%d + %d =' % (op1, op2)
144+
145+
146+class LoginOrRegister(CaptchaMixin):
147 """Merges the former CookieLoginPage and JoinLaunchpadView classes
148 to allow the two forms to appear on a single page.
149
150@@ -399,41 +435,6 @@
151 L.append(html % (name, cgi.escape(value, quote=True)))
152 return '\n'.join(L)
153
154- def validateCaptcha(self):
155- """Validate the submitted captcha value matches what we expect."""
156- expected = self.request.form.get(self.captcha_hash)
157- submitted = self.request.form.get(self.captcha_submission)
158- if expected is not None and submitted is not None:
159- return md5.new(submitted).hexdigest() == expected
160- return False
161-
162- @cachedproperty
163- def captcha_answer(self):
164- """Get the answer for the current captcha challenge.
165-
166- With each failed attempt a new challenge will be given. Our answer
167- space is acknowledged to be ridiculously small but is chosen in the
168- interest of ease-of-use. We're not trying to create an iron-clad
169- challenge but only a minimal obstacle to dumb bots.
170- """
171- return random.randint(10, 20)
172-
173- @property
174- def get_captcha_hash(self):
175- """Get the captcha hash.
176-
177- The hash is the value we put in the form for later comparison.
178- """
179- return md5.new(str(self.captcha_answer)).hexdigest()
180-
181- @property
182- def captcha_problem(self):
183- """Create the captcha challenge."""
184- op1 = random.randint(1, self.captcha_answer)
185- op2 = self.captcha_answer - op1
186- return '%d + %d =' % (op1, op2)
187-
188-
189 def logInPrincipal(request, principal, email):
190 """Log the principal in. Password validation must be done in callsites."""
191 session = ISession(request)
192@@ -542,16 +543,25 @@
193 return ''
194
195
196-class ForgottenPasswordPage:
197+class ForgottenPasswordPage(CaptchaMixin):
198
199 errortext = None
200 submitted = False
201+ captcha_submission = 'captcha_submission'
202+ captcha_hash = 'captcha_hash'
203
204 def process_form(self):
205 request = self.request
206 if request.method != "POST":
207 return
208
209+ # Validate the user is human, more or less.
210+ if not self.validateCaptcha():
211+ self.errortext = (
212+ "The answer to the simple math question was incorrect "
213+ "or missing. Please try again.")
214+ return
215+
216 email = request.form.get("email").strip()
217 person = getUtility(IPersonSet).getByEmail(email)
218 if person is None:
219
220=== modified file 'lib/lp/registry/stories/foaf/xx-createaccount.txt'
221--- lib/lp/registry/stories/foaf/xx-createaccount.txt 2009-10-08 19:52:06 +0000
222+++ lib/lp/registry/stories/foaf/xx-createaccount.txt 2009-10-16 19:59:17 +0000
223@@ -36,7 +36,7 @@
224 email address from before has been retained in the form.
225
226 >>> from lp.testing.registration import set_captcha_answer
227- >>> set_captcha_answer(browser)
228+ >>> set_captcha_answer(browser, prefix='loginpage_')
229 >>> browser.getControl('Register').click()
230 >>> print_feedback_messages(browser.contents)
231 >>> print extract_text(find_main_content(browser.contents))
232@@ -103,7 +103,7 @@
233 >>> browser.open('http://launchpad.dev/+login')
234 >>> browser.getControl(name='loginpage_email', index=1).value = (
235 ... 'jperson@example.com')
236- >>> set_captcha_answer(browser)
237+ >>> set_captcha_answer(browser, prefix='loginpage_')
238 >>> browser.getControl('Register').click()
239
240 >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
241
242=== modified file 'lib/lp/registry/stories/foaf/xx-reg-with-existing-email.txt'
243--- lib/lp/registry/stories/foaf/xx-reg-with-existing-email.txt 2009-10-08 19:52:06 +0000
244+++ lib/lp/registry/stories/foaf/xx-reg-with-existing-email.txt 2009-10-16 19:59:17 +0000
245@@ -16,7 +16,7 @@
246 >>> browser.getControl('E-mail address:', index=1).value = (
247 ... 'test@canonical.com')
248 >>> from lp.testing.registration import set_captcha_answer
249- >>> set_captcha_answer(browser)
250+ >>> set_captcha_answer(browser, prefix='loginpage_')
251 >>> browser.getControl('Register').click()
252
253 >>> for message in get_feedback_messages(browser.contents):
254@@ -33,7 +33,7 @@
255 on with the registration process.
256
257 >>> browser.getControl('E-mail address:', index=1).value = 'mpo@iki.fi'
258- >>> set_captcha_answer(browser)
259+ >>> set_captcha_answer(browser, prefix='loginpage_')
260 >>> browser.getControl('Register').click()
261
262 >>> print extract_text(find_tag_by_id(browser.contents, 'address'))
263@@ -85,7 +85,7 @@
264
265 >>> anon_browser.getControl('E-mail address:', index=1).value = (
266 ... 'christian.reis@ubuntulinux.com')
267- >>> set_captcha_answer(anon_browser)
268+ >>> set_captcha_answer(anon_browser, prefix='loginpage_')
269 >>> anon_browser.getControl('Register').click()
270
271 >>> from_addr, to_addr, msg = stub.test_emails.pop()
272@@ -147,7 +147,7 @@
273
274 >>> browser.getControl('E-mail address:', index=1).value = (
275 ... 'bad-user@canonical.com')
276- >>> set_captcha_answer(browser)
277+ >>> set_captcha_answer(browser, prefix='loginpage_')
278 >>> browser.getControl('Register').click()
279
280 >>> print extract_text(find_main_content(browser.contents).p)
281
282=== modified file 'lib/lp/registry/stories/foaf/xx-resetpassword.txt'
283--- lib/lp/registry/stories/foaf/xx-resetpassword.txt 2009-09-18 01:09:10 +0000
284+++ lib/lp/registry/stories/foaf/xx-resetpassword.txt 2009-10-16 19:59:17 +0000
285@@ -15,10 +15,12 @@
286 >>> browser.title
287 'Need a new Launchpad password?'
288
289-He types the email address registered in Launchpad and submit the form.
290+He types the email address registered in Launchpad and submits the form.
291
292+ >>> from lp.testing.registration import set_captcha_answer
293 >>> browser.getControl(name='email').value = (
294 ... 'david.allouche@canonical.com')
295+ >>> set_captcha_answer(browser)
296 >>> browser.getControl('Request Reset').click()
297 >>> print extract_text(find_main_content(browser.contents))
298 Need a new Launchpad password?
299@@ -68,8 +70,7 @@
300 >>> browser.url
301 'http://launchpad.dev/token/.../+resetpassword'
302
303- >>> for tag in get_feedback_messages(browser.contents):
304- ... print extract_text(tag)
305+ >>> print_feedback_messages(browser.contents)
306 There is 1 error.
307 The password provided contains non-ASCII characters.
308
309@@ -82,8 +83,7 @@
310 >>> browser.url
311 'http://launchpad.dev'
312
313- >>> for tag in get_feedback_messages(browser.contents):
314- ... print extract_text(tag)
315+ >>> print_feedback_messages(browser.contents)
316 Your password has been reset successfully.
317
318 >>> print extract_text(find_tag_by_id(browser.contents, 'logincontrol'))
319@@ -104,8 +104,7 @@
320 logs out and then logs in again:
321
322 >>> browser.open('http://launchpad.dev/+logout')
323- >>> for tag in get_feedback_messages(browser.contents):
324- ... print extract_text(tag)
325+ >>> print_feedback_messages(browser.contents)
326 You have been logged out
327
328 >>> browser.getLink('Log in / Register').click()
329@@ -122,6 +121,20 @@
330 David Allouche...
331
332
333+== Reset is protected by a math captcha ==
334+
335+When requesting a password reset David is presented with a simple math
336+problem which must be solved correctly before proceeding.
337+
338+ >>> browser.open('http://launchpad.dev/+forgottenpassword')
339+ >>> browser.getControl(name='email').value = (
340+ ... 'david.allouche@canonical.com')
341+ >>> browser.getControl(name='captcha_submission').value = '-1'
342+ >>> browser.getControl('Request Reset').click()
343+ >>> print_feedback_messages(browser.contents)
344+ The answer to the simple math question was incorrect or missing. Please try again.
345+
346+
347 == Using email addresses other than the preferred one ==
348
349 Any of a person's validated email addresses can be used to reset his
350@@ -134,8 +147,9 @@
351 >>> browser.open('http://launchpad.dev/+login')
352 >>> browser.getLink('Forgotten your password?').click()
353 >>> browser.getControl(name='email').value = 'david@canonical.com'
354+ >>> set_captcha_answer(browser)
355 >>> browser.getControl('Request Reset').click()
356- >>> print "\n".join(get_feedback_messages(browser.contents))
357+ >>> print_feedback_messages(browser.contents)
358
359 He follows the link sent to his email just like he did before.
360
361@@ -155,7 +169,7 @@
362 >>> browser.getControl('Continue').click()
363 >>> browser.url
364 'http://launchpad.dev'
365- >>> print "\n".join(get_feedback_messages(browser.contents))
366+ >>> print_feedback_messages(browser.contents)
367 Your password has been reset successfully.
368
369
370@@ -165,12 +179,11 @@
371
372 >>> browser.open('http://launchpad.dev/+forgottenpassword')
373 >>> browser.getControl(name='email').value = 'support@ubuntu.com'
374+ >>> set_captcha_answer(browser)
375 >>> browser.getControl('Request Reset').click()
376-
377- >>> for tag in find_tags_by_class(browser.contents, 'error'):
378- ... print tag
379- <div class="error">The email address <strong>support@ubuntu.com</strong>
380- belongs to a team, and teams cannot log in to Launchpad.</div>
381+ >>> print_feedback_messages(browser.contents)
382+ The email address support@ubuntu.com
383+ belongs to a team, and teams cannot log in to Launchpad.
384
385
386 == Reactivating an account ==
387@@ -202,8 +215,7 @@
388 >>> browser.title
389 'Log in or register with Launchpad'
390
391- >>> for tag in get_feedback_messages(browser.contents):
392- ... print extract_text(tag)
393+ >>> print_feedback_messages(browser.contents)
394 The email address belongs to a deactivated account. Use the
395 "Forgotten your password" link to reactivate it.
396
397@@ -215,6 +227,7 @@
398
399 >>> browser.getControl(name='email').value = (
400 ... 'former-user@canonical.com')
401+ >>> set_captcha_answer(browser)
402 >>> browser.getControl('Request Reset').click()
403
404 >>> print extract_text(find_main_content(browser.contents).p)
405@@ -248,8 +261,7 @@
406 >>> browser.url
407 'http://launchpad.dev'
408
409- >>> for tag in get_feedback_messages(browser.contents):
410- ... print extract_text(tag)
411+ >>> print_feedback_messages(browser.contents)
412 Welcome back to Launchpad.
413 Your password has been reset successfully.
414
415@@ -300,6 +312,7 @@
416 'Need a new Launchpad password?'
417 >>> browser.getControl(name='email').value = (
418 ... 'bad-user@canonical.com')
419+ >>> set_captcha_answer(browser)
420 >>> browser.getControl('Request Reset').click()
421
422 >>> print extract_text(find_main_content(browser.contents).p)
423@@ -330,9 +343,6 @@
424 >>> browser.getControl(name='field.password_dupe').value = 'test'
425 >>> browser.getControl('Continue').click()
426
427- >>> for tag in find_tags_by_class(
428- ... browser.contents, 'warning'):
429- ... print tag
430- <div ...>Your password cannot be reset because your account is suspended.
431- Contact a <a href="mailto:feedback@launchpad.net?subject=SU...">Launchpad
432- admin</a> about this issue.</div>
433+ >>> print_feedback_messages(browser.contents)
434+ Your password cannot be reset because your account is suspended.
435+ Contact a Launchpad admin about this issue.
436
437=== modified file 'lib/lp/testing/registration.py'
438--- lib/lp/testing/registration.py 2009-10-08 20:22:25 +0000
439+++ lib/lp/testing/registration.py 2009-10-16 19:59:17 +0000
440@@ -24,9 +24,9 @@
441 return ''
442
443
444-def set_captcha_answer(browser, answer=None):
445+def set_captcha_answer(browser, answer=None, prefix=''):
446 """Given a browser, set the login captcha with the correct answer."""
447 if answer is None:
448 answer = get_captcha_answer(browser.contents)
449- browser.getControl(name='loginpage_captcha_submission').value = (
450+ browser.getControl(name=prefix + 'captcha_submission').value = (
451 answer)