Введение
В текущем проекте я занимаюсь обслуживанием экранов входа и управления учетными записями на базе Keycloak. На этот раз я обнаружил проблему, когда экран первоначальной настройки учетной записи сотрудника переключается на экран восстановления пароля при обновлении, и проанализировал её причины.
Сначала я думал об этом как о стандартном React SPA, полагая, что можно просто добавить значение, разделяющее экраны, в URL-параметры запроса. Однако, изучив код, я понял, что этого подхода недостаточно для Keycloakify. Проблема заключалась в способе переключения экранов Keycloakify и процессе круговой обработки Keycloak.
Управление состоянием экрана было недостаточным только по URL
Когда я впервые увидел проблему, я подошёл к ней просто. Поскольку необходимо было различать экран первоначальной настройки учетной записи сотрудника и экран восстановления пароля, я подумал, что можно добавить значение page_hint=isTemp в URL и разделить экраны на основе этого значения.
Для стандартного React SPA этот подход не кажется странным. Перемещение между экранами обычно обрабатывается с помощью react-router, и при изменении пути URL или параметров запроса можно отрисовать соответствующий компонент. Состояние экрана также часто управляется с помощью состояния React или параметров запроса. Однако экран Keycloakify работает немного по-другому. Keycloak предоставляет экраны, такие как вход, сброс пароля, изменение пароля, на уровне pageId.
login.ftl
login-reset-password.ftl
login-update-password.ftl
Keycloakify отрисовывает React-компонент, соответствующий этому pageId. То есть экран создается на React, но общий поток происходит внутри аутентификационного потока Keycloak. Например, при переходе на экран восстановления пароля это не просто перемещение по внутреннему роутеру React, а полноценный переход на URL, предоставленный Keycloak.
const findPassword = useCallback(() => {
if (!("loginResetCredentialsUrl" in kcContext.url)) return;
window.location.href = kcContext.url.loginResetCredentialsUrl;
}, [kcContext.url]);
В этом подходе при смене экрана страница полностью перезагружается, и значения, сохраненные только в состоянии React, не сохраняются. Состояние, которое, казалось бы, должно сохраняться в обычном SPA, может исчезнуть на этапе переключения экранов в Keycloakify.
Круговая обработка Keycloak и потеря состояния
В таком случае, возможно, стоит добавить значения в параметры запроса URL, но для Keycloakify этого недостаточно. Чтобы понять причину, сначала необходимо определиться, что такое "круговая обработка".
Круговая обработка здесь означает, что запрос покидает браузер, отправляется на сервер Keycloak, затем приходит обратно с новым экраном, пройдя весь круг. В обычном SPA, даже если экран меняется, браузер остается на той же странице, и JavaScript просто обновляет экран. Но поток аутентификации Keycloak работает иначе. В потоке, таком как вход или сброс пароля, фронт не вызывает API напрямую, а отправляет форму POST на action URL, предоставленный Keycloak.
<form method="post" action={kcContext.url.loginAction}>
Когда эта форма отправляется, процесс протекает примерно следующим образом.
-
Браузер покидает текущий экран React и отправляет POST-запрос на сервер Keycloak.
-
Keycloak обрабатывает поток аутентификации на сервере.
-
Сервер создает новый экран (новый pageId), соответствующий результатам обработки, и отправляет его обратно.
-
Браузер принимает этот ответ и рендерит страницу заново с самого начала.
То есть контроль за сменой экрана находится не у React, а у сервера Keycloak, и в этом процессе страница полностью перерисовывается. Этот один цикл называется раунд-трипом.
Проблема в том, что при этом URL браузера также снова создается Keycloak. Поэтому нет гарантии, что query-параметр, который я оставил на предыдущем экране, например ?page_hint=isTemp, останется в следующем экране. С другой стороны, состояние React, как и следовало ожидать, исчезает в момент перезагрузки страницы. Поэтому в этом проекте мы управляли page_hint как в URL, так и в sessionStorage.
let page = params.get("page_hint") || sessionStorage.getItem("page_hint");
Причина, по которой одно и то же значение читается в двух местах, становится ясной, если учесть раунд-трип.
-
Query в URL хорошо демонстрирует текущее состояние экрана, но может исчезнуть во время раунд-трипа.
-
sessionStorage сохраняется даже после обновления или раунд-трипа, но если слишком долго хранится, может повлиять на другие потоки.
То есть одного URL недостаточно, и полагаться только на sessionStorage рискованно. Поэтому существовала структура, которая сохраняла значение в sessionStorage и снова отражала его в URL при рендеринге.
function syncPageHintToUrl(pageHint: string) {
const url = new URL(window.location.href);
url.searchParams.set("page_hint", pageHint);
window.history.replaceState(null, "", url.toString());
}
Способ различения экранов по page_hint
На практике необходимо было использовать page_hint для разделения нескольких экранов даже в одном pageId Keycloak. Экран сброса пароля также должен был отличаться от обычного экрана восстановления пароля и экрана первоначальной настройки учетной записи сотрудника. Когда я переходил на экран первоначальной настройки учетной записи сотрудника, я сохранял page_hint как isTemp, а затем переходил к потоку сброса пароля.
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
В пришедшем контейнере решали, какой экран показывать, основываясь на этом значении.
return isTemp
? <ResetVerifyStaff kcContext={kcContext} />
: <FindPassword kcContext={kcContext} />;
Причина изменения экрана при обновлении
Проблема возникла, когда я обновил экран первоначальной настройки учетной записи сотрудника. При первом входе экран сотрудника был нормально виден, но после обновления он изменился на обычный экран восстановления пароля. Проследив за причиной, я обнаружил, что в том же контейнере был следующий код.
useEffect(() => {
sessionStorage.removeItem("page_hint");
const url = new URL(window.location.href);
url.searchParams.delete("page_hint");
window.history.replaceState(null, "", url.toString());
}, []);
Когда контейнер был смонтирован, page_hint, разделяющий экран сотрудника, был удален как из sessionStorage, так и из URL, поэтому не было возможности определить, что это экран сотрудника при обновлении.
Проверка причин наличия кода удаления
Я стремился сразу удалить, но для экранов аутентификации и учетной записи есть много связанных потоков, поэтому сначала проверил историю коммитов. Этот код удаления был защитным кодом, чтобы предотвратить ошибку в экране смены пароля 'Моя страница'. Если page_hint=isTemp продолжит находиться в sessionStorage, при последующем входе на экран смены пароля по другому пути его также можно ошибочно определить как экран сотрудника, поэтому он удалялся после входа.
Здесь проявилась суть проблемы. page_hint=isTemp выполнял две роли одновременно.
-
Значение, необходимое для поддержания первоначального потока настройки учетной записи сотрудника
-
Значение, которое не должно оставаться в других общих потоках
С одной стороны, его нужно сохранять, а с другой — удалять, поэтому простое «удаление логики» могло повлиять на другие потоки.
Направление улучшений
Проверив путь повторного входа, я обнаружил, что в основном входном пункте на экран сброса пароля уже явно было установлено значение page_hint в момент отправления.
// 일반 비밀번호 찾기
onClick={() => {
sessionStorage.setItem("page_hint", "");
links.findPassword();
}}
// 직원 계정 최초 설정
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
То есть в LoginResetPasswordContainer уже была определена логика на момент входа, но после достижения точки она безусловно удаляла page_hint, из-за чего состояние экрана не сохранялось при обновлении. LoginUpdatePasswordContainer связан с потоком изменения пароля на личной странице, поэтому прежняя защитная логика всё ещё была необходима. В то время как LoginResetPasswordContainer уже явно устанавливал page_hint на входе, поэтому я решил, что нет необходимости безусловно его удалять и внес изменения следующим образом.
-
Удаление логики page_hint при монтировании LoginResetPasswordContainer
-
Логика удаления в LoginUpdatePasswordContainer сохранена
Я не вносил много изменений, но для безопасного удаления должен был также проверить маршрутизирующую структуру Keycloakify, процесс кругового перемещения и причины существования прежнего защитного кода.
Итоги
Причиной данной проблемы стало то, что значение page_hint, определяющее экран, удалялось сразу после монтирования, что не позволяло определить, что это экран сотрудника при обновлении. Я понял, что экраны на основе Keycloakify должны управляться иначе, чем обычные React SPA. Keycloak управляет переключениями экранов, и существует процесс кругового перемещения, при котором экран заново загружается через сервер после отправки формы, поэтому поддерживать состояние экрана только с помощью URL-запроса или состояния React может быть сложно.
Также, когда используется долговечное хранилище, как sessionStorage, необходимо следить за тем, чтобы состояния не просочились в другие потоки. Когда одно значение page_hint одновременно выполняет роли «сохранения экрана» и «разделения потоков», могут возникнуть конфликты о том, когда его следует сохранять, а когда удалять.
Таким образом, хотя это исправление заключалось в удалении нескольких строк, для безопасного удаления этих строк мне сначала нужно было понять структуру маршрутизации Keycloakify и намерения в управлении состоянием.
Линн