본문 바로가기

Study

SpringSecurity OAuth CORS 문제

CORS

Cross-Origin Resource Sharing의 약자로 추가 HTTP Header를 사용하여 다른 오리진(도메인이나 포트번호가 다른 서버)의 자원에 접근권한을 부여하는 메커니즘이다.

 

보통 다른 오리진에게 리소스를 요청할 때 cross-origin Http request에 의해 요청이 실행된다. 하나 이 요청은 동일 출처 정책(Same-origin policy)에 의해 요청이 차단된다.

동일 출처 정책

만약 응답 리소스에 올바른 CORS 헤더가 포함되어 있지 않으면 다른 오리진에 요청한 리소스를 브라우저에서 보 안목 적으로 차단하는 것이다

 

 

스프링 부트와 뷰를 통해 개발 중인 프로젝트를 통해 이 문제를 확인해보자.

서버 측 코드

@RequiredArgsConstructor
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("SpringBoot");
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                .antMatchers(HttpMethod.POST, "/api/members/join").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler());
    }
}

SpringBoot와 SpringSecurity를 이용하여 구축한 Oauth2 리소스 서버 측 코드이다. configure를 보면 회원가입을 위한 /api/members/join로의 POST 요청은 permitAll() 한 것을 알 수 있다. 현재 이 서버는 8080 포트로 띄워진 상태이다.

클라이언트 측 코드

import axios from 'axios'

const config = {
    baseUrl: 'http://localhost:8080'
};

function requestJoinMember(member) {
    return axios.post(`${config.baseUrl}/api/members/join`, member);
}

클라이언트 측에서 axios를 사용하여 http://localhost:8080/api/members/join에 POST 요청을 하도록 하였다. 현재 클라이언트는 8081 포트에 띄워져 있다. 실제로 이 요청을 보낸 후 콘솔 창을 확인해보면 아래와 같은 메시지가 나타나 있다.

 

8081 포트에서 8080 포트로 요청을 했을 때 서버 측에서 CORS 헤더를 포함시키지 않아 동일 출처 정책으로 인해 발생한 에러이다. 콘솔을 보면 분명 POST 요청으로 보냈지만 OPTIONS 요청으로 갔으며 preflight 요청이 통과되지 않았다는 메시지가 있는 것을 알 수 있다. 

 

CORS로 요청을 할 때 보통 사전에 preflight 요청을 통해 서버 측에서 응답 헤더에 CORS와 관련에 헤더를 담아서 보내줘야 하지만 현재 서버에서는 어떠한 CORS설정을 하지 않아 발생한 것이다.

CORS 설정하기

@Override
public void configure(HttpSecurity http) throws Exception {
    http
            // cors 허용
            .cors().and()
            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/api/**").permitAll()
            .antMatchers(HttpMethod.POST, "/api/members/join").permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler());
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.addAllowedOrigin("http://localhost:8081");
    corsConfiguration.addAllowedHeader("*");
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", corsConfiguration);
    return source;
}

 

 

이렇게 서버 측 코드에 Cors설정을 통해 8081 포트의 모든 요청을 허락하고 cofigure에 cors()를 추가해주면 정상적으로 회원가입이 되는 것을 알 수 있다.

 

 

 

회원가입은 정상적으로 동작하지만 로그인을 통해 토큰 요청을 하게 되면 에러가 발생한다.

 


CORS 설정 후 토큰 발급 시 발생하는 문제

function requestLogin(info) {
    let form = new FormData();
    form.append('username', info.email);
    form.append('password', info.password);
    form.append("grant_type", "password");
    const requestData = {
        url: `${config.baseUrl}/oauth/token`,
        method: "POST",
        auth: {
            username: 'clientApp',
            password: 'secret'
        },
        mimeType: "multipart/form-data",
        data: form
    };
    return axios(requestData);
}

회원가입을 완료한 후에 로그인 시 AuthorizaitonServer에 요청을 통해 AccessToken을 발급하기 위한 클라이언트 측 코드이다.

 

실제 로그인을 시도한 후 콘솔을 확인해보면 콘솔 에러가 나타난다. 마지막 문장을 보면 회원가입 시 발생한 어려와는 조금 다른 것을 알 수 있다. 위에서는 Header가 없어서 생긴 문제이지만 여기서는 HTTP 상태 응답이 OK가 아니라서 차단되었다고 알려준다.

 

회원가입 시는 잘되었는데 왜 토큰 발급 시에는 이러한 문제가 발생할까?

 

토큰 발급은 AuthorizationServer에서 이루어지는데 AuthorizationServer에서는 CORS 설정이 되어 있지 않아 발생한 것으로 추정되었고 인터넷에서 해결법을 찾아보니 두 가지 해결방법이 있었다. 두 가지 방법을 직접 알아보자

 

방법 1

@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

    HttpServletResponse response = (HttpServletResponse) res;
    HttpServletRequest request = (HttpServletRequest) req;
    response.setHeader("Access-Control-Allow-Origin", "*");
    response.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT");     //허용할 request http METHOD : POST, GET, DELETE, PUT
    response.setHeader("Access-Control-Allow-Headers", "Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With,observe");

    if(request.getMethod().equals(HttpMethod.OPTIONS.name())){
        response.setStatus(HttpStatus.OK.value());
    }else{
        chain.doFilter(req, res);
    }
}

우선 필터를 통해 CORS설정을 해주고 만약 OPTIONS요청이 올 때는 Status OK로 응답을 하게 필터를 등록한다.

 

Securiry 설정에 OPTIONS요청을 ignoring 해주면 토큰 발급이 정상적으로 된다.

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers(HttpMethod.OPTIONS, "/**");
}

 

이렇게 필터를 통해 CORS 설정 시 원 가입 시 작성한 CorsConfigurationSource와 http.cors()를 제거해도 정상적으로 동작한다. 하지만 문제점은 모든 요청이 필터링되기 때문에 다른 모든 오리진에서 요청을 보내도 서버 측에서는 CORS 헤더를 담아서 응답하게 된다.

 

방법 2

@RequiredArgsConstructor
@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {

    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final MemberService memberService;


    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.passwordEncoder(passwordEncoder);
        CorsFilter filter = new CorsFilter(corsConfigurationSource());
        security.addTokenEndpointAuthenticationFilter(filter);
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("http://localhost:8081");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }
}

회원가입 시 작성한 CorsConfigurationSource를 AuthorizationServer 측으로 옮기고 AuthoizationServerConfigurer의 TokenEndPointFiler에 CORS설정을 등록시켜 주면 된다. 그리고 CORS설정을 빈으로 등록하였기 때문에 ResourceServer의 http configure에서 작성한 http.cors()가 정상적으로 적용이 된다.

 

이렇게 설정하면 특정 오리진의 CORS 요청에 대해 응답할 수 있으므로 필터로 설정하는 것보다 보안성 측면에서 더욱 효율적인 거 같다.

 

8083 포트로 클라이언트를 띄워서 확인해보면 요청이 막히는 것을 알 수 있다.

만약 방법 1로 했더라면 8083 포트의 요청도 정상적으로 수락되었을 것이다.