Токен обновления не используется Spring OAuth2 по истечении срока действия токена доступа

Здравствуйте, коллеги-разработчики! Уже пару дней я пытаюсь выяснить, почему 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

когда-либо вызывается для повторного получения токена обновления.

Любая помощь приветствуется, так как я действительно застрял здесь. Большое спасибо за любой ответ, вопросы и помощь!

Пожалуйста, добавьте свойства вашего приложения в свой вопрос.

ch4mp 12.04.2024 19:43

Теоретически вам следует запрашивать область offline_access в дополнение к области openid, если вы собираетесь использовать поток токена обновления.

ch4mp 13.04.2024 14:15

Это знакомые, но разные понятия. Я попробовал это, но это не имеет никакого значения в поведении. См. keycloak.org/docs/latest/server_admin «Во время входа в систему с автономным доступом клиентское приложение запрашивает автономный токен вместо токена обновления. (...) Это действие полезно, если вашему приложению необходимо выполнить автономные действия на от имени пользователя, даже если пользователь не в сети. Например, обычное резервное копирование данных».

grange 13.04.2024 16:42
Это больше, чем просто «знакомый» и без этой возможности возможность использовать токен обновления будет зависеть от вашей конфигурации Keycloak, определяющей, как и как долго ваш пользователь считается «онлайн».
ch4mp 13.04.2024 19:33

Кроме того, я только что заметил, что вы установили продолжительность сеанса Spring на 15 секунд. Это слишком коротко. Вы уверены, что запрос, который вы хотите авторизовать с помощью потока токена обновления, отправляется менее чем через 15 секунд после предыдущего?

ch4mp 13.04.2024 19:35

Я не совсем понимаю, что вы имеете в виду. Как написано в моем вопросе, я уже получаю токен обновления (без области offline_access) от Keycloak, но Spring Oauth2 его не использует. Как моя конфигурация Keycloak изменит это поведение?

grange 13.04.2024 20:42

Что касается времени жизни сеанса: я также написал, что установил его на 15 секунд для тестирования. Кажется, что дата истечения срока действия токена доступа не проверяется Spring, пока сеанс действителен. Я думал, что токен обновления можно использовать, когда срок сеанса уже истек. Но ваш ответ подразумевает обратное. Я проверю это еще раз.

grange 13.04.2024 20:46

В Spring токен обновления хранится в сеансе => по истечении срока действия сеанса токен теряется. То, что запускает запрос токена обновления от клиента Spring, не является истекшим сеансом. Это если срок действия токена доступа в сеансе уже истек или истечет в течение следующих 60 секунд. Если Keycloak не считает пользователя «онлайн», вам понадобится автономный токен. Если пользователь находится «в сети», то работают как токены обновления, так и автономные.

ch4mp 13.04.2024 21:53

Приятно знать, но мое приложение ведет себя не так, как вы описали. На данный момент я установил продолжительность жизни токена доступа на 10 секунд, а время ожидания сеанса Spring - на 60 секунд. При входе в систему я получаю токен доступа (срок действия которого истекает через 10 секунд) и токен обновления (срок действия которого истекает через 30 минут). Теперь, когда я жду 30 секунд, пока срок действия токена доступа не истечет, а затем отправляю запрос на защищенную конечную точку API, ничего не происходит. Даже org.springframework.security.oauth2.core.AbstractOAuth2Token‌#getExpiresAt не вызывается. По истечении времени сеанса происходит новый вход в систему.

grange 13.04.2024 22:56

Не могли бы вы привести меня к правильному классу/методу, который выполняет описанные вами проверки? Я не могу найти какой-либо метод, который проверяет дату истечения срока годности. Я установил много точек останова, особенно во всех случаях использования org.springframework.security.oauth2.core.AbstractOAuth2Token‌#getExpiresAt, но ни одна из них не срабатывает (за исключением процесса входа в систему).

grange 13.04.2024 22:59

Давайте продолжим обсуждение в чате.

grange 13.04.2024 22:59

По умолчанию поток токена обновления управляется элементом RefreshTokenOAuth2AuthorizedClientProvider. Он должен запускаться с помощью DelegatingOAuth2AuthorizedClientProvider.

ch4mp 13.04.2024 23:14
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
1
12
591
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

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

Основная проблема заключалась в том, что наличие oauth2Login() и oauth2ResourceServer() внутри одной и той же цепочки фильтров просто не дает того, чего я ожидал. Позвольте мне объяснить: я хотел защитить свой REST API (сервер ресурсов) с помощью oauth2. Поэтому я проследил и попробовал несколько руководств, в которых они действительно используют оба внутри одной и той же цепочки фильтров. На первый взгляд все работало нормально. Мне удалось войти в систему с помощью OAuth2 (Keycloak) и соответственно получить доступ к REST API. Но Spring проверял токен только во время входа в систему и больше никогда после этого. Таким образом, после входа в систему с действительным токеном Spring запускает сеанс, и до тех пор, пока срок действия сеанса не истечет, пользователь остается в системе. Независимо от того, истек срок действия токена доступа или нет, Spring никогда больше не проверяет токен доступа. Кроме того, из-за этого Spring не обновляет токен доступа, используя токен обновления, который был правильно предоставлен Keycloak. Если срок сеанса истекает, Spring запускает новый рабочий процесс входа в систему, а Keycloak возвращает новый токен доступа (и токен обновления). Но это приводит к обновлению на стороне клиента из-за перенаправлений, происходящих во время предоставления кода авторизации.

Благодаря ch4mp и его великолепному уроку по паттерну BFF, в конце концов я его запустил. По сути, он разделяет функциональность на три разных приложения:

  1. сервер ресурсов oauth2 (REST API)
  2. клиент oauth2 (BFF) и
  3. обратный прокси

Сервер ресурсов представляет собой 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).

Другие вопросы по теме