From 0762876ec5b9fdad909d2eaf623aacd3f4e2bd29 Mon Sep 17 00:00:00 2001 From: heinzel Date: Tue, 22 Dec 2020 15:53:32 +0100 Subject: [PATCH] UPD: auth: added score based password validator --- dav_auth/tests/test_validators.py | 170 ++++++++++++++++++++++++++++++ dav_auth/validators.py | 161 ++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 dav_auth/tests/test_validators.py create mode 100644 dav_auth/validators.py diff --git a/dav_auth/tests/test_validators.py b/dav_auth/tests/test_validators.py new file mode 100644 index 0000000..31af7a2 --- /dev/null +++ b/dav_auth/tests/test_validators.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase + +from ..validators import PasswordScoreValidator, CharacterClassPasswordValidator + + +class PasswordScoreValidatorTestCase(SimpleTestCase): + def test_too_little_score(self): + passwords = [ + '', # score = 0 + 'abcdefghijklmnopq', # score = 17 + 'Abcdefghijklmnop', # score = 16 + 1 + 'ABcdefghijklmno', # score = 15 + 2 + 'AB1defghijklmn', # score = 14 + 2 + 1 + 'AB12efghijklm', # score = 13 + 2 + 2 + 'AB12+fghijkl', # score = 12 + 2 + 2 + 1 + 'AB12+-ghijk', # score = 11 + 2 + 2 + 2 + 'AB12+-*hij', # score = 10 + 2 + 2 + 3 + 'AB12+-*hi/', # score = 10 + 2 + 2 + 3 + 'AB12+-*hi3', # score = 10 + 2 + 2 + 3 + 'AB12+-*hiC', # score = 10 + 2 + 2 + 3 + 'abcbbbgbbjklmnopqr', # score = 17 + ] + + validator = PasswordScoreValidator(min_classes=0) + + for password in passwords: + try: + validator.validate(password) + except ValidationError as e: + self.assertEqual('too_little_score', e.code) + else: + self.fail('%s: no validation error was raised' % password) + + def test_too_few_classes(self): + passwords = [ + '', # classes = 0 + 'abcdefgh', # classes = 1 + 'abcdef+-', # classes = 2 + 'abcd12gh', # classes = 2 + 'abcd12-+', # classes = 3 + 'abCDefgh', # classes = 2 + 'abCDef+-', # classes = 3 + 'abCD12gh', # classes = 3 + 'ABCD12-+', # classes = 3 + ] + + validator = PasswordScoreValidator(min_score=0, min_classes=4) + + for password in passwords: + try: + validator.validate(password) + except ValidationError as e: + self.assertEqual('too_few_classes', e.code) + else: + self.fail('%s: no validation error was raised' % password) + + def test_valid(self): + passwords = [ + 'Abcdefghijklmnopq', + 'ABcdefghijklmnop', + 'AB1defghijklmno', + 'AB12efghijklmn', + 'AB12+fghijklm', + 'AB12+-ghijkl', + 'AB12+-*hijk', + 'ab1defghijklmnopq', + 'abcd+fghijklmnopq', + 'AB1CDEFGHIJKLMNOPQ', + ] + + validator = PasswordScoreValidator() + + for password in passwords: + try: + validator.validate(password) + except ValidationError as e: + self.fail(e) + + +class CharacterClassPasswordValidatorTestCase(SimpleTestCase): + def setUp(self): + super(CharacterClassPasswordValidatorTestCase, self).setUp() + self.validator = CharacterClassPasswordValidator() + + def test_invalid(self): + invalid_passwords = [ + ('', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 digits from 0-9', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('A+-', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 digits from 0-9', + ]), + ('1234567890*', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('34*/()', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 characters from A-Z', + ]), + ('AA', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 digits from 0-9', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('CD0.,', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 digits from 0-9', + ]), + ('EF56', [ + u'The password must contain at least 2 characters from a-z', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('8GH?!8', [ + u'The password must contain at least 2 characters from a-z', + ]), + ('bbX', [ + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 digits from 0-9', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('$cd%', [ + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 digits from 0-9', + ]), + ('ef90', [ + u'The password must contain at least 2 characters from A-Z', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('1g=h3~', [ + u'The password must contain at least 2 characters from A-Z', + ]), + ('Gi&jH', [ + u'The password must contain at least 2 digits from 0-9', + u'The password must contain at least 2 non alpha numeric characters', + ]), + ('IkK:i;', [ + u'The password must contain at least 2 digits from 0-9', + ]), + ('mKn4L8', [ + u'The password must contain at least 2 non alpha numeric characters', + ]), + ] + for password, expected_errors in invalid_passwords: + try: + self.validator.validate(password) + except ValidationError as e: + errors = e.messages + for expected_error in expected_errors: + self.assertIn(expected_error, errors) + for error in errors: + self.assertIn(error, expected_errors) + else: + self.fail('%s: no validation error was raised' % password) + + def test_valid(self): + valid_passwords = ['abCD12+-'] + for password in valid_passwords: + try: + self.validator.validate(password) + except ValidationError as e: + self.fail(e) diff --git a/dav_auth/validators.py b/dav_auth/validators.py new file mode 100644 index 0000000..ed796b9 --- /dev/null +++ b/dav_auth/validators.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +import re +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + + +class CharacterClassPasswordValidator(object): + def _is_enough_lower(self, password): + lower = re.sub(r'[^a-z]', '', password) + if len(lower) < self.minimum_lower: + return False + return True + + def _is_enough_upper(self, password): + upper = re.sub(r'[^A-Z]', '', password) + if len(upper) < self.minimum_upper: + return False + return True + + def _is_enough_digits(self, password): + digits = re.sub(r'[^0-9]', '', password) + if len(digits) < self.minimum_digits: + return False + return True + + def _is_enough_others(self, password): + others = re.sub(r'[a-zA-Z0-9]', '', password) + if len(others) < self.minimum_others: + return False + return True + + def __init__(self, minimum_lower=2, minimum_upper=2, minimum_digits=2, minimum_others=2): + self.minimum_lower = minimum_lower + self.minimum_upper = minimum_upper + self.minimum_digits = minimum_digits + self.minimum_others = minimum_others + + def validate(self, password, user=None): + errors = [] + if not self._is_enough_lower(password): + errors.append(ValidationError(_(u'The password must contain at least %(min_lower)d characters from a-z'), + code='too_few_lower_characters', + params={'min_lower': self.minimum_lower})) + if not self._is_enough_upper(password): + errors.append(ValidationError(_(u'The password must contain at least %(min_upper)d characters from A-Z'), + code='too_few_upper_characters', + params={'min_upper': self.minimum_upper})) + if not self._is_enough_digits(password): + errors.append(ValidationError(_(u'The password must contain at least %(min_digits)d digits from 0-9'), + code='too_few_digits', + params={'min_digits': self.minimum_digits})) + if not self._is_enough_others(password): + errors.append(ValidationError(_(u'The password must contain at least %(min_others)d' + u' non alpha numeric characters'), + code='too_few_other_characters', + params={'min_others': self.minimum_others})) + if errors: + raise ValidationError(errors) + + def get_help_text(self): + text = u'%s %s %s %s' % ( + _('The password must contain at least %d characters from a-z.') % self.minimum_lower, + _('The password must contain at least %d characters from A-Z.') % self.minimum_upper, + _('The password must contain at least %d digits from 0-9.') % self.minimum_digits, + _('The password must contain at least %d non alpha numeric characters.') % self.minimum_others, + ) + return text + + +class PasswordScoreValidator(object): + def _get_score(self, password, user=None): + score = 0 + char_counters = {} + used_classes = [] + credits = { + 'lower': self.lcredit, + 'upper': self.ucredit, + 'digit': self.dcredit, + 'other': self.ocredit, + } + + for c in password: + if c not in char_counters: + char_counters[c] = 1 + else: + char_counters[c] += 1 + if (self.max_repeat > 0) and (char_counters[c] > self.max_repeat): + continue + + score += 1 + + if c.isalpha() and c.islower(): + char_class = 'lower' + elif c.isupper(): + char_class = 'upper' + elif c.isdigit(): + char_class = 'digit' + else: + char_class = 'other' + + if char_class not in used_classes: + used_classes.append(char_class) + if credits[char_class] > 0: + score += 1 + credits[char_class] -= 1 + + return score, len(used_classes) + + def __init__(self, min_score=18, max_repeat=5, lcredit=0, ucredit=2, dcredit=2, ocredit=3, min_classes=2): + self.min_score = min_score + self.max_repeat = max_repeat + self.lcredit = lcredit + self.ucredit = ucredit + self.dcredit = dcredit + self.ocredit = ocredit + self.min_classes = min_classes + + def validate(self, password, user=None): + score, used_classes = self._get_score(password, user=user) + + if used_classes < self.min_classes: + raise ValidationError(_(u'The password must contain characters from at least %(min_classes)d' + u' different character classes (i.e. lower, upper, digits, others).'), + code='too_few_classes', + params={'min_classes': self.min_classes}) + + if score < self.min_score: + raise ValidationError(_(u'The password is too simple. Use more characters' + u' and maybe use more different character classes' + u' (i.e. lower, upper, digits, others).'), + code='too_little_score') + + return score + + def get_help_text(self): + text = u'%s\n%s' % ( + _(u'The password must get a minimum score of %d points.') % self.min_score, + _(u'For each character the score is increased by 1 point.'), + ) + if self.lcredit > 0: + text += '\n' + text += _(u'The first %d lower characters increase the score by 1 point each.') % self.lcredit + if self.ucredit > 0: + text += '\n' + text += _(u'The first %d upper characters increase the score by 1 point each.') % self.ucredit + if self.dcredit > 0: + text += '\n' + text += _(u'The first %d digits increase the score by 1 point each.') % self.dcredit + if self.ocredit > 0: + text += '\n' + text += _(u'The first %d non alpha numeric characters' + u' increase the score by 1 point each.') % self.ocredit + if self.max_repeat > 0: + text += '\n' + text += _(u'If a particular character is used more than %d times,' + u' it will not increase the score anymore.') % self.max_repeat + if self.min_classes > 0: + text += '\n' + text += _(u'Also the password must contain characters from %d different character classes' + u' (i.e. lower, upper, digits, others).') % self.min_classes + return text