인증/인가 과정에서 XSS, CSRF 보안 개선 사례
프로젝트 개선 과정에서 'accessToken이 로컬스토리지에 저장되는 것이 옳은가?' 생각해보게 되었다. accessToken은 JWT로 만들어졌는데, 페이로드에는 권한 인증을 위한 개인정보가 담긴다. 따라서 accessToken을 로컬스토리지에 저장하는 것은 접근, 탈취의 위험이 있어 좋은 방법같지 않았다.
그래서 accessToken이 로컬스토리지에 저장되었을 때, 그리고 탈취됐을 때 발생할 수 있는 문제에는 무엇이 있는지 알아보았고 XSS 공격에 취약하다는 점을 알게되었다.
해당 공격에 대한 자세한 내용은 별도로 작성한 글에서 확인할 수 있다.
XSS 공격을 대비하기 위해, accessToken을 브라우저에서 접근할 수 없도록 로컬스토리지에 저장하지 않고 frontend 브라우저 자바스크립트 코드 메모리에서 저장하여 사용하는 방법을 적용했다. 이 경우, XSS 공격은 물론 CSRF 공격을 무력화할 수 있다.
그러나화면이 다시 랜더링(새로고침, 탭 전환)되면 accessToken이 사라지는 것이 문제가 된다. 따라서, refreshToken을 발행하여 refreshToken으로 accessToken을 재발급 받을 수 있는 API를 개발했다.
refreshToken은 브라우저에 저장되어 있어야 하는데, 로컬 스토리지에 저장하는 것은 accessToken처럼 XSS 공격에 취약하다는 문제를 갖게 된다. 따라서, refreshToken은 cookie에 담아 보내주기로 했다. cookie도 XSS 공격에 취약점이 있지만, httpOnly 옵션을 주어서 브라우저에서 쿠키에 접근할 수 없도록 했고(XSS 방어), 클라이언트와 서버의 도메인이 달라 same-site 옵션을 none으로 설정했다. 따라서, secure 옵션을 주었다. 이는 https 통신일 때만 쿠키를 사용할 수 있게 통신 과정에서의 탈취 위험을 보완할 수 있기도 하다.
결론적으로, accessToken은 브라우저에서는 private 변수로 저장하여 사용하고 refreshToken은 httpOnly cookie로 발행해 XSS 공격을 방어한다. CSRF 공격은 referrer 검증 또는 CSRF 토큰을 발행해 대부분의 공격을 방어할 수 있다고 한다. 다만, referrer check의 경우, 변조 프로그램으로 referrer를 쉽게 조작할 수 있다고 하니 CSRF 토큰 발행을 하는 것이 나아보인다.
이러한 사항들을 반영하여 로컬 개발을 완료했다. 하지만, 코드를 AWS 인프라로 배포하는 과정에서 생각지 못한 문제들을 겪게 되었고 코드를 수정하는 것 보다, 인프라 이슈들을 해결하는데 더욱 많은 시간을 쓰게되었다. 어떠한 문제들을 겪었고 어떻게 해결했는지 이 글과 함께 연결하여 반드시 정리해 두어야겠다는 생각이 들었기 때문에, 정리가 되는대로 이 글에서도 참조할 수 있도록 링크를 추가할 예정이다.
참고 :
새롭게 알게 된 것
✅ 다양한 웹 취약점에 대해 알고 공격에 대한 보안 조치를 취할 수 있게 되었다. 그리고 https가 만능이 아니라는 생각이 들었다.
✅ CORS 정책의 기본을 다질 수 있었고, 이를 고려하여 올바르게 API를 개발할 수 있게 되었다.
기존의 Login 및 권한부여 방식
📌 1. 로그인 시도 : github code를 body에 담아 post 요청
📌 2. JWT 생성 : github code로 github access token을 받아와서 github access token으로 github에 있는 user 정보에 접근(userName, github id 등)하여 우리 DB에 있는 유저인지 확인 후 등록되어 있는 유저이면 JWT(accessToken) 생성
📌 3. AccessToken return : accessToken을 Response body에 담아 return
📌 4. 브라우저 저장 : accessToken을 브라우저의 LocalStorage에 담아 저장
📌 5. Authorization request(권한 부여 요청) : 인가(Authorization)가 필요한 리소스에 접근할 때 마다 request header Authorization에 accessToken을 담아서 요청.
📌 6. 유효성 체크 : accessToken을 AuthGuard를 활용하여 토큰의 만료기한이 지났는지, 우리가 만들어준 토큰인지, 토큰에 담긴 유저 식별 정보가 올바른지 검증한 후 리소스에 접근할 권한을 부여(Authorization).
📌 7. 로그아웃 : 로그아웃 시 브라우저에 저장된 accessToken을 삭제한다.
기존 로직의 문제
📌 1. JWT는 개인정보 : 유저 식별 정보가 담겨있기 때문에 개인정보나 다름 없다.
📌 2. 토큰 만기 : 토큰의 만기는 30분이나, 짧은 유효기간으로 인해 잦은 로그인이 불편할 것으로 예상되어 토큰의 만기를 검증하지 않도록 설정되어 있음.
📌 3. token을 localStorage에 token 저장 : accessToken의 탈취가 쉬움
→ 세 가지 문제가 복합적으로 작용하여 보안 문제를 겪을 수 있음.
개선 방향
✅ accessToken은 인가(Authorization)용, refreshToken은 accessToken 재발급 용으로 사용됩니다.
✅ refreshToken을 secure httpOnly 쿠키로, accessToken은 JSON payload로 받아와서 웹 어플리케이션 내 로컬 변수로 이용.
✅ 이를 통해 CSRF 취약점 공격 방어하고, XSS 취약점 공격으로 저장된 유저 정보 읽기는 막을 수 있음
✅ 하지만 XSS 취약점을 통해 API 콜을 보낼 때는 무방비하니 XSS 자체를 막기 위해 서버와 클라이언트 모두 노력해야 함
변경된 Login 및 권한부여 방식
사진 출처 : 원티드 프리온보딩 3월 프론트엔드 강의자료
📌 1. 로그인 시도 : 기존과 동일
📌 2. JWT 생성 : 기존과 동일하게 accessToken 생성하고 추가로 refreshToken을 생성하고 암호화하여 DB에 저장.
정상적인 사용자는 기존의 accessToken으로 접근하며 accessToken이 만료된 경우 서버에서는 데이터베이스에 저장된 refreshToken과 검증 후 새로운 accessToken을 발급한다.
📌 3. refreshToken, accessToken 반환 : refreshToken은 응답 쿠키에 httpOnly secure signed 옵션을 적용하여 담고, accessToken은 body에 담아 return.
이때의 이슈는 same origin이 아닌 경우 secure cookie를 브라우저에 저장할 수 없다. 이럴 때는 reverse proxy 서버를 두고 cors를 우회하거나 아래의 cors 설정을 통해 서로 다른 도메인간 쿠키를 주고받을 수 있다.
✅ cross-site 간 리소스 공유 시, 브라우저는 credentials 모드가 include인 경우 모든 요청에 인증 정보를 담게 된다.
서버는 응답 시, 모든 요청을 허용한다는 의미의 Access-Control-Allow-Origin 헤더에 *(와일드카드)를 사용하면 안되고, 반드시 명시적인 URL을 지정해야 한다.
또한 응답 헤더에는 반드시 Access-Control-Allow-Credentials : true가 존재해야 한다,
✅ CORS 응답에 설정된 쿠키에는 일반적인 third-party cookie 정책이 적용된다.
따라서, cross-site간 cookie를 주고 받기 위해서는 sameSite 속성을 'none'으로 변경하고 쿠키를 secure 쿠키로 만들어 주어야 한다.
✅ CORS에 대한 자세한 내용은 이 글에서 확인할 수 있다.
📌 4. accessToken이 만료된 경우 또는 페이지 새로고침으로 메모리에서 사라진 경우 : refreshToken으로 accessToken을 재발급 받는다. 이 때, refreshToken의 만기까지 절반 이상 지난 경우 refreshToken을 재발급한다.
📌 5. 로그아웃 : 로그아웃 시 api 요청을 통해 db 서버에 저장된 refreshToken을 삭제하고 브라우저의 캐쉬를 삭제하는 응답을 보낸다.
✅ 결론 : CORS를 사용하려면 클라이언트와 서버가 Access-Control-** 류의 Header를 주고 받도록 설정이 되어있어야 한다.
예시 코드
// 아래 코드는 예시 코드로, 실제 프로젝트의 코드와는 다릅니다. // main.ts async function bootstrap() { const app: NestExpressApplication = await NestFactory.create(AppModule); // other something... app.enableCors({ origin: `https://${process.env.CORS_ORIGIN}`, methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], credentials: true, }) // other something... await app.listen(PORT); } bootstrap() // auth.controller.ts const login = (req, res) => { // do something... const refreshToken = this.authService.getCookieWithRefreshToken(); await this.userService.saveRefreshToken(refreshToken, userId); res .cookie('x_auth', refreshToken, { domain: `https://${process.env.CORS_ORIGIN}`, path: '/', httpOnly: true, maxAge: Number(jwtConstants.jwtRefreshExpiresIn) * 1000, sameSite: 'none' as const, secure: true, signed: true, }) .json({ success: true }); };