Практический пример: повышение безопасности упрощённого входа

Практический пример: повышение безопасности упрощённого входа

—Когда последние четыре цифры номера телефона становятся паролем—

1. Предпосылки: удобство входа по ссылке и скрытый риск

Проект, над которым я работал, представлял собой систему электронных анкет для пациентов крупной многопрофильной больницы. Перед приёмом пациенты заполняют на мобильных устройствах анкету, отправленную больницей, а медицинский персонал использует результаты во время консультаций. Система состоит из аутентификации на базе Keycloak, бэкенда на Java/Spring с микросервисной архитектурой и фронтенда на React. Я подключился к уже работающей системе для исправления дефектов и операционной поддержки. Эта статья начинается с проблемы, с которой я столкнулся во время выполнения этих задач по сопровождению.

Основной способ входа в эту систему — «вход по ссылке». Пациенты открывают ссылку на анкету, отправленную больницей через Alimtalk, и могут сразу заполнить анкету, введя только последние четыре цифры своего номера телефона, без регистрации и ввода пароля.

image1.png

Рисунок 1. Процесс входа по ссылке

Проблема выявилась во время проверки безопасности, проведённой клиентом. Клиент задал вопрос: «Если пользователь, вошедший по ссылке, является зарегистрированным участником, не сможет ли он получить доступ ко всему разделу для участников?» При воспроизведении проблемы выяснилось, что это действительно возможно. Если получатель ссылки уже был зарегистрирован как участник с идентификатором и паролем, Keycloak выдавал токен для этой учётной записи. Поскольку экраны и API вообще не различали способы входа, сессия, аутентифицированная только последними четырьмя цифрами номера телефона, получала доступ к странице «Моя страница», уведомлениям и полной истории анкет. Для зарегистрированных участников последние четыре цифры фактически становились паролем. Это означало, что третье лицо, получившее доступ к текстовому сообщению, могло просмотреть связанную с медициной информацию другого человека, используя всего лишь четырёхзначное число с 10 000 возможных комбинаций. Клиент официально направил запрос на устранение проблемы.

2. Определение требований: «Пользователи, вошедшие по ссылке, должны обрабатываться так же, как гости»

Самым простым решением было бы заблокировать вход по ссылке для зарегистрированных участников, но это лишило бы сервис его ключевого преимущества: «Пользователь может просто нажать ссылку и заполнить анкету». После обсуждения с клиентом мы решили не блокировать таких пользователей, а понизить уровень их привилегий. Иными словами, «сессия входа по ссылке должна обрабатываться в точности так же, как сессия незарегистрированного пользователя (гостя), даже если учётная запись принадлежит зарегистрированному участнику».

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

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

  • Меню, доступные только участникам, например «Моя страница» и уведомления, не отображаются. (Сценарий киоска исключён.)

После такой формализации требований техническая задача свелась к одному вопросу: как надёжно определить, «каким способом была выполнена аутентификация этой сессии».

3. Технический выбор: где хранить способ входа?

Мы рассмотрели три варианта хранения информации о способе входа и её передачи.

  • ① Состояние фронтенда (sessionStorage и т. п.): Такой вариант легко реализовать, но данные можно изменить через инструменты разработчика, поэтому их нельзя использовать как основу для решений безопасности.

  • ② Серверная сессия: Этот вариант не соответствует архитектуре без сохранения состояния на основе JWT и создаёт связанность, поскольку каждому микросервису пришлось бы обращаться к хранилищу сессий.

  • ③ Утверждение в токене: Этот подход помещает способ входа в токен, подписанный Keycloak — поставщиком удостоверений. Изменить его нельзя, а фронтенд и бэкенд могут определить способ входа без дополнительных запросов.

Мы выбрали вариант ③. Мы решили, что поставщик удостоверений должен гарантировать не только информацию о том, «кто вошёл», но и о том, «как он вошёл». Изначально мы планировали использовать её только для ограничения экранов, однако при дальнейшем расширении подхода для блокировки API бэкенда эту информацию можно будет использовать без изменений.

3-1. Реализация в Keycloak: заметка пользовательской сессии и маппер

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

// LoginAuthenticator.java
if (formData.containsKey(FIELD_PHONE_LAST_4)) {
    // [CASE 1] 링크(전화번호 뒤 4자리) 로그인
    user = handlePhoneLogin(context, formData);
    if (user != null) {
        // User Session Note mapper를 통해 토큰 claim으로 발급된다
        context.getAuthenticationSession()
            .setUserSessionNote("login_method", "link");
    }
} else {
    // 키오스크 / 일반 ID/PW 로그인 — note를 기록하지 않는다
    ...
}

User Session Note существует только в рамках сессии Keycloak, поэтому в выделенной области клиента необходимо зарегистрировать маппер протокола, чтобы представить эту заметку как утверждение токена.

# Keycloak 관리 콘솔 — 클라이언트 dedicated scope > Add mapper (By configuration)
Mapper type          : User Session Note
User Session Note    : login_method
Token Claim Name     : login_method
Claim JSON Type      : String
Add to ID token      : On
Add to access token  : On

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

// 링크 로그인 토큰 payload (일부)
{
  "preferred_username": "u-93f2...",
  "login_method": "link",   // <- mapper가 발급한 claim
  ...
}

// ID/PW 로그인 토큰 payload (일부) - login_method claim 자체가 없다
{
  "preferred_username": "u-93f2...",
  ...
}

Это преднамеренная отказоустойчивая конструкция. Логика обнаружения ограничивает доступ «только если значение утверждения равно link». Поэтому даже при наличии дефекта в коде обнаружения или сбое разбора токена результат всегда трактуется как «не ограничивать», то есть существующий сценарий для зарегистрированных участников остаётся неизменным.

4. Приложение фронтенда: одна утилита обнаружения, шесть уровней защиты

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

/** 링크 로그인 세션 여부 — ID/PW 토큰에는 claim 자체가 없다 */
export const isLinkLoginSession = (): boolean => {
  const token = getAccessToken();
  if (!token) return false;
  try {
    return JSON.parse(atob(token.split('.')[1]))?.login_method === 'link';
  } catch {
    return false;   // 파싱 실패 시에도 "제한하지 않음" (fail-safe)
  }
};

Существующие экраны определяли гостевой режим по тому, является ли пользователь незарегистрированным (isGuest). Поэтому мы добавили сессии по ссылке в интегрированную концепцию isRestricted внутри хука проверки авторизации, чтобы экранам было достаточно проверять это значение.

const isGuest = user?.isGuest ?? false;
const isLinkSession = isLinkLoginSession();
const isRestricted = isGuest || isLinkSession;  // 제한 세션

Мы применили этот критерий к шести уровням защиты: макету маршрутизации, хуку меню, странице первоначального входа, панели пациента, общему заголовку и списку анкет. Для ограниченных сессий остаются доступны только отправленная анкета и [Просмотреть предыдущие записи]. После завершения проверки личности мы унифицировали сценарий: с главного экрана, вкладки анкет или выхода со страницы сведений пользователь одинаковым образом переходит на страницу истории. Нижняя мобильная навигация отображается только после завершения проверки личности.

5. Уровень API: баланс между открытостью и защитой

Приведение экранов к уровню доступа гостя создало проблему противоположного характера. Три API, вызываемые гостевыми экранами — проверка личности, список анкет гостя и поиск истории гостя — были ограничены ролью ROLE_GUEST, поэтому их также пришлось открыть для участников сессий по ссылке, использующих токены ROLE_USER. Поскольку это изменение расширило права доступа, мы отдельно проверили его безопасность. Все три API определяют целевого пациента по аутентифицированному субъекту в токене, а не по данным клиента, и возвращают данные только после повторной проверки флага успешной проверки личности, сохранённого в Redis (TTL: 30 минут). Поэтому путь к данным другого человека не появился.

И наоборот, мы решили исключить из текущего объёма работ «блокировку API, предназначенных только для участников, на бэкенде, если сессия является сессией по ссылке». Ранее эти области ограничивались только на уровне экранов, а сами API для участников также имели собственные механизмы защиты, поэтому фактический риск был невелик. Однако, поскольку утверждение включается в Access Token, основа для дальнейшего расширения системы уже заложена: при необходимости логика авторизации бэкенда сможет читать это утверждение и блокировать доступ.

image2.png

Рисунок 2. Общая архитектура — передача утверждения о способе входа и точки обнаружения

6. Уроки производственного развёртывания: конфигурация, которая завершается без явной ошибки

Главный урок был связан не с кодом, а с развёртыванием. Authenticator (SPI) является кодом и поэтому проходит через конвейер развёртывания, тогда как маппер — это «конфигурация» в области Keycloak, которую необходимо вручную регистрировать для каждого окружения через консоль администрирования. Проблема в том, что при отсутствии этой конфигурации ничего не ломается. Утверждение просто не выдаётся, а поскольку функция обнаружения спроектирована с безопасным поведением при сбое, она незаметно возвращает false. В результате ограничения экранов исчезают, хотя всё выглядит нормально. Такой «тихий сбой» маловероятно будет обнаружен даже по обращениям пользователей.

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

  • При развёртывании в продуктивную среду вручную зарегистрировать маппер User Session Note в консоли Keycloak (с той же конфигурацией, что и в DEV/STG)

  • Сразу после развёртывания проверить, что утверждение login_method присутствует в токенах входа по ссылке и отсутствует в токенах входа по идентификатору и паролю

  • Выполнить финальную проверку на уровне экранов: меню, доступные только участникам, не должны отображаться в сессиях по ссылке

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

7. Заключение

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

  • «Способ» аутентификации также является информацией, которую должен гарантировать поставщик удостоверений. Подход с размещением способа входа в подписанном утверждении токена можно применять в любых системах с несколькими путями аутентификации, например с упрощённой аутентификацией и входом через социальные сети.

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

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

eunice

Site footer