Merge lp:~canonical-isd-hackers/canonical-identity-provider/password-reset into lp:canonical-identity-provider/release
- password-reset
- Merge into trunk
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 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ricardo Kirkner (community) | Approve | ||
Review via email: mp+27285@code.launchpad.net |
Commit message
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 : | # |
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 |
In line 158 of the diff, what happens if token.requester is None? You should probably test for that.