시연 2시간 남았는데 하얀 화면이 뜬다고?

teamdevelup·2024년 8월 29일
2

데벨업 개발기

목록 보기
1/1

데벨업에서 인증을 하는 방법 : 쿠키

우리 서비스는 쿠키를 이용해 인증 작업을 수행하기로 했다. 따라서, 우리 서비스에 로그인하면 JWT 형식으로 인증 토큰을 생성해 클라이언트에 응답한다. 이를 JSON 포맷으로 표현하면 다음과 같다.

{
	"token":"~~~.~~~.~~~"
}

쿠키의 일반적인 특징.

우리 서비스에서 쿠키를 인증 수단으로 사용한 이유는 간편해서다. 쿠키는 별도로 설정하지 않는 한, 쿠키를 발급한 사이트에 보내는 모든 요청에 자동으로 포함돼서 전송되기 때문에, 인증 정보를 요청에 포함하기 위해 별도의 작업을 하지 않아도 된다.

이는 같은 사이트에서 요청이 발생할 때 한정이다!

이런 기능은 아주 편리하지만, 이는 웹사이트 사용자의 의도와는 다르게 요청이 보내지는 경우 위험한 상황이 발생할 수 있다.

이를 막기 위해 SameSite 라는 설정이 존재한다. 이는 사이트 외부에서 발생한 요청에 대해 쿠키를 포함하지 않거나, 제한적인 상황에서만 허용하도록 하는 설정이다. 그러나 대부분의 개발자는 이를 적용하지 않았고, 해당 스펙이 존재함에도 위험한 상황은 계속해서 발생하였다.

2020년 2월, 크롬에선 SameSite 쿠키 설정이 되어있지 않은 쿠키를 Lax 로 해석하도록 업데이트했다. 이후 다른 메이저 브라우저에서도 차례차례 이런 설정을 적용했고, 4년이 지난 현재는 거의 모든 브라우저에서 이 정책을 사용한다.

문제의 원인 및 해결

데벨업의 구조

현시점에서 데벨업은 백엔드 API를 제공하는 Spring Boot 가 동작하는 AWS EC2 인스턴스와 React를 이용해 작성된 프론트엔드가 동작하는 EC2로 구성되어 있다. 이를 그림으로 표현하면 다음과 같다.

독립된 두 개의 EC2 인스턴스이므로, 당연히 IP 주소가 다르고 다른 사이트로 취급된다. 따라서, “모든 요청에 쿠키가 자동으로 포함되어 전송될 것”이라는 가정이 깨지게 된다. 그래서 쿠키가 전달되지 않아 인증이 수행되지 않았고, 인증이 필요한 여러 기능이 제대로 동작하지 않았다.

해결책 - 이론

구글 검색 센터 블로그에 나와 있는 것처럼 SameSite 속성을 정해주지 않은 쿠키는 모두 lax 옵션으로 설정된 것으로 간주하여 <a> 태그를 이용한 링크 접속 등의 몇몇 특수한 상황을 제외하고는 쿠키가 전달되지 않는다. 이를 해결하는 방법으로는 두 가지가 있다.

  1. SameSite=None;Secure 쿠키
  2. SameSite=Strict;Secure 쿠키 + 백엔드 도메인이 프론트엔드보다 상위 도메인이 되도록 변경

SameSite=None;Secure

말 그대로 SameSite 설정을 None으로 해주고, Secure 옵션을 설정하여 쿠키를 사용하는 것이다. 단, 이 경우 쿠키가 모든 상황에서 포함되어 전송되므로 CSRF 공격에 완전히 열려있게 된다. 따라서 CSRF 공격을 막기 위한 다른 수단을 적용해야 한다. 단, 우리가 사용하는 쿠키의 목적은 사용자의 인증과 인가에서 활용하는 것이므로, 이런 설정이 된 쿠키를 사용해선 안 된다.

Secure 옵션은 쉽게 말해 HTTPS가 적용된 상황에서만 해당 쿠키를 사용할 수 있다는 의미다. 따라서 인증서 발급이 필요하고, 도메인 연결이 필요하다.

SameSite=Strict;Secure 쿠키 + 백엔드 도메인과 프론트엔드 도메인이 SameSite 판정

이 방식은 앞에서 말한 방식보다 조금 복잡하게 쿠키와 관련된 설정과 도메인 관련된 설정이 모두 필요하다.

SameSite 설정을 Strict로 한다는 것은 같은 사이트에서만 쿠키를 사용할 수 있다는 것이다. 같은 사이트란 서브 도메인만 다른 경우를 말한다. 이때, Public suffix 를 기준으로 그것의 바로 앞까지를 비교한다.

따라서, example.comapi.example.com 은 SameSite이지만, a.gihub.iob.github.io 는 SameSite가 아니다.

아래 사이트 중간에 사이트를 입력하면 SameSite인지 여부를 알려준다.

How to win at CORS

해결책 - 실전

쿠키 설정

우선 쿠키에 앞서 서술한 설정을 추가하기 위해서는 Servlet 스펙에서 제공하는 Cookie 클래스 대신 Spring에서 제공하는 ResponseCookie 클래스를 사용해야 한다. Cookie 클래스에는 SameSite 옵션을 설정할 수 있는 메서드가 없기 때문이다.

ResponseCookie responseCookie = ResponseCookie.from(TOKEN_COOKIE_NAME, token)
        .path("/")
        .sameSite("None")
        .httpOnly(true)
        .secure(true)
        .maxAge(COOKIE_MAX_AGE_ONE_DAY)
        .build();

response.addHeader("Set-Cookie", responseCookie.toString());

도메인 발급

도메인을 살 수 있는 중개 서비스에서 도메인을 산 뒤, 서버의 IP 주소와 매칭되도록 DNS 설정을 해주면 된다. 우리는 2시간 남짓 남은 시간 문제 때문에, 우선 팀원 한 명의 개인 도메인에 연결했다. 시연은 해야 하니까…

인증서 발급 및 적용

공인된 인증서를 발급받기 위해서는 인증된 CA에게 보통은 돈을 주고 인증서를 발급받을 수 있다. 그런데 돈도 없거니와 뚝딱하면 인증서가 생겨야 하는 우리는 공짜로 인증서를 발급 해주는 Let’s Encrypt를 이용했다. Let’s Encrypt는 인증서를 발급받고자 하는 사이트를 인증하기 위해 ACME 라는 것을 사용하고, Certbot이라는 프로그램이 이를 지원한다. 그리고 Let’s Encrypt에서도 Certbot 의 이용을 추천한다.

우선 서버에 Certbot을 설치해야 한다.

sudo apt-get install certbot -y

certbot을 이용하는 방법에는 여러 가지가 있는데, 서버에서 실행 중인 웹 서버를 꺼버리고 발급받는 것이 가장 간단하다. 지금 우리는 사용자도 없고, 시간도 없기 때문에 이 방법을 사용했다.

ps -ef |grep java

kill -15 62941

현재 80 포트를 쓰고 있는 java 프로그램을 찾아 중지한다.

certbot certonly --standalone -d {도메인 주소}

인증서를 발급한다. 인증서를 바로 설치하는 것이 아니라, 우선 인증서를 발급받은 뒤 이를 웹서버에 설정하는 것이기 때문에 certonly 옵션을 사용했다. 인증서 발급 과정에서 현재 동작하는 웹서버를 이용하지 않고 자체 웹서버를 사용하기 때문에 standalone 옵션을 사용했다. 이렇게 하면 아래 경로에 인증서 파일들이 생성된다.

/etc/letsencrypt/live/{도메인 주소}/

인증서를 발급받았으면 이제 웹서버에서 HTTPS 사용 설정을 해줘야 한다. 사건 발생 시점에서 할 수 있는 방법은 크게 2가지가 있었다.

  1. Spring Boot 에 HTTPS 설정 추가
  2. Nginx 를 이용해 HTTPS 설정 추가 및 리버스 프록시 적용

첫 번째 방법은 결국 Java 코드를 변경해야 하고, 이를 깃허브에 PR - merge 작업을 통해 반영해야 하며, 실수가 발생할 경우 이를 고치고 다시 반영하는 과정이 복잡했다.

반면, 두 번째 방법은 Nginx 는 정적 웹서버이기 때문에 설정에 문제가 있는 경우 수정하고 다시 반영하는 과정이 아주 빨리 이루어진다(아주 길어야 수 초 이내). 다만, 이전에 리버스 프록시 설정하려다가 알 수 없는 이유로 실패했었기 때문에 다시 도전하기가 꺼려지는 상황이었다.

두 방법 중 어느 것을 택하더라도, 시간 내에 문제를 해결할 수 있을지 없을지 모르는 상황이기 때문에 실패 시 깃허브 기록이 더러워지지는 않는 두 번째 방법을 선택했다.

이를 위해 다음 설정 파일을 /etc/nginx/sites-available 경로에 생성했다. 이 경로는 nginx 기본 설정 파일에 주석으로 적혀있다. 설정 파일 문법은 공식 홈페이지를 참고하자. 블로그 같은데 검색해도 잘 나온다.

# 1)
server {
 listen 80; # 80포트로 받을 때
 server_name {도메인주소}; # 도메인주소
 return 301 https://{도메인주소}$request_uri;

}

# 2)
server {
 listen 443 ssl http2;
 server_name {도메인 주소};

 # ssl 인증서 적용하기
 ssl_certificate /etc/letsencrypt/live/develup.robinjoon.xyz/fullchain.pem;
 ssl_certificate_key /etc/letsencrypt/live/develup.robinjoon.xyz/privkey.pem;
  
 location / { # location 이후 특정 url을 처리하는 방법을 정의(여기서는 / -> 즉, 모든 request)
  proxy_pass http://localhost:8080; # Request에 대해 어디로 리다이렉트하는지 작성. 8443 -> 자신의 springboot app 이사용하는 포트
  proxy_set_header Host $http_host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
 }
}

이후 위에서 생성한 파일의 심볼릭 링크를 /etc/nginx/sites-enabled 경로에 생성하자.

ln -s /etc/nginx/sites-available/develup /etc/nginx/sites-enabled/

이후 nginx 의 설정 파일이 잘 작성되었는지 확인하는 테스트를 하고 문제가 없으면 설정을 재적용한다.

nginx -t

nginx -s reload

대응 후 데벨업의 서버 구조는 다음과 같다.

CORS withCredential 설정

이는 CORS 설정과 관련되어 있는데, CORS 에서 Origin은 SameSite 판정 기준보다 훨씬 엄격하게 작동한다. 포트 번호까지 모두 동일해야 Same Origin으로 판단한다. 따라서, 프론트엔드와 백엔드는 도메인 주소가 다르기 때문에 Cross Origin 판정을 받는다.

Cross Origin 사이에서 쿠키를 전송하려면 withCredentials 옵션을 true로 설정해야 한다.

axios.defaults.withCredentials = true; // withCredentials 전역 설정

물론, 서버에서도 이를 허용하기 위해 설정을 해줘야 한다. 와일드카드는 허용되지 않는다.

public class WebConfig implements WebMvcConfigurer {

    private final AuthArgumentResolver authArgumentResolver;

    public WebConfig(AuthArgumentResolver authArgumentResolver) {
        this.authArgumentResolver = authArgumentResolver;
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("도메인들 명시적으로 적기")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
                .allowCredentials(true);
    }
}
profile
팀 데벨업입니다

0개의 댓글

관련 채용 정보