Spring Security

[Spring] OAuth2.0 Client + JWT 로그인 Unauthorized, AuthenticationEntryPoint 오류 해결

후뿡이 2024. 11. 16. 17:56

1. 문제 상황


JWT 로그인 시에 토큰 인증에 실패한 경우 401 에러를 리턴하기 위해서 AuthenticationEntryPoint 을 구현했다.

그런데 localhost:8080/login 에 접속할 때도 Unauthorized 오류가 발생했다.

login 페이지에 접속하는데 unauthorize 에러 ... ?

 

정말 이게 무슨 상황인가 싶다 ... 

분명 잘 됐었는데..

 

갑자기 문득 생각나는 이 짤 ..

가위 포장을 뜯기 위해 가위가 필요한 아이러니한 상황

 

 

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 도 이용할 수 있게 됐습니다 !