토큰과 인증, 그리고 개발 경험

서론

현재 지원해야 되는 플랫폼은

이렇게 2가지 플랫폼을 지원해야 한다.

이렇게 여러 곳의 플랫폼을 지원해야 하는 상황에서
어떻게 인증을 깔끔하고 안전하게 처리할 수 있을지 고민해보려고 한다.


Cookie와 Set-Cookie를 이용

장점

장점 (1) 클라에서 할 게 없다

서버는

등 XSS를 방어하게끔 완벽하게 세팅한 토큰들을
Set-Cookie 헤더를 통해 내려준다.

클라에서는 무엇을 해야하나?
아무것도 할 게 없다.
그냥 알아서 브라우저가 쿠키를 저장해주기 때문이다.

다음에 인증으로 보호되는 api를 요청할 때는 무엇을 해야하나?
아무것도 할 게 없다.
그냥 알아서 브라우저가 쿠키를 Cookie 헤더에 심어서 보내주기 때문이다.

해야하는 것은 단지
401이 떨어졌을 때 사용하는 상황에 맞게
적절한 글로벌 에러핸들링 및 리다이렉션을 해주면 된다.

서버의 refresh 엔드포인트로 갱신을 요청하거나
interval을 걸어서 만료를 체크하거나 등
이런 짓들을 안해도 된다.

장점 (2) 서버도 딱히 힘들지 않다

쿠키 빌드 / 파싱해주는 코드만 추가되는 거라서
전혀 힘들지가 않다.

// Cookie 파싱
@Override
protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain filterChain
) throws ServletException, IOException {
    String cookie = request.getHeader("Cookie");
    String[] cookieValues = StringUtils.split(cookie, ";");
    ...
}
 
// Cookie 빌드 (Controller)
OAuthTokenResponse res = new OAuthTokenResponse(accessToken, idToken);
String[] cookies = {
    new CookieBuilder.Builder("idToken", idToken).build(),
    new CookieBuilder.Builder("accessToken", accessToken).build(),
    new CookieBuilder.Builder("refreshToken", refreshToken).build()
};
 
return ResponseEntity
    .status(HttpStatus.OK)
    .header(HeaderName.SET_COOKIE.toString(), cookies)
    .body(res);

이게 끝이다.

브라우저 및 웹 생태계에서 봤을 때는 더할 나위 없이 완벽해보인다.

아직까지는 최고의 방법처럼 보인다.

단점

단점 (1) CSRF 공격 방어를 해줘야한다

이는 웹의 경우 해당되는 얘기이다.

위에서 언급한 모든 장점은
브라우저가 알아서 쿠키를 저장하고 보낸다라는 것에 기인한다.

이 점을 악용하는 것이 CSRF 공격이며
이는 쿠키를 다루게 될 경우 빠질 수 없는 문제이다.

그러나 어떤 기술 스택을 선택했냐에 따라 달라질 수 있겠지만
spring과 spring-security에서는 매우 쉽게 해결할 수 있는 문제이다.

// Filter에서 csrf 필드 가져오기
protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain filterChain
) throws ServletException, IOException {
    CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
    csrfToken.getToken();
    filterChain.doFilter(request, response);
}
 
// csrfConfig
private Customizer<CsrfConfigurer<HttpSecurity>> csrfConfig() {
    return csrf -> {
        csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler());
    };
}

이 2개가 끝이다.

한편, 굳이 프레임워크의 도움 없어도 방어의 논리 자체는 간단하다.

  1. 랜덤하게 생성한 CSRF 토큰을 쿠키에 심어서 내려준다.
    • 단, HttpOnly를 걸지 않는다.
  2. 클라이언트에서는 이 토큰을 쿠키에 저장해 뒀다가 X-XSRF-TOKEN 헤더에 심어서 보내준다.
    • 헤더 네이밍은 상관없다. 이건 하나의 예시일 뿐이다.
  3. 서버에서는 세션에 담아뒀던 토큰과 비교해서 일치하면 통과시켜준다.
    • 일치하지 않을 경우 403을 내려주면 된다.

흐름도 직관적이고 분명하다.

단점까지라고 하기는 뭐하지만
그래도 어느 정도는 귀찮아지기에 일단 단점에 추가했다.

단점 (2) 여러 플랫폼을 지원하게 될 경우 문제가 발생한다

이 글을 쓰게 된 계기이다.

현재 데스크탑을 만들기 위해 사용중인 Tauri
macOS 환경에서 렌더러로 WebView를 사용한다.

webview의 경우 생각보다 많은 것들을
데스크탑 환경에서 쉽게 허용해주지 않는데
그 중 하나가 popup을 맘대로 띄우지 못한다는 것이다.

[bug] Popup Window not open issue in macos

나의 경우 OAuth 로그인으로 google을 사용중인데
이 때문에 구글 oauth 로그인 버튼을 사용하지 못하게 되었다.
왜냐하면 기본 동작이 팝업을 띄우는 것이기 때문이다.

하지만 솔직히 이때까지도 그리 실망하지는 않았다.
서버에서 만들어서 내려준 google oauth uri로
직접 location을 변경하면 되는 문제라고 생각했기 때문이다.

하지만 아래와 같은 문제를 마주하게 됐다.

[MacOS / Linux] WebKit doesn't set Secure cookies on localhost

크롬 형님들의 따스한 품 뒤에 있다가
웹킷이라는 냉혹한 환경을 맞이하니 차마 정신을 차릴 수가 없었다.

크롬에서는 비록 쿠키에 Secure 가 걸려있고 localhost 환경이라고 해도
문제없이 테스팅이 가능하다.

근데 왜 JSESSIONID는 저장이 되는 거지?

뭐가 되었건 간에 웹킷은 이를 허용하지 않는다.

이런 식으로는 개발을 진행할 수가 없다.
이 문제를 모르기 전까지는 아무런 고민이 없었지만
알고나니까 모바일이 두려워졌다.
더 심하면 심했지 널널할 거 같지는 않았다.

물론 electron 으로 스택을 변경할 수도 있었다.
팝업 문제도 해결되고, 쿠키 문제도 해결된다.
하지만 너무 느린 릴리즈 빌드 속도와 모바일까지 고려했을 때
더 확장성 있는 방법과 결론을 내리고 싶었다.


Authorization 헤더 방식

이 방식은 아주 간단하다.

인증에 필요한 토큰들을
persistent storage에 저장해뒀다가
인증이 필요한 endpoint에 요청을 보낼때마다
Authorization 헤더에 심어서 보내주면 된다.

서버는 단순하게 이 헤더를 읽어서 인증을 처리하면 된다.

장점

장점 (1) CSRF 공격 방어가 필요없다

CSRF의 공격은 쿠키에 기인한다고 아까 설명했었다.

"Authorization: Bearer ..." 형식으로 토큰을 헤더에 심어서 보내기 위해서는
쿠키에 HttpOnly를 걸 수 없고
따라서 local이나 session storage를 사용하게 된다.

메모리나 BFF를 사용하는 방식에 대해서는 일단 패스...

장점 (2) 여러 플랫폼을 지원하게 될 경우 문제가 발생하지 않는다

이유는 당연히 이 방식은 그저 key-value 스토리지만 있으면 되기 때문이다.

Is there any built-in safe storage api for securely storing secrets?

Tauri의 경우 위 이슈에서 stronghold 라는 방식을 제시해주고,
React Native의 경우에는 무난하게 AsyncStorage 를 사용하면 된다.

단점

단점 (1) 클라에서 할 일이 많아진다

1. 인증 요청

클라에서는 분명하게 payload에다가 인증에 필요한 토큰을 담아 보내야한다.
더 이상 브라우저가 자동으로 쿠키를 담아서 보내주는 행복은 존재하지 않는다.

2. 인증 응답

응답결과 또한 Set-Cookie 헤더를 읽어서
쿠키가 브라우저에 자동으로 저장되던 행복은 더 이상 존재하지 않는다.
응답결과를 스스로 저장해야한다.

3. 토큰 갱신

더 이상 서버가 implicit 하게 갱신해줄 수 없다.
서버가 지정해놓은 /refresh 엔드포인트로 직접 갱신 요청을 보내야한다.

당연히 refresh를 직접 했으니 그에 따른
모든 에러핸들링, 리다이렉션, 토큰 저장 등의 로직도 직접 구현해야한다.

단점 (2) 서버도 할 일이 생긴다

HTTP 프로토콜은 기본적으로 stateless하다.
이걸 보완하기 위해 쿠키를 사용했던 것이고 이제는 못하는 상황이다.

갱신이 아닌 api 요청이 들어왔을 때 만약 토큰이 만료됐다면
서버는 어떻게 해야할까?

  1. 만료됐다고 클라한테 401을 내려줌
  2. 401을 받은 클라는 refresh를 요청함
  3. 서버는 refresh를 받아서 새로운 토큰을 내려줌
  4. 클라는 새로운 토큰을 받아서 이전에 거부되었던 요청을 다시 보냄

이 흐름을 구성해야하며
이를 위해서 /refresh 엔드포인트를 만들어야한다.

상당한 roundtrip이 발생하는 것을 볼 수 있다.

단점 (3) XSS 공격에 취약해진다

이는 웹의 경우 해당되는 얘기이다.

필연적으로 XSS 공격에 노출되게 된다.

local/session storage는 그냥
window.localStorage 또는 window.sessionStorage 를 통해
바로 접근이 되는 녀석들이다.

네이버와 같은 유명한 사이트의 Application 탭을 살펴보자.
그 어떤 곳도 웹 브라우저 환경에서 인증토큰 저장 장소로
local/session storage를 사용하지 않는다.
(네이버의 경우 httpOnly가 걸린 NID_AUT, NID_SES 쿠키를 사용한다.)

마무리

나의 결론은 이렇다.

  1. 웹이 아닌 경우 Authorization 헤더 방식을 사용한다.
  2. 웹의 경우 XSS + CSRF를 모두 방어한 쿠키 방식을 사용한다.

하지만 이런 의문들이 남아있다.

지금의 나는 여기서 마무리하려고 한다.
훗날 이 글을 다시 보게됐을 때 너무 창피하지 않을지..
박제용으로 남겨둔다 😇


Reference