Вечная аутентификация на сайте

версия для печати

Постановка задачи: я хочу, чтобы юзер всегда был залогинен, пока сам не выйдет. И не надо ему предлагать "Запомнить меня", это тупо, имхо. Если юзер хочет, чтоб его забыли, пусть жмет "Выход".

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

Содержание

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

Disclaimer

Прежде чем изобретать свой велосипед, я изучил, как устроена аутентификация в Yii и Laravel, а так же разобрался в JWT. Мое решение отчасти похоже на JWT, с другой стороны чем-то напоминает классическую аутентификацию. Но это ни то, ни другое. Я взял из описанных подходов то, что было удобно для решения поставленной задачи.

Токен - пропуск в зону

У нас есть секретный ключ. Очень длинный и совершенно неизвестный снаружи, прописан в конфигурации приложения. Это еще не подпись, но "соль" к ней.

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

Поэтому, когда юзер авторизовался, мы выдадим ему пропуск (токен), на нем написано, кто этот юзер (uid). Эту пропуск мы пометим, чтобы знать, что он выдан именно нами: объединим его содержимое с нашим секретным ключом и посчитаем хеш. Вот это уже подпись. Полученный хеш прикрепим к пропуску и вернем наконец юзеру. С таким пропуском он сможет ходить к нам без проверки в базе, кто он такой, так быстрее. На пропуске же уже написано, и он с нашей подписью, которую мы при каждом обращении будем проверять. Т.е. пересчитывать содержимое пропуска + наша секретная соль и сравнивать с тем, какая подпись на пропуске стоит. Если совпадает, значит данные в пропуске не меняли, и он все еще выдан нами.

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

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

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

Итак, у пропуска есть срок действия. Срок прописан рядом с id юзера. Когда срок продлевается, очевидно, что нужно заново указать его в пропуске. И подписать его. Хеш подписи изменится, т.к. данные поменялись (новый срок действия). Выдаем новый пропуск юзеру.

А чтобы юзер не посеял свой пропуск, пристегиваем его к юзеру на резиночку (cookie). Если чувак проболел, явился через месяц, мы пропуск проверим, обновим и выдадим новый. Резиночка стареет и хранится год, потом приходит в негодность. Если юзер год не появлялся, будет авторизовываться заново. При выдаче нового пропуска выдаем и новую резиночку.

Так должна работать бесконечная аутентификация без постоянных проверок в базе, что юзер допущен.

Теперь коротко техническим языком: после успешного входа по логину/паролю выдаем юзеру токен авторизации. В нем прописаны, как минимум, id юзера и время жизни токена. При последующих обращениях юзера на сайт проверяем целостность токена по подписи с нашей секретной солью. Eсли выданный токен старше 10 минут, проверяем в базе статус юзера. Храним токен в cookie юзера, для печеньки срок годности большой, чтоб юзер мог заглядывать нечасто, но быть допущенным к проверке.

Имеем:

$ourSignKey = 'dfbg24t123rfcрусский25tgv,5fg2RH5g$%$efgr34fg==4t1k+4vDSrgGrh4h25hfdf';

$credentials = json_encode([
    'uid' => 12,
    'exp' => 1507665854, //unix timestamp, time() + 10 мин
]);

$sign = md5($credentials . $ourSignKey);
$packedCredentials = rtrim(base64_encode($credentials), '=');

$token = "{$packedCredentials}.{$sign}";

// 365 дней, по всему сайту, без шифрованного канала передачи, только http доступ
setcookie('auth', $token, strtotime('+1 year'), '/', '', false, true);

return 'Welcome, dear user #12';

Содержимое токена должно быть сведено к минимуму, чтобы меньше места занимало при передаче и хранении в cookie. Поэтому ключи в $credentials короткие.

Зачем применять base64_encode() - нам нужно дописать подпись к данным токена. Есть два варианта, как ее потом отделять: либо вычислять по длине - применяли md5(), следовательно 32 символа с конца, - либо разбить по символу-разделителю. Но в таком случае нужна гарантия, что выбранный нами разделитель не встречается в склеиваемых частях токена. Такую гарантию, при выборе разделителем точки, дает base64_encode() или bin2hex(). Однако обычно результат второй функции получается длинее.

Шифрование

У нас есть еще один длинный непредсказуемый ключ, для шифрования.

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

$token = ...;

// получили подписанный токен, но еще не занесли его в печеньку

$encryptedToken = CryptString::encrypt($token);

setcookie('auth', $encryptedToken, ...);

return 'Welcome, dear user #12';

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

Обновление токена и его серийный номер

Внимание, вопрос: когда некто предоставляет старый пропуск, мы что делаем? Правильно, обновляем. Но! Мы вообще не проверяем, что перед нами то самое лицо, кому мы пропуск выдавали. Мы проверяем только, что сам пропуск не поддельный: юзер не пытался, например, поменять uid или добавить себе роль суперадмина. Но что, если сам юзер - не тот человек?!

Как это выглядит. Хакер спер пропуск (угнал cookie с компа жертвы), откатал копию, и по нему заявился. Мы выдали ему новый пропуск. Потом юзер заявился со старым пропуском (еще с тем, который спер хакер). Мы и ему выдали новый. Получается теперь два человека под видом одного и того же юзера шарятся по разным пропускам и без проблем их обновляют - красота..

И вот зачем "соль" в самом пропуске: некий случайный, воообще непредсказуемый код, который, внимание!, хранится в базе в паре с id юзера. Т.е. это серийный номер пропуска и в базе есть инфа, какому юзеру мы выдали пропуск с таким номером.

Как теперь работает проверка и выдача нового пропуска. Юзер пришел, срок действия пропуска истек. Идем в базу и проверяем, что у нас есть запись о таком пропуске, т.е. ищем запись по паре sn:uid. Если запись будет найдена, создаем новый токен, в базу пишем новую пару sn:uid, отдаем токен юзеру. Если записи нет, отправляем юзера на страницу входа.

Когда юзер логинится, обязательно нужно удалять закрепленный за ним токен (искать по uid), даже если по паре sn:uid его не нашли. Только потом выдать юзеру новый токен. Поиск по uid, потому что если хакер обновил токен раньше, то по паре sn:uid уже ничего не найдем, а хакер тем не менее сможет остаться в зоне. Его-то пропуск в базе есть.

Вот зачем прописываем серийный номер в токене и он хранится в базе с привязкой к юзеру. Содержимое будущего токена становится таким:

$credentials = json_encode([
    'sn'  => 'web34q',
    'uid' => 12,
    'exp' => 1507665854,
]);

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

Положим, меня это не устраивает, т.к. мастер-база одна, запись в нее - узкое место, зато реплик на чтение - много и проверять пары sn:uid с каждым обновлением токена - не проблема. Поэтому я придумал свой срок жизни для серийников токенов - сутки. Теперь раз в 10 минут в базе проверяется существование пары sn:uid и с ней же вытягивается время, когда серийник устареет. Далее, либо просто продлевается токен, что приведет к его полной перегенерации но без обновления БД, либо еще и новый серийник заводится и прописывается пара sn:uid в базу.

Назовем таблицу с парами sn:uid таблицей учета действительных пропусков, - auth_tokens.

А теперь рассмотрим серийник поближе. Его можно даже не скрывать и не придумывать длинную случайную последовательность. Тут все просто: можно задавать номера по порядку, прям как есть primary key из таблицы, где хранятся sn:uid. Даже если хакер сможет предсказать, что у такого-то юзера сейчас выдан пропуск с таким-то номером, ему это бесполезно, т.к. подделать пропуск он сможет, но правильно подписать - нет, потому что соль подписи - это наша строгая тайна.

Так вот, я все же не хочу связывать серийник с первичным ключом этой таблицы, хотя это проще всего. Более того, я в принципе не хочу в ней заводить классический первичный ключ - id BIGINT UNSIGNED AUTO_INCREMENT. И вот почему: даже если серийник будет обновляться раз в сутки, это даст 365 записей с одного юзера. А лучше бы делать обновление раз в несколько часов, для большей безопасности. Следовательно еще больше записей на одного юзера. А если еще и посещаемость сайта высокая, то в итоге очень быстро будет расти значение классического первичного ключа и может в обозримом будущем достичь предела.

Мое предложение такое: первичный ключ в auth_tokens - это пара sn:uid. При этом sn - короткое случайное число или число+буквы, если угодно. Допустим, 6 символов. Что это даст: в таблице действительных токенов всегда будут только записи по количеству залогиненных юзеров, даже если их over 9000 человек на сайте. Вероятность совпадения пары sn:uid просто невозможна. Ну или в крайнем случае один из всех тысяч юзеров увидит ошибку и зайдет заново.

Один юзер с разных браузеров

Теперь представим, есть честный юзер, не хакер. Мы выдали ему пропуск, запомнили пару sn:uid. Юзер ходит без обновления серийника часа два, все круто.. Тут он пересаживается на мобильник, поехал в город. В дороге залогинился на сайт - мы ему выдали новый пропуск, инфа о старом стерлась. Так задумана логика хранения и обновления серийников токенов.

Юзер приехал домой, заходит в браузер - "введите пароль". Некозявенько получилось, что мы так резко про юзера забыли. Но на самом деле мы-то не забыли, просто по введенным с мобильника логину и паролю стерли инфу о ранее выданном пропуске.

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

Предполагаемое решение: использовать user agent. Не катит. Во-первых, есть достаточная вероятность, что user agent хакера и жертвы совпадет, и тогда мы опять не сможем отслеживать, кто приходит с пропуском. Во-вторых, если юзер обновит браузер, его токен станет не действительным, что не очень красиво в т.з. вечной аутентификации. Другой вариант: полагаться на IP - ненадежно, т.к. почти у любого юзера IP серый, и провайдер может сменить клиенту адрес в любой момент. Вообщем, я пока не придумал, как 100% защитить юзера от воровства токена и при этом разрешить мультивход.

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

Что еще можно сделать по теме мультивхода (Google style): когда юзер логинится и при этом у нас есть запись о выданном токене для другого user agent, можно отправлять юзеру письмо, что произошел вход под его учеткой. Это не весть что, но добавит юзеру немного контроля за его активностью.

В личном кабинете (ЛК) нужна страница, где юзер может узнать, сколько пропусков у него есть и в каком браузере/устройстве они выданы. Т.е. список мест, где он залогинился. И там же - возможность отменить авторизацию по каждому пункту в списке или на всех сразу, кроме текущего.

Вход

Для защиты от брут-форса можно ввести ограничения на количество попыток, скажем, 5 с блокировкой учетки минут на 10. Можно еще на мыло уведомлять, что учетка блокирована. Это будет письмо #1. В этом же письме дать ссылку, по которой можно залогиниться (если еще не сделано) и заблокировать вход в свою учетку насовсем.

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

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

Снять блокировку юзер может не только по мылу, но и прямо в ЛК, пока не разлогинился. Надо повешать где-нибудь красную трапку напоминание, что он вход себе завалил камнями :)

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

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

Логирование

Как я уже упомянул, мы можем выдать юзеру сводку, где он залогинился и сколько у него активных пропусков. Но логировать нужно не только эти данные. Желательно еще ip, с которого юзер входил, и адрес, где инициировалась аутентификация. Отдельно - неудачные попытки входа: когда, откуда, сколько. Eще можно логировать выход юзера.

Таблица логирования может быть отдельной в базе, при условии, что есть и другие логи. Ее структура скорее всего будет сильно отличаться от обычного лога. Вот набросок: id, created_at, user_id, log_type, is_success, message, user_agent, ip, url.

Оценка надежности защиты

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

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

Хотя я бы рекомендовал все же шифровать токен. Это дает две степени защиты (подпись + шифрование) и очень затрудняет получение представления о системе аутентификации, принятой на сайте. Только не надо изобретать велосипеды, используйте уже готовые алгоритмы шифрования, например, из библиотеки OpenSSL. Если не хочется с ней разибраться, смотрите в сторону libsodium, есть версия на PHP кроме прочих.

Cons & Pros

Что в плюсе:

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

  • На сессии вообще не завязываемся. К тому же в течение 10 минут у нас приложение работает с юзером в режиме stateless. Меньше нагрузка на базу.

  • Обновление данных токена, например, ролей - нивапрос, раз в 10 минут его данные проверяются в базе. Тут же без проблем сработает инвалидация токена, если юзер, например, в бане. Я думаю, можно потерпеть гада 10 минут. В классическом же решении на каждый запрос браузера нужно проверять, что там у юзера с ролями/статусом учетки.

  • Обновление серийника токена в целях безопасности - раз в сутки (или час, два, в конфиг вынести). Если хацкер угнал у юзера токен, то тут у него все еще есть лазейка: пока сам юзер на сайт не обратится, хакер за него может сидеть и получать новый токен каждый день. Как только юзер попробует зайти, а его токен уже давно неизвестен, ему предложено будет перелогиниться. В базе обновиться запись о токене - хакер отвалится.

    Есть малая вероятнось гонки - и юзер логинится и хакер с токеном на обновление. Но тут просто нужно повысить приоритет для юзера с его аутентификацией. Блокировать на чтение запись в таблице, когда обновляем токен? Как быть с удалением и гонкой в таком случае? Можно просто забить на такую маловероятную ситуацию.

Чем жертвуем:

  • Реализация самобытная. Без описания непонятно, зачем столько периодов жизней, зачем столько полей и разных приватных ключей. Почему так похоже на JWT, но не оно. Тут ничего не могу поделать, вот документация, - RTFM.

  • Не использую JWT (JWE, JWS - без разницы), хотя на него похоже. Отказался, потому что эта фича стандартизирована, а я не нашел в ней красивое решение с вечной авторизованностью юзера. Следовательно для моей затеи придется писать костыли в JWT, поменяется стандарт - править костыли. Ну и к тому же JWT подразумевает работу без обращения к БД вообще (после логина), в том и суть stateless-аутентификации, я считаю. Но если не сверяться с базой, то придется придумывать, как по-другому инвалидировать токен.

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

Реализация "Запомнить меня" в других движках

Классическая аутентификация

В классической аутентификации инфа о юзере хранится в сессии, при этом юзер только id сессии передает в печеньке, мы ищем его сессию и выясняем, можно ли ему в закрытую зону. Но сессии кратковременные, и юзеру заново придется записываться. С флагом "запомнить меня", ему выдается в cookie какое-нибудь другое уникальное значение. Оно так же записано в БД. По этому значению сайт признает юзера и заново создает сессию. Как-то так.

Laravel

Проверка авторизованности юзера: сначала наличие сессионной печеньки. В ней, кстати, ID юзера прописано, похоже, прям в cookie. Если такой печеньки нет, тогда ищем remember-me cookie и по ее данным проверяем, что про юзера в базе записано. Если по ней юзер верный, пересоздается сессия.

Скрипт [Illuminate/Auth/Recaller.php]

В шифрованную remember-me печеньку пишется ID|remember_token|password_hash. TTL печеньки - 5 лет.

Все эти поля так же прописаны в базе, таблица users.

Yii

(c) Yii. Аутентификация и авторизация

Обычная аутентификация - на сессиях. Проверили логин/пароль, отметили в сессии минимум необходимых данных о залогиненном юзере, эти же данные загрузили в спец.объект через CBaseUserIdentity::setState().

Продленная аутентификация - через cookie состояния. В ней сохраняется та же необходимая инфа о юзере, кроме конфиденциальной конечно. При чтении печеньки так же записываем данные в объект юзера через CBaseUserIdentity::setState().

Кроме того, в Yii для усиления безопасности можно включить одноразовые коды в печеньке состояния. Как подпись или nonce в других реализациях, суть одна. Кроме инфы юзера в печеньку пишется код, он же хранится в базе. При обращении к серверу этот код проверяется и пересоздается. Т.о. на каждый запрос браузера дергает БД на чтение/запись кода + перезапись cookie.

[1oo%, EoF]
Похожие материалы:
Надежное хранение пароля юзера

Понравилась статья? Расскажите о ней друзьям:


Комментарии
Для работы модуля комментариев включите javaScript


Показать/скрыть правила
Имя
[i] [b] [u] [s] [url]
:-) ;-) :D *lol* 8-) :-* :-| :-( *cry* :o :-? *unsure* *oops* :-x *shocked* *zzz* :P *evil*

Осталось 1000 символов.
Код защиты от спама Обновить код
Каждый комментарий проходит ручную модерацию. 100% фильтрация спама.
Продвижение
Время
Метки