SpringBoot 3.x 버전에서는 'org.springframework.boot:spring-boot-starter-oauth2-client' 의존성을 추가함으로써, OAuth2 인증 절차를 더 간단하게 처리 가능하다.
Spring Security 는 세션안에 Security Context 를 가지고 있고, Security Context 안에는 Authentication 이라는 인증 객체를 가지고 있다. 즉 인증 객체는 세션에서 관리된다. 그럼 세션이 아니라 JWT를 사용하면?
일반적으로 JWT를 이용해서 인증을 구현 할 때는 아래와 같은 방식을 사용한다.
public Authentication getAuthentication(String token) {
CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(this.getTokenSubject(token));
return new UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities());
}
public String getTokenSubject(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
} catch (Exception e) {
throw new JwtException("Invalid JWT token");
}
}
...
// UsernamePasswordAuthenticationToken 의 생성자이다.
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
위와 같이 Custom Filter 를 통해서 인증 객체를 직접 만들어 주는 이유는 서버 세션에서 이 정보를 저장하지 않기 때문이다. 따라서 모든 요청(인증이 필요한)마다 필터를 사용해서 사용자의 정보를 다시 load 해 오는 것.
이번에는 가장 간단한 방식으로 인증을 구현하고 싶다. 추가적으로 필요한 것들이 생기면 더 불러오는 것으로 하고, 한 번 세션방식으로 구현해보자.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain config(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry
.anyRequest().authenticated()
)
.oauth2Login(
httpSecurityOAuth2LoginConfigurer ->
httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")
)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.build();
}
}
OAuth2 클라이언트는 일반적으로 OAuth2 인증을 하기 위한 "인증 코드" 방식을 간소화 시켜준다.
1. 인증 코드 발급
2. 인증 코드로 Github 인증서버 액세스 토큰 발급
3. 발급된 액세스 토큰으로 사용자 정보 응답
절차를 생략하고
// 로그인 요청 -> 사용자 정보 응답 ()
...
.oauth2Login(
httpSecurityOAuth2LoginConfigurer ->
httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")
)
위와 같이 지정한 URL로 사용자의 정보를 바로 받아보게 할 수 있다.
그리고 인증이 완료되면 세션 내의 Security Context에 Authentication 을 곧바로 저장하기 때문에 성공 url에서 바로 @AuthenticationPrincipal 어노테이션을 사용해서 인증 정보를 즉시 사용 할 수 있다.
@GetMapping("/oauth2/login")
public void oauth2Login(@AuthenticationPrincipal OAuth2User oAuth2User){
log.info("[{}] 유저 로그인 \n 상세정보 : {}", () -> oAuth2User.getAttribute("login"), oAuth2User::toString);
authService.saveIfNotPresent(LoginRequestDto.of(oAuth2User));
}
그렇다면 의문점이 생긴다.
테스트를 몇 개 만들어가며 검증해보자.
SecurityContext 를 커스텀해서, 동작 방식을 비교해보자.
@WithSecurityContext 어노테이션을 사용하면 테스트 환경에서 시큐리티 컨텍스트를 직접 조작 가능하다. 스프링 공식문서를 참고하면, 쉽게 따라 이 방법을 시도 해 볼 수 있다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2UserSecurityContextFactory.class)
public @interface WithMockOAuth2User {
String username() default "testuser";
String nameAttributeKey() default "name";
String[] authorities() default {"ROLE_USER"};
String[] attributes() default {"name:123", "email:testuser@example.com"};
}
public class WithMockOAuth2UserSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User> {
@Override
public SecurityContext createSecurityContext(WithMockOAuth2User withMockOAuth2User) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Map<String, Object> attributes = Arrays.stream(withMockOAuth2User.attributes())
.map(attr -> attr.split(":"))
.collect(Collectors.toMap(arr -> arr[0], arr -> arr[1]));
OAuth2User principal = new DefaultOAuth2User(
Arrays.stream(withMockOAuth2User.authorities())
.map(SimpleGrantedAuthority::new)
.toList(),
attributes,
withMockOAuth2User.nameAttributeKey()
);
context.setAuthentication(new OAuth2AuthenticationToken(principal, principal.getAuthorities(), "github"));
return context;
}
}
나는 위와 같이 설정했다. 이로써 @WithMockOAuth2User 어노테이션을 사용 시, 테스트에서의 인증 정보는 내가 설정한 OAuth2User 의 정보가 나오게 될 것이다.
정말 그런지 테스트해보자.
@Test
@WithMockOAuth2User
@DisplayName("OAuth2 유저 인증 또한 Security Context 안에 Authentication 객체로 저장된다. 이 인스턴스는 주로 사용되는 UserNameAuthenticationToken 이 아닌 OAuth2AuthenticationToken 으로 저장된다.")
void authenticationObjectStoredInSecurityContext() {
SecurityContext securityContext = SecurityContextHolder.getContext();
Authentication authentication = securityContext.getAuthentication();
assertThat(authentication).isNotNull();
assertThat(authentication.isAuthenticated()).isTrue();
assertThat(authentication).isInstanceOf(OAuth2AuthenticationToken.class); // <- 여기
}
@Test
@WithMockOAuth2User(attributes = {"name:myTestOAuth2User"})
@DisplayName("커스텀 어노테이션의 어트리뷰트를 변경하여 인증 객체의 정보를 컨트롤 할 수 있다.")
void canControlAuthenticationAttributesWithCustomAnnotation() {
OAuth2AuthenticationToken authentication = (OAuth2AuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
OAuth2User oAuth2User = authentication.getPrincipal();
assertThat(oAuth2User).isNotNull();
assertThat(oAuth2User.getName()).isEqualTo("myTestOAuth2User");
}
@Test
@WithMockOAuth2User
@DisplayName("커스텀 어노테이션을 이용해 인증이 필요한 요청을 테스트 할 수 있다.")
void canTestAuthenticatedRequestsWithCustomAnnotation() throws Exception {
mockMvc.perform(get("/oauth2/login")).andExpect(status().isOk());
}
이 테스트를 사용해서 우리의 의문 중 하나는 풀렸다. 즉, OAuth2User 타입은 OAuth2AuthenticationToken 의 principal 로써 Security Context 안의 Authentication 으로 저장된다 는 사실을 알게 되었다.
그렇다면 한가지 더 궁금한 것
마찬가지로 간단한 테스트를 작성해보자.
@Test
@WithMockOAuth2User(attributes = {"name:99999"})
@DisplayName("SecurityContext 는 세션에 저장된다.")
void testSecurityContextStoredInSession() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
.andExpect(status().isOk())
.andReturn();
HttpSession session = mvcResult.getRequest().getSession(false);
assertThat(session).isNotNull();
SecurityContext securityContext = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
assertThat(securityContext).isNotNull();
OAuth2User principal = (OAuth2User) securityContext.getAuthentication().getPrincipal();
assertThat(principal.getName()).isEqualTo("99999");
}
위 테스트를 통해서
는 것을 검증 할 수 있다.
그런데, 우리가 컨텍스트를 만들어서 직접 바꿔치기하는 방식으로는 운영 환경의 security context 동작을 테스트 할 수 없지않나? 라는 생각도 들었다. 그래서 직접 컨피규레이션을 조정해서 인증 시 세션을 사용하지 않도록 변경 하고 테스트를 해 보았다.
@Bean
public SecurityFilterChain config(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry
.requestMatchers("/oauth2/login").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(
httpSecurityOAuth2LoginConfigurer ->
httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")
)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
...
@RestController
@RequiredArgsConstructor
@Log4j2
public class AuthController {
private final AuthService authService;
@GetMapping("/oauth2/login")
public void oauth2Login(@AuthenticationPrincipal OAuth2User oAuth2User){
log.info("[{}] 유저 로그인 \n 상세정보 : {}", () -> oAuth2User.getAttribute("login"), oAuth2User::toString);
authService.saveIfNotPresent(LoginRequestDto.of(oAuth2User));
}
}
위와 같이 SessionCreationPolicy.STATELESS를 사용하면, 인증 시 세션을 사용하지 않겠다는 명시를 할 수 있다 (세션이 아예 생성되지 않는것은 아니다.)
결과는 NPE 가 발생한다.
security:
oauth2:
client:
registration:
github:
client-id: {client-id}
client-secret: {client-secret}
위 절차는 비교적 다른곳에 많아서 후순위로 미루었다.