Merge lp:~twom/canonical-identity-provider/confirm-password-before-changing into lp:canonical-identity-provider/release

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~twom/canonical-identity-provider/confirm-password-before-changing
Merge into: lp:canonical-identity-provider/release
Diff against target: 338 lines (+98/-8)
6 files modified
src/identityprovider/forms.py (+18/-2)
src/identityprovider/tests/sso_server/test_home_page.py (+1/-0)
src/identityprovider/tests/test_forms.py (+23/-3)
src/identityprovider/tests/test_views_server.py (+3/-1)
src/webui/templates/widgets/passwords.html (+15/-0)
src/webui/tests/test_views_account.py (+38/-2)
To merge this branch: bzr merge lp:~twom/canonical-identity-provider/confirm-password-before-changing
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Maximiliano Bertacchini Approve
Review via email: mp+360817@code.launchpad.net

Commit message

Add a confirm password box to the edit account details screen.

Description of the change

Adds a confirm password box to the edit account details screen, required for changing any account details (email, display name, password, 2fa options).

As the password boxes are handled by a mixin, allow the mixin to take a parameter, as we don't want a 'forgotten password' reset to require the password that you just forgot.

To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

LGTM, with a couple of nitpicks. Ran locally and tested:

- Create account - new password and new password confirmation are required as usual.
- Edit account - current password confirmation is required as expected.
- Edit account - POST with no "oldpassword" correctly validated server-side.
- Forgot password - no current password is requested.

Would be great to hear a second opinion, just in case. Thanks!

review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :
Revision history for this message
Colin Watson (cjwatson) wrote :

If the user has 2FA enabled, is that also checked? I don't see any obvious mention of that in this MP, but I haven't tried running it so I might have missed something.

Revision history for this message
Tom Wardill (twom) wrote :

> If the user has 2FA enabled, is that also checked? I don't see any obvious
> mention of that in this MP, but I haven't tried running it so I might have
> missed something.

2FA is not checked before password change. It's something I considered, but on checking around (Google, Github, Dropbox), other sites don't seem to require it for a password change.

I guess you're already at '2FA' (ish) if you're in a position to be able to change someone else's password, you'd need a working session _and_ the current password.

Revision history for this message
Daniel Manrique (roadmr) wrote :

Looks OK code wise.

I played with this a bit UI-wise and it seems fine, I don't like that it asks for the password for everything (e.g. I wanted to switch 2FA policy to "always ask" and I was asked for the password anyway) - OTOH it's not a stretch to require that users know their password at all times :) so this is slightly more inconvenient but answers the security concern that prompted these changes.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/identityprovider/forms.py'
2--- src/identityprovider/forms.py 2018-05-28 19:44:56 +0000
3+++ src/identityprovider/forms.py 2018-12-13 16:29:16 +0000
4@@ -6,6 +6,7 @@
5
6 from django import forms
7 from django.conf import settings
8+from django.contrib.auth.hashers import check_password
9 from django.forms import Form, fields, widgets
10 from django.urls import reverse
11 from django.utils import translation
12@@ -105,9 +106,12 @@
13
14 def __init__(self, *args, **kwargs):
15 self.account = kwargs.pop('account', None)
16+ self.require_confirmation = kwargs.pop('require_confirmation', True)
17 super(ResetPasswordFormMixin, self).__init__(*args, **kwargs)
18
19 validate_policy = get_password_validator(self.account)
20+ if self.require_confirmation:
21+ self.fields['oldpassword'] = PasswordField()
22 self.fields['password'] = PasswordField(
23 placeholder=validate_policy.PASSWORD_POLICY_HELP_TEXT,
24 help_text=validate_policy.PASSWORD_POLICY_HELP_TEXT,
25@@ -116,6 +120,14 @@
26 self.fields['passwordconfirm'] = PasswordField(
27 placeholder=_('Retype password'))
28
29+ def clean_oldpassword(self):
30+ old_password = self.cleaned_data.get('oldpassword')
31+ if (not old_password and self.data.get('oldpassword')):
32+ raise forms.ValidationError(FIELD_REQUIRED)
33+ if not check_password(old_password, self.account.encrypted_password):
34+ raise forms.ValidationError(_("Incorrect password"))
35+ return old_password
36+
37 def clean_password(self):
38 password = self.cleaned_data.get('password')
39 if (not password and self.data.get('passwordconfirm')):
40@@ -124,7 +136,7 @@
41
42 def clean_passwordconfirm(self):
43 password = self.cleaned_data.get('passwordconfirm')
44- if (not password and self.data.get('passwordconfirm')):
45+ if (not password and self.data.get('password')):
46 raise forms.ValidationError(FIELD_REQUIRED)
47 return password
48
49@@ -141,7 +153,10 @@
50
51
52 class ResetPasswordForm(ResetPasswordFormMixin, forms.Form):
53- pass
54+
55+ def __init__(self, *args, **kwargs):
56+ kwargs['require_confirmation'] = False
57+ super(ResetPasswordForm, self).__init__(*args, **kwargs)
58
59
60 class NewAccountForm(GenericEmailForm, ResetPasswordForm):
61@@ -155,6 +170,7 @@
62 def __init__(self, *args, **kwargs):
63 self.require_username = kwargs.pop('require_username', False)
64 self.require_password = kwargs.pop('require_password', True)
65+ kwargs['require_confirmation'] = False
66 super(NewAccountForm, self).__init__(*args, **kwargs)
67 # validate password against email
68 leak_creds_validator = get_password_validator(
69
70=== modified file 'src/identityprovider/tests/sso_server/test_home_page.py'
71--- src/identityprovider/tests/sso_server/test_home_page.py 2015-06-29 20:44:44 +0000
72+++ src/identityprovider/tests/sso_server/test_home_page.py 2018-12-13 16:29:16 +0000
73@@ -58,6 +58,7 @@
74 # The user can edit their details from the main account page:
75 data = dict(
76 displayname="New name",
77+ oldpassword=self.default_password,
78 password="TestPass23", passwordconfirm="TestPass23",
79 preferred_email=self.account.preferredemail.id,
80 )
81
82=== modified file 'src/identityprovider/tests/test_forms.py'
83--- src/identityprovider/tests/test_forms.py 2018-05-25 21:47:30 +0000
84+++ src/identityprovider/tests/test_forms.py 2018-12-13 16:29:16 +0000
85@@ -426,7 +426,8 @@
86
87 class EditAccountFormTestCase(FormBaseTestCase):
88
89- fields = ['displayname', 'preferred_email', 'password', 'passwordconfirm']
90+ fields = ['displayname', 'preferred_email',
91+ 'oldpassword', 'password', 'passwordconfirm']
92
93 def setUp(self):
94 super(EditAccountFormTestCase, self).setUp()
95@@ -471,6 +472,7 @@
96 password='foobarbaz', passwordconfirm='foobarbaz',
97 displayname='Foo Bar',
98 preferred_email=self.account.preferredemail.id,
99+ oldpassword=DEFAULT_USER_PASSWORD,
100 )
101 form = EditAccountForm(data=data, instance=self.account)
102 self.assertEqual(form.is_valid(), True)
103@@ -526,7 +528,8 @@
104 invalid_email = self.account.unverified_emails()[0]
105 form = self.get_form(
106 data={'displayname': self.account.displayname,
107- 'preferred_email': invalid_email.id})
108+ 'preferred_email': invalid_email.id,
109+ 'oldpassword': DEFAULT_USER_PASSWORD})
110 self.assert_form_is_valid(form)
111 self.assertFalse(form.password_changed)
112 form.save()
113@@ -570,6 +573,7 @@
114 original_value = self.account.accountpassword.password
115 form = self.get_form(
116 data={'password': 'testingpass', 'passwordconfirm': 'testingpass',
117+ 'oldpassword': DEFAULT_USER_PASSWORD,
118 'displayname': self.account.displayname,
119 'preferred_email': preferred_email})
120 self.assert_form_is_valid(form)
121@@ -578,6 +582,15 @@
122 account = Account.objects.get(id=self.account.id)
123 self.assertNotEqual(account.accountpassword.password, original_value)
124
125+ def test_confirm_change_password(self):
126+ preferred_email = self.account.preferredemail.id
127+ form = self.get_form(
128+ data={'password': 'testingpass', 'passwordconfirm': 'testingpass',
129+ 'displayname': self.account.displayname,
130+ 'preferred_email': preferred_email})
131+ self.assertFalse(form.is_valid())
132+ self.assertEqual(1, len(form.errors['oldpassword']))
133+
134 def test_password_change_resets_last_leak_check(self):
135 preferred_email = self.account.preferredemail.id
136 last_checked = now()
137@@ -587,6 +600,7 @@
138 last_checked)
139 form = self.get_form(
140 data={'password': 'testingpass', 'passwordconfirm': 'testingpass',
141+ 'oldpassword': DEFAULT_USER_PASSWORD,
142 'displayname': self.account.displayname,
143 'preferred_email': preferred_email})
144 self.assert_form_is_valid(form)
145@@ -611,6 +625,7 @@
146 data = dict(
147 preferred_email=self.account.preferredemail.id,
148 displayname='Zaraza New',
149+ oldpassword=DEFAULT_USER_PASSWORD,
150 )
151 form = self.get_form(data=data)
152
153@@ -624,7 +639,8 @@
154 class EditAccountFormDevicePrefsEnabledTestCase(EditAccountFormTestCase):
155
156 fields = ['displayname', 'preferred_email', 'password', 'passwordconfirm',
157- 'warn_about_backup_device', 'twofactor_required']
158+ 'warn_about_backup_device', 'twofactor_required',
159+ 'oldpassword']
160 placeholders = ['displayname', 'password', 'passwordconfirm']
161
162 def get_form(self, **kwargs):
163@@ -654,6 +670,7 @@
164 twofactor_required=new_value,
165 preferred_email=self.account.preferredemail.id,
166 displayname=self.account.displayname,
167+ oldpassword=DEFAULT_USER_PASSWORD,
168 )
169 form = self.get_form(data=data)
170
171@@ -683,6 +700,7 @@
172 warn_about_backup_device=new_value,
173 preferred_email=self.account.preferredemail.id,
174 displayname=self.account.displayname,
175+ oldpassword=DEFAULT_USER_PASSWORD,
176 )
177 form = self.get_form(data=data)
178
179@@ -706,6 +724,7 @@
180 data = dict(
181 preferred_email=self.account.preferredemail.id,
182 displayname='Zaraza New',
183+ oldpassword=DEFAULT_USER_PASSWORD,
184 twofactor_required=False,
185 )
186 form = self.get_form(data=data)
187@@ -739,6 +758,7 @@
188 data = {
189 'preferred_email': self.account.preferredemail.id,
190 'displayname': self.account.displayname,
191+ 'oldpassword': DEFAULT_USER_PASSWORD,
192 'twofactor_required': False,
193 'warn_about_backup_device': False,
194 }
195
196=== modified file 'src/identityprovider/tests/test_views_server.py'
197--- src/identityprovider/tests/test_views_server.py 2018-09-07 14:19:01 +0000
198+++ src/identityprovider/tests/test_views_server.py 2018-12-13 16:29:16 +0000
199@@ -1352,7 +1352,8 @@
200 self.assertTemplateUsed(response, 'account/edit.html')
201 self.assert_message_shown(response, messages.ERROR, msg)
202 self.assertEqual(response.context['form'].errors,
203- {'username': ['Field required']})
204+ {'username': [u'Field required'],
205+ 'oldpassword': [u'Field required']})
206
207 @switches(USERNAME_UI=True)
208 def test_nickname_required__no_username__flag_enabled__post_valid(self):
209@@ -1362,6 +1363,7 @@
210 'displayname': self.account.displayname,
211 'preferred_email': self.account.preferredemail.id,
212 'username': 'test-valid+username.',
213+ 'oldpassword': self.login_password,
214 }
215 response = self.client.post(self.url, data, follow=True)
216 # Simulate username set in Launchpad.
217
218=== modified file 'src/webui/templates/widgets/passwords.html'
219--- src/webui/templates/widgets/passwords.html 2014-12-10 15:30:54 +0000
220+++ src/webui/templates/widgets/passwords.html 2018-12-13 16:29:16 +0000
221@@ -1,4 +1,19 @@
222 {% load i18n %}
223+{% if fields.oldpassword %}
224+<p>{% trans "To edit any details on this page, you must confirm your current password." %}</p>
225+
226+<div class="input-row{% if fields.oldpassword.errors or fields.non_field_errors %} haserrors{% endif %} oldpassword-input">
227+ {% if edit_account_labels %}
228+ <label for="id_oldpassword">{% trans "Current password" %}:</label>
229+ {% endif %}
230+
231+ {% if fields.oldpassword.errors %}
232+ <span class="error">{{ fields.oldpassword.errors|first }}</span>
233+ {% endif %}
234+
235+ {{ fields.oldpassword }}
236+</div>
237+{% endif %}
238
239 <div class="input-row{% if fields.password.errors or fields.non_field_errors %} haserrors{% endif %} password-input"
240 id="password-change">
241
242=== modified file 'src/webui/tests/test_views_account.py'
243--- src/webui/tests/test_views_account.py 2018-05-28 20:15:33 +0000
244+++ src/webui/tests/test_views_account.py 2018-12-13 16:29:16 +0000
245@@ -261,13 +261,15 @@
246
247 def test_index_edit_displayname(self):
248 data = {'displayname': "New Display Name",
249- 'preferred_email': self.account.preferredemail.id}
250+ 'preferred_email': self.account.preferredemail.id,
251+ 'oldpassword': DEFAULT_USER_PASSWORD}
252 r = self.client.post(self.url, data)
253 self.assertEqual(r.status_code, 302)
254
255 def test_index_edit_displayname_no_email(self):
256 data = {'displayname': "New Display Name",
257- 'preferred_email': self.account.preferredemail.id}
258+ 'preferred_email': self.account.preferredemail.id,
259+ 'oldpassword': DEFAULT_USER_PASSWORD}
260 r = self.client.post(self.url, data)
261 self.assertEqual(r.status_code, 302)
262 # no mail when editing this attribute
263@@ -287,6 +289,7 @@
264 def post_username_change(self, username, flush=True):
265 data = {'displayname': self.account.displayname,
266 'preferred_email': self.account.preferredemail.id,
267+ 'oldpassword': DEFAULT_USER_PASSWORD,
268 'username': username}
269 r = self.client.post(self.url, data)
270 if flush:
271@@ -415,6 +418,7 @@
272 data = {
273 'displayname': "New Display Name",
274 'preferred_email': self.account.preferredemail.id,
275+ 'oldpassword': DEFAULT_USER_PASSWORD,
276 'password': 'new-Password',
277 'passwordconfirm': 'new-Password',
278 'accept_tos': True
279@@ -651,6 +655,35 @@
280 username=self.login_email, password=DEFAULT_USER_PASSWORD)
281 self.assertFalse(success)
282
283+ def test_confirm_password_before_changing(self):
284+ oauth_tokens = self.account.token_set.all()
285+ # web login token should be there
286+ assert oauth_tokens
287+ orig_token = oauth_tokens[0]
288+
289+ data = {
290+ 'displayname': "New Display Name",
291+ 'preferred_email': self.account.preferredemail.id,
292+ 'password': 'new-Password',
293+ 'passwordconfirm': 'new-Password',
294+ 'accept_tos': True
295+ }
296+ response = self.client.post(self.url, data)
297+ self.assertEqual(
298+ response.context['form'].errors,
299+ {'oldpassword': [u'Field required']})
300+
301+ data['oldpassword'] = DEFAULT_USER_PASSWORD
302+
303+ response = self.client.post(self.url, data)
304+ self.assertEqual(response.status_code, 302)
305+
306+ account = Account.objects.get(displayname='New Display Name')
307+ oauth_tokens = account.token_set.all()
308+ self.assertEqual(oauth_tokens.count(), 1)
309+ # previous token was invalidated
310+ self.assertNotIn(orig_token, oauth_tokens)
311+
312
313 class AccountEditTestCase(AuthenticatedTestCase):
314
315@@ -730,6 +763,7 @@
316 data = dict(
317 displayname=self.account.displayname,
318 preferred_email=self.account.preferredemail.id,
319+ oldpassword=DEFAULT_USER_PASSWORD,
320 )
321 if self.twofactor_enabled in (True, None):
322 data['twofactor_required'] = self.user_wants_twofactor
323@@ -794,6 +828,7 @@
324 data = dict(
325 displayname=self.account.displayname,
326 preferred_email=self.account.preferredemail.id,
327+ oldpassword=DEFAULT_USER_PASSWORD,
328 twofactor_required=True,
329 )
330
331@@ -820,6 +855,7 @@
332 data = dict(
333 displayname=self.account.displayname,
334 preferred_email=self.account.preferredemail.id,
335+ oldpassword=DEFAULT_USER_PASSWORD,
336 twofactor_required=False,
337 )
338 response = self.client.post(self.url, data=data)