목차
문제 상황
SSO(Single Sign-On) 로그인 기능을 구현하던 중, 다음과 같은 문제를 발견했습니다:
- OAuth2 표준 플로우: Access Token(JWT), Refresh Token(Opaque) 생성 및 DB 저장 ✅
- SSO 로그인: Access Token(JWT), Refresh Token(JWT) 생성, DB 저장 안 됨 ❌
두 플로우가 서로 다른 방식으로 토큰을 생성하고 있었고, SSO로 생성된 Refresh Token은 JWT 형식이라 DB에 저장되지 않아 갱신이 불가능했습니다.
목표: SSO 로그인도 OAuth2 표준 플로우와 동일하게 Opaque Refresh Token을 생성하고 DB에 저장하도록 통일
Spring OAuth2 토큰 생성 메커니즘 이해
1. Spring이 자동으로 생성하는 토큰 생성기
Spring OAuth2 Authorization Server는 내부적으로 토큰 생성기를 자동 구성합니다:
// Spring 내부에서 자동 생성 (OAuth2AuthorizationServerConfiguration)
@Bean
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(...) {
JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
jwtGenerator.setJwtCustomizer(jwtCustomizer);
OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
return new DelegatingOAuth2TokenGenerator(
jwtGenerator, // 1순위: JWT 생성 (Access Token용)
accessTokenGenerator, // 2순위: Opaque Access Token (fallback)
refreshTokenGenerator // 3순위: Opaque Refresh Token
);
}
2. DelegatingOAuth2TokenGenerator의 동작 원리
DelegatingOAuth2TokenGenerator는 여러 토큰 생성기를 순회하며 첫 번째로 성공한 생성기를 사용합니다:
public class DelegatingOAuth2TokenGenerator implements OAuth2TokenGenerator<OAuth2Token> {
@Override
public OAuth2Token generate(OAuth2TokenContext context) {
for (OAuth2TokenGenerator<OAuth2Token> tokenGenerator : this.tokenGenerators) {
OAuth2Token token = tokenGenerator.generate(context);
if (token != null) {
return token; // 첫 번째 성공한 생성기의 결과 반환
}
}
return null;
}
}
토큰 타입별 생성 과정:
- Access Token (OAuth2TokenType.ACCESS_TOKEN):
- JwtGenerator 시도 → JWT 형식 Access Token 생성 성공 ✅
- 나머지 생성기는 실행되지 않음
- Refresh Token (OAuth2TokenType.REFRESH_TOKEN):
- JwtGenerator 시도 → Refresh Token은 생성하지 않음, null 반환
- OAuth2AccessTokenGenerator 시도 → Access Token만 생성, null 반환
- OAuth2RefreshTokenGenerator 시도 → Opaque Refresh Token 생성 성공 ✅
3. 왜 내부 생성기는 주입받을 수 없나?
Spring이 자동 생성한 토큰 생성기는 OAuth2 엔드포인트 내부에서만 사용되며, Application Context에 Bean으로 등록되지 않습니다:
// Spring 내부 설정
public class OAuth2AuthorizationServerConfiguration {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE) // 인프라 Bean (주입 불가)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
OAuth2TokenGenerator<?> tokenGenerator = getTokenGenerator(http);
// 이 tokenGenerator는 HttpSecurity SharedObjects에만 저장됨
}
}
SharedObjects vs Application Context Bean:
- SharedObjects: HttpSecurity 내부에서만 공유되는 객체 저장소
- Application Context Bean: @Autowired로 주입 가능한 Bean
따라서 커스텀 서비스에서 토큰 생성이 필요하면 직접 Bean을 생성해야 합니다.
OAuth2TokenGenerator 커스터마이징
1. JwtConfig에 Bean 등록
@Configuration
public class JwtConfig {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer(
LoginUserDetailsService userDetailsService) {
return (jwtEncodingContext) -> {
OAuth2TokenType tokenType = jwtEncodingContext.getTokenType();
if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
try {
String username = jwtEncodingContext.getPrincipal().getName();
LoginUserDetails user = (LoginUserDetails) userDetailsService
.loadUserByUsername(username);
jwtEncodingContext.getClaims().claims((claims) -> {
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
claims.put("roles", roles);
});
} catch(UsernameNotFoundException e) {
String clientId = jwtEncodingContext.getRegisteredClient().getClientId();
jwtEncodingContext.getClaims().claims(claims -> {
claims.put("client_id", clientId);
});
}
}
};
}
/**
* OAuth2 토큰 생성기 Bean 등록
* - JwtGenerator: Access Token을 JWT 형식으로 생성 (1순위)
* - OAuth2AccessTokenGenerator: Access Token을 Opaque 형식으로 생성 (fallback)
* - OAuth2RefreshTokenGenerator: Refresh Token을 Opaque 형식으로 생성
*/
@Bean
public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(
JwtEncoder jwtEncoder,
OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer) {
JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
jwtGenerator.setJwtCustomizer(jwtTokenCustomizer);
OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
return new DelegatingOAuth2TokenGenerator(
jwtGenerator,
accessTokenGenerator,
refreshTokenGenerator
);
}
}
2. 토큰 생성 순서 및 형식
토큰 타입생성기 우선순위최종 형식용도
| Access Token | JwtGenerator → OAuth2AccessTokenGenerator | JWT | API 인증 |
| Refresh Token | Opaque (UUID) | 토큰 갱신 |
Opaque Token 예시: a1b2c3d4-e5f6-7890-abcd-ef1234567890
SSO 로그인에 적용하기
1. SsoTokenService 구현
@Service
public class SsoTokenService {
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationService authorizationService;
private final AuthorizationServerSettings authorizationServerSettings;
@Autowired
public SsoTokenService(
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationService authorizationService,
AuthorizationServerSettings authorizationServerSettings) {
this.tokenGenerator = tokenGenerator;
this.registeredClientRepository = registeredClientRepository;
this.authorizationService = authorizationService;
this.authorizationServerSettings = authorizationServerSettings;
}
/**
* SSO 로그인 완료 후 Access Token과 Refresh Token을 생성하고 DB에 저장
* OAuth2 표준 방식 사용 (Access Token: JWT, Refresh Token: Opaque)
*/
public TokenResponse createAndSaveTokens(LoginUserDetails userDetails) {
RegisteredClient registeredClient = registeredClientRepository
.findByClientId("your-client");
if (registeredClient == null) {
throw new IllegalStateException("RegisteredClient를 찾을 수 없습니다");
}
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// Access Token 생성 (JWT)
OAuth2TokenContext accessTokenContext = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(authentication)
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.authorizedScopes(Collections.emptySet())
.authorizationServerContext(createAuthorizationServerContext())
.build();
OAuth2Token generatedAccessToken = tokenGenerator.generate(accessTokenContext);
if (generatedAccessToken == null) {
throw new IllegalStateException("Access Token 생성에 실패했습니다");
}
OAuth2AccessToken accessToken = (OAuth2AccessToken) generatedAccessToken;
// Refresh Token 생성 (Opaque)
OAuth2TokenContext refreshTokenContext = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(authentication)
.tokenType(OAuth2TokenType.REFRESH_TOKEN)
.authorizedScopes(Collections.emptySet())
.authorizationServerContext(createAuthorizationServerContext())
.build();
OAuth2Token generatedRefreshToken = tokenGenerator.generate(refreshTokenContext);
if (generatedRefreshToken == null) {
throw new IllegalStateException("Refresh Token 생성에 실패했습니다");
}
OAuth2RefreshToken refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
// OAuth2Authorization 생성 및 DB 저장
OAuth2Authorization authorization = OAuth2Authorization
.withRegisteredClient(registeredClient)
.id(UUID.randomUUID().toString())
.principalName(userDetails.getUsername())
.authorizationGrantType(AuthorizationGrantType.PASSWORD)
.authorizedScopes(Collections.emptySet())
.accessToken(accessToken)
.refreshToken(refreshToken)
.attribute("java.security.Principal", authentication)
.build();
authorizationService.save(authorization);
log.info("OAuth2Authorization saved for SSO user: {}", userDetails.getUsername());
return new TokenResponse(accessToken, refreshToken);
}
/**
* AuthorizationServerContext 생성 헬퍼 메서드
* JWT 토큰에 issuer를 포함시키기 위해 필요
*/
private AuthorizationServerContext createAuthorizationServerContext() {
return new AuthorizationServerContext() {
@Override
public String getIssuer() {
return authorizationServerSettings.getIssuer();
}
@Override
public AuthorizationServerSettings getAuthorizationServerSettings() {
return authorizationServerSettings;
}
};
}
@Getter
public static class TokenResponse {
private final OAuth2AccessToken accessToken;
private final OAuth2RefreshToken refreshToken;
public TokenResponse(OAuth2AccessToken accessToken,
OAuth2RefreshToken refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
}
2. SsoController에서 사용
@RestController
public class SsoController {
private final SsoTokenService ssoTokenService;
private final LoginUserDetailsService loginUserDetailsService;
@RequestMapping(value = "/sso-login", method = {RequestMethod.POST})
public void loginSSOCallback(@ModelAttribute SsoDto ssoDto,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
// SSO 세션 검증 및 사용자 조회 로직...
String userName = request.getHeader("user");
LoginUserDetails userDetails = (LoginUserDetails)
loginUserDetailsService.loadUserByUsername(userName);
// OAuth2 표준 방식으로 토큰 생성 및 DB 저장
SsoTokenService.TokenResponse tokenResponse =
ssoTokenService.createAndSaveTokens(userDetails);
String token = tokenResponse.getAccessToken().getTokenValue();
String refreshToken = tokenResponse.getRefreshToken().getTokenValue();
// 쿠키 설정
Cookie tokenCookie = new Cookie("token", token);
tokenCookie.setDomain(ssoProperties.getCookieDomain());
tokenCookie.setPath("/");
response.addCookie(tokenCookie);
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setDomain(ssoProperties.getCookieDomain());
refreshTokenCookie.setPath("/");
response.addCookie(refreshTokenCookie);
response.sendRedirect(baseUri);
}
}
3. 동작 플로우
[SSO 서버] → [SsoController]
↓
사용자 정보 검증
↓
[SsoTokenService]
↓
OAuth2TokenGenerator
┌──────────┴──────────┐
↓ ↓
Access Token Refresh Token
(JWT 형식) (Opaque 형식)
└──────────┬──────────┘
↓
OAuth2AuthorizationService
↓
DB에 저장 (oauth2_authorization 테이블)
↓
쿠키로 클라이언트 전송
테스트 전략
1. 단위 테스트: SsoTokenServiceTest
@RunWith(MockitoJUnitRunner.class)
public class SsoTokenServiceTest {
@Mock
private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
@Mock
private RegisteredClientRepository registeredClientRepository;
@Mock
private OAuth2AuthorizationService authorizationService;
@Mock
private RegisteredClient registeredClient;
private AuthorizationServerSettings authorizationServerSettings;
private SsoTokenService ssoTokenService;
@Before
public void setUp() {
authorizationServerSettings = AuthorizationServerSettings.builder()
.issuer("http://localhost:8900/api/user")
.build();
ssoTokenService = new SsoTokenService(
tokenGenerator,
registeredClientRepository,
authorizationService,
authorizationServerSettings
);
}
@Test
public void createAndSaveTokens_shouldGenerateAndSaveTokens() {
// Given
LoginUserDetails userDetails = new LoginUserDetails(
1, "encodedPassword", "1100001", "dev-team",
true, true, true, true,
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))
);
given(registeredClientRepository.findByClientId("your-client"))
.willReturn(registeredClient);
OAuth2AccessToken mockAccessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"mock-access-token",
Instant.now(),
Instant.now().plus(1, ChronoUnit.HOURS)
);
OAuth2RefreshToken mockRefreshToken = new OAuth2RefreshToken(
"mock-refresh-token",
Instant.now(),
Instant.now().plus(7, ChronoUnit.DAYS)
);
given(tokenGenerator.generate(any(OAuth2TokenContext.class)))
.willReturn(mockAccessToken)
.willReturn(mockRefreshToken);
// When
SsoTokenService.TokenResponse response =
ssoTokenService.createAndSaveTokens(userDetails);
// Then
assertNotNull("TokenResponse should not be null", response);
assertNotNull("Access token should not be null", response.getAccessToken());
assertNotNull("Refresh token should not be null", response.getRefreshToken());
assertEquals("mock-access-token", response.getAccessToken().getTokenValue());
assertEquals("mock-refresh-token", response.getRefreshToken().getTokenValue());
verify(registeredClientRepository).findByClientId("your-client");
verify(authorizationService).save(any(OAuth2Authorization.class));
}
}
2. WebMvcTest 문제와 해결
문제: WebMvcTest 환경에서 SecurityConfig를 Import하면 OAuth2 관련 Bean들을 모두 로드해야 함
- AuthorizationServerSettings
- CustomUserInfoMapper
- JdbcOperations
- RegisteredClientRepository
- 등등...
해결: 테스트용 간소화된 SecurityConfig 생성
@TestConfiguration
@EnableWebSecurity
public class TestSecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.disable())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers("/sso-login", "/sso-login2").permitAll()
.requestMatchers("/users/**").hasAuthority("ROLE_ADMIN")
.requestMatchers("/authorities/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.build();
}
}
@RunWith(SpringRunner.class)
@WebMvcTest(SsoController.class)
@Import(TestSecurityConfig.class) // SecurityConfig 대신 TestSecurityConfig 사용
@ContextConfiguration(classes = SpringSecurityWebAuthTestConfig.class)
public class SsoControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private SsoTokenService ssoTokenService;
@Test
public void loginSSOCallback_shouldRedirectWithTokens() throws Exception {
// Given
TokenResponse tokenResponse = new TokenResponse(accessToken, refreshToken);
given(ssoTokenService.createAndSaveTokens(any())).willReturn(tokenResponse);
// When & Then
this.mvc.perform(post("/sso-login")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("authinform", "session123")
.header("serversessionid", "session123")
.header("user", "testuser"))
.andExpect(status().is3xxRedirection())
.andExpect(cookie().exists("token"))
.andExpect(cookie().exists("refreshToken"));
}
}
3. 테스트 결과
✅ SsoTokenServiceTest: 2 tests passed
✅ SsoControllerTest: 5 tests passed
✅ AuthorityControllerTest: 14 tests passed
✅ UserControllerTest: 21 tests passed
────────────────────────────────────────
✅ Total: 42 tests passed, 0 failures
트러블슈팅
1. "value cannot be null" 오류
증상:
java.lang.IllegalArgumentException: value cannot be null
원인: OAuth2TokenContext에 authorizationServerContext가 누락되어 JWT의 iss (issuer) claim을 생성할 수 없음
해결:
OAuth2TokenContext context = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(authentication)
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.authorizedScopes(Collections.emptySet())
.authorizationServerContext(createAuthorizationServerContext()) // 추가!
.build();
2. OAuth2TokenGenerator Bean을 찾을 수 없음
증상:
No qualifying bean of type 'OAuth2TokenGenerator' available
원인: Spring이 자동 생성한 토큰 생성기는 내부용이며 Application Context에 Bean으로 등록되지 않음
해결: JwtConfig에서 직접 Bean 생성 (위의 "OAuth2TokenGenerator 커스터마이징" 섹션 참조)
3. WebMvcTest에서 SecurityConfig 로드 실패
증상:
Error creating bean with name 'SecurityConfig':
Unsatisfied dependency expressed through constructor parameter 0:
No qualifying bean of type 'CustomUserInfoMapper' available
원인: WebMvcTest는 제한된 컨텍스트만 로드하므로 OAuth2 관련 Bean들이 없음
해결:
- TestSecurityConfig 생성 (OAuth2 설정 제외)
- 테스트에서 @Import(TestSecurityConfig.class) 사용
핵심 요약
📌 Spring OAuth2 토큰 생성 메커니즘
- DelegatingOAuth2TokenGenerator가 여러 생성기를 순회
- JwtGenerator: Access Token → JWT 형식
- OAuth2RefreshTokenGenerator: Refresh Token → Opaque 형식
- 내부 생성기는 주입 불가 → 필요시 직접 Bean 생성
📌 커스터마이징 포인트
@Bean
public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(...) {
JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);
jwtGenerator.setJwtCustomizer(jwtTokenCustomizer); // 커스텀 Claim 추가
return new DelegatingOAuth2TokenGenerator(
jwtGenerator,
new OAuth2AccessTokenGenerator(),
new OAuth2RefreshTokenGenerator()
);
}
📌 SSO 통합 체크리스트
- OAuth2TokenGenerator Bean 등록
- AuthorizationServerSettings Bean 등록
- SsoTokenService 구현
- OAuth2Authorization DB 저장 확인
- AuthorizationServerContext 설정 (issuer)
- 단위 테스트 작성
- 통합 테스트 작성 (TestSecurityConfig 사용)
참고 자료
'Java & Spring' 카테고리의 다른 글
| Spring Boot 2.4 -> 3.2 Trouble Shooting 정리 (0) | 2024.04.18 |
|---|---|
| Spring Security 기능 활용 #3 (@AuthenticationPrincipal, UserDetails) (0) | 2024.04.03 |
| Spring WebFlux를 활용한 Mutli API 호출통합 #1 (0) | 2022.11.04 |
| Geohash를 이용한 좌표기반 시스템 개선 (0) | 2022.07.08 |
| Spring WebClient 사용 #3 (Configuration, Timeout) (0) | 2022.05.31 |