##// END OF EJS Templates
account: convert change password form to colander schema and fix bug...
dan -
r665:a92da8f2 default
parent child Browse files
Show More
@@ -0,0 +1,33 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 """
22 Base module for form rendering / validation - currently just a wrapper for
23 deform - later can be replaced with something custom.
24 """
25
26 from rhodecode.translation import _
27 from deform import Button, Form, widget, ValidationFailure
28
29
30 class buttons:
31 save = Button(name='Save', type='submit')
32 reset = Button(name=_('Reset'), type='reset')
33 delete = Button(name=_('Delete'), type='submit')
@@ -0,0 +1,61 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import colander
22
23 from rhodecode import forms
24 from rhodecode.model.db import User
25 from rhodecode.translation import _
26 from rhodecode.lib.auth import check_password
27
28
29 @colander.deferred
30 def deferred_user_password_validator(node, kw):
31 username = kw.get('username')
32 user = User.get_by_username(username)
33
34 def _user_password_validator(node, value):
35 if not check_password(value, user.password):
36 msg = _('Password is incorrect')
37 raise colander.Invalid(node, msg)
38 return _user_password_validator
39
40
41 class ChangePasswordSchema(colander.Schema):
42
43 current_password = colander.SchemaNode(
44 colander.String(),
45 missing=colander.required,
46 widget=forms.widget.PasswordWidget(redisplay=True),
47 validator=deferred_user_password_validator)
48
49 new_password = colander.SchemaNode(
50 colander.String(),
51 missing=colander.required,
52 widget=forms.widget.CheckedPasswordWidget(redisplay=True),
53 validator=colander.Length(min=6))
54
55
56 def validator(self, form, values):
57 if values['current_password'] == values['new_password']:
58 exc = colander.Invalid(form)
59 exc['new_password'] = _('New password must be different '
60 'to old password')
61 raise exc
@@ -0,0 +1,10 b''
1 <%def name="panel(title, class_='default')">
2 <div class="panel panel-${class_}">
3 <div class="panel-heading">
4 <h3 class="panel-title">${title}</h3>
5 </div>
6 <div class="panel-body">
7 ${caller.body()}
8 </div>
9 </div>
10 </%def>
@@ -0,0 +1,72 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import colander
22 import pytest
23
24 from rhodecode.model import validation_schema
25 from rhodecode.model.validation_schema.schemas import user_schema
26
27
28 class TestChangePasswordSchema(object):
29 original_password = 'm092d903fnio0m'
30
31 def test_deserialize_bad_data(self, user_regular):
32 schema = user_schema.ChangePasswordSchema().bind(
33 username=user_regular.username)
34
35 with pytest.raises(validation_schema.Invalid) as exc_info:
36 schema.deserialize('err')
37 err = exc_info.value.asdict()
38 assert err[''] == '"err" is not a mapping type: ' \
39 'Does not implement dict-like functionality.'
40
41 def test_validate_valid_change_password_data(self, user_util):
42 user = user_util.create_user(password=self.original_password)
43 schema = user_schema.ChangePasswordSchema().bind(
44 username=user.username)
45
46 schema.deserialize({
47 'current_password': self.original_password,
48 'new_password': '23jf04rm04imr'
49 })
50
51 @pytest.mark.parametrize(
52 'current_password,new_password,key,message', [
53 ('', 'abcdef123', 'current_password', 'required'),
54 ('wrong_pw', 'abcdef123', 'current_password', 'incorrect'),
55 (original_password, original_password, 'new_password', 'different'),
56 (original_password, '', 'new_password', 'Required'),
57 (original_password, 'short', 'new_password', 'minimum'),
58 ])
59 def test_validate_invalid_change_password_data(self, current_password,
60 new_password, key, message,
61 user_util):
62 user = user_util.create_user(password=self.original_password)
63 schema = user_schema.ChangePasswordSchema().bind(
64 username=user.username)
65
66 with pytest.raises(validation_schema.Invalid) as exc_info:
67 schema.deserialize({
68 'current_password': current_password,
69 'new_password': new_password
70 })
71 err = exc_info.value.asdict()
72 assert message.lower() in err[key].lower()
@@ -531,9 +531,7 b' def make_map(config):'
531 531 action='my_account_update', conditions={'method': ['POST']})
532 532
533 533 m.connect('my_account_password', '/my_account/password',
534 action='my_account_password', conditions={'method': ['GET']})
535 m.connect('my_account_password', '/my_account/password',
536 action='my_account_password_update', conditions={'method': ['POST']})
534 action='my_account_password', conditions={'method': ['GET', 'POST']})
537 535
538 536 m.connect('my_account_repos', '/my_account/repos',
539 537 action='my_account_repos', conditions={'method': ['GET']})
@@ -32,6 +32,7 b' from pylons.controllers.util import redi'
32 32 from pylons.i18n.translation import _
33 33 from sqlalchemy.orm import joinedload
34 34
35 from rhodecode import forms
35 36 from rhodecode.lib import helpers as h
36 37 from rhodecode.lib import auth
37 38 from rhodecode.lib.auth import (
@@ -39,10 +40,12 b' from rhodecode.lib.auth import ('
39 40 from rhodecode.lib.base import BaseController, render
40 41 from rhodecode.lib.utils2 import safe_int, md5
41 42 from rhodecode.lib.ext_json import json
43
44 from rhodecode.model.validation_schema.schemas import user_schema
42 45 from rhodecode.model.db import (
43 46 Repository, PullRequest, PullRequestReviewers, UserEmailMap, User,
44 47 UserFollowing)
45 from rhodecode.model.forms import UserForm, PasswordChangeForm
48 from rhodecode.model.forms import UserForm
46 49 from rhodecode.model.scm import RepoList
47 50 from rhodecode.model.user import UserModel
48 51 from rhodecode.model.repo import RepoModel
@@ -185,38 +188,44 b' class MyAccountController(BaseController'
185 188 force_defaults=False
186 189 )
187 190
188 @auth.CSRFRequired()
189 def my_account_password_update(self):
190 c.active = 'password'
191 self.__load_data()
192 _form = PasswordChangeForm(c.rhodecode_user.username)()
193 try:
194 form_result = _form.to_python(request.POST)
195 UserModel().update_user(c.rhodecode_user.user_id, **form_result)
196 instance = c.rhodecode_user.get_instance()
197 instance.update_userdata(force_password_change=False)
198 Session().commit()
199 session.setdefault('rhodecode_user', {}).update(
200 {'password': md5(instance.password)})
201 session.save()
202 h.flash(_("Successfully updated password"), category='success')
203 except formencode.Invalid as errors:
204 return htmlfill.render(
205 render('admin/my_account/my_account.html'),
206 defaults=errors.value,
207 errors=errors.error_dict or {},
208 prefix_error=False,
209 encoding="UTF-8",
210 force_defaults=False)
211 except Exception:
212 log.exception("Exception updating password")
213 h.flash(_('Error occurred during update of user password'),
214 category='error')
215 return render('admin/my_account/my_account.html')
216
191 @auth.CSRFRequired(except_methods=['GET'])
217 192 def my_account_password(self):
218 193 c.active = 'password'
219 194 self.__load_data()
195
196 schema = user_schema.ChangePasswordSchema().bind(
197 username=c.rhodecode_user.username)
198
199 form = forms.Form(schema,
200 buttons=(forms.buttons.save, forms.buttons.reset))
201
202 if request.method == 'POST':
203 controls = request.POST.items()
204 try:
205 valid_data = form.validate(controls)
206 UserModel().update_user(c.rhodecode_user.user_id, **valid_data)
207 instance = c.rhodecode_user.get_instance()
208 instance.update_userdata(force_password_change=False)
209 Session().commit()
210 except forms.ValidationFailure as e:
211 request.session.flash(
212 _('Error occurred during update of user password'),
213 queue='error')
214 form = e
215 except Exception:
216 log.exception("Exception updating password")
217 request.session.flash(
218 _('Error occurred during update of user password'),
219 queue='error')
220 else:
221 session.setdefault('rhodecode_user', {}).update(
222 {'password': md5(instance.password)})
223 session.save()
224 request.session.flash(
225 _("Successfully updated password"), queue='success')
226 return redirect(url('my_account_password'))
227
228 c.form = form
220 229 return render('admin/my_account/my_account.html')
221 230
222 231 def my_account_repos(self):
@@ -1116,9 +1116,11 b' class CSRFRequired(object):'
1116 1116 For use with the ``webhelpers.secure_form`` helper functions.
1117 1117
1118 1118 """
1119 def __init__(self, token=csrf_token_key, header='X-CSRF-Token'):
1119 def __init__(self, token=csrf_token_key, header='X-CSRF-Token',
1120 except_methods=None):
1120 1121 self.token = token
1121 1122 self.header = header
1123 self.except_methods = except_methods or []
1122 1124
1123 1125 def __call__(self, func):
1124 1126 return get_cython_compat_decorator(self.__wrapper, func)
@@ -1131,6 +1133,9 b' class CSRFRequired(object):'
1131 1133 return supplied_token and supplied_token == cur_token
1132 1134
1133 1135 def __wrapper(self, func, *fargs, **fkwargs):
1136 if request.method in self.except_methods:
1137 return func(*fargs, **fkwargs)
1138
1134 1139 cur_token = get_csrf_token(save_if_missing=False)
1135 1140 if self.check_csrf(request, cur_token):
1136 1141 if request.POST.get(self.token):
@@ -102,20 +102,6 b' def LoginForm():'
102 102 return _LoginForm
103 103
104 104
105 def PasswordChangeForm(username):
106 class _PasswordChangeForm(formencode.Schema):
107 allow_extra_fields = True
108 filter_extra_fields = True
109
110 current_password = v.ValidOldPassword(username)(not_empty=True)
111 new_password = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6))
112 new_password_confirmation = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6))
113
114 chained_validators = [v.ValidPasswordsMatch('new_password',
115 'new_password_confirmation')]
116 return _PasswordChangeForm
117
118
119 105 def UserForm(edit=False, available_languages=[], old_data={}):
120 106 class _UserForm(formencode.Schema):
121 107 allow_extra_fields = True
@@ -147,7 +147,6 b' class UserModel(BaseModel):'
147 147 # cleanups, my_account password change form
148 148 kwargs.pop('current_password', None)
149 149 kwargs.pop('new_password', None)
150 kwargs.pop('new_password_confirmation', None)
151 150
152 151 # cleanups, user edit password change form
153 152 kwargs.pop('password_confirmation', None)
@@ -13,7 +13,3 b' def ip_addr_validator(node, value):'
13 13 except ValueError:
14 14 msg = _(u'Please enter a valid IPv4 or IpV6 address')
15 15 raise colander.Invalid(node, msg)
16
17
18
19
@@ -12,6 +12,7 b''
12 12
13 13 .control-label {
14 14 width: 200px;
15 padding: 10px;
15 16 float: left;
16 17 }
17 18 .control-inputs {
@@ -26,6 +27,13 b''
26 27
27 28 .form-group {
28 29 clear: left;
30 margin-bottom: 20px;
31
32 &:after { /* clear fix */
33 content: " ";
34 display: block;
35 clear: left;
36 }
29 37 }
30 38
31 39 .form-control {
@@ -34,6 +42,11 b''
34 42
35 43 .error-block {
36 44 color: red;
45 margin: 0;
46 }
47
48 .help-block {
49 margin: 0;
37 50 }
38 51
39 52 .deform-seq-container .control-inputs {
@@ -62,7 +75,9 b''
62 75 }
63 76 }
64 77
65 .form-control.select2-container { height: 40px; }
78 .form-control.select2-container {
79 height: 40px;
80 }
66 81
67 82 .deform-two-field-sequence .deform-seq-container .deform-seq-item label {
68 83 display: none;
@@ -74,7 +89,7 b''
74 89 display: none;
75 90 }
76 91 .deform-two-field-sequence .deform-seq-container .deform-seq-item.form-group {
77 background: red;
92 margin: 0;
78 93 }
79 94 .deform-two-field-sequence .deform-seq-container .deform-seq-item .deform-seq-item-group .form-group {
80 95 width: 45%; padding: 0 2px; float: left; clear: none;
@@ -1,42 +1,5 b''
1 <div class="panel panel-default">
2 <div class="panel-heading">
3 <h3 class="panel-title">${_('Change Your Account Password')}</h3>
4 </div>
5 ${h.secure_form(url('my_account_password'), method='post')}
6 <div class="panel-body">
7 <div class="fields">
8 <div class="field">
9 <div class="label">
10 <label for="current_password">${_('Current Password')}:</label>
11 </div>
12 <div class="input">
13 ${h.password('current_password',class_='medium',autocomplete="off")}
14 </div>
15 </div>
1 <%namespace name="widgets" file="/widgets.html"/>
16 2
17 <div class="field">
18 <div class="label">
19 <label for="new_password">${_('New Password')}:</label>
20 </div>
21 <div class="input">
22 ${h.password('new_password',class_='medium', autocomplete="off")}
23 </div>
24 </div>
25
26 <div class="field">
27 <div class="label">
28 <label for="password_confirmation">${_('Confirm New Password')}:</label>
29 </div>
30 <div class="input">
31 ${h.password('new_password_confirmation',class_='medium', autocomplete="off")}
32 </div>
33 </div>
34
35 <div class="buttons">
36 ${h.submit('save',_('Save'),class_="btn")}
37 ${h.reset('reset',_('Reset'),class_="btn")}
38 </div>
39 </div>
40 </div>
41 ${h.end_form()}
42 </div> No newline at end of file
3 <%widgets:panel title="${_('Change Your Account Password')}">
4 ${c.form.render() | n}
5 </%widgets:panel>
@@ -15,7 +15,6 b" if getattr(c, 'rhodecode_user', None) an"
15 15
16 16 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
17 17 %>
18
19 18 <html xmlns="http://www.w3.org/1999/xhtml">
20 19 <head>
21 20 <title>${self.title()}</title>
@@ -21,6 +21,7 b''
21 21 import pytest
22 22
23 23 from rhodecode.lib import helpers as h
24 from rhodecode.lib.auth import check_password
24 25 from rhodecode.model.db import User, UserFollowing, Repository, UserApiKeys
25 26 from rhodecode.model.meta import Session
26 27 from rhodecode.tests import (
@@ -34,6 +35,7 b' fixture = Fixture()'
34 35
35 36 class TestMyAccountController(TestController):
36 37 test_user_1 = 'testme'
38 test_user_1_password = '0jd83nHNS/d23n'
37 39 destroy_users = set()
38 40
39 41 @classmethod
@@ -158,7 +160,8 b' class TestMyAccountController(TestContro'
158 160 ('email', {'email': 'some@email.com'}),
159 161 ])
160 162 def test_my_account_update(self, name, attrs):
161 usr = fixture.create_user(self.test_user_1, password='qweqwe',
163 usr = fixture.create_user(self.test_user_1,
164 password=self.test_user_1_password,
162 165 email='testme@rhodecode.org',
163 166 extern_type='rhodecode',
164 167 extern_name=self.test_user_1,
@@ -167,7 +170,8 b' class TestMyAccountController(TestContro'
167 170
168 171 params = usr.get_api_data() # current user data
169 172 user_id = usr.user_id
170 self.log_user(username=self.test_user_1, password='qweqwe')
173 self.log_user(
174 username=self.test_user_1, password=self.test_user_1_password)
171 175
172 176 params.update({'password_confirmation': ''})
173 177 params.update({'new_password': ''})
@@ -321,8 +325,55 b' class TestMyAccountController(TestContro'
321 325 response = response.follow()
322 326 response.mustcontain(no=[api_key])
323 327
324 def test_password_is_updated_in_session_on_password_change(
325 self, user_util):
328 def test_valid_change_password(self, user_util):
329 new_password = 'my_new_valid_password'
330 user = user_util.create_user(password=self.test_user_1_password)
331 session = self.log_user(user.username, self.test_user_1_password)
332 form_data = [
333 ('current_password', self.test_user_1_password),
334 ('__start__', 'new_password:mapping'),
335 ('new_password', new_password),
336 ('new_password-confirm', new_password),
337 ('__end__', 'new_password:mapping'),
338 ('csrf_token', self.csrf_token),
339 ]
340 response = self.app.post(url('my_account_password'), form_data).follow()
341 assert 'Successfully updated password' in response
342
343 # check_password depends on user being in session
344 Session().add(user)
345 try:
346 assert check_password(new_password, user.password)
347 finally:
348 Session().expunge(user)
349
350 @pytest.mark.parametrize('current_pw,new_pw,confirm_pw', [
351 ('', 'abcdef123', 'abcdef123'),
352 ('wrong_pw', 'abcdef123', 'abcdef123'),
353 (test_user_1_password, test_user_1_password, test_user_1_password),
354 (test_user_1_password, '', ''),
355 (test_user_1_password, 'abcdef123', ''),
356 (test_user_1_password, '', 'abcdef123'),
357 (test_user_1_password, 'not_the', 'same_pw'),
358 (test_user_1_password, 'short', 'short'),
359 ])
360 def test_invalid_change_password(self, current_pw, new_pw, confirm_pw,
361 user_util):
362 user = user_util.create_user(password=self.test_user_1_password)
363 session = self.log_user(user.username, self.test_user_1_password)
364 old_password_hash = session['password']
365 form_data = [
366 ('current_password', current_pw),
367 ('__start__', 'new_password:mapping'),
368 ('new_password', new_pw),
369 ('new_password-confirm', confirm_pw),
370 ('__end__', 'new_password:mapping'),
371 ('csrf_token', self.csrf_token),
372 ]
373 response = self.app.post(url('my_account_password'), form_data)
374 assert 'Error occurred' in response
375
376 def test_password_is_updated_in_session_on_password_change(self, user_util):
326 377 old_password = 'abcdef123'
327 378 new_password = 'abcdef124'
328 379
@@ -330,12 +381,14 b' class TestMyAccountController(TestContro'
330 381 session = self.log_user(user.username, old_password)
331 382 old_password_hash = session['password']
332 383
333 form_data = {
334 'current_password': old_password,
335 'new_password': new_password,
336 'new_password_confirmation': new_password,
337 'csrf_token': self.csrf_token
338 }
384 form_data = [
385 ('current_password', old_password),
386 ('__start__', 'new_password:mapping'),
387 ('new_password', new_password),
388 ('new_password-confirm', new_password),
389 ('__end__', 'new_password:mapping'),
390 ('csrf_token', self.csrf_token),
391 ]
339 392 self.app.post(url('my_account_password'), form_data)
340 393
341 394 response = self.app.get(url('home'))
General Comments 0
You need to be logged in to leave comments. Login now