Здравствуйте, коллеги-разработчики! Уже пару дней я пытаюсь выяснить, почему Spring OAuth2 не использует токен обновления, возвращаемый для гранта авторизации_кода, когда срок действия токена доступа истекает.
Настраивать
По сути, я следовал этому руководству, чтобы настроить все необходимое. Мой Keycloak работает на локальном хосте: 8090, и я настроил его следующим образом:
package com.my.project.config.security;
import com.my.project.security.authentication.CustomOidcClientInitiatedLogoutSuccessHandler;
import jakarta.servlet.http.Cookie;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint;
@Configuration
public class WebSecurityConfig {
public static String[] PUBLIC_PATHS = {
"/api/csrf",
"/oauth2/login",
"/oauth2/logout",
"/oauth2/error",
"/api/contact-form"
};
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http,
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
final ClientRegistrationRepository clientRegistrationRepository) throws Exception {
final CustomOidcClientInitiatedLogoutSuccessHandler clientInitiatedLogoutSuccessHandler =
new CustomOidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
clientInitiatedLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/oauth2/login?redirect=true");
final DefaultOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository,
OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
oAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
return http
.csrf(Customizer.withDefaults())
.cors(Customizer.withDefaults())
.authorizeHttpRequests((customizer) -> customizer
.requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2
.jwt(Customizer.withDefaults()))
.oauth2Client(configurer -> {
//override behaviour of authentication: don't redirect but change status and add location header.
//it's a bit hacky, but otherwise we get CORS errors on client side, because through the redirect we're running into cross-origin issues
//and keycloak is just not setting correct CORS headers :/
//delete this hack when bug https://github.com/keycloak/keycloak/pull/27334 is fixed
configurer.authorizationCodeGrant(customizer -> {
customizer.authorizationRedirectStrategy((request, response, url) -> {
response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, HttpHeaders.LOCATION);
response.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.LOCATION);
response.addCookie(new Cookie("test", System.currentTimeMillis() + ""));
response.setHeader(HttpHeaders.LOCATION, url);
if ("true".equals(request.getParameter("redirect"))) {
response.setStatus(302);
} else {
response.setStatus(201);
}
});
customizer.authorizationRequestResolver(oAuth2AuthorizationRequestResolver);
});
})
.oauth2Login(configurer -> {
configurer.userInfoEndpoint(customizer -> customizer.userAuthoritiesMapper(customGrantedAuthoritiesMapper()));
configurer.loginPage("/oauth2/login");
configurer.defaultSuccessUrl("/oauth2/success");
configurer.failureUrl("/oauth2/error");
})
.logout(configurer -> {
configurer.invalidateHttpSession(true);
configurer.clearAuthentication(true);
configurer.deleteCookies("JSESSIONID");
configurer.logoutUrl("/oauth2/logout");
configurer.logoutSuccessHandler(clientInitiatedLogoutSuccessHandler);
})
//.addFilterBefore(new TokenExpiredFilter(), AnonymousAuthenticationFilter.class)
.build();
}
@Bean
public GrantedAuthoritiesMapper customGrantedAuthoritiesMapper() {
return new CustomGrantedAuthoritiesMapper();
}
}
Мне потребовалось несколько настроек, потому что у меня были проблемы с Keycloak, который не устанавливал заголовки ACCESS-CONTROL-ALLOW_ORIGIN. Поэтому я последовал предложению изменить перенаправление по умолчанию (HTTP 302) на код 2xx при настройке заголовка местоположения. Затем в коде внешнего интерфейса (React) я добавляю window.location к значению этого заголовка местоположения. Для пользовательского обработчика выхода из системы я делаю то же самое. В противном случае это копия SimpleUrlLogoutSuccessHandler.
Мой application.yml выглядит так:
service:
mock: false
keycloak:
realm-id: my-realm-id
base-uri: http://localhost:8090
base-rest-uri: ${keycloak.base-uri}/admin/realms/${keycloak.realm-id}
token-uri: ${keycloak.base-uri}/realms/${keycloak.realm-id}/protocol/openid-connect/token
logging:
level:
root: INFO
server:
port: 8080
servlet:
session:
timeout: 15s
cookie:
same-site: none
http-only: true
secure: true
error:
include-message: never
include-binding-errors: never
include-stacktrace: never
include-exception: false
spring:
datasource:
url: jdbc:postgresql://localhost:5432/db-name
driverClassName: org.postgresql.Driver
username: my-user
password: my-password
jpa:
open-in-view: false
hibernate:
ddl-auto: none
properties:
database-platform: org.hibernate.dialect.PostgreSQL10Dialect
show-sql: false
security:
oauth2:
client:
registration:
keycloak:
client_id: my-client-id
client_secret: my-client-secret
authorization-grant-type: authorization_code
scope: openid
provider:
keycloak:
issuerUri: ${keycloak.base-uri}/realms/${keycloak.realm-id}
user-name-attribute: preferred_username
resourceserver:
jwt:
issuer-uri: ${keycloak.base-uri}/realms/${keycloak.realm-id}
flyway:
url: ${spring.datasource.url}
user: ${spring.datasource.username}
password: ${spring.datasource.password}
locations: classpath:/db/migration/ddl
Описание поведения/проблемы
Когда я пытаюсь запросить защищенный ресурс в своем бэкэнде Spring, я получаю перенаправление на /oauth2/login, которое перенаправляет на /oauth2/authorization/keycloak, которое, наконец, «перенаправляет» на страницу входа в Keycloak. Это работает хорошо, я никогда не связываюсь с учетными данными пользователя, а интерфейс знает только JSESSIONID. Также выход из системы работает нормально, сеанс в области Keycloak успешно уничтожается, и сеанс Spring также уничтожается.
Мои проблемы начинаются, когда дело доходит до токена доступа с истекшим сроком действия. В целях тестирования я установил продолжительность жизни токена доступа на 10 секунд в Keycloak, а продолжительность жизни сеанса Spring — на 15 секунд. Всякий раз, когда я запрашиваю что-то из своего защищенного бэкэнда и срок действия сеанса истекает, процесс входа в систему запускается снова, перенаправляясь на /oauth2/login, который перенаправляется на /oauth2/authorization/keycloak и, наконец, на настроенный URL-адрес успешного входа в систему oauth2, не видя входа в систему. сформировать заново. В пользовательских событиях Keycloak я вижу событие входа другого пользователя и кода для токена с новым токеном доступа в качестве результата.
Когда я отлаживаю код Spring, я вижу, что грант авторизации_кода правильно возвращает как токен доступа, так и токен обновления, но кажется, что токен обновления никогда больше не используется. К сожалению, я не могу понять, в чем дело, но я читал, что Spring должен обрабатывать обновление токена доступа, используя токен обновления из коробки. Но
org.springframework.security.oauth2.client.RefreshTokenOAuth2AuthorizedClientProvider#authorize
никогда не вызывается и не
org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient#getTokenResponse
Я это вижу
org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter#attemptAuthentication
звонит
this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Аутентификация, запрос, ответ)
Похоже, это последний след токена обновления, поскольку он не существует в OAuth2AuthenticationToken, возвращаемом из этого метода.
К сожалению, ни в одной реализации нет точки останова.
org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository#loadAuthorizedClient
когда-либо вызывается для повторного получения токена обновления.
Любая помощь приветствуется, так как я действительно застрял здесь. Большое спасибо за любой ответ, вопросы и помощь!
Теоретически вам следует запрашивать область offline_access
в дополнение к области openid
, если вы собираетесь использовать поток токена обновления.
Это знакомые, но разные понятия. Я попробовал это, но это не имеет никакого значения в поведении. См. keycloak.org/docs/latest/server_admin «Во время входа в систему с автономным доступом клиентское приложение запрашивает автономный токен вместо токена обновления. (...) Это действие полезно, если вашему приложению необходимо выполнить автономные действия на от имени пользователя, даже если пользователь не в сети. Например, обычное резервное копирование данных».
Кроме того, я только что заметил, что вы установили продолжительность сеанса Spring на 15 секунд. Это слишком коротко. Вы уверены, что запрос, который вы хотите авторизовать с помощью потока токена обновления, отправляется менее чем через 15 секунд после предыдущего?
Я не совсем понимаю, что вы имеете в виду. Как написано в моем вопросе, я уже получаю токен обновления (без области offline_access) от Keycloak, но Spring Oauth2 его не использует. Как моя конфигурация Keycloak изменит это поведение?
Что касается времени жизни сеанса: я также написал, что установил его на 15 секунд для тестирования. Кажется, что дата истечения срока действия токена доступа не проверяется Spring, пока сеанс действителен. Я думал, что токен обновления можно использовать, когда срок сеанса уже истек. Но ваш ответ подразумевает обратное. Я проверю это еще раз.
В Spring токен обновления хранится в сеансе => по истечении срока действия сеанса токен теряется. То, что запускает запрос токена обновления от клиента Spring, не является истекшим сеансом. Это если срок действия токена доступа в сеансе уже истек или истечет в течение следующих 60 секунд. Если Keycloak не считает пользователя «онлайн», вам понадобится автономный токен. Если пользователь находится «в сети», то работают как токены обновления, так и автономные.
Приятно знать, но мое приложение ведет себя не так, как вы описали. На данный момент я установил продолжительность жизни токена доступа на 10 секунд, а время ожидания сеанса Spring - на 60 секунд. При входе в систему я получаю токен доступа (срок действия которого истекает через 10 секунд) и токен обновления (срок действия которого истекает через 30 минут). Теперь, когда я жду 30 секунд, пока срок действия токена доступа не истечет, а затем отправляю запрос на защищенную конечную точку API, ничего не происходит. Даже org.springframework.security.oauth2.core.AbstractOAuth2Token#getExpiresAt не вызывается. По истечении времени сеанса происходит новый вход в систему.
Не могли бы вы привести меня к правильному классу/методу, который выполняет описанные вами проверки? Я не могу найти какой-либо метод, который проверяет дату истечения срока годности. Я установил много точек останова, особенно во всех случаях использования org.springframework.security.oauth2.core.AbstractOAuth2Token#getExpiresAt, но ни одна из них не срабатывает (за исключением процесса входа в систему).
Давайте продолжим обсуждение в чате.
По умолчанию поток токена обновления управляется элементом RefreshTokenOAuth2AuthorizedClientProvider
. Он должен запускаться с помощью DelegatingOAuth2AuthorizedClientProvider
.
Итак, я собираюсь поделиться здесь всеми своими выводами, потому что я искренне верю, что многие другие люди имеют, имели или будут иметь те же проблемы, что и я.
Основная проблема заключалась в том, что наличие oauth2Login() и oauth2ResourceServer() внутри одной и той же цепочки фильтров просто не дает того, чего я ожидал. Позвольте мне объяснить: я хотел защитить свой REST API (сервер ресурсов) с помощью oauth2. Поэтому я проследил и попробовал несколько руководств, в которых они действительно используют оба внутри одной и той же цепочки фильтров. На первый взгляд все работало нормально. Мне удалось войти в систему с помощью OAuth2 (Keycloak) и соответственно получить доступ к REST API. Но Spring проверял токен только во время входа в систему и больше никогда после этого. Таким образом, после входа в систему с действительным токеном Spring запускает сеанс, и до тех пор, пока срок действия сеанса не истечет, пользователь остается в системе. Независимо от того, истек срок действия токена доступа или нет, Spring никогда больше не проверяет токен доступа. Кроме того, из-за этого Spring не обновляет токен доступа, используя токен обновления, который был правильно предоставлен Keycloak. Если срок сеанса истекает, Spring запускает новый рабочий процесс входа в систему, а Keycloak возвращает новый токен доступа (и токен обновления). Но это приводит к обновлению на стороне клиента из-за перенаправлений, происходящих во время предоставления кода авторизации.
Благодаря ch4mp и его великолепному уроку по паттерну BFF, в конце концов я его запустил. По сути, он разделяет функциональность на три разных приложения:
Сервер ресурсов представляет собой REST API без сохранения состояния, который получает токен доступа OAuth2, передаваемый в каждый запрос. В этой части приложения нет функции входа в систему.
Клиентское приложение BFF/Oauth2 обрабатывает часть входа в систему и вызывает сервер ресурсов через REST. Он также обрабатывает запросы от клиентского приложения (в моем случае React) с использованием сеансов (с сохранением состояния). Никакой токен доступа никогда не передается клиенту. После входа в систему Spring создает сеанс и соответствующим образом устанавливает файл cookie на стороне клиента. Из-за функциональности TokenRelay, используемой приложением BFF, Spring обменивает этот сеанс с токеном доступа при вызове сервера ресурсов. В этом суть того, что Spring проверяет токен доступа на достоверность и при необходимости обновляет его, используя токен обновления.
Обратный прокси-сервер по сути скрывает эту сложность от клиента, перенаправляя запросы на правильные конечные точки. Таким образом, единственной точкой контакта для клиента является обратный прокси-сервер (см. диаграмму в связанном руководстве). Сначала я попытался разместить BFF и обратный прокси внутри одного и того же приложения, но это не сработало, потому что Spring связывается с Keycloak при запуске, чтобы получить конечные точки, необходимые, например, для проверять токены. Но это происходит до запуска обратного прокси-сервера и заканчивается исключениями из-за ошибок 404 (эти URL-адреса не существуют до запуска обратного прокси-сервера). В любом случае лучше разделить приложения, особенно из-за разделения задач и масштабирования.
Еще одним преимуществом обратного прокси-сервера является то, что он устраняет проблемы, связанные с неправильной установкой заголовков CORS в Keycloak. Таким образом, CORS больше не является проблемой, поскольку с точки зрения клиента Keycloak, BFF и API REST находятся на одном хосте.
Возможно, можно было бы каким-то образом выполнить одни и те же действия с помощью нескольких цепочек фильтров безопасности в одном приложении вместо того, чтобы разделять его на три приложения. Но мне это не удалось, и с архитектурной точки зрения лучше в любом случае разделить их.
Я надеюсь, что этот ответ поможет кому-то, кто борется с теми же проблемами. К сожалению, документация Spring в этом случае не очень полезна, особенно из-за серьезных изменений в Spring Security 6.2, которые делают многие примеры кода бесполезными. Кроме того, большинство руководств по этой теме, которые я нашел, работали неправильно, вероятно, не осознавая «скрытых» проблем, описанных выше.
Еще раз спасибо ch4mp за вашу помощь и отличный урок! (см. обсуждения здесь, а также в чате SO).
Пожалуйста, добавьте свойства вашего приложения в свой вопрос.