Сайт, который забывал вас каждые три часа
Вход в личный кабинет требовал биометрической проверки через распознавание лица — а через три часа её приходилось проходить снова. Проблема была не в коротком сроке сессии, а в отсутствии механизма её продления. Теперь сайт незаметно продлевает сессию на пятнадцать дней, а самая сложная часть заключалась в том, чтобы гарантировать, что продление никогда не запускается дважды одновременно.
Вход в личный кабинет — это не пароль. Это MyID: вы подносите телефон, сканируете лицо, а государственный сервис идентификации подтверждает вашу личность. Это действительно работает и обеспечивает высокий уровень безопасности.
Но проходить такую процедуру каждый раз, когда вы просто хотите проверить, действует ли ваш полис, совсем не хочется.
А именно это и происходило. Через три часа после входа сайт забывал пользователя. Прямо во время сессии, заполнения формы и без какого-либо предупреждения: возврат на главную страницу и повторное сканирование лица. Некоторые пользователи делали это по несколько раз в день.
Три часа не были ошибкой
Очевидное решение — сделать сессию длиннее. Но это неправильный подход, и вот почему.
Токен сессии — это ключ. Если кто-то его скопирует — например, на общем компьютере, украденном ноутбуке или через вредоносное расширение браузера, — он сможет действовать от вашего имени до тех пор, пока токен не истечёт. Трёхчасовой ключ ограничивает потенциальный ущерб несколькими часами. Ключ на три месяца фактически передаёт доступ к страховому кабинету до осени.
Поэтому короткий срок был правильным. Не хватало другой половины решения: возможности продлить сессию, не заставляя пользователя снова доказывать свою личность. В мобильном приложении уже использовалась семидневная сессия. Именно сайт оставался исключением — короткоживущие токены без механизма их продления.
Два ключа вместо одного
Решение — добавить второй токен, который делает только одну вещь: подтверждает, что пользователю разрешено получить новый токен сессии.
Короткий ключ остаётся коротким — три часа. Он отправляется с каждым запросом, и если попадёт не в те руки, к вечеру уже станет бесполезным. Ключ продления действует пятнадцать дней, никогда не используется для доступа к данным и может быть потрачен только на один специальный endpoint.
Когда короткий токен истекает, сайт замечает это, незаметно обменивает ключ продления на новую пару токенов и повторяет запрос, который только что завершился ошибкой. Для пользователя ничего не происходит: ни редиректа, ни мерцания страницы, ни повторного сканирования лица.
За этим стоят две важные детали.
Каждый ключ продления используется только один раз. После использования он становится недействительным, а вместе с новой сессией выдаётся новый ключ. Если украденный ключ кто-то использует раньше вас, это проявится окончанием вашей сессии — что намного безопаснее, чем позволить злоумышленнику оставаться внутри аккаунта вместе с вами две недели.
В базе данных сам ключ не хранится. Там находится только его односторонний отпечаток. Поэтому человек, получивший доступ к таблице, не получает готовый ключ для входа.
Четыре запроса — один ключ
Вот часть, которая заняла больше всего времени, хотя на первый взгляд её легко не заметить.
При открытии личного кабинета одновременно отправляется несколько запросов: профиль, полисы, медицинская карта, страховые случаи. Все они используют один и тот же токен сессии. Поэтому когда он истекает, все запросы завершаются ошибкой одновременно.
Наивная реализация заставила бы каждый запрос самостоятельно продлевать сессию. Первый запрос успешно использует ключ и делает его недействительным. Остальные три пытаются использовать уже несуществующий ключ, получают отказ и разлогинивают пользователя — сразу после успешного продления. В результате пользователь случайно выходил бы из аккаунта каждые несколько часов, а воспроизвести проблему по требованию было бы практически невозможно.
Теперь приложение использует одно общее продление в процессе выполнения. Первый неудачный запрос запускает его, остальные ждут тот же результат и продолжают работу уже с новым ключом. Одно продление — четыре спасённых запроса.
Сервер решает ту же проблему со своей стороны. Две вкладки всё ещё могут попытаться использовать один ключ одновременно. Поэтому пометка ключа как использованного выполняется одной условной операцией — отозвать эту запись, но только если она ещё не отозвана. Одна вкладка выигрывает, вторая получает отказ. Никаких блокировок и временных окон для гонки.
Есть и небольшая, но намеренная защита: сервер берёт длительность сессии из сохранённого ключа, а не из параметров запроса пользователя. Иначе веб-сессия могла бы просто попросить выдать ей срок как у мобильного приложения.
Ошибка, которая появляется только при одновременной работе нескольких пользователей
Во время реализации продления я нашёл связанную проблему, которая давно и незаметно ломала шапку сайта.
Страница сначала рендерится на сервере, а уже потом попадает в браузер. Чтобы не запрашивать один и тот же профиль дважды, использовался маркер «запрос уже выполняется» — обычная переменная, объявленная в файле.
На собственном компьютере это работает нормально. Но на сервере, который одновременно обслуживает множество пользователей, эта переменная общая для всех. В итоге один пользователь мог начать ждать запрос другого, никогда не получить свой собственный результат и отобразиться как неавторизованный — хотя сам личный кабинет продолжал работать, потому что ему был нужен только ключ. Это была одна из причин старой жалобы: «шапка сайта забывает, что я вошёл в аккаунт».
Теперь этот маркер хранится отдельно для каждого пользователя. Та же идея, но правильная область видимости.
Когда система всё-таки должна вас разлогинить
Продление не может длиться вечно — и притворяться, что это возможно, было бы ещё одной ошибкой.
Если ключ продления действительно больше недействителен — истёк, был отозван или уже использован, — сессия корректно завершается: ключи удаляются, пользователь попадает на главную страницу, и открывается окно MyID. При этом каждый запрос повторяется только один раз. Если повторный запрос тоже завершается ошибкой, это настоящий выход из аккаунта, а не бесконечный цикл попыток продления.
И ещё одна проблема, которая проявилась только за пределами разработки: cookie сессии была помечена как «только HTTPS», но браузеры делают исключение для localhost. На компьютере каждого разработчика всё работало. На общем тестовом сервере, где соединение уже было настоящим сетевым подключением, коллеги могли успешно войти, но всё равно воспринимались как гости. Теперь правило проверяет, действительно ли соединение зашифровано, вместо того чтобы предполагать это по окружению.
Почему я доволен результатом
Безопасность не стала слабее — доступный ключ по-прежнему действует всего три часа, как и раньше. Изменилось только одно: теперь пользователь больше не платит за это решение своим лицом по шесть раз в день.