[Spring] OAuth2.0 Client + JWT 로그인 Unauthorized, AuthenticationEntryPoint 오류 해결
1. 문제 상황
JWT 로그인 시에 토큰 인증에 실패한 경우 401 에러를 리턴하기 위해서 AuthenticationEntryPoint 을 구현했다.
그런데 localhost:8080/login 에 접속할 때도 Unauthorized 오류가 발생했다.
정말 이게 무슨 상황인가 싶다 ...
분명 잘 됐었는데..
갑자기 문득 생각나는 이 짤 ..
2. 원인
잘 동작하던 친구가 401 에러 처리를 위해 AuthenticationEntryPoint 추가한 후 부터 동작을 하지 않는다.
아래는 내가 작성한 코드이다.
2-1. AuthenticationEntryPoint Code
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"" + authException.getMessage() + "\"}");
}
}
2-2. SecurityConfig
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final TokenService tokenService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 처리
.accessDeniedHandler(jwtAccessDeniedHandler) // 403 처리
)
.addFilterBefore(new JwtAuthenticationFilter(tokenService), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/health-check").permitAll()
.requestMatchers("/login/**").permitAll()
.requestMatchers("/oauth2/**").permitAll()
.anyRequest().authenticated());;
http
.oauth2Login(config -> config
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(endpointConfig -> endpointConfig
.userService(oAuth2UserService))
);
return http.build();
}
}
2-3. 실패했던 시도 방법
1. JwtAuthenticationFilter 에 shouldNotFilter 에 /login 경로 등록하기
경로 등록은 되고 정상 동작하나 OAuth2.0 Client 가 제공하는 /login html 을 받아올 수 없었음
2. AuthenticationEntryPoint 에서 login 경로 제외하기
1번과 같은 이유로 실패..
3. SecurtyConfig 에서 permiAll() 을 이 경로 저 경로에 계속 추가해보기 ...
모두 다 실패함 ...
시도했으나 실패했던 방법은 이런 걸 시도해 봤다 정도로만 간략히 기술하겠습니다.
3. 해결 방법
제가 찾은 해결 방법은 OAuth SecurityFilterChain 과 JWT 용 SecurityFilterChain 를 분리하는 것이었습니다.
기존의 SecurityFilterChain 은 JWT, OAuth 설정을 한번에 주입하려고 하니 어떤 건 permitAll 해주는 등의 처리가 필요했습니다.
그런데 AuthenticationEntryPoint 는 토큰 인증이 실패한 경우 401에러를 리턴하기 위한 것이므로 JWT 토큰의 경우에만 필요했습니다.
그래서 JWT SecurityFilterChain 과 OAuth SecurityFilterChain 을 분리해야 한다고 생각했습니다.
실제로 Spring Security 는 다양한 SecurityFilterChain 을 둘 수 있도록 지원합니다.
코드와 함께 살펴 보겠습니다.
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final TokenService tokenService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.addFilterBefore(new JwtAuthenticationFilter(tokenService), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/health-check").permitAll()
.requestMatchers("/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 처리
.accessDeniedHandler(jwtAccessDeniedHandler) // 403 처리
);
return http.build();
}
@Bean
public SecurityFilterChain oauth2FilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/login/**")
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/oauth2/**", "/login/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.oauth2Login(config -> config
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(endpointConfig -> endpointConfig
.userService(oAuth2UserService))
);
return http.build();
}
}
기존의 한 개 였던 SecurityFilterChain을 jwtSecurityFilterChain 과 oauth2SecurityFilterChain 으로 분리했습니다.
그리고 의도했던 대로 jwtSecurityFilterChain 에만 AuthenticationEntryPoint 를 달아주었습니다.
이제 정상적으로 localhost:8080/login 페이지에 접속할 수 있게 됐습니다.
3-1. Jwt Token 에 인증에 대한 401 에러 테스트
3-1-1. 테스트용 컨트롤러 작성
@RestController
public class HealthController {
@GetMapping("/health-check")
public ResponseEntity<String> health() {
return ResponseEntity.ok("OK");
}
@GetMapping("/api/v1/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("Hello, World!");
}
}
위와 같은 Controller 를 만들었습니다.
이 상태로 /api/v1/hello 로 요청을 보내면 어떻게 될까요 ?
401 에러가 발생하고 있습니다.
이는 jwtSecurityFilterChain 을 타고 JwtAuthenticationEntryPoint 로 요청이 들어가서 401 에러가 발생했다는 의미가 되겠네요.
이렇게 AuthenticationEntryPoint Class 도 적용하고 OAuth2.0 도 이용할 수 있게 됐습니다 !