Merge lp:~canonical-isd-hackers/canonical-identity-provider/password-reset into lp:canonical-identity-provider/release

Proposed by Łukasz Czyżykowski
Status: Merged
Merged at revision: 52
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/password-reset
Merge into: lp:canonical-identity-provider/release
Diff against target: 368 lines (+233/-7)
7 files modified
doctests/stories/api-workflows.txt (+30/-3)
identityprovider/tests/test_webservice.py (+47/-0)
identityprovider/webservice/interfaces.py (+13/-0)
identityprovider/webservice/models.py (+52/-0)
mockservice/api-workflows.txt (+20/-1)
mockservice/mockserver.py (+12/-3)
mockservice/wadl.xml (+59/-0)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/password-reset
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+27285@code.launchpad.net

Description of the change

Implementation of two method calls enabling doing password change via API.

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

In line 158 of the diff, what happens if token.requester is None? You should probably test for that.

review: Needs Fixing
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

Nice work. Thanks for the patience.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'doctests/stories/api-workflows.txt'
2--- doctests/stories/api-workflows.txt 2010-05-03 21:27:13 +0000
3+++ doctests/stories/api-workflows.txt 2010-06-14 12:47:24 +0000
4@@ -19,6 +19,7 @@
5
6 >>> if real_webservice:
7 ... from identityprovider.models import AuthToken, APIUser
8+ ... from identityprovider.models.const import LoginTokenType
9 ... from identityprovider.utils import encrypt_launchpad_password
10 ... password = encrypt_launchpad_password('password')
11 ... user, created = APIUser.objects.get_or_create(
12@@ -57,7 +58,7 @@
13 all of that to the API:
14
15 >>> api.registrations.register(
16- ... captcha_solution='bla', password='blogdf3D',
17+ ... captcha_solution='bla', password='blogdf3Daa',
18 ... captcha_id='bli', email='blu@bli.com')
19 {u'status': u'ok', u'message': u'Email verification required.'}
20
21@@ -79,11 +80,35 @@
22 indicating that fact:
23
24 >>> api.registrations.register(
25- ... captcha_solution='bla', password='blo',
26+ ... captcha_solution='bla', password='bla',
27 ... captcha_id='bli', email='blu')
28 {u'status': u'error', u'errors': {u'password': [u'Password must be at least 8 characters long, and must contain at least one number and an upper case letter.'], u'email': [u'Enter a valid e-mail address.']}}
29
30
31+Reset User Password
32+-------------------
33+
34+To be able to set new password for an account you can request sending
35+of password reset token to the email address.
36+
37+ >>> api.registrations.request_password_reset_token(email="blu@bli.com")
38+ {u'status': u'ok', u'message': u'Password reset token sent.'}
39+
40+ >>> if real_webservice:
41+ ... token = AuthToken.objects.get(
42+ ... email='blu@bli.com',
43+ ... token_type=LoginTokenType.PASSWORDRECOVERY).token
44+ ... else:
45+ ... token = "abcd"
46+
47+Then, when actually changing it you have to pass this token to the
48+call:
49+
50+ >>> api.registrations.set_new_password(
51+ ... email="blu@bli.com", token=token, new_password="blogdf3D")
52+ {u'status': u'ok', u'message': u'Password changed'}
53+
54+
55 As Authenticated User Using Basic Authentication
56 ================================================
57
58@@ -166,7 +191,9 @@
59 which in turn will call this method.
60
61 >>> if real_webservice:
62- ... email_token = AuthToken.objects.get(email='blu@bli.com').token
63+ ... email_token = AuthToken.objects.get(
64+ ... email='blu@bli.com',
65+ ... token_type=LoginTokenType.VALIDATEEMAIL).token
66 ... else:
67 ... email_token = "jJRkmngbHjmnJDEK"
68
69
70=== added file 'identityprovider/tests/test_webservice.py'
71--- identityprovider/tests/test_webservice.py 1970-01-01 00:00:00 +0000
72+++ identityprovider/tests/test_webservice.py 2010-06-14 12:47:24 +0000
73@@ -0,0 +1,47 @@
74+from identityprovider.tests.utils import SQLCachedTestCase
75+from identityprovider.models.authtoken import (AuthTokenFactory,
76+ LoginTokenType,
77+ AuthToken)
78+from identityprovider.models.account import Account
79+from identityprovider.models.const import AccountStatus
80+from identityprovider.webservice.models import (RegistrationSet,
81+ CanNotResetPassowrdError)
82+
83+
84+class RegistrationsTestCase(SQLCachedTestCase):
85+
86+ fixtures = ["test"]
87+ pgsql_functions = ["generate_openid_identifier"]
88+
89+ def setUp(self):
90+ self.registration = RegistrationSet()
91+
92+ def test_authtoken_without_requester_is_handled_properly(self):
93+ authtoken = AuthTokenFactory().new(None, None, "test@example.com",
94+ LoginTokenType.PASSWORDRECOVERY,
95+ None)
96+
97+ response = self.registration.set_new_password("test@example.com",
98+ authtoken.token, "pass")
99+
100+ self.assertEquals(response['status'], "error")
101+
102+ def test_reset_token_when_email_is_invalid(self):
103+ self.assertRaises(CanNotResetPassowrdError,
104+ self.registration.request_password_reset_token,
105+ "non-existing@example.com")
106+
107+ def test_reset_token_when_account_is_disabled(self):
108+ account = Account.objects.get_by_email("mark@example.com")
109+ account.status = AccountStatus.SUSPENDED
110+ account.save()
111+
112+ self.assertRaises(CanNotResetPassowrdError,
113+ self.registration.request_password_reset_token,
114+ "mark@example.com")
115+
116+ def test_reset_password_when_not_existing_token_is_passed(self):
117+ self.assertRaises(AuthToken.DoesNotExist,
118+ self.registration.set_new_password,
119+ "mark@example.com", "not-existing-token",
120+ "password")
121
122=== modified file 'identityprovider/webservice/interfaces.py'
123--- identityprovider/webservice/interfaces.py 2010-04-21 15:29:24 +0000
124+++ identityprovider/webservice/interfaces.py 2010-06-14 12:47:24 +0000
125@@ -149,6 +149,19 @@
126 def register(email, password, captcha_id, captcha_solution):
127 """ Generate a new captcha """
128
129+ @operation_parameters(email=TextLine(title=u"Email address."))
130+ @export_write_operation()
131+ def request_password_reset_token(email):
132+ """Request password reset code to be sent to the email"""
133+
134+ @operation_parameters(
135+ email=TextLine(title=u"Email address."),
136+ token=TextLine(title=u"Password reset token."),
137+ new_password=TextLine(title=u"New password"))
138+ @export_write_operation()
139+ def set_new_password(email, token, new_password):
140+ """Set new password for given user"""
141+
142
143 class ICaptchaSet(IDjangoLocation):
144 export_as_webservice_collection(ICaptcha)
145
146=== modified file 'identityprovider/webservice/models.py'
147--- identityprovider/webservice/models.py 2010-04-21 15:29:24 +0000
148+++ identityprovider/webservice/models.py 2010-06-14 12:47:24 +0000
149@@ -10,6 +10,7 @@
150 from lazr.restful.frameworks.django import DjangoWebServiceConfiguration
151 from lazr.restful.wsgi import BaseWSGIWebServiceConfiguration
152 from lazr.restful.utils import get_current_browser_request
153+from lazr.restful.declarations import webservice_error
154
155 from identityprovider.models import (Account, EmailAddress,
156 AccountPassword, Token, APIUser, Person)
157@@ -26,6 +27,10 @@
158 from identityprovider.webservice.forms import WebserviceCreateAccountForm
159 from identityprovider.models.captcha import Captcha as CaptchaModel
160 from identityprovider.views.server import get_team_memberships
161+from identityprovider.utils import (CannotResetPasswordException,
162+ PersonAndAccountNotFoundException, get_person_and_account_by_email)
163+
164+
165
166
167 class RootAbsoluteURL(RootResourceAbsoluteURL):
168@@ -183,6 +188,10 @@
169 return []
170
171
172+class CanNotResetPassowrdError(Exception):
173+ webservice_error(403)
174+
175+
176 class RegistrationSet(make_set('BaseRegistrationSet', 'registration',
177 IRegistrationSet, Account, 'id')):
178 def register(self, **kwargs):
179@@ -224,6 +233,49 @@
180 'message': "Email verification required."
181 }
182
183+ def request_password_reset_token(self, email):
184+ error = False
185+ try:
186+ person, account = get_person_and_account_by_email(email)
187+ except (CannotResetPasswordException,
188+ PersonAndAccountNotFoundException):
189+ error = True
190+
191+ if error or (account is not None and not account.can_reset_password):
192+ raise CanNotResetPassowrdError(
193+ "Can't reset password for this account")
194+
195+ token = AuthTokenFactory().new(account, email, email,
196+ LoginTokenType.PASSWORDRECOVERY, None)
197+
198+ token.sendPasswordResetEmail()
199+
200+ return {
201+ 'status': 'ok',
202+ 'message': "Password reset token sent."
203+ }
204+
205+ def set_new_password(self, email, token, new_password):
206+ token = AuthToken.objects.get(
207+ email=email, token=token,
208+ token_type=LoginTokenType.PASSWORDRECOVERY)
209+ if not token.requester:
210+ token.delete()
211+ return {
212+ 'status': 'error',
213+ 'message': "Wrong token, request new one."
214+ }
215+ password_obj = token.requester.accountpassword
216+ password_obj.password = encrypt_launchpad_password(new_password)
217+ password_obj.save()
218+
219+ token.consume()
220+
221+ return {
222+ 'status': 'ok',
223+ 'message': "Password changed"
224+ }
225+
226
227 class Captcha(object):
228 implements(ICaptcha)
229
230=== modified file 'mockservice/api-workflows.txt'
231--- mockservice/api-workflows.txt 2010-04-21 15:29:24 +0000
232+++ mockservice/api-workflows.txt 2010-06-14 12:47:24 +0000
233@@ -45,7 +45,7 @@
234 all of that to the API:
235
236 >>> api.registrations.register(
237- ... captcha_solution='bla', password='blogdf3D',
238+ ... captcha_solution='bla', password='blogdf3Daa',
239 ... captcha_id='bli', email='blu@bli.com')
240 {u'status': u'ok', u'message': u'Email verification required.'}
241
242@@ -72,6 +72,25 @@
243 {u'status': u'error', u'errors': {u'password': [u'Password must be at least 8 characters long, and must contain at least one number and an upper case letter.'], u'email': [u'Enter a valid e-mail address.']}}
244
245
246+Reset User Password
247+-------------------
248+
249+To be able to set new password for an account you can request sending
250+of password reset token to the email address.
251+
252+ >>> api.registrations.request_password_reset_token(email="blu@bli.com")
253+ {u'status': u'ok', u'message': u'Password reset token sent.'}
254+
255+
256+Then, when actually changing it you have to pass this token to the
257+call:
258+
259+ >>> api.registrations.set_new_password(
260+ ... email="blu@bli.com", token="abcd", new_password="blogdf3D")
261+ {u'status': u'ok', u'message': u'Password changed'}
262+
263+
264+
265 As Authenticated User Using Basic Authentication
266 ================================================
267
268
269=== modified file 'mockservice/mockserver.py'
270--- mockservice/mockserver.py 2010-05-10 15:40:33 +0000
271+++ mockservice/mockserver.py 2010-06-14 12:47:24 +0000
272@@ -20,6 +20,8 @@
273 'validated email': '{"email": "blu@bli.com"}',
274 'bad email token': '{"errors": {"email_token": ["Bad email token!"]}}',
275 'team_memberships': '[]',
276+ 'password reset token': '{"status": "ok", "message": "Password reset token sent."}',
277+ 'set new password': '{"status": "ok", "message": "Password changed"}'
278 }
279
280 email_re = re.compile(
281@@ -192,9 +194,16 @@
282 clength = int(self.environ['CONTENT_LENGTH'])
283 input = self.environ['wsgi.input'].read(clength)
284 form = self.decode_form(input)
285- form.pop('ws.op')
286- errors = validate_new_account_form(**form)
287- return self.jsons(json=simplejson.dumps(errors))
288+ if 'ws.op' in form:
289+ op = form['ws.op']
290+ if op == 'register':
291+ form.pop('ws.op')
292+ errors = validate_new_account_form(**form)
293+ return self.jsons(json=simplejson.dumps(errors))
294+ elif op == 'request_password_reset_token':
295+ return self.jsons('password reset token')
296+ elif op == 'set_new_password':
297+ return self.jsons('set new password')
298
299 def handle_authentications(self):
300 # 1. Check that we have some auth
301
302=== modified file 'mockservice/wadl.xml'
303--- mockservice/wadl.xml 2010-04-21 15:29:24 +0000
304+++ mockservice/wadl.xml 2010-06-14 12:47:24 +0000
305@@ -488,6 +488,65 @@
306 </wadl:request>
307
308 </wadl:method>
309+ <wadl:method id="registrations-request_password_reset_token" name="POST">
310+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
311+Generate a new captcha
312+</wadl:doc>
313+ <wadl:request>
314+ <wadl:representation
315+ mediaType="application/x-www-form-urlencoded">
316+ <wadl:param style="query" name="ws.op"
317+ required="true" fixed="request_password_reset_token">
318+ <wadl:doc>The name of the operation being invoked.</wadl:doc>
319+ </wadl:param>
320+ <wadl:param style="query" required="true"
321+ name="email">
322+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
323+Email address.
324+</wadl:doc>
325+
326+ </wadl:param>
327+ </wadl:representation>
328+ </wadl:request>
329+
330+ </wadl:method>
331+ <wadl:method id="registrations-set_new_password" name="POST">
332+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
333+Generate a new captcha
334+</wadl:doc>
335+ <wadl:request>
336+ <wadl:representation
337+ mediaType="application/x-www-form-urlencoded">
338+ <wadl:param style="query" name="ws.op"
339+ required="true" fixed="set_new_password">
340+ <wadl:doc>The name of the operation being invoked.</wadl:doc>
341+ </wadl:param>
342+ <wadl:param style="query" required="true"
343+ name="email">
344+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
345+Email address.
346+</wadl:doc>
347+
348+ </wadl:param>
349+ <wadl:param style="query" required="true"
350+ name="token">
351+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
352+Password reset token.
353+</wadl:doc>
354+
355+ </wadl:param>
356+ <wadl:param style="query" required="true"
357+ name="new_password">
358+ <wadl:doc xmlns="http://www.w3.org/1999/xhtml">
359+New password
360+</wadl:doc>
361+
362+ </wadl:param>
363+ </wadl:representation>
364+ </wadl:request>
365+
366+ </wadl:method>
367+
368 </wadl:resource_type>
369
370