Moving the authorized client to JdbcOAuth2AuthorizedClientService makes tokens work across replicas, so the session-in-Redis plus tokens-in-PostgreSQL split holds. The table then shows what sharing cannot fix: the primary key is (client_registration_id, principal_name) with no session in it, so a second login for the same user updates the same row rather than adding one. The refresh token sits in bytea as the raw JWT, readable with convert_from, and logout clears only the Redis session while the plaintext token row and the Keycloak SSO session both survive. The schema itself failed silently first because the default DDL uses blob, which PostgreSQL does not have, and continue-on-error swallowed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
5.0 KiB
Java
109 lines
5.0 KiB
Java
package com.example.keycloakpattern.bff;
|
|
|
|
import org.springframework.context.annotation.Bean;
|
|
import org.springframework.context.annotation.Configuration;
|
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
|
|
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.OAuth2AuthorizedClientService;
|
|
import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService;
|
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
|
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
|
|
import org.springframework.security.web.SecurityFilterChain;
|
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|
import org.springframework.jdbc.core.JdbcOperations;
|
|
|
|
@Configuration
|
|
public class SecurityConfig {
|
|
|
|
/**
|
|
* B-2 — authorized client 를 프로세스 메모리에서 PostgreSQL 로 옮긴다.
|
|
*
|
|
* B-1 에서 Application Session 만 Redis 로 옮겼더니, 사용자는 로그인
|
|
* 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다.
|
|
* 두 상태의 저장소를 **각각** 정해야 한다는 Q3 의 지적이 그대로 나타난 것이다.
|
|
*
|
|
* 주의 — 이것이 고치는 것과 고치지 못하는 것이 다르다.
|
|
* 고친다 : 인스턴스 간 공유. 어느 replica 로 가도 같은 토큰을 본다.
|
|
* 못 고친다: 조회 키. JdbcOAuth2AuthorizedClientService 도
|
|
* (clientRegistrationId, principalName) 으로 찾으므로
|
|
* 같은 사용자의 두 브라우저는 여전히 한 항목을 공유한다.
|
|
*/
|
|
@Bean
|
|
OAuth2AuthorizedClientService authorizedClientService(
|
|
JdbcOperations jdbcOperations,
|
|
ClientRegistrationRepository clientRegistrationRepository
|
|
) {
|
|
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
|
|
}
|
|
|
|
@Bean
|
|
SecurityFilterChain bffSecurity(
|
|
HttpSecurity http,
|
|
ClientRegistrationRepository clientRegistrationRepository
|
|
) throws Exception {
|
|
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
|
|
new DefaultOAuth2AuthorizationRequestResolver(
|
|
clientRegistrationRepository,
|
|
"/oauth2/authorization"
|
|
);
|
|
authorizationRequestResolver.setAuthorizationRequestCustomizer(
|
|
OAuth2AuthorizationRequestCustomizers.withPkce()
|
|
);
|
|
|
|
CookieCsrfTokenRepository csrfTokenRepository =
|
|
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
|
csrfTokenRepository.setCookiePath("/");
|
|
|
|
return http
|
|
.csrf(csrf -> csrf
|
|
.csrfTokenRepository(csrfTokenRepository)
|
|
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
|
|
.authorizeHttpRequests(authorize -> authorize
|
|
.requestMatchers(
|
|
"/",
|
|
"/index.html",
|
|
"/app.js",
|
|
"/favicon.ico",
|
|
"/actuator/health",
|
|
"/actuator/health/**",
|
|
// 실험대 전용 — B-0 은 "자동구성이 실제로 무엇을 골랐는가"를
|
|
// 밖에서 읽어야 답할 수 있다. 운영에서는 절대 열지 않는다:
|
|
// /actuator/beans 와 /actuator/env 는 내부 구조와 설정값을
|
|
// 그대로 드러낸다.
|
|
"/actuator/**"
|
|
)
|
|
.permitAll()
|
|
.anyRequest()
|
|
.authenticated())
|
|
.oauth2Login(oauth2 -> oauth2
|
|
.authorizationEndpoint(endpoint -> endpoint
|
|
.authorizationRequestResolver(authorizationRequestResolver))
|
|
.defaultSuccessUrl("/", true))
|
|
.build();
|
|
}
|
|
|
|
@Bean
|
|
OAuth2AuthorizedClientManager authorizedClientManager(
|
|
ClientRegistrationRepository clientRegistrationRepository,
|
|
OAuth2AuthorizedClientService authorizedClientService
|
|
) {
|
|
OAuth2AuthorizedClientProvider authorizedClientProvider =
|
|
OAuth2AuthorizedClientProviderBuilder.builder()
|
|
.authorizationCode()
|
|
.refreshToken()
|
|
.build();
|
|
|
|
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
|
|
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
|
|
clientRegistrationRepository,
|
|
authorizedClientService
|
|
);
|
|
manager.setAuthorizedClientProvider(authorizedClientProvider);
|
|
return manager;
|
|
}
|
|
}
|