Проверка сложности паролей на Python

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Пользователи очень любят простые пароли. Причины этого могут быть разные - кто-то просто не задумывается о сложности пароля, кому-то лень запоминать, а кому-то просто нравится когда в качестве пароля используется распространенное, но крутое слово.

Адекватной реакцией на эту проблему со стороны разработчиков является проверка пользовательских паролей и, соответственно если пароль слишком прост, предложение создать пароль посерьезней. Давайте рассмотрим как можно реализовать наиболее распространенные проверки.

Начальные действия

Создадим свой класс ошибки ValidationError, и все функции валидации будем строить по следующему принципу: если пароль валиден - функция просто молча отрабатывает и не возвращает ничего, если пароль не валиден, то функция будет выбрасывать нашу ошибку валидации. Для удобства я буду запускать проверку функций валидаций используя pytest.

Валидация по формату пароля

Самый простой способ это проверить пароль по регулярному выражению. Наиболее частые требования - проконтролировать минимальную длину пароля, наличие символов в верхнем и нижнем регистрах, наличие в пароле чисел и, иногда, спецсимволов.

import re  


class ValidationError(Exception):    
  	"""Raises when password is not valid."""

# Проверяет наличие символов в обоих регистрах, 
# чисел, спецсимволов и минимальную длину 8 символов
pattern1 = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$'

# Проверяет наличие символов в обоих регистрах, 
# числел и минимальную длину 8 символов
pattern2 = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$'

def validate_by_regexp(password, pattern):    
  	"""Валидация пароля по регулярному выражению."""    
  	if re.match(pattern, password) is None:        
    	raise ValidationError('Password has incorrecr format.')
    
    
def test_validate_by_regexp():    
  	password1 = 'qWer5%ty'    
  	password2 = '5qWerty5'    
  	assert validate_by_regexp(password1, pattern1) is None    
  	with pytest.raises(ValidationError):        
    		validate_by_regexp(password2, pattern1)        
  	assert validate_by_regexp(password2, pattern2) is None
$ pytest main.py::test_validate_by_regexp -v
main.py::test_validate_by_regexp PASSED

Валидация по списку наиболее часто встречающихся паролей

Валидация по регулярным выражением это здорово, но я думаю у вас вызвал некоторое подозрение пароль 5qWerty5, который формально проходит нашу проверку. А ведь кроме qwerty существует еще тысячи подобных слов, которые очень любят использовать в качестве паролей пользователи. password, iloveyou, football...тысячи их. Хорошо бы составить список таких слов и проверять не находится ли присланный нам пароль среди них. Хорошая новость - есть на свете такой замечательный человек по имени Royce Williams, который уже собрал тысячи таких паролей. Весь список доступен на gist.

Мы можем скачать архив, который содержит текстовый файл с паролями в следующем формате frequency:sha1-hash:plain, то есть - частота встречаемости пароля, его хеш, и собственно сам пароль как он есть. Давайте напишем функцию, которая будет открывать файл со списком и, итерируясь по строкам, сверять наш пароль с очередным паролем в списке:

from itertools import dropwhile
from pathlib import Path


def validate_by_common_list(password):    
		"""Валидация пароля по списку самых распространенных паролей."""
		common_passwords_filepath = Path(__file__).parent.resolve() / 'common-passwords.txt'txt'
  	with open(common_passwords_filepath) as f:
        for line in dropwhile(lambda x: x.startswith('#'), f):
            common = line.strip().split(':')[-1] # выделяем сам пароль
            if password.lower() == common:
                raise ValidationError('Do not use so common password.')

          
def test_validate_by_common_list():
  	with pytest.raises(ValidationError):
    		validate_by_common_list('qwerty')
    with pytest.raises(ValidationError):
      	validate_by_common_list('flower')
      	assert validate_by_common_list('C_$s^8C7') is None # Хороший пароль
$ pytest main.py::test_validate_by_common_list -v
main.py::test_validate_by_common_list PASSED

Что ж наша функция легко находит такие очевидные слова типа qwerty, но что если пользователь будет не так прост, и на наше замечание, что его пароль слишком очевиден, скажем, просто добавит куда нибудь точку или поставит пару цифр вначале и в конце: (вставить результаты тестов?) qwert.y, 0qwerty0 или даже q.w.e.r.t.y.?

Добавим эти проверки в тест:

def test_validate_by_common_list():
    with pytest.raises(ValidationError):
        validate_by_common_list_simply('qwerty')

    with pytest.raises(ValidationError):
        validate_by_common_list_simply('flower')

    with pytest.raises(ValidationError):
        validate_by_common_list_simply('qwert.y')

    with pytest.raises(ValidationError):
        validate_by_common_list_simply('0qwerty0')
        
    assert validate_by_common_list_simply('C_$s^8C7') is None # Хороший пароль
$ pytest main.py::test_validate_by_common_list -v
main.py::test_validate_by_common_list FAILED
...
 with pytest.raises(ValidationError):
>           validate_by_common_list('qwert.y')
E           Failed: DID NOT RAISE <class 'main.ValidationError'>

Такие очевидные хаки наш валидатор уже не в состоянии отловить.

В качестве решения можно было бы, конечно же, попробовать вставить удаление из строки точек (и/или других спецсимволов), например как-то так password = password.replace('.', ''). Однако всем понятно что такой путь, мягко говоря, не очень эстетичный и правильный. Вместо этого можно воспользоваться модулем стандартной библиотеки python difflib.

Как следует из описания - этот модуль предоставляет классы и функции для сравнения последовательностей, что нам отлично подходит - ведь строки в python обладают свойствами последовательностей. Давайте рассмотри поближе объект difflib.SequenceMatcher.

Класс SequenceMatcher принимает на вход две последовательности и предоставляет несколько методов для оценки их сходства. Нас интересует метод ratio() который возвращает число в диапазоне [0,1] характеризующее "похожесть" двух последовательностей, где 1 соответствует двум абсолютно одинаковым последовательностям, а 0 абсолютно разным.

Перепишем нашу функцию валидации следующим образом:

from difflib import SequenceMatcher
from itertools import dropwhile
from pathlib import Path


def validate_by_common_list(password):
    """
    Валидация по списку самых распространенных паролей,
    с учетом слишком похожих случаев.
    """
    common_passwords_filepath = Path(__file__).parent.resolve() / 'common-passwords.txt'
    max_similarity = 0.7
    
    with open(common_passwords_filepath) as f:
        for line in dropwhile(lambda x: x.startswith('#'), f):
            common = line.strip().split(':')[-1]
            diff = SequenceMatcher(a=password.lower(), b=common)
            if diff.ratio() >= max_similarity:
                raise ValidationError('Do not use so common password.')

Несколько пояснений:

max_similarity - характеризует максимально допустимое сходство, увлекаться и слишком занижать этот параметр не стоит, иначе ваш валидатор будет улавливать малейшие совпадения вплоть до пары символов. По моему опыту, значение 0.7 это минимальный порог ниже которого опускаться не стоит, при этом порог 0.75 уже пропустит вот такой пароль 'q.w.e.r.t.y' , так что определите размер этого параметра для себя сами.

Кроме того, здесь я использую функцию:

dropwhile(lambda x: x.startswith('#'), f)

из модуля itertools для того, чтобы пропустить закомментированные строки вначале файла common-passwords.txt, впрочем их можно было просто удалить вручную.

Протестируем наш переписанный валидатор:

def test_validate_by_common_list():
    with pytest.raises(ValidationError):
        validate_by_common_list('qwerty')

    with pytest.raises(ValidationError):
        validate_by_common_list('flower')

    with pytest.raises(ValidationError):
        validate_by_common_list('qWer5%ty')

    with pytest.raises(ValidationError):
        validate_by_common_list('5qWerty5')

    with pytest.raises(ValidationError):
        validate_by_common_list('q.w.e.r.t.y')

    assert validate_by_common_list('C_$s^8C7') is None # Хороший пароль
$ pytest main.py::test_validate_by_common_list -v
main.py::test_validate_by_common_list PASSED

Валидация по использованию в качестве пароля других полей

Итак, мы обозначили необходимый формат пароля и проверили его, чтобы он не был слишком очевидным. Другим распространенным случаем является использование в качестве пароля значения другого атрибута пользователя. Например, если пользователь просто скопирует в поле пароля свой email или логин. Для определения таких случаев можно воспользоваться тем же способом, что мы использовали для определения похожих паролей - объектом difflib.SequenceMatcher, только в этот раз мы будем сравнивать пароль со значением других полей:

def validate_by_similarity(password, *other_fields):
    """Проверяем, что пароль не слишком похож на другие поля пользователя."""
    max_similarity = 0.75

    for field in other_fields:
        field_parts = re.split(r'\W+', field) + [field]
        for part in field_parts:
            if SequenceMatcher(a=password.lower(), b=part.lower()).ratio() >= max_similarity:
                raise ValidationError('Password is too similar on other user field.')

Здесь мы разделяем пароль на части на части по шаблону \W+, под который подходят все нестандартные символы (то есть не включающие в себя буквы, цифры и нижнее подчеркивание), для случаев, когда пользователь может использовать в качестве пароля часть своего имейла без домена. Например при использовании в качестве пароля имейла someemailname@gmail.com получим следующие части: ['someemailname', 'gmail', 'com', 'someemailname@gmail.com'].

Проверим как работает наша функция:

def test_validate_by_similarity():
    user_login = 'joda777jedi'
    email = 'jedimaster1@jediacademy.co'

    with pytest.raises(ValidationError):
        validate_by_similarity('jedimaster1', user_login, email)

    with pytest.raises(ValidationError):
        validate_by_similarity('joda777jedi', user_login, email)

    with pytest.raises(ValidationError):
        validate_by_similarity('jedimaster1@jediacademy.co', user_login, email)

    with pytest.raises(ValidationError):
        validate_by_similarity('joda777', user_login, email)

    assert validate_by_similarity('C_$s^8C7') is None
$ pytest main.py::test_validate_by_similarity -v
main.py::test_validate_by_similarity PASSED

Перечисленных способов, как правило, будет достаточно, впрочем, и ими тоже не стоит слишком увлекаться, иначе дружелюбность вашего интерфейса для пользователей станет примерно вот такой:

Так что, выбор какие способы и с какой степенью сложности использовать в своих проектах зависит лишь от серьезности вашего проекта, необходимой степени защиты...ну и пожалуй от ваших садистских наклонностей.

Источник: https://habr.com/ru/post/584020/


Интересные статьи

Интересные статьи

Будучи наивным чукотским программистом, я думал: "питон такой кроссплатформенный, напишу игрушку для сына, запущу на планшетике, пусть играется". В результате две недели ...
Научные специалисты, увлекающиеся альпинизмом, обратили внимание на малоизученную проблему в области строительства подводных кабельных систем. Обсудим, в чем тут дело. ...
История голодного студента с пытливым умом Не знаю, как вы, а я обожаю пиццу. Особенно если это особые чесночные пицца-палочки Papa John’s. Поэтому я был в восторге, когда после заказа е...
Компании переполнили рынок товаров и услуг предложениями. Разнообразие наблюдается не только в офлайне, но и в интернете. Достаточно вбить в поисковик любой запрос, чтобы получить подтверждение насыще...
Многие программисты начали переходить со второй версии Python на третью из-за того, что уже довольно скоро поддержка Python 2 будет прекращена. Автор статьи, перевод которой мы публикуем, отмечае...