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

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

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

Создадим свой класс ошибки 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

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

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

Комментарии (21)


  1. herrjemand
    19.10.2021 04:14
    +7

    Я поставил минус и я хотел бы объяснить почему:

    Ваши требования к паролям устарели так на лет 10. Сегодня пароли должны быть минимум 12 символов в длину, И самое важно что в приоритете идет длина а потом сложность. То есть пользователь и дальше может делать вырвиглаз комбинацию больших/маленьких/цифр/спец но это только вторичный фактор, если пароль короче, на пример, 15ти символов. Очень советую окунуться в NIST SP800-63B или почитать эту статью https://auth0.com/blog/dont-pass-on-the-new-nist-password-guidelines/

    То есть если убрать тот факт что в статье нету ничего нового в плане определения силы паролей то остается только "как использовать регулярные выражение в питоне" и "как считать файл в питоне".

    Было бы круто если бы вы раскрыли как раз таки специфики и сложности парольной валидации, а в плане проверки популярных паролей могли бы показать интеграцию с HaveIbeenPwnd(https://haveibeenpwned.com/Passwords) у которых есть базы с самыми популярными паролями на основе анализа 5.5млрд слитых учетных записей.

    А пока это немного Stackoverflow, но может быть реально отличный и полезный материал.


    1. csl
      19.10.2021 04:16

      Ваш комментарий оборвался.


      1. csl
        19.10.2021 04:56
        +4

        "Если бы люди могли потратить своё время на то, чтобы объяснить своё несогласие, было бы очень здорово." ©

        В момент моего комментария, коммент выше содержал три строки, оканчивался словами "лет на 10, и вот по каким причинам:". Я обратил внимание автора, чтобы при желании он сделал корректировку до истечения получаса.


        1. herrjemand
          19.10.2021 10:30

          случайно лбом ударился об Enter XD


    1. V-ampre Автор
      19.10.2021 07:27
      +1

      Было бы круто если бы вы раскрыли как раз таки специфики и сложности парольной валидации, а в плане проверки популярных паролей могли бы показать интеграцию с HaveIbeenPwnd(https://haveibeenpwned.com/Passwords) у которых есть базы с самыми популярными паролями на основе анализа 5.5млрд слитых учетных записей.

      Спасибо за идею!


    1. Nehc
      19.10.2021 10:55
      -2

      Не понимаю — а чего сразу минус-то… Типа человек не добавил еще проверку на длину пароля? Ну так из всех возможных проверок — эта самая простая…

      А на счет HaveIbeenPwnd — мне вот, например, интересно, кэшируют ли они то, что им отправляют «на проверку»? ;) Строго говоря если ты куда-то в сторонний сервис отправляешь свой пароль — ты этим действием его автоматом компрометируешь!


      1. desertkun
        19.10.2021 14:52

        Я думаю, речь о оффлайн-базе, которую вы скачиваете, и своими скриптами к локальной копии этой базы делаете запрос(ы).


        1. Nehc
          19.10.2021 15:34

          Вы про HaveIbeenPwnd? Насколько я знаю они вам таких данных не предоставляют — у них API интереснее они вам на запрос по ЧАСТИЧНОМУ хешу возвращают список всех возможных совпадений и вы уже в этом списке локально ищите свой. В принципе неплохо… Что не мешает им на главной сайта предложить ввести пароль для проверки был он слит ранее или нет. ;)) После этого можно сразу писать: «Ага, был слито ТОЛЬКО ЧТО!»…


          1. herrjemand
            19.10.2021 17:51

            Прямо в моем комментарии есть ссылка на скачивание базы хешей самых популярных паролей

            https://haveibeenpwned.com/Passwords


            1. Nehc
              19.10.2021 18:02

              Да, хеши есть в виде оффлайн базы, не обратил внимания.

              Так и представляю себе продакт-решение, где чисто для проверки нужной сложности пароля развернута локальная копия базы на десяток с лишним ГиГ! )

              Зачем? API же есть…

              Ну и самое главное — я все равно не понимаю за что минуса человеку за статью… Нормальная статья, как по мне!


              1. herrjemand
                19.10.2021 18:31

                Если проект маленький и нету проблем с юристами то да, API вам в помощь. Но есть компании с лямом запросов в месяц и им не до чьих-то API. 20гб на AWS это 7.5$ dynamodb. Можно еще захостить себе сервер для проверки хешей за 10-20$ с SSD жд. Если все в одной зоне то там скорость проверки будет космической.


  1. Firsto
    19.10.2021 06:18
    +7

    Простой 12-символьный лучше сложного 8-символьного.


    1. 13werwolf13
      19.10.2021 07:20
      +1

      о, спасибо, сохранил.. а не подскажешь первоисточник?



      1. Firsto
        20.10.2021 19:02
        +1

        Кстати, вот ещё наглядный комикс от xkcd на эту тему:


    1. hello_im_player
      19.10.2021 09:30

      хммм интересно посмотреть какие системы какие пароли предлагают нашёл маил и гугл

      84nNSpgN!qqhkQj гугл пароль взламывать очень долго

      &yrVbtpXUO22 маил поменьше будет но тоже достаточно


    1. Nehc
      19.10.2021 10:49

      Я так понимаю, что в данном случае это тупо время, которое затратит некий «сферический в вакууме» вычислитель на простой перебор в цикле, без учета времени на отправку и валидацию пароля? И эта табличка практически полностью лишена смысла, если каждый следующий ввод экспоненциально увеличивает время валидации (т.е. стоит простенькая защита от перебора)?

      Т.е. это удобно для оценки относительно сложности пароля, но не стоит цифры воспринимать буквально… Типа что ваш пароль из 7 букв разного регистра подберут за 2 секунды — нет, не подберут, если система не совсем примитивная.


      1. polearnik
        19.10.2021 12:27

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


        1. Nehc
          19.10.2021 15:27

          резонно…


      1. hello_im_player
        19.10.2021 16:31
        +1

        на сайте что выше говорится

        Password cracking is becoming very trivial with the vast amount of computing power readily available for anyone who desires so.

        At a current rate of 25$ per hour, an AWS p3.16xlarge nets you a cracking power of 632GH/s (assuming we’re cracking NTLM hashes). This means we’re capable of trying a whopping 632.000.000.000 different password combinations per second!


  1. unsignedchar
    19.10.2021 08:49
    +3

    Проверка наличия символов в строке с помощью регэкспов выглядит ужасно странно. Это же python. help set что-ли. Возврат результата через exception.. Я художник, я так вижу ;)