Как правильно проверять сложность пароля пользователя при регистрации

в 13:30, , рубрики: php, безопасность, бэкенд, валидация, пароли, пароль, проверка пароля

За десятилетия айтишки сложилась практика ограничивать пользователей в сложности их паролей. Сейчас пароль пользователя должен:

  • быть не меньше N символов;

  • && быть не больше M символов (чуть реже встречается такое правило);

  • Содержать хотя бы одну большую букву;

  • Содержать хотя бы одну маленькую букву;

  • Содержать хотя бы одну цифру;

  • Содержать хотя бы один спецсимвол;

  • Иногда можно наткнуться на шедевры, вроде ограничения по количеству символов одинакового типа подряд, из чего рождается бессмертный анекдот про одну грёбаную розовую розу...

Уж где мы только не подстелили соломы для наших нерадивых пользователей, лишь бы они были в безопасности. Ведь и мы, и матанализ, и различные софтинки – все наперебой говорят, что пароль qwerty и пароль W0wS3qur3P/-$$ имеют кардинально разную подбороустойчивость...

Алгоритм везде один и тот же. К слову, обычно проверка на спецсимволы производится "хотя бы однин символ из заранее захардкоженного словаря". Обычно, словарь вида "не буквы и не цифры с обычной клавиатуры английской раскладки". Просто курсор в кавычки константы, глазки на клавиатуру – и погнали составлять наш шикарнейший словарь.

Данный алгоритм настолько распространён, что встречается почти что в каждой первой статье. Примеров подобных статей – тысячи. Есть даже на хабре (в примере кода можно заметить ту самую регулярку).

Однако, всё ли у нас в порядке с нашим алгоритмом?

Что есть спецсимвол?

Дисклеймер: примеры кода будут на пыхе, для простоты забудем про ограничения длины пароля

Типовой шаблон для поиска спецсимволов выглядит в лучшем случае вот так:

$password = 'fF6:';
var_dump(preg_match('/[.:,;?!@#$%^&*_-+=]/', $password));
//true

А что если скобка?

<?php
$password = 'fF6:(';
var_dump(preg_match('/[.:,;?!@#$%^&*_-+=]/', $password));
//false

Ок, не проблема, добавим в словарь ещё и все скобки

<?php
$password = 'fF6:(';
var_dump(preg_match('/[.:,;?!@#$%^&*_-+=()<>{}[]]/', $password));
//true

А как же слэш?

<?php
$password = 'fF6:(/';
var_dump(preg_match('/[.:,;?!@#$%^&*_-+=()<>{}[]]/', $password));
//false

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

<?php
$password = 'fF6:(/';
var_dump(preg_match('/[.:,;?!@#$%^&*_-+=()<>{}[]'"`/_~|\]/', $password));
//true

И вроде мы собой довольны, однако... "•√π÷׶∆£¢€¥°©®™✓". Вот лишь некоторые символы, которые я слишком легко могу ввести со стандартной GBoard клавиатуры на своём телефоне. Буквально два нажатия: открыть клавиатуру символов и перейти на вторую страницу. Это уже не говоря о различных альтернативах вроде длинного и очень длинного тире (также известные как &ndash; (–) и &mdash; (—)). Что, опять словарь дополнять? Кстати, если переключить перед этим раскладку клавиатуры – то гборд даже поменяет некоторые символы. То есть мы уже видим проблему множества раскладок. А теперь ещё можно вспомнить про то, что есть другие клавиатуры, и даже другие операционые системы, у которых свои клавиатуры. Ну и на сладенькое...

😏 Эмоджи
😐 тоже
🙁 символы
☹️ .
🤯 БЭЭЭЭМ!

Наш словарь скоро начнёт требовать отдельное распределённое хранилище.

К какому же выводу мы приходим? Константный словарь спецсимволов – это пло что? павильно, хо. Плохо это... Поэтому наш герой-бэкендер достаёт из инвентаря пузырёк витаминов C++ зиплок с регулярками и пишет '/[^wd]/i', что в переводе с драугрского означает "Что угодно, кроме букв и цифр. Регистронезависимо."

Удивительно, но с этим даже эмоджи находятся

Как правильно проверять сложность пароля пользователя при регистрации - 1

Не обращайте внимания, что матчей 3. Эмоджи – двухбайтовые символы, поэтому регулярка их воспринимает как "первая половина" и "вторая половина".

Итак, со спецсимволами разобрались. Но всё ли теперь в порядке с нашим алгоритмом?

Отдельный котёл для проверяющих регистр букв

Казалось бы, всё нормально:

<?php
$password = 'Bye, World!';
if (!preg_match('/[A-Z]/', $password)) {
  $errors[] = 'В пароле должна быть хотя бы одна большая буква';
}

if (!preg_match('/[a-z]/', $password)) {
  $errors[] = 'В пароле должна быть хотя бы одна маленькая буква');
}

Однако... Стоит сделать всего лишь вот так

<?php
$password = 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'
    . 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';

И наш пароль не пройдёт ни одну проверку на регистры букв. То есть, мы только что напали на проблему: наш алгоритм ищет только буквы англиского алфавита. Это значит, что пароли на французском (é, è, ê), испанском (ñ), немецком (ä, ö, ü), норвежском(ø, Æ) и я могу продолжать ещё очень долго – могут не пройти проверку, если там не будет ни одной буквы английского алфавита.

То есть мы приходим к выводу, что нам нужно проверять как-то иначе. На этом моменте наш герой-бэкендер складывает регулярки обратно в зиплок, кладёт рядом с витаминками, и достаёт старые добрые методы работы со строками:

<?php
$password = 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'
    . 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';

if ($password === mb_strtolower($password)) {
  $errors[] = 'В пароле должна быть хотя бы одна большая буква';
}

if ($password === mb_strtoupper($password)) {
  $errors[] = 'В пароле должна быть хотя бы одна маленькая буква');
}

Вот так нужно проверять строки на наличие букв конкретного регистра.

Неожиданное заключение

Всё это хорошо, однако...

Существуют языки, в которых просто нет деления на большие и маленькие буквы. Моё исследование под названием "Спроси у ChatGPT" показало, что в мире чуть больше 7000 языков, из них около половины (3500) имеют уникальные алфавиты, и около 20-30% всех языков (1400-2100) не имеют разделения на маленькие и большие буквы. Например, китайский, японский, корейский, иврит, арабский (ChatGPT говорит, что в арабском всего несколько букв имеют разделение на маленькие и большие) и индоевропейские языки. А также почему-то латынь и греческий, с чем я в корне не согласен, ибо тексты на обоих этих языках я видел в большом количестве, и больших букв там ровно каждой маленькой по паре...

ОТРЕДАКТИРОВАНО:

Ранее тут был пассаж про существование букв, у которых нет формы "большая" или "маленькая". В пример я приводил букву эсцет (ß) из немецкого алфавита. И даже проводил некоторые тесты, а потом обнаружил, что я забыл использовать mb_*.

В чём проблема: если проверять буквы юникода и не использовать mb-аналоги обычных методов работы со строками, то результат будет странным. Например, в случае эсцет – буква просто не меняла свой шифт, и выглядела как в квантовой суперпозиции:

<?php
$eszett = 'ß';
var_dump(strtoupper($eszett) === strtolower($eszett)); //true
Как правильно проверять сложность пароля пользователя при регистрации - 2

Однако потом меня поправили в комментариях, что у сей буквы в 2017-ом появился её аналог со шифтом. Промариновавшись в фоне где-то часочек эта мысль навела меня на то, что я дурак и забыл про mb. И тут вскрылось такое...

Как правильно проверять сложность пароля пользователя при регистрации - 3

ẞ – большая, ß – маленькая. Как можно заметить, большая эсцет (ẞ) при её понижении спокойно превращается в маленькую (ß), но вот маленькая эсцет при вознесении её шифтом животворящим внезапно превращается в "SS". То есть применяется её написание до 2017 года.

И при том, каждая из этих "S" – это отдельный символ. То есть если потом опять понизить регистр – вы не получите исходный вариант:

Как правильно проверять сложность пароля пользователя при регистрации - 4

Вот такие вот могут быть подводные камни при работе с регистрами.

/ОТРЕДАКТИРОВАНО

Не будем забывать, что навязанные обязательства на пароль, порой, могут зафейлить просто безупречно подбороустойчивый экземпляр:

Как правильно проверять сложность пароля пользователя при регистрации - 5

Поэтому, на самом деле правильным будет никак не ограничивать пользователя в его паролях. В конце концов, безопасность пользователя – это дело пользователя. Наша же задача как бэкендеров лишь обеспечить безопасность системы (в плане утечек) и хранения (хотя бы захэшировать с солью). Ну и защититься от брутфорса какой-нибудь рекапчей после трёх попыток.

А пароль пользователя должен просто... быть?..

<?php
//...
if ($password1 === '')) {
  throw new ValidationError('Придумайте пароль');
}

if ($password1 !== $password2)) {
  throw new ValidationError('Пароли не совпадают. Проверьте внимательно и попробуйте ещё раз');
}

//encrypt password and do things

Автор:
Lenald

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js