Выход из системы, инициированный RP, на сервере авторизации Spring не работает

У меня есть проект с шлюзом Spring в качестве клиента oauth для сервера авторизации Spring. С точки зрения аутентификации oidc все работает нормально, кроме выхода из системы. Выход из системы не работает из-за CORS, так как каким-то образом после нажатия кнопки выхода из системы на странице выхода Spring по умолчанию источник имеет значение null. Если я добавлю «нулевой» источник в список разрешенных источников или отключу cors, он будет работать как положено.

Ниже приведена конфигурация клиента Spring Gateway:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {

    @Autowired
    public SecurityConfiguration(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    ReactiveClientRegistrationRepository clientRegistrationRepository;

    private ServerLogoutSuccessHandler serverLogoutSuccessHandler() {
        OidcClientInitiatedServerLogoutSuccessHandler successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        successHandler.setPostLogoutRedirectUri("{baseUrl}/welcome");
        return successHandler;
    }


    @Bean
    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
        String[] unprotectedPaths = new String[]{"/api-docs/**", "/swagger-ui.html", "/webjars/swagger-ui/**", "/actuator/**",
                "/oidc/**","/welcome/**", "/customer/registration", "/customer/registration-confirmation/**"};

        http.cors(withDefaults()).csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
                        .csrfTokenRequestHandler(new SpaServerCsrfTokenRequestHandler()))
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec.pathMatchers(unprotectedPaths).permitAll()
                        .pathMatchers("/notification/ws-connect").hasAuthority("SCOPE_notification.read")
                        .anyExchange().authenticated())
                .oauth2Login(withDefaults())
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults()))
                .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutSuccessHandler(serverLogoutSuccessHandler()));

        return http.build();
    }


    static final class SpaServerCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler {
        private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler();

        @Override
        public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) {
            // Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body.
            this.delegate.handle(exchange, csrfToken);
        }

        @Override
        public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) {
            final var hasHeader = exchange.getRequest().getHeaders().get(csrfToken.getHeaderName()) != null;
            return hasHeader ? super.resolveCsrfTokenValue(exchange, csrfToken) : this.delegate.resolveCsrfTokenValue(exchange, csrfToken);
        }
    }

    @Bean
    public WebFilter csrfCookieWebFilter() {
        return (exchange, chain) -> {
            exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()).subscribe(o -> ((CsrfToken) o).getToken());
            return chain.filter(exchange);
        };
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:63342"));
        configuration.setAllowedMethods(List.of(CorsConfiguration.ALL));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of(CorsConfiguration.ALL));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

Ниже приведен журнал API-шлюза:

2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=GET}
2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] athPatternParserServerWebExchangeMatcher : Checking match of request : '/logout'; against '/logout'
2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2024-07-16T22:37:22.395+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-11] Completed 200 OK, headers = {masked}
2024-07-16T22:37:22.395+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.h.s.r.ReactorHttpHandlerAdapter      : [130c2a73-5, L:/[0:0:0:0:0:0:0:1]:9990 - R:/[0:0:0:0:0:0:0:1]:63469] Handling completed
2024-07-16T22:37:37.036+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-12] HTTP POST "/logout", headers = {masked}
2024-07-16T22:37:37.038+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.c.reactive.DefaultCorsProcessor    : Reject: 'null' origin is not allowed
2024-07-16T22:37:37.038+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-12] Completed 403 FORBIDDEN, headers = {masked}
2024-07-16T22:37:37.039+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.h.s.r.ReactorHttpHandlerAdapter      : [130c2a73-6, L:/[0:0:0:0:0:0:0:1]:9990 - R:/[0:0:0:0:0:0:0:1]:63469] Handling completed

Позже редактировать:

Кажется, Chrome рассматривает два разных порта на локальном хосте как один и тот же сайт. Кроме того, JSESSIONID, используемый Spring Security, не является SameSite, поэтому он будет отправлен в запросе из перекрестного источника. Однако моя первоначальная проблема не связана с запросами из перекрестного источника.

Почему вы добавляете конфигурацию CORS? Разве ваш SPA и шлюз не должны иметь одинаковое происхождение, чтобы совместно использовать файл cookie сеанса (требуется для работы oauth2Login и TokenRelay фильтра)? Почему вы настраиваете oauth2ResourceServer в цепочке фильтров, уже настроенной с помощью oauth2Login? Требования безопасности для 2 сильно различаются: необходимость в сеансах и защите CSRF, авторизация на основе файлов cookie и токенов VS, статус ответа для неавторизованного доступа. Запросы,...

ch4mp 16.07.2024 18:48

Кроме того, после того, как вы вызовете выход из системы в клиенте Spring с помощью OidcClientInitiatedServerLogoutSuccessHandler, если выход из системы завершится успешно, ответом должно быть перенаправление. Что это за ответ (статус и местоположение)? Как вы следите за этим перенаправлением?

ch4mp 16.07.2024 18:49

SPA и шлюз не будут иметь одинаковое происхождение, не знаю, почему вы так думаете. Пока шлюз настроен на разрешение источника SPA, они могут совместно использовать файл cookie сеанса. Также я использую oauth2ResourceServer только для доступа к нему из Postman с помощью токена на предъявителя. Так не будет, поскольку нет смысла быть одновременно клиентом и сервером ресурсов. После нажатия кнопки выхода на странице /logout я получаю ошибку 403.

IonutB 16.07.2024 20:56

Я пробовал как /logout из api-gateway(localhost:9990/logout ), так и из auth-сервера( localhost:9989/logout), но ни один из них не вышел из системы и не перенаправился. При выходе из сервера аутентификации выводится сообщение о том, что вы вышли из системы, но перенаправления не происходит.

IonutB 16.07.2024 21:03

RP. Выход из системы, инициированный RP, означает «Доверяющая сторона». Это означает, что выход из системы начинается с клиента, который закрывает свой сеанс, и, если операция успешна (найден сеанс для закрытия), перенаправляет пользовательский агент на OP (он же провайдер OpenID или сервер авторизации), чтобы OP мог завершить свой собственный сеанс для вышедшего из системы пользователя. Поэтому выйдите из шлюза и предоставьте дополнительные журналы отладки и сведения о перенаправлении.

ch4mp 16.07.2024 21:34

«SPA и шлюз не будут иметь одинаковое происхождение, не знаю, почему вы думаете, что они должны», как я уже писал, файл cookie сеанса должен быть помечен как тот же сайт. Заголовков CORS будет недостаточно, чтобы браузер мог поделиться этим файлом cookie Spring с другими источниками (службами, работающими на разных портах). Вам нужен обратный прокси-сервер, а не CORS. WebPack One отлично подходит для разработки. В любой среде вы можете использовать шлюз в качестве прокси (для маршрутизации к SPA). Вы также можете использовать экземпляр nginx или любой другой вход перед обоими. Последний вариант, который работает при упаковке SPA, — это обслуживание его из статических ресурсов шлюза.

ch4mp 16.07.2024 21:43

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

IonutB 16.07.2024 21:48

Почему при выходе из системы запрашиваются земли null происхождения? Не попытаетесь ли вы отправить его с помощью Postman вместо использования уже авторизованного браузера (с активным сеансом на шлюзе)?

ch4mp 16.07.2024 21:49

Вот это меня тоже смущает. Для этого теста я использую не Postman, а браузер. Единственное, что я делаю, это вхожу в систему, а затем перехожу к api-gateway/logout, где получаю страницу подтверждения выхода, на которой я выхожу из системы... вот и все.

IonutB 16.07.2024 21:52

аналогичная проблема опубликована здесь и, судя по всему, она будет работать нормально, если ее не развернуть на локальном компьютере. На данный момент я добавлю «null» в список разрешенных источников CORS. Я также обновлю, когда появится новая версия Spring Security, так как, похоже, это проблема Spring, а не конфигурация.

IonutB 19.07.2024 19:03

У вас по-прежнему будет одна и та же цепочка фильтров, настроенная как с отслеживанием состояния (oauth2Login) и без сохранения состояния (oauth2ResourceServer), с защитой от CSRF, необходимой для некоторых запросов и бесполезной для других, необходимостью использования разных стратегий сопоставления полномочий, необходимостью различного статуса ответа для несанкционированные запросы... А как насчет попыток понять OAuth2 и преимущества безопасности хранения токенов на сервере?

ch4mp 20.07.2024 04:02

Как я уже упоминал, (oauth2ResourceServer) предназначен для использования с почтальоном и является временным. Насколько мне известно по Oauth, это никак не влияет на выход из системы. Если вы считаете, что использование шлюза в качестве клиента и сервера ресурсов каким-либо образом повлияет на проблему, которую я описал в вопросе, объясните. Мой вопрос был очень конкретным. Я уже пробовал удалить конфигурацию сервера ресурсов, но это не решило проблему.

IonutB 21.07.2024 16:37

Я также подробно объяснил, чего не хватает в вашем вопросе, чтобы мы могли понять вашу проблему (точный запрос, используемый для инициирования выхода из системы: что его отправляет, HTTP-команд, URL-адрес, полезная нагрузка). Я также четко заявил о том, что oauth2Login (и выход из системы) основаны на сеансе и что файл cookie сеанса должен быть помечен с тем же сайтом, что является причиной необходимости того же источника вместо CORS для запросов к вашему бэкэнду. Наконец, вам не нужен перекрестный запрос к конечной точке авторизации сервера авторизации. Установка windows.location.href — это нормально.

ch4mp 21.07.2024 17:59

Но поскольку вы, похоже, также размещаете сервер авторизации, вы можете разместить его за тем же обратным прокси-сервером, что и шлюз и ваш интерфейс, имея один и тот же источник для всего (больше никаких проблем с CORS, конфигурации CORS или взлома для отслеживания авторизации с помощью установка windows.location.href в Javascript вместо того, чтобы позволить браузеру следовать за 302. Это может даже позволить вам отображать формы входа в рамке внутри интерфейса, а не покидать его).

ch4mp 21.07.2024 18:02

Последнему почтальону не нужно проходить через шлюз. Он может отправлять запросы непосредственно на сервер ресурсов. Если вы действительно хотите, чтобы он проходил через шлюз (и разрабатывался с конфигурацией безопасности, отличной от prod), определите вторую цепочку фильтров без oauth2Login и второй набор маршрутов без TokenRelay фильтра.

ch4mp 21.07.2024 18:20

Извиняюсь, если я неправильно понял, но вы говорите об изменении моей архитектуры. Это не то, что я ищу. У меня будет SPA-клиент (в настоящее время веб-страница, работающая на HTTP-сервере Intelj), который может не быть развернут в том же домене, что и шлюз, поэтому необходимы заголовки CORS. Серверные службы работают нормально, поскольку им не нужен файл cookie сеанса, а только токен, который поступает через TokenRelay. Шлюз работает как прокси. Серверные службы будут скрыты от внешнего мира и доступны только через шлюз, отсюда и ситуация с почтальоном.

IonutB 21.07.2024 19:25

Я добавил пример на github.com/ionutbarau/demo-stackoverflow. Это было предназначено для другого вопроса, но может быть использовано для воспроизведения ошибки. Просто проигнорируйте первую часть README и выполните шаги 1–3 из раздела «Как использовать». Чтобы воспроизвести проблему, просто попробуйте выйти из системы (укажите в браузере localhost:9990/logout) после входа в систему. Страницы входа/выхода являются стандартными для Spring Security.

IonutB 21.07.2024 19:43

«Измени мою архитектуру. Это не то, что я ищу». изменить вашу архитектуру кажется проще, чем изменить способ защиты файлов cookie сеанса браузерами...

ch4mp 21.07.2024 20:40

пожалуйста, попробуйте пример, который я опубликовал, и выполните точные шаги для воспроизведения. При воспроизведении не используется SPA (не требуется перекрестный запрос). Также опубликуйте раздел в документации, где говорится, что для того, чтобы выход из системы, инициированный RP, работал, и SPA, и клиент oauth должны находиться в одном домене. Файл cookie сеанса всегда будет отправляться при вызове домена, который его выдал, независимо от того, кто его отправляет (именно так происходят атаки CSRF). Поэтому вполне нормально иметь SPA в другом источнике, через который вы выполняете POST для /logout.

IonutB 21.07.2024 21:02

«Файл cookie сеанса всегда будет отправляться при вызове домена, который его выдал». Это неправильно. Прочтите о флаге SameSite для файлов cookie. Сеансовые файлы cookie всегда должны быть помечены SameSite (весенние файлы есть, проверьте инструменты отладки браузера). И я уже потратил достаточно времени здесь, пока.

ch4mp 21.07.2024 21:25
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
20
95
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

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

Выход из системы, инициированный RP (откройте ссылку и прочтите спецификацию) начинается после того, как выход был выполнен на проверяющей стороне (приложение Spring, настроенное с помощью oauth2Login). Чтобы инициировать этот первый выход из системы, SPA должен POST подключиться к конечной точке шлюза /logout.

Как и любой запрос к приложению, настроенному с помощью oauth2Login, он авторизуется с помощью сеанса (запрос должен содержать файл cookie сеанса). По этой причине внешний интерфейс, отправляющий этот запрос, должен обслуживаться с того же хоста, что и RP.

В случае SPA на сегодняшний день самым простым вариантом является обслуживание SPA и BFF через один и тот же обратный прокси-сервер (который может быть самим шлюзом). Это удаляет запросы перекрестного происхождения между интерфейсом и сервером. Если SPA должен обслуживаться из домена, отличного от API, то BFF (шлюз с oauth2Login и фильтром TokenRelay) должен размещаться в этом другом домене.

Как и любой запрос POST, PUT, PATCH или DELETE, авторизованный с помощью сеанса, он должен быть защищен от CSRF (содержать токен CSRF). В случае одностраничного приложения этот токен считывается из файла cookie (с значением «только для http» — false) и устанавливается в качестве заголовка.

Ответ на запрос POST (после завершения сеанса шлюза) должен содержать заголовок Location с URI для завершения сеанса на сервере авторизации. Если источник, из которого обслуживается SPA (обратный прокси-сервер), разрешен на сервере авторизации, вы можете разрешить браузеру, в котором запускается SPA, следовать по этому местоположению. В противном случае вам, возможно, придется изменить статус ответа шлюза с 302 на что-то в диапазоне 2xx, чтобы код Javascript мог наблюдать за ответом, читать Location и затем устанавливать window.location.href (изменить начало вкладки браузера вместо отправки запрос из перекрестного источника).

Подробный рабочий пример (включая выход из системы) spring-cloud-gateway, используемого в качестве OAuth2 BFF (с oauth2Login и фильтром TokenRelay) для SPA в этой статье Baeldung, которую я написал.

Видимо, есть веская причина иметь «нулевой» Origin. Любой желающий может проверить этот ТАКОЙ вопрос для получения дополнительной информации.

По сути, если во время запроса CORS происходит перенаправление на другой сервер, заголовок Origin будет изменен на «нулевой», поскольку это контекст, чувствительный к конфиденциальности. Это происходит в моей ситуации, поскольку выполнение POST со страницы выхода Spring по умолчанию к действию шлюза/выхода делает его запросом CORS. Во время этого запроса происходит перенаправление на сервер авторизации, поэтому браузер устанавливает для заголовка Origin значение null. Поскольку у меня настроен CORS, запрос отклоняется. Сначала я запутался, подумав, что, поскольку я не использую SPA для выхода из системы, CORS здесь не задействован.

Кажется, то, чего я хотел достичь, уже не осуществимо.

Конечная цель состояла в том, чтобы развернуть SPA на mydomain:80 и шлюз на mydomain:9990 (настройка аналогична локальному хосту). Теоретически это должно работать, поскольку это один и тот же сайт, но разное происхождение. Это означает, что файл cookie сеанса будет отправлен, но для отправки запроса на публикацию необходимо настроить CORS. Я хотел сохранить простоту (без прокси-сервера перед SPA и шлюзом), но также не хотел объединять SPA в шлюзе.

В качестве решения мне придется переосмыслить свою настройку и попытаться обслуживать SPA через шлюз, как предложил @ch4mp.

Как упоминалось в комментариях, вы можете использовать шлюз в качестве обратного прокси-сервера для SPA: просто определите для него маршрут (без фильтра TokenRelay, который следует переместить из фильтров по умолчанию в настройку маршрутов к серверам ресурсов).

ch4mp 26.07.2024 00:02

Да, я сделал это, и это сработало. Я также удалю фильтр TokenRelay, но, похоже, он с ним работает. Спасибо за помощь !

IonutB 26.07.2024 13:14

Да, работает, как только будет маршрут до СПА permitAll() . Но. Вы можете сэкономить немного ресурсов: то, что обслуживает активы SPA, не требует токена доступа. Кроме того, если вы используете Actuator на шлюзе, вы можете захотеть защитить его конечные точки с помощью цепочки фильтров сервера ресурсов (отличной от клиентской с oauth2Login, без сеансов и используемой клиентами на вашем бэкэнде, например, монитором кластера). Таким образом, вы можете использовать эту цепочку фильтров сервера ресурсов (или еще одну, вообще не защищенную) и сохранять ресурсы, используемые сеансами, и фильтр TokenRelay для запроса доступа к ресурсам SPA.

ch4mp 26.07.2024 18:16

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

Похожие вопросы

Конечная точка Spring Boot @PostMapping не поражена, пока @GetMapping работает после добавления securityFilterChain
Как сохранить области, пользователей и роли в Keycloak при использовании Docker и Docker Compose для его запуска
AccessDeniedHandler не вызывается при использовании AadResourceServerHttpSecurityConfigurer
Требование токена CSRF, если реализован JWT
Не могу понять, почему мой логинконтроллер не может аутентифицировать пользователя. Spring Security сводит меня с ума
API не может получить доступ к частным конечным точкам (403 запрещено), даже если пользователь прошел аутентификацию
Bean-компонент типа «org.springframework.security.crypto.password.PasswordEncoder», который не удалось найти
Проблема Spring Security с JWT: невозможно создать подкласс финального класса JwtAuthenticationProvider
Проблемы с пользовательским поставщиком аутентификации при весенней загрузке 3.3.0
Как защитить конечную точку веб-сокета микросервиса с помощью Spring Cloud Gateway?