В ближайшее время напишу подробнее о новой версии, но именно сейчас хочу немного поговорить об обманчиво простой проблеме, с которой приходится иметь дело. Это имена пользователей. Да, я мог бы написать одну из популярных статеек типа «Заблуждения программистов об X», но всё-таки предпочитаю реально объяснить, почему это сложнее, чем кажется, и предложить некоторые советы, как решить проблему. А не просто стебаться без полезного контекста.
Ремарка: правильный способ идентификации
Юзернеймы — в том виде, в каком они реализованы на многих сайтах и сервисах и во многих популярных фреймворках (включая Django) — почти наверняка неправильный способ решения той проблемы, которую с их помощью пытаются решить. Что нам действительно нужно в смысле идентификации пользователей, так это некоторая комбинация следующего:
- Идентификатор системного уровня для внешних ключей в базе данных.
- Идентификатор входа для выполнения проверки учётных данных.
- Публичный идентификатор для отображения другим пользователям.
Многие системы запрашивают имя пользователя — и используют один и тот же юзернейм для всех трёх указанных задач. Вероятно, это неправильно. Более грамотный подход — это трёхсторонний шаблон идентификации, в котором каждый идентификатор различен, а несколько идентификаторов входа и/или публичных идентификаторов могут быть связаны с одним системным идентификатором.
Многие проблемы и страдания при попытках построить и масштабировать систему аккаунтов вызваны игнорированием этой модели. Досадно большое количество хаков применяют в системах, которые не поддерживают такой шаблон, чтобы они выглядели и работали так, словно поддерживают его.
Так что если вы разрабатываете систему с нуля сейчас в 2018 году, я бы предложил взять эту модель и использовать в качестве основы. Сначала придётся немного потрудиться, но в будущем она обеспечит хорошую гибкость и сэкономит время, а однажды кто-нибудь может даже создать приемлемую универсальную реализацию для многоразового использования (конечно, я думал сделать такую для Django, а может когда-то и сделаю).
В остальной части этой статьи будем полагать, что вы используете более распространённую реализацию, в которой уникальное имя пользователя служит по крайней мере системным идентификатором и логином для входа в систему, а также, скорее всего, публичным идентификатором, который демонстрируется всем юзерам. И под «именем пользователя» я имею в виду по сути любой строковый идентификатор. Например, у вас могут быть юзернеймы как на форумах вроде Reddit или Hacker News или вы можете использовать адреса электронной почты, или какие-то другие уникальные строки. Неважно, вы всё равно вероятно используете какую-то уникальную строку. Значит, вам нужно знать о некоторых проблемах.
Добиться уникальности труднее, чем кажется
Возможно, вы задаёте вопрос: насколько это сложно? Можно просто создать уникальную колонку в базе — и готово! Сделаем таблицу с пользователями в Postgres:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE,
password TEXT,
email_address TEXT
);
Вот наша таблица с пользователями и колонка с уникальными именами. Легко же!
Ну, это легко, пока мы не начнём думать о реальном применении. Если вы зарегистрированы как
john_doe
, что произойдет, если я зарегистрируюсь как JOHN_DOE
? Это другой юзернейм, но могу ли я заставить людей думать, что я — это вы? Будут ли люди принимать мои запросы на добавление в друзья и делиться со мной конфиденциальной информацией, потому что они не осознают, что для компьютера разный регистр — это разные символы?Это простая вещь, которая неправильно реализована во многих системах. Во время исследования для этой статьи я обнаружил, что система auth в Django не обеспечивает уникальность имен пользователей без учёта регистра, несмотря на правильный подход в реализации многих других вещей. В баг-трекере есть тикет на то, чтобы сделать юзернеймы нечувствительными к регистру, но теперь он помечен как WONTFIX, потому что создание юзернеймов без учёта регистра в массовом порядке поломает обратную совместимость — и никто не уверен, как это сделать и нужно ли делать вообще. Вероятно, я подумаю о принудительном применении этого в django-registration 3.0, но не уверен, что это можно реализовать даже там — проблемы начнутся на любом сайте, где уже существуют учётные записи, учитывающие регистр.
Так что если соберётесь сегодня строить систему с нуля, то нужно с самого начала делать проверки на уникальность имени пользователей без учёта регистра:
john_doe
, John_Doe
и JOHN_DOE
следует считать одинаковыми именами. Как только один из них зарегистрирован, остальные становятся недоступными.Но это только начало. Мы живем в мире Юникода, а здесь сравнить два имени на совпадение сложнее, чем просто выполнить операцию
username1 == username2
. Во-первых, существует композиция и декомпозиция символов. Они различаются при их сравнении как последовательностей кодовых точек Юникода, но на экране выглядят одинаково. Поэтому здесь нужно думать о нормализации, выбрать форму нормализации (NFC или NFD), а затем нормализовать каждый юзернейм до выбранной формы прежде чем выполнять какие-либо проверки уникальности.Иллюстрация из статьи «Нормализация Unicode» — прим. пер.
Также при разработке системы проверки уникальности имён без учёта регистра придётся рассмотреть символы, не входящие в ASCII. Считать ли идентичными юзернеймы
Stra?burgJoe
и StrassburgJoe
? Ответ часто зависит от того, делаете вы проверку с нормализацией в нижнем или верхнем регистре. И при этом есть ещё разные варианты декомпозиции в Юникоде; вы можете получить (и получите) разные результаты для многих строк в зависимости от того, используете каноническую эквивалентность или режим совместимости.Если всё это сбивает с толку — а это так и есть, даже если вы эксперт по Юникоду! — рекомендую следовать советам технического отчета Unicode Technical Report 36 и нормализовать имена по форме NFKC. Если вы используете
UserCreationForm
в Django или его подкласс (django-registration использует подклассы UserCreationForm
), то это уже сделали для вас. Если используете Python, но без Django (или не применяете UserCreationForm
), то это можно сделать в одну строку с помощью хелпера из стандартной библиотеки:import unicodedata
username_normalized = unicodedata.normalize('NFKC', username)
Для других языков поищите хорошую библиотеку Юникода.
Нет, реально, добиться уникальности труднее, чем кажется
К сожалению, это ещё не всё. Проверка уникальности без учёта регистра в нормализованных строках — это начало, но она не охватывает все случаи, которые нужно отловить. Например, рассмотрим следующее имя пользователя:
jane_doe
. Теперь рассмотрим другое имя пользователя: jane_doe
. Это одно и то же имя пользователя?В том шрифте, который я использую для этой статьи, и в любом шрифте, доступном для моего блога, они кажутся одинаковыми. Но для программного обеспечения они совершенно разные, и по-прежнему останутся разными после нормализации Юникода и сравнения без учёта регистра (независимо от того, выбрали вы проверку с нормализацией в нижнем или верхнем регистре).
Чтобы понять причину, обратите внимание на вторую кодовую точку. В одном из юзернеймов это
U+0061 LATIN SMALL LETTER A
. А в другом это U+0430 CYRILLIC SMALL LETTER A
. И никакая нормализация Юникода или устранение чувствительности к регистру не сделает эти кодовые точки одинаковыми, хотя они часто визуально совершенно неразличимы.Это основа гомографических атак, которые впервые получили широкую известность в контексте интернационализированных доменных имён. И для решения проблемы потребуется ещё немного поработать.
Для сетевых хостов одним из вариантов решения будет показ имён в представлении Punycode, созданном для решения именно этой проблемы путём отображения имён в любой кодировке, используя только символы ASCII. Возвращаясь к приведённым выше именам пользователей, так различия между ними становятся очевидными. Если хотите попробовать сами, вот однострочник на Python и результат на юзернейме с кириллическим символом:
>>> 'jаne_doe'.encode('punycode')
b'jne_doe-2fg'
(Если у вас проблемы с копипастом символов, не входящих в ASCII, то это имя можно выразить как строковый литерал
j\u0430ne_doe
).Но отображать имена пользователей в таком виде на практике не годится. Конечно, можно каждый раз показывать Punycode, но это сломает отображение многих совершенно нормальных юзернеймов с символами не из в ASCII. Что мы на самом деле хотим — это отклонить вышеуказанное имя пользователя во время регистрации. Как это сделать?
Ну, на этот раз направляемся к техническому отчёту Unicode Technical Report 39 и начинаем читать разделы 4 и 5. Наборы кодовых точек, которые отличаются друг от друга (даже после нормализации), но визуально идентичны или до смешения похожи при визуализации, называются «ведущими к путанице» (confusables), а Юникод предоставляет механизмы обнаружения таких кодовых точек.
Имя пользователя в нашем примере — это то, что в Юникоде именуется как «ведущая к путанице смесь алфавитов» (mixed-script confusable), и это то, что мы хотим обнаружить. Другими словами, имя пользователя полностью на латинском шрифте с «ведущими к путанице» символами, вероятно, можно считать нормальным. И полностью кириллическое имя пользователя с «ведущими к путанице» символами, вероятно, тоже можно считать нормальным. А вот если имя составлено преимущественно из латинских символов плюс единственная кириллическая кодовая точка, которая при визуализации оказалась до смешения похожа на символ латиницы… то это уже не пройдёт.
К сожалению, в стандартной библиотеке Python не предоставляет необходимый доступ к полному набору свойств и таблиц Юникода, чтобы сделать такое сравнение. Но любезный разработчик по имени Виктор Фелдер написал подходящую библиотеку и выпустил её под свободной лицензией с открытым исходным кодом. С помощью библиотеки
confusable_homoglyphs
мы можем выявить проблему:>>> from confusable_homoglyphs import confusables
>>> s1 = 'jane_doe'
>>> s2 = 'j\u0430ne_doe'
>>> bool(confusables.is_dangerous(s1))
False
>>> bool(confusables.is_dangerous(s2))
True
Реальный результат выполнения функции
is_dangerous()
для второго имени пользователя — структура данных с подробной информацией о потенциальных проблемах, но главное то, что можно выявить строку со смешанными алфавитами и кодовыми точками, которые ведут к путанице. Это нам и надо.Django допускает использование в юзернеймах символов не из ASCII, но не проверяет одинаковые символы из разных кодировок. Однако с версии 2.3 в django-registration появилась зависимость от библиотеки
confusable_homoglyphs
а её функция is_dangerous()
применяется в процессе валидации имён пользователей и адресов электронной почты. Если вам нужно реализовать регистрацию пользователей в Django (или вообще на Python) и вы не можете или не хотите использовать django-registration, то рекомендую применить библиотеку confusable_homoglyphs
таким же способом.Я уже упомянул, что добиться уникальности трудно?
Если мы имеем дело с ведущими к путанице кодовыми точками Юникода, имеет смысл подумать, что делать с похожими символами из одного алфавита. Например,
paypal
и paypa1
. В некоторых шрифтах их трудно отличить друг от друга. До сих пор все мои предложения подходили в общем случае для всех, но здесь мы вступаем на территорию, специфичную для конкретных языков, алфавитов и географических регионов. Здесь следует принимать решения с осторожностью и с учётом возможных последствий (например, запрет на вводящие в заблуждение латинские символы может вызвать больше ложноположительных результатов, чем вам хотелось бы). Об этом стоит подумать. То же самое касается имён пользователей, которые отличаются, но всё равно очень похожи друг на друга. На уровне базы данных вы можете проверить в различных формах — например, Postgres идёт с поддержкой Soundex и Metaphone, а также c поддержкой расстояния Левенштейна и триграмм нечёткого соответствия — но опять же, этим придётся заниматься разве что от случая к случаю, а не постоянно.Хочу упомянуть ещё одну проблему с уникальностью имён. Правда, она относится в основном к адресам электронной почты, которые в наше время часто используются в качестве юзернеймов (особенно в сервисах, которые полагаются на стороннего провайдера идентити и используют OAuth и подобные протоколы). Предположим, что нам требуется обеспечить уникальность адресов электронной почты. Сколько разных адресов перечислено ниже?
johndoe@example.com
johndoe+yoursite@example.com
john.doe@example.com
Однозначного ответа нет. Большинство почтовых серверов давно игнорируют все символы после знака
+
в локальной части адреса при определении имени пользователя. В свою очередь, многие люди используют эту техническую особенность для указания произвольного текста после «плюса» как специальную систему меток и фильтрации. А Gmail ещё и лихо игнорирует точки (.
) в локальной части, в том числе в раздаваемых доменах на своих сервисах, так что без запроса DNS вообще невозможно понять, различит ли чужой почтовый сервер johndoe
и john.doe
.Так что если вам нужны уникальные адреса электронной почты или вы используете адреса электронной почты в качестве идентификатора пользователя, вероятно, нужно удалить все точки из локальной части, а также
+
и любой текст после него, прежде чем выполнять проверку уникальности. В настоящее время django-registration не делает этого, но у меня есть планы добавить эту функцию в версии 3.x.Кроме того, при обработке ведущих к путанице кодовых точек Юникода в адресах электронной почты применяйте эту проверку отдельно к локальной части и к домену. Люди не всегда могут изменить алфавит, который используется в домене, так что их нельзя наказывать за то, что в локальной части и доменной части используются разные алфавиты. Если ни локальная часть, ни часть домена по отдельности не содержат ведущей к путанице смеси алфавитов, то вероятно, всё в порядке (и валидатор django-registration делает такую проверку).
Вы можете столкнуться с многими другими проблемами в отношении имён пользователей, которые слишком похожи друг на друга, чтобы не считаться «разными», но как только вы начинаете отключать чувствительность к регистру, запускать нормализацию и проверку на смесь алфавитов, то быстро заходите на территорию с убывающей отдачей [когда польза уменьшается с каждым нововведением — прим. пер.], тем более что начинают применяться многие правила, которые зависят от языка, алфавита или региона. Это не означает, что о них не нужно думать. Просто здесь сложно дать универсальный совет, который подойдёт всем.
Давайте немного развернём ситуацию и рассмотрим другой тип проблем.
Некоторые имена следует резервировать
Многие сайты используют имя пользователя не только как поле в форме входа. Некоторые создают страницу профиля для каждого пользователя и ставят имя пользователя в URL. Некоторые создают адреса электронной почты для каждого пользователя. Некоторые создают поддомены. Так что возникает несколько вопросов:
- Если ваш сайт помещает имя пользователя в URL на странице профиля, что произойдёт, если я создам пользователя с именем
login
? Если я опубликую в своём профиле текст «Наша страница входа в систему перемещена, пожалуйста, нажмите здесь, чтобы войти» со ссылкой на мой сайт сбора учётных данных. Как думаете, сколько пользователей я смогу одурачить? - Если ваш сайт создаёт адреса электронной почты из имён пользователей, что произойдет, если я зарегистрируюсь в качестве пользователя с именем
webmaster
илиpostmaster
? Буду ли я получать письма, направленные на эти адреса для вашего домена? Смогу ли получить сертификат SSL для вашего домена с правильным именем пользователя и автоматически созданным адресом электронной почты? - Если ваш сайт создаёт поддомены из имен пользователей, что произойдёт, если я зарегистрируюсь в качестве пользователя с именем
www
? Илиsmtp
, илиmail
?
Если думаете, что это просто глупые гипотетические вопросы, ну что ж, кое-что из этого на самом деле произошло. И не однажды, а несколько раз. Нет, в самом деле, такие вещи случались несколько раз.
Вы можете — и должны — принять некоторые меры предосторожности для гарантии, что, скажем, автоматически созданный поддомен для учётной записи пользователя не конфликтует с уже существующим поддоменом, который вы реально используете с какой-то целью. Или что автоматически созданные адреса электронной почты не конфликтуют с важными и/или уже существующими адресами.
Но для максимальной безопасности, вероятно, нужно просто запретить регистрировать определённые имена пользователей. Я впервые увидел такой совет — и список зарезервированных имён, а также первые две статьи, упомянутые выше — в этой статье Джеффри Томаса. Начиная с версии 2.1 django-registration поставляется со списком зарезервированных имён, и этот список увеличивается с каждой версией; теперь там около ста записей.
В списке django-registration имена разбиты на несколько категорий, что позволяет создавать подмножества из них в зависимости от ваших потребностей (валидатор по умолчанию применяет их все, но можно перенастроить его с указанием только нужных наборов зарезервированных имен):
- Адреса хостов, используемые для автообнаружения/автонастройки некоторых известных сервисов.
- Адреса хостов, связанные с общеупотребительными протоколами.
- Адреса электронной почты, используемые центрами сертификации для проверки владения доменом.
- Адреса электронной почты, перечисленные в RFC 2142, которые не указаны в любом другом наборе зарезервированных имён.
- Общеупотребительные адреса no-reply@.
- Строки, совпадающие с конфиденциальными именами файлов (например, политиками междоменного доступа).
- Длинный список других потенциально чувствительных имён вроде
contact
иlogin
.
Валидатор в django-registration также отклонит любое имя пользователя, которое начинается с
.well-known
для защиты всего, что использует стандарт RFC 5785 для указания «хорошо известных URI».Как и в случае с ведущими к путанице символами в именах пользователей, рекомендую скопировать нужные элементы списка django-registration и дополнить его в случае необходимости. В свою очередь, этот список представляет собой расширенную версию списка Джеффри Томаса.
Это только начало
Здесь перечислено не всё, что можно сделать для проверки юзернеймов. Если бы я попытался написать полный список, то застрял бы тут навечно. Однако это хорошая стартовая платформа, и я рекомендую последовать большинству или всем указанным советам. Надеюсь, статья приблизительно показала, какие сложности могут скрываться за такой на первый взгляд «простой» проблемой как учётные записи с именами пользователей.
Как я уже упоминал, Django и/или django-registration уже выполняет бoльшую часть этих проверок. А то, что не делает, вероятно будет добавлено по крайней мере в версии django-registration 3.0. Сам по себе Django может и не в состоянии реализовать такие проверки в ближайшее время (или вообще когда-либо) из-за сильных проблем обратной совместимости. Весь исходный код открыт (под лицензией BSD), поэтому без проблем копируйте, адаптируйте и улучшайте его.
Если я пропустил что-то важное, пожалуйста, дайте мне знать об этом: можно сообщить о баге или отправить пулл-реквест в django-registration на GitHub или просто связаться со мной напрямую.
Комментарии (11)
Xorton
16.02.2018 09:27+1Некоторые сайты не парятся с юникодом и просто ограничивают символы в юзернейме набором [0-9A-Za-z_.].
xi-tauw
16.02.2018 09:51+1Именно поэтому автору не стоило бы писать статью в виде «Заблуждения программистов о...». Разработчик не может запретить 29 февраля, високосную секунду, UTC?0:25:21, написание имен, возможности наличие кавычек в имейле и дома без адреса. А вот то, какие логины будут на его сайте — на это он может легко повлиять.
foldr
16.02.2018 11:01В начале статьи автор ссылается на трёхсторонний шаблон идентификации, а потом зачем-то рассуждает об уникальности юзернейма. Зачем? Чтобы использовать его в качестве Login Id? При этом пытаться понять, что один человек скрывается под john_doe, John_Doe и JOHN_DOE или нет? Это уже, по указанной терминологии, Public Id.
Зачем вообще юзернеймы, если логиниться можно по емейлу, который, скорее всего, и так потребуют при регистрации на сайте, и который точно будет уникальный, в отличии от комбинации Имя-Фамилия
m1rko Автор
16.02.2018 11:30«В остальной части этой статьи будем полагать, что вы используете более распространённую реализацию, в которой уникальное имя пользователя служит по крайней мере системным идентификатором и логином для входа в систему, а также, скорее всего, публичным идентификатором»
green_tree
16.02.2018 21:12+1Идиотская реализация, очень бесит на сайтах, где заставляют для регистрации юзернейм придумывать. Пусть будет отображаемое имя, но при регистрации один хрен емейл указывать приходится, из-за чего получается уже 3 строки в менеджере паролей. А нафига?
hdfan2
16.02.2018 16:15Вот почему поддержку confusable_homoglyphs (или её аналога) не включат во все браузеры? Проверять доменное имя, и если оно подозрительно, то будь оно хоть трижды https, помечать красным или вообще заставлять создавать для него исключение. Сколько бы возможностей для фишинга отпало бы!
Livid
16.02.2018 18:18Всё хорошо, но вот рассуждение про уникальность e-mail вызывает негодование. Давайте уж пользователь сам определится, считать адреса с расширением (+smth) за разные или за один. Потому что есть масса вариантов и отождествлять user+spam@email.net и user+important@email.net может оказаться вот прямо совсем некорректно.
rhangelxs
17.02.2018 20:06Подскажите, пожалуйста, как ваше решение уживается с Django Social Auth, Allauth?
iWex
Deosis
Про это говорится в части про гомоглифы и после.