SSO с одноразовым кодом на базе Redis

SSO с одноразовым кодом на базе Redis

- Опыт временного выпуска, использования и контроля повторных запросов токенов для снижения прямой экспозиции токенов -

1. Фон применения

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

Однако токен фактически относится к действительным учетным данным аутентификации. Если это значение останется во внешнем ответе, URL-запросах, истории браузера, журнале сервера, захваченных экранах, сообщениях мессенджеров и т.д., это создаст риски безопасности. Особенно необходимо было учитывать возможность появления значений в URL или журнале фронта, когда браузер открывается для перехода между экранами. Поэтому было решено не передавать токен напрямую, а передать одноразовый loginCode, действительный в течение короткого времени, после чего фронт получает токен через отдельный API аутентификации.

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

2. Общая структура

Структура реализации была разделена на четыре основные области. Первой является сервер, выполняющий роль адаптера, принимающего запросы внутренней рабочей системы и выдающего код входа. Второй — Redis, временно хранящий loginCode и tokenJson. Третьим является сервер аутентификации с ролью аккаунта, который использует переданный фронтом loginCode для возврата токена. Последняя часть — фронт, который считывает loginCode из параметра запроса URI, вызывает API consume (использовать) и сохраняет токен.

Вся схема следующая.

업무 시스템
  -> adapter: 진입 URI 발급 요청
  -> adapter: 인증 서버에서 직원 token 발급
  -> Redis: tokenJson, loginCode, userKey 저장
  -> adapter: loginCode가 포함된 rsltUri 반환
  -> front: rsltUri로 진입 후 loginCode 추출
  -> account: loginCode consume 요청
  -> Redis: tokenJson 반환 및 key 삭제
  -> front: token 저장 후 목표 화면 표시

Ключевым моментом этой структуры является то, что сам токен не помещается в путь перехода экранов. Между рабочей системой и фронтом передается только одноразовый loginCode, а не токен. Сервер аккаунта, получив loginCode, извлекает tokenJson из Redis и одновременно уничтожает данный код.

3. Причины передачи loginCode вместо токена

accessToken и refreshToken являются значениями, которые подтверждают аутентифицированного пользователя. Поэтому необходимо было избегать добавления токена напрямую в URL. Если значение токена будет выставлено на показ, то в течение его срока действия можно будет вызывать API с правами данного пользователя.

Поскольку loginCode также является значением, позволяющим извлекать токен, его нельзя считать не чувствительным. Однако в отличие от самого токена он имеет короткий TTL и может быть настроен так, чтобы сразу же удаляться после использования. То есть, loginCode не является прямой заменой токена, а играет роль ваучера для одной единственной выборки временно хранящегося токена из Redis.

accessToken / refreshToken
  - 실제 인증 자격증명입니다.
  - URL이나 외부 응답에 직접 노출하지 않는 것이 안전합니다.
loginCode
  - Redis에 저장된 tokenJson을 꺼내기 위한 일회용 교환권입니다.
  - TTL과 GETDEL 성격의 소비 로직으로 사용 범위를 제한합니다.

4. Проектирование ключей Redis

В Redis хранилось несколько типов ключей. Изначально я думал, что достаточно просто сохранить tokenJson под ключом loginCode, но на самом деле в процессе применения пришлось учитывать дублированный выпуск, одновременные запросы и организацию после использования.

codeKey
app:sso:login-code:{loginCode}
-> tokenJson
userKey
app:sso:login-code:user:{userHash}
-> loginCode
codeUserKey
app:sso:login-code:code-user:{loginCode}
-> userKey
lockKey
app:sso:login-code:lock:{userHash}
-> LOCKED

codeKey — это ключ, используемый для поиска tokenJson по loginCode. userKey — это ключ для проверки, существует ли уже выданный, но не использованный loginCode в том же входном блоке. codeUserKey — это обратный ключ, разработанный, чтобы аккаунт мог находить и удалять userKey только по loginCode. lockKey — это ключ, контролирующий, чтобы только один запрос фактически выполнял выпуск, когда несколько запросов на выпуск приходят одновременно.

5. Разделение URI и определение целевой аудитории

Способы, которыми пользователь открывает экран сервиса в бизнес-системе, были двумя. Один из них — это прямой переход на определённый целевой экран, а другой — это вход на персональную панель управления. Поэтому адаптер изменил своё поведение, чтобы не просто возвращать loginCode, а генерировать и возвращать rsltUri, который должен открыть фронтенд.

대상 식별값이 있는 경우:
/service/staff/survey/response?loginCode={code}&targetId={targetId}
대상 식별값이 없는 경우:
/service/staff/dashboard/personal?loginCode={code}

В открытых записях фактический домен, фактический путь и фактические имена идентификаторов были обобщены. Важно отметить, что loginCode включается в URI перемещения экрана, а фронтенд читает это значение и вызывает API учёта аккаунта.

private String buildLoginUri(String loginCode, String targetId) {
    if (hasText(targetId)) {
        return baseUrl + "/staff/survey/response"
                + "?loginCode=" + loginCode
                + "&targetId=" + targetId;
    }
    return baseUrl + "/staff/dashboard/personal"
            + "?loginCode=" + loginCode;
}

6. Причины коррекции 기준 userHash

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

Однако, если посмотреть на требования к разделению URI, случаи наличия и отсутствия целевого идентификатора имеют разные цели входа. Один и тот же сотрудник может последовательно открыть целевой экран A и целевой экран B за короткий промежуток времени, и оба экрана могут быть открыты одновременно в браузерных вкладках. Если делать userHash только на основе сотрудников в этом случае, разные запросы на вход на экраны могут быть связаны с одним и тем же userKey.

직원 기준으로만 userHash를 만들면:
직원 A + 대상 111 -> userHash H1 -> loginCode X
직원 A + 대상 222 -> userHash H1 -> loginCode X
결과:
두 화면이 같은 loginCode를 공유할 수 있습니다.
loginCode는 일회용이므로 한 화면이 먼저 consume하면 다른 화면은 실패할 수 있습니다.

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

직원 + 진입 대상 기준으로 userHash를 만들면:
직원 A + 대상 111 -> userHash H111 -> loginCode X
직원 A + 대상 222 -> userHash H222 -> loginCode Y
직원 A + 대시보드 -> userHash HD -> loginCode Z

7. lockKey для предотвращения одновременных запросов

Только userKey не может полностью предотвратить проблемы конкурентности. Например, если один и тот же пользователь делает несколько запросов на открытие одного и того же экрана почти одновременно, все эти запросы могут рассматривать userKey как отсутствующий. В таком случае может быть выдано несколько loginCode.

Чтобы этого избежать, мы использовали lockKey с использованием функции setIfAbsent в Redis. Только запросы, которые получили блокировку, выполняют выдачу реального токена и сохранение в Redis, а запросы, которые не получили блокировку, кратковременно ожидают и возвращают существующий loginCode, сохранённый в userKey.

locked = setIfAbsent(lockKey, "LOCKED", lockTtl)
if (!locked) {
    existingCode = waitAndGet(userKey)
    return buildLoginUri(existingCode, targetId)
}
try {
    token = loginOrJoin(user)
    code = createLoginCode()
    saveCodeKeys(code, token, userKey)
    return buildLoginUri(code, targetId)
} finally {
    delete(lockKey)
}

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

8. Порядок хранения и роль codeUserKey

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

set(codeKey, tokenJson, ttl)
set(codeUserKey, userKey, ttl)
set(userKey, loginCode, ttl)

codeUserKey необходим для упорядочивания до userKey после успешного учёта. Сервер учёта получает только loginCode от фронтенда. Поэтому аккаунт не может напрямую вычислить userHash. Если адаптер создаёт codeUserKey в момент выдачи, аккаунт может найти и удалить userKey через codeUserKey в момент учёта.

9. Обработка consume на аккаунте и GETDEL

на сервере account принимается loginCode, переданный фронтом, извлекается tokenJson из Redis и одновременно удаляется соответствующий ключ. При этом использовался не простой GET, а getAndDelete, аналогичный GETDEL.

tokenJson = getAndDelete(codeKey)

if (tokenJson is blank) {
    throw expiredOrInvalid
}

userKey = getAndDelete(codeUserKey)
if (userKey exists) {
    delete(userKey)
}

return parseToken(tokenJson)

Этот API выполняет только роль потребления ключа Redis, не изменяя БД, поэтому отдельная транзакция БД не использовалась. Кроме того, он должен быть вызван до входа, поэтому он настроен как permitAll в отличие от обычного API, требующего аутентификации.

10. Почему TTL недостаточно

TTL и GETDEL решают разные задачи. TTL ограничивает «до какого времени можно использовать», в то время как GETDEL ограничивает «сколько раз можно использовать».

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

TTL만 있는 경우:
1번째 consume ABC -> token 반환
2번째 consume ABC -> token 반환
3번째 consume ABC -> token 반환

GETDEL을 사용하는 경우:
1번째 consume ABC -> token 반환 + ABC 삭제
2번째 consume ABC -> expired or invalid

Кроме того, если разделить GET и DELETE, то при одновременных запросах оба запроса могут успешно пройти GET. Так как GETDEL в Redis или getAndDelete в Spring Data Redis обрабатывают получение и удаление атомарно, даже если несколько запросов на потребление приходят с одним и тем же loginCode, только один из них может успешно завершиться. Таким образом, TTL можно рассматривать как механизм для очистки неактивных кодов, а GETDEL — как механизм для превращения кода в реально одноразовый.

11. Результаты тестирования

Мы проверили следующие пункты на локальном и девелоперском окружениях. Мы убедились, что адаптер корректно возвращает rsltUri, и что URI правильно разделяется в случаях, когда есть и когда нет идентификатора цели. Когда был вызван API потребления account, токен был возвращен корректно, и было также подтверждено, что при повторном потреблении того же loginCode возвращается expired or invalid.

Также было подтверждено, что после успешного потребления удаляются codeKey, codeUserKey и userKey в Redis. После корректировки основы userHash по критериям сотрудник + критерий входа было также подтверждено, что различные запросы на вход в экран не делят один и тот же loginCode.

확인한 항목
- URI 발급 성공
- targetId의 유무에 따른 화면 분기 성공
- loginCode consume 성공
- 동일 loginCode 재사용 실패
- consume 후 Redis key 정리 확인
- 서로 다른 진입 대상의 loginCode 분리 확인

12. Уроки, извлеченные из применения

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

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

Причину использования GETDEL в начале тоже легко было воспринимать как простую необходимость быстро удалить ключ. Тем не менее, на самом деле это была ключевая политика, чтобы сделать loginCode по-настоящему одноразовым. TTL — это временное ограничение, а GETDEL — это ограничение по количеству использований. Эти два аспекта не заменяют друг друга, а должны использоваться вместе как взаимодополняющие.

13. Завершение

В конечном итоге эта работа стала примером применения структуры, в которой токен безопасно обменивается через одноразовый loginCode на основе Redis, не раскрывая токен непосредственно в процессе входа в сервисный экран внутренней системы работы. Адаптер создает URI на основе информации о пользователе и цели входа, а account атомарно потребляет loginCode и возвращает токен.

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

Wade

Site footer