Commit 91d859bb2708608fad75a8bab13dbe9952715e9a
Exists in
master
and in
3 other branches
Merge pull request #124 from colab/validate-passwd
Validate passwd
Showing
8 changed files
with
256 additions
and
22 deletions
Show diff stats
colab/accounts/forms.py
1 | 1 | # -*- coding: utf-8 -*- |
2 | 2 | |
3 | +from importlib import import_module | |
4 | + | |
3 | 5 | from django import forms |
4 | 6 | from django.conf import settings |
5 | 7 | from django.contrib.auth import get_user_model |
6 | -from django.contrib.auth.forms import ReadOnlyPasswordHashField | |
8 | +from django.contrib.auth.forms import (ReadOnlyPasswordHashField, | |
9 | + SetPasswordForm, PasswordChangeForm) | |
7 | 10 | from django.core.urlresolvers import reverse |
8 | 11 | from django.utils.functional import lazy |
9 | 12 | from django.utils.translation import ugettext_lazy as _ |
... | ... | @@ -160,7 +163,41 @@ class ListsForm(forms.Form): |
160 | 163 | choices=lazy(get_lists_choices, list)()) |
161 | 164 | |
162 | 165 | |
163 | -class UserCreationForm(UserForm): | |
166 | +class ColabSetPasswordFormMixin(object): | |
167 | + | |
168 | + def apply_custom_validators(self, password): | |
169 | + for app in settings.COLAB_APPS.values(): | |
170 | + if 'password_validators' in app: | |
171 | + for validator_path in app.get('password_validators'): | |
172 | + module_path, func_name = validator_path.rsplit('.', 1) | |
173 | + module = import_module(module_path) | |
174 | + validator_func = getattr(module, func_name, None) | |
175 | + if validator_func: | |
176 | + validator_func(password) | |
177 | + | |
178 | + return password | |
179 | + | |
180 | + def clean_new_password2(self): | |
181 | + try: | |
182 | + password = super(ColabSetPasswordFormMixin, | |
183 | + self).clean_new_password2() | |
184 | + except AttributeError: | |
185 | + password = self.cleaned_data['new_password2'] | |
186 | + | |
187 | + self.apply_custom_validators(password) | |
188 | + return password | |
189 | + | |
190 | + def clean_password2(self): | |
191 | + try: | |
192 | + password = super(ColabSetPasswordFormMixin, self).clean_password2() | |
193 | + except AttributeError: | |
194 | + password = self.cleaned_data['password2'] | |
195 | + | |
196 | + self.apply_custom_validators(password) | |
197 | + return password | |
198 | + | |
199 | + | |
200 | +class UserCreationForm(UserForm, ColabSetPasswordFormMixin): | |
164 | 201 | """ |
165 | 202 | A form that creates a user, with no privileges, from the given username and |
166 | 203 | password. |
... | ... | @@ -231,6 +268,8 @@ class UserCreationForm(UserForm): |
231 | 268 | self.error_messages['password_mismatch'], |
232 | 269 | code='password_mismatch', |
233 | 270 | ) |
271 | + | |
272 | + super(UserCreationForm, self).clean_password2() | |
234 | 273 | return password2 |
235 | 274 | |
236 | 275 | def save(self, commit=True): |
... | ... | @@ -282,3 +321,11 @@ class UserChangeForm(forms.ModelForm): |
282 | 321 | # This is done here, rather than on the field, because the |
283 | 322 | # field does not have access to the initial value |
284 | 323 | return self.initial["password"] |
324 | + | |
325 | + | |
326 | +class ColabSetPasswordForm(ColabSetPasswordFormMixin, SetPasswordForm): | |
327 | + pass | |
328 | + | |
329 | + | |
330 | +class ColabPasswordChangeForm(ColabSetPasswordFormMixin, PasswordChangeForm): | |
331 | + pass | ... | ... |
colab/accounts/models.py
... | ... | @@ -2,9 +2,9 @@ |
2 | 2 | |
3 | 3 | import urlparse |
4 | 4 | |
5 | -from django.db import models | |
6 | 5 | from django.contrib.auth.models import AbstractUser, UserManager |
7 | 6 | from django.core.urlresolvers import reverse |
7 | +from django.db import models | |
8 | 8 | from django.utils.crypto import get_random_string |
9 | 9 | from django.utils.translation import ugettext_lazy as _ |
10 | 10 | ... | ... |
colab/accounts/tests/test_forms.py
... | ... | @@ -6,16 +6,50 @@ Objective: Test parameters, and behavior. |
6 | 6 | import datetime |
7 | 7 | from mock import patch |
8 | 8 | |
9 | -from django.test import TestCase | |
9 | +from django.test import TestCase, override_settings | |
10 | 10 | from django.core.urlresolvers import reverse |
11 | 11 | |
12 | -from colab.accounts.forms import UserCreationForm, UserChangeForm,\ | |
13 | - UserUpdateForm, UserForm, get_lists_choices | |
12 | +from colab.accounts.forms import (UserCreationForm, UserChangeForm, | |
13 | + UserUpdateForm, UserForm, get_lists_choices, | |
14 | + ColabSetPasswordForm, | |
15 | + ColabPasswordChangeForm) | |
14 | 16 | from colab.accounts import forms as accounts_forms |
15 | 17 | from colab.accounts.models import User |
16 | 18 | from colab.accounts.utils import mailman |
17 | 19 | |
18 | 20 | |
21 | +class SetPasswordFormTestCase(TestCase): | |
22 | + | |
23 | + TEST_COLAB_APPS = { | |
24 | + 'test_plugin': { | |
25 | + 'password_validators': ( | |
26 | + 'colab.accounts.tests.utils.password_validator', | |
27 | + ) | |
28 | + } | |
29 | + } | |
30 | + | |
31 | + @property | |
32 | + def user(self): | |
33 | + return User.objects.create_user(username='test_user', | |
34 | + email='test@example.com') | |
35 | + | |
36 | + @property | |
37 | + def valid_form_data(self): | |
38 | + return {'new_password1': '12345', | |
39 | + 'new_password2': '12345'} | |
40 | + | |
41 | + def test_no_custom_validators(self): | |
42 | + form = ColabSetPasswordForm(self.user, data=self.valid_form_data) | |
43 | + self.assertTrue(form.is_valid(), True) | |
44 | + | |
45 | + @override_settings(COLAB_APPS=TEST_COLAB_APPS) | |
46 | + @patch('colab.accounts.tests.utils.password_validator') | |
47 | + def test_custom_validator(self, validator): | |
48 | + form = ColabSetPasswordForm(self.user, data=self.valid_form_data) | |
49 | + self.assertTrue(form.is_valid()) | |
50 | + validator.assert_called_with('12345') | |
51 | + | |
52 | + | |
19 | 53 | class FormTest(TestCase): |
20 | 54 | |
21 | 55 | def setUp(self): |
... | ... | @@ -183,3 +217,121 @@ class FormTest(TestCase): |
183 | 217 | ('listB', u'listB (B)'), |
184 | 218 | ('listC', u'listC (C)'), |
185 | 219 | ('listD', u'listD (D)')]) |
220 | + | |
221 | + | |
222 | +class ChangePasswordFormTestCase(TestCase): | |
223 | + | |
224 | + TEST_COLAB_APPS = { | |
225 | + 'test_plugin': { | |
226 | + 'password_validators': ( | |
227 | + 'colab.accounts.tests.utils.password_validator', | |
228 | + ) | |
229 | + } | |
230 | + } | |
231 | + | |
232 | + @property | |
233 | + def user(self): | |
234 | + u = User.objects.create_user(username='test_user', | |
235 | + email='test@example.com') | |
236 | + u.set_password("123colab4") | |
237 | + return u | |
238 | + | |
239 | + @property | |
240 | + def valid_form_data(self): | |
241 | + return {'old_password': '123colab4', | |
242 | + 'new_password1': '12345', | |
243 | + 'new_password2': '12345'} | |
244 | + | |
245 | + def test_no_custom_validators(self): | |
246 | + form = ColabPasswordChangeForm(self.user, data=self.valid_form_data) | |
247 | + self.assertTrue(form.is_valid(), True) | |
248 | + | |
249 | + @override_settings(COLAB_APPS=TEST_COLAB_APPS) | |
250 | + @patch('colab.accounts.tests.utils.password_validator') | |
251 | + def test_custom_validator(self, validator): | |
252 | + form = ColabPasswordChangeForm(self.user, data=self.valid_form_data) | |
253 | + self.assertTrue(form.is_valid()) | |
254 | + validator.assert_called_with('12345') | |
255 | + | |
256 | + | |
257 | +class UserCreationFormTestCase(TestCase): | |
258 | + | |
259 | + @classmethod | |
260 | + def setUpClass(cls): | |
261 | + cls.user = User.objects.create_user(username='user1234', | |
262 | + email='teste1234@example.com', | |
263 | + first_name='test_first_name', | |
264 | + last_name='test_last_name') | |
265 | + | |
266 | + cls.user.set_password("123colab4") | |
267 | + cls.user.save() | |
268 | + | |
269 | + def get_form_data(self, email, username='test_user', | |
270 | + password1='12345', password2='12345'): | |
271 | + return { | |
272 | + 'first_name': 'test_first_name', | |
273 | + 'last_name': 'test_last_name', | |
274 | + 'username': username, | |
275 | + 'email': email, | |
276 | + 'password1': password1, | |
277 | + 'password2': password2 | |
278 | + } | |
279 | + | |
280 | + def test_clean_mail_error(self): | |
281 | + creation_form = UserCreationForm( | |
282 | + data=self.get_form_data('teste1234@example.com')) | |
283 | + self.assertFalse(creation_form.is_valid()) | |
284 | + self.assertTrue('email' in creation_form.errors) | |
285 | + self.assertEqual(1, len(creation_form.errors)) | |
286 | + | |
287 | + def test_clean_mail(self): | |
288 | + creation_form = UserCreationForm( | |
289 | + data=self.get_form_data('teste12345@example.com')) | |
290 | + self.assertTrue(creation_form.is_valid()) | |
291 | + | |
292 | + def test_clean_username_error(self): | |
293 | + creation_form = UserCreationForm( | |
294 | + data=self.get_form_data('teste12345@example.com', | |
295 | + username='user1234')) | |
296 | + self.assertFalse(creation_form.is_valid()) | |
297 | + self.assertTrue('username' in creation_form.errors) | |
298 | + self.assertEqual(1, len(creation_form.errors)) | |
299 | + | |
300 | + def test_clean_username(self): | |
301 | + creation_form = UserCreationForm( | |
302 | + data=self.get_form_data('teste12345@example.com', | |
303 | + username='user12345')) | |
304 | + self.assertTrue(creation_form.is_valid()) | |
305 | + | |
306 | + def test_clean_password2_empty_password1(self): | |
307 | + creation_form = UserCreationForm( | |
308 | + data=self.get_form_data('teste12345@example.com', | |
309 | + username='user12345', | |
310 | + password1='')) | |
311 | + self.assertFalse(creation_form.is_valid()) | |
312 | + self.assertTrue('password1' in creation_form.errors) | |
313 | + self.assertEqual(1, len(creation_form.errors)) | |
314 | + | |
315 | + def test_clean_password2_empty_password2(self): | |
316 | + creation_form = UserCreationForm( | |
317 | + data=self.get_form_data('teste12345@example.com', | |
318 | + username='user12345', | |
319 | + password2='')) | |
320 | + self.assertFalse(creation_form.is_valid()) | |
321 | + self.assertTrue('password2' in creation_form.errors) | |
322 | + | |
323 | + def test_clean_password2_different_passwords(self): | |
324 | + creation_form = UserCreationForm( | |
325 | + data=self.get_form_data('teste12345@example.com', | |
326 | + username='user12345', | |
327 | + password1='1234')) | |
328 | + self.assertFalse(creation_form.is_valid()) | |
329 | + self.assertTrue('password2' in creation_form.errors) | |
330 | + self.assertEqual(1, len(creation_form.errors)) | |
331 | + self.assertEqual(1, len(creation_form.errors)) | |
332 | + | |
333 | + def test_clean_password(self): | |
334 | + creation_form = UserCreationForm( | |
335 | + data=self.get_form_data('teste12345@example.com', | |
336 | + username='user12345')) | |
337 | + self.assertTrue(creation_form.is_valid()) | ... | ... |
colab/accounts/urls.py
1 | 1 | |
2 | 2 | from django.conf import settings |
3 | 3 | from django.conf.urls import patterns, url |
4 | +from django.contrib.auth import views as auth_views | |
4 | 5 | |
5 | 6 | from .views import (UserProfileDetailView, UserProfileUpdateView, |
6 | 7 | ManageUserSubscriptionsView) |
7 | - | |
8 | -from colab.accounts import views | |
9 | -from django.contrib.auth import views as auth_views | |
8 | +from .forms import ColabPasswordChangeForm, ColabSetPasswordForm | |
10 | 9 | |
11 | 10 | |
12 | 11 | urlpatterns = patterns('', |
... | ... | @@ -22,7 +21,8 @@ urlpatterns = patterns('', |
22 | 21 | |
23 | 22 | url(r'^password-reset-confirm/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$', |
24 | 23 | auth_views.password_reset_confirm, |
25 | - {'template_name':'registration/password_reset_confirm_custom.html'}, | |
24 | + {'template_name':'registration/password_reset_confirm_custom.html', | |
25 | + 'set_password_form': ColabSetPasswordForm}, | |
26 | 26 | name="password_reset_confirm"), |
27 | 27 | |
28 | 28 | url(r'^password-reset/?$', auth_views.password_reset, |
... | ... | @@ -30,7 +30,8 @@ urlpatterns = patterns('', |
30 | 30 | name="password_reset"), |
31 | 31 | |
32 | 32 | url(r'^change-password/?$', auth_views.password_change, |
33 | - {'template_name':'registration/password_change_form_custom.html'}, | |
33 | + {'template_name': 'registration/password_change_form_custom.html', | |
34 | + 'password_change_form': ColabPasswordChangeForm}, | |
34 | 35 | name='password_change'), |
35 | 36 | |
36 | 37 | url(r'^change-password-done/?$', | ... | ... |
colab/utils/conf.py
... | ... | @@ -49,7 +49,7 @@ def _load_py_file(py_path, path): |
49 | 49 | sys.path.remove(path) |
50 | 50 | |
51 | 51 | py_setting = {var: getattr(py_settings, var) for var in dir(py_settings) |
52 | - if not var.startswith('__')} | |
52 | + if not var.startswith('_')} | |
53 | 53 | |
54 | 54 | return py_setting |
55 | 55 | |
... | ... | @@ -125,16 +125,7 @@ def load_colab_apps(): |
125 | 125 | |
126 | 126 | app_label = app_name.split('.')[-1] |
127 | 127 | COLAB_APPS[app_label] = {} |
128 | - COLAB_APPS[app_label]['menu_title'] = py_settings_d.get('menu_title') | |
129 | - | |
130 | - fields = ['verbose_name', 'upstream', 'urls', | |
131 | - 'menu_urls', 'middlewares', 'dependencies', | |
132 | - 'context_processors', 'private_token', 'name', 'extra'] | |
133 | - | |
134 | - for key in fields: | |
135 | - value = py_settings_d.get(key) | |
136 | - if value: | |
137 | - COLAB_APPS[app_label][key] = value | |
128 | + COLAB_APPS[app_label] = py_settings_d | |
138 | 129 | |
139 | 130 | return {'COLAB_APPS': COLAB_APPS} |
140 | 131 | ... | ... |
docs/source/plugindev.rst
... | ... | @@ -189,3 +189,32 @@ Example Usage: |
189 | 189 | |
190 | 190 | def get_last_updated_timestamp(self): |
191 | 191 | return TimeStampPlugin.get_last_updated_timestamp('TestPlugin') |
192 | + | |
193 | + | |
194 | +Password Validation | |
195 | +------------------- | |
196 | + | |
197 | +Allows the plugin to define rules to set the password. The validators | |
198 | +are functions which receive the password as only argument and if it | |
199 | +doesn't match the desired rules raises a `ValidationError`. The message | |
200 | +sent in the validation error will be displayed to user in the HTML form. | |
201 | + | |
202 | +Example: | |
203 | + | |
204 | +.. code-block:: python | |
205 | + | |
206 | + ## myplugin/password_validators.py | |
207 | + | |
208 | + def has_uppercase_char(password): | |
209 | + for char in password: | |
210 | + if char.isupper(): | |
211 | + return | |
212 | + | |
213 | + raise ValidationError('Password must have at least one upper case char') | |
214 | + | |
215 | + ## /etc/colab/plugins.d/myplugin.py | |
216 | + | |
217 | + password_validators = ( | |
218 | + 'myplugin.password_validators.has_uppercase_char', | |
219 | + ) | |
220 | + | ... | ... |
docs/source/user.rst
... | ... | @@ -165,6 +165,15 @@ Declares the additional installed apps that this plugin depends on. |
165 | 165 | This doesn't automatically install the python dependecies, only add to django |
166 | 166 | apps. |
167 | 167 | |
168 | +.. attribute:: password_validators | |
169 | + | |
170 | +A lista of functions to validade password in the moment it's set. | |
171 | +This allows plugins to define their own password validators. For | |
172 | +example if the proxied app requires the password to have at least | |
173 | +one upper case character it should provide a password validator | |
174 | +for that. | |
175 | + | |
176 | + | |
168 | 177 | urls |
169 | 178 | ++++ |
170 | 179 | ... | ... |