[JWT]CORS 이슈 상황에서 쿠키전달

박진형·2022년 1월 9일
2

JWT

목록 보기
3/4

[JWT] Session과 JWT에 대해 알아보기

[JWT] 프로젝트에 JWT 적용하기

앞서 작성한 포스팅에서 JWT를 쿠키로 저장하기로 했었다.

하지만 문제점이 발생했는데 CORS와 브라우저의 Samesite 정책 이슈가 발생했다!

CORS란?

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)로, 보안상의 이유로 서로 다른 출처간의 HTTP 요청을 제한하는 정책이다.

여기서 출처란 도메인, 프로토콜, 포트를 말한다.

프론트엔드의 도메인이 http://football-love.com:3000이고, 백엔드의 도메인이 http://football-love.com:8080이라고 가정했을 때 프로토콜과 도메인이 같아도 포트가 다르니 CORS 문제가 발생한다.

이 경우 SpringSecurity에서 CORS에 관한 설정을 해주어야한다.

CORS 설정

SpringSecurity 및 JWT 설정을 할 때 사용했던 WebSecurityConfig 파일에서 아래와 같이 Bean 등록을 해준다.

@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();

		configuration.addAllowedOrigin("http://도메인:포트");
		configuration.addAllowedHeader("*");
		configuration.addAllowedMethod("*");
		configuration.setAllowCredentials(true);
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}
  • addAllowedOrigin : 허용할 출처를 입력(프론트엔드의 도메인과 포트를 입력하면 된다.)
  • addAllowedHeader : 허용할 헤더를 입력
  • addAllowedMethod : 허용할 Http Method를 입력
  • setAllowCredentials : 쿠키 요청을 허용하도록 true로 설정

이렇게 하면 CORS 설정이 완료 되었다.

브라우저의 SameSite 정책

SameSite란?

SameSite는 웹 애플리케이션에서 CSRF(교차 사이트 요청 위조) 공격을 방지하기 위해 HTTP 쿠키에서 설정할 수 있는 속성이다.
SameSite는 Strict, Lax, None으로 3가지의 설정값이 있다.
Strict : 자사 도메인으로만 쿠키를 전송한다. (도메인이 일치해야 함)

Lax : Get으로의 접근은 허용하지만 Post로의 접근은 자사도메인이 아니라면 접근을 제한한다.

None : None으로 설정된 쿠키의 경우 크로스 사이트 요청의 경우에도 항상 전송된다.

현재 프로젝트에서는 프론트엔드는 리액트, 백엔드는 스프링으로 분리되어 서로 다른 VM인스턴스에 올려져 있고 서로 다른 출처로 인해 CORS 환경에 놓여져 있다.
보안상의 문제로 현재 다양한 브라우저들은 SameSite의 기본값으로 lax를 채택하고 있다.

이러한 이유로 JWT의 토큰을 쿠키로 전달하기 위해서는 SameSite 속성을 None으로 변경해주어야한다.

SameSite=None, 어떻게?

SameSite를 None으로 변경하려면 일단 Secure 옵션이 활성화 되어야한다. 정책상 그렇게 해야만한다. Secure 옵션이 활성화되면 HTTPS환경에서만 쿠키 전달이 가능하다는 것이다. 즉, 우리는 HTTPS사용과 더불어 쿠키의 속성을 변경을 해줄 수 있도록 몇가지 작업을 해주어야한다.

기존 MemberController 수정

앞서 작성한 포스트에는 Cookie를 생성하기 위해 Cookie 클래스를 이용했다. Cookie 클래스에는 SameSite속성을 설정하는 함수가 없어 따로 쿠키들을 읽어와 쿠키의 뒷부분에 "; secure; SameSite=None"를 임의로 붙여주는 방식의 후처리가 필요하다.

Spring Core 5.0 이상의 버전을 사용하면 ResponseCookie라는 클래스를 이용하면 쉽게 SameSite 설정이 가능하다.

  • 수정 전
@PostMapping("/login_jwt/{id}")
    public ResponseEntity<TokenInfo> login_jwt(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
        TokenInfo loginResponse = memberService.login_jwt(loginRequest);
        if (loginResponse.getResult().equals("fail")) {
            return new ResponseEntity<TokenInfo>(HttpStatus.CONFLICT);
        } else {
            ArrayList<String> data = new ArrayList<>();
            data.add(loginRequest.getId());
            data.add(loginResponse.getAccessToken());
            Cookie accessTokenCookie = new Cookie(JwtTokenProvider.ACCESS_TOKEN_NAME, loginResponse.getAccessToken());
            Cookie refreshTokenCookie = new Cookie(JwtTokenProvider.REFRESH_TOKEN_NAME, loginResponse.getRefreshToken());
            // accessTokenCookie.setMaxAge((int) JwtTokenProvider.TOKEN_VALIDATION_SECOND);
            // accessTokenCookie.setSecure(true);
            // accessTokenCookie.setHttpOnly(true);
            // refreshTokenCookie.setMaxAge((int) JwtTokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
            // refreshTokenCookie.setSecure(true);
            // refreshTokenCookie.setHttpOnly(true);
            response.addCookie(accessTokenCookie);
            response.addCookie(refreshTokenCookie);
            redisService.setStringValue(loginResponse.getRefreshToken(), data, JwtTokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
            return new ResponseEntity<TokenInfo>(loginResponse, HttpStatus.OK);
        }
    }
  • 수정 후
 @PostMapping("/login_jwt/{id}")
    public ResponseEntity<LoginResponse> login_jwt(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
        LoginResponse loginResponse = memberService.login_jwt(loginRequest);
        if (loginResponse.getResult().equals("fail")) {
            return new ResponseEntity<LoginResponse>(HttpStatus.CONFLICT);
        } else {
            ArrayList<String> data = new ArrayList<>();
            data.add(loginRequest.getId());
            data.add(loginResponse.getAccessToken());
            ResponseCookie accessTokenCookie = ResponseCookie.from(JwtTokenProvider.ACCESS_TOKEN_NAME, loginResponse.getAccessToken())
                    .path("/")
                    .secure(true)
                    .sameSite("None")
                    .build();
            ResponseCookie refreshTokenCookie = ResponseCookie.from(JwtTokenProvider.REFRESH_TOKEN_NAME, loginResponse.getRefreshToken())
                    .path("/")
                    .secure(true)
                    .sameSite("None")
                    .build();
            response.setHeader("Set-Cookie", accessTokenCookie.toString());
            response.addHeader("Set-Cookie", refreshTokenCookie.toString());
            redisService.setStringValue(loginResponse.getRefreshToken(), data, JwtTokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
            return new ResponseEntity<LoginResponse>(loginResponse, HttpStatus.OK);
        }
    }

HTTP -> HTTPS

SameSite를 None으로 설정하고 쿠키를 주고 받기 위해서는 프론트와 백 모두 HTTPS를 사용해야한다.
HTTPS를 사용하기 위해서는 SSL 인증서를 발급 받아야하고 그렇게 하기 위해서는 도메인을 만들어야한다. 나는 가난한 취준생이기 때문에 도메인도 무료, SSL인증서도 무료, 모두 비용이 들지 않는 것으로 사용했다.

도메인 생성

kro.kr 에 회원 가입 후 사용할 도메인을 검색하고 등록하기를 누른다.

위와 같이 서브도메인명과, 서버의 IP주소를 입력하고 수정하기를 누르면 도메인 생성은 완료.

백엔드, 프론트엔드가 분리되어 있기 때문에 프론트엔드의 ip주소도 입력해준다.

SSL 발급(CentOS7 기준)

  • back
    • sudo yum install certbot
    • sudo certbot certonly --standalone -d api.football-love.p-e.kr(내 도메인)

잘 발급이 됐읍니다.

  • front
    -sudo vim /etc/nginx/nginx.conf 에서 server_name을 프론트의 도메인과 일치하도록 수정 해준다.

    • sudo yum install certbot
    • sudo yum install certbot-nginx
    • sudo certbot --nginx -d front.football-love.p-e.kr(내 도메인)


발급이 잘 됐읍니다.

다시 nginx.conf를 보면 자동으로 https 설정이 완료 됐다.

nginx.conf 수정

좀 더 세부적인 내용을 수정하도록 한다.

아래와 같이 user명과 프론트엔드 빌드 폴더의 소유주가 같도록 수정 해준다.
403에러를 해결하기 위함이다.

이렇게해도 403 해결이 안된다면 아래 명령어를 실행해본다.

  • chcon -R -t httpd_sys_content_t {build 폴더 절대경로}

다음은 아래와 같이 root부분을 수정해주고 location 부분을 추가해준다.

root는 빌드된 폴더의 절대 경로를 입력해주고, location은 웹 페이지의 하위 경로로 이동 시 404 에러가 뜨는 문제를 해결하기 위함이다.

PEM -> PKCS12

.pem으로 발급된 인증서를 pkcs12형태로 바꿔야한다.

변경을 위해서는 "/etc/letsencrypt/live/{내 도메인}" 폴더로 접근을 해야하는데 접근 권한이 없다. 접근 권한을 변경해준다.

  • sudo chmod 755 /etc/letsencrypt/live/
    이제 "/etc/letsencrypt/live/{내 도메인}"로 이동한 후 다음 명령어를 입력한다.
    원하는 keyStore의 alias 명을 지정해준다.

  • sudo openssl pkcs12 -export -in fullchain.pem -inkey privkey.pem -out keystore.p12 -name {keyStore alias} -CAfile chain.pem -caname root

이후 비밀번호를 설정한다.
나중에 Spring의 Application.yml 파일에 입력할 것이기 때문에 잘 기억해둬야한다.

keystore.p12 파일은 "/etc/letsencrypt/live/{내 도메인}"에 생성되고 이 파일을 스프링 프로젝트의 resources 폴더로 옮겨준다.

application.yml

application.yml 파일을 아래와 같이 수정해준다.(yml파일은 들여쓰기에 주의)

server:
  port: 443
  ssl:
    key-store: classpath:keystore.p12
    key-store-type: PKCS12
    key-store-password: 입력한 비밀번호

테스트

아래와 같이 테스트 페이지를 작성하고 스프링 부트를 시작해 https://내 도메인/hi에 접속해본다.

@RequestMapping("/hi")
    public String hello() {
        return "hi";
    }

아래와 같이 https로 tomcat이 실행되고 브라우저로 접속 시 자물쇠가 이쁘게 뜨면 성공!

프론트도 자물쇠가 이쁘게 떴다.

이제 쿠키가 잘 전달이 되는지 확인하면된다!


로그인


개발자 도구를 열어 확인해보면 쿠키가 잘 들어가있다.

🙊주의할점 : 쿠키는 프론트엔드 페이지가 아닌 백엔드에 들어가 있다. 이것 때문에 계속 쿠키가 저장안되는줄 알고 삽질함..

※ flove가 리액트 프론트엔드, love가 스프링 백엔드 (위에서 설정한 도메인은 복습겸 다시만든 도메인이므로 다릅니다. 새로 만든 도메인에서는 각각 front.football-love.p-e.kr과 api.football-love.p-e.kr에 해당합니다)

이렇게 해서 쿠키가 잘 전달되는 것이 확인 됐다.

다음번에는 리버스 프록시와 도커를 적용해본 것에 대해 글을 써보도록 해야겠다.

0개의 댓글