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 | # -*- coding: utf-8 -*- | 1 | # -*- coding: utf-8 -*- |
| 2 | 2 | ||
| 3 | +from importlib import import_module | ||
| 4 | + | ||
| 3 | from django import forms | 5 | from django import forms |
| 4 | from django.conf import settings | 6 | from django.conf import settings |
| 5 | from django.contrib.auth import get_user_model | 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 | from django.core.urlresolvers import reverse | 10 | from django.core.urlresolvers import reverse |
| 8 | from django.utils.functional import lazy | 11 | from django.utils.functional import lazy |
| 9 | from django.utils.translation import ugettext_lazy as _ | 12 | from django.utils.translation import ugettext_lazy as _ |
| @@ -160,7 +163,41 @@ class ListsForm(forms.Form): | @@ -160,7 +163,41 @@ class ListsForm(forms.Form): | ||
| 160 | choices=lazy(get_lists_choices, list)()) | 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 | A form that creates a user, with no privileges, from the given username and | 202 | A form that creates a user, with no privileges, from the given username and |
| 166 | password. | 203 | password. |
| @@ -231,6 +268,8 @@ class UserCreationForm(UserForm): | @@ -231,6 +268,8 @@ class UserCreationForm(UserForm): | ||
| 231 | self.error_messages['password_mismatch'], | 268 | self.error_messages['password_mismatch'], |
| 232 | code='password_mismatch', | 269 | code='password_mismatch', |
| 233 | ) | 270 | ) |
| 271 | + | ||
| 272 | + super(UserCreationForm, self).clean_password2() | ||
| 234 | return password2 | 273 | return password2 |
| 235 | 274 | ||
| 236 | def save(self, commit=True): | 275 | def save(self, commit=True): |
| @@ -282,3 +321,11 @@ class UserChangeForm(forms.ModelForm): | @@ -282,3 +321,11 @@ class UserChangeForm(forms.ModelForm): | ||
| 282 | # This is done here, rather than on the field, because the | 321 | # This is done here, rather than on the field, because the |
| 283 | # field does not have access to the initial value | 322 | # field does not have access to the initial value |
| 284 | return self.initial["password"] | 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,9 +2,9 @@ | ||
| 2 | 2 | ||
| 3 | import urlparse | 3 | import urlparse |
| 4 | 4 | ||
| 5 | -from django.db import models | ||
| 6 | from django.contrib.auth.models import AbstractUser, UserManager | 5 | from django.contrib.auth.models import AbstractUser, UserManager |
| 7 | from django.core.urlresolvers import reverse | 6 | from django.core.urlresolvers import reverse |
| 7 | +from django.db import models | ||
| 8 | from django.utils.crypto import get_random_string | 8 | from django.utils.crypto import get_random_string |
| 9 | from django.utils.translation import ugettext_lazy as _ | 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,16 +6,50 @@ Objective: Test parameters, and behavior. | ||
| 6 | import datetime | 6 | import datetime |
| 7 | from mock import patch | 7 | from mock import patch |
| 8 | 8 | ||
| 9 | -from django.test import TestCase | 9 | +from django.test import TestCase, override_settings |
| 10 | from django.core.urlresolvers import reverse | 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 | from colab.accounts import forms as accounts_forms | 16 | from colab.accounts import forms as accounts_forms |
| 15 | from colab.accounts.models import User | 17 | from colab.accounts.models import User |
| 16 | from colab.accounts.utils import mailman | 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 | class FormTest(TestCase): | 53 | class FormTest(TestCase): |
| 20 | 54 | ||
| 21 | def setUp(self): | 55 | def setUp(self): |
| @@ -183,3 +217,121 @@ class FormTest(TestCase): | @@ -183,3 +217,121 @@ class FormTest(TestCase): | ||
| 183 | ('listB', u'listB (B)'), | 217 | ('listB', u'listB (B)'), |
| 184 | ('listC', u'listC (C)'), | 218 | ('listC', u'listC (C)'), |
| 185 | ('listD', u'listD (D)')]) | 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 | from django.conf import settings | 2 | from django.conf import settings |
| 3 | from django.conf.urls import patterns, url | 3 | from django.conf.urls import patterns, url |
| 4 | +from django.contrib.auth import views as auth_views | ||
| 4 | 5 | ||
| 5 | from .views import (UserProfileDetailView, UserProfileUpdateView, | 6 | from .views import (UserProfileDetailView, UserProfileUpdateView, |
| 6 | ManageUserSubscriptionsView) | 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 | urlpatterns = patterns('', | 11 | urlpatterns = patterns('', |
| @@ -22,7 +21,8 @@ urlpatterns = patterns('', | @@ -22,7 +21,8 @@ urlpatterns = patterns('', | ||
| 22 | 21 | ||
| 23 | url(r'^password-reset-confirm/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$', | 22 | url(r'^password-reset-confirm/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$', |
| 24 | auth_views.password_reset_confirm, | 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 | name="password_reset_confirm"), | 26 | name="password_reset_confirm"), |
| 27 | 27 | ||
| 28 | url(r'^password-reset/?$', auth_views.password_reset, | 28 | url(r'^password-reset/?$', auth_views.password_reset, |
| @@ -30,7 +30,8 @@ urlpatterns = patterns('', | @@ -30,7 +30,8 @@ urlpatterns = patterns('', | ||
| 30 | name="password_reset"), | 30 | name="password_reset"), |
| 31 | 31 | ||
| 32 | url(r'^change-password/?$', auth_views.password_change, | 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 | name='password_change'), | 35 | name='password_change'), |
| 35 | 36 | ||
| 36 | url(r'^change-password-done/?$', | 37 | url(r'^change-password-done/?$', |
colab/utils/conf.py
| @@ -49,7 +49,7 @@ def _load_py_file(py_path, path): | @@ -49,7 +49,7 @@ def _load_py_file(py_path, path): | ||
| 49 | sys.path.remove(path) | 49 | sys.path.remove(path) |
| 50 | 50 | ||
| 51 | py_setting = {var: getattr(py_settings, var) for var in dir(py_settings) | 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 | return py_setting | 54 | return py_setting |
| 55 | 55 | ||
| @@ -125,16 +125,7 @@ def load_colab_apps(): | @@ -125,16 +125,7 @@ def load_colab_apps(): | ||
| 125 | 125 | ||
| 126 | app_label = app_name.split('.')[-1] | 126 | app_label = app_name.split('.')[-1] |
| 127 | COLAB_APPS[app_label] = {} | 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 | return {'COLAB_APPS': COLAB_APPS} | 130 | return {'COLAB_APPS': COLAB_APPS} |
| 140 | 131 |
docs/source/plugindev.rst
| @@ -189,3 +189,32 @@ Example Usage: | @@ -189,3 +189,32 @@ Example Usage: | ||
| 189 | 189 | ||
| 190 | def get_last_updated_timestamp(self): | 190 | def get_last_updated_timestamp(self): |
| 191 | return TimeStampPlugin.get_last_updated_timestamp('TestPlugin') | 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,6 +165,15 @@ Declares the additional installed apps that this plugin depends on. | ||
| 165 | This doesn't automatically install the python dependecies, only add to django | 165 | This doesn't automatically install the python dependecies, only add to django |
| 166 | apps. | 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 | urls | 177 | urls |
| 169 | ++++ | 178 | ++++ |
| 170 | 179 |