[Project] 나만의 블로그 만들기 (5) - RefreshToken + Redis 통한 자동 로그인 기능 구현

    사실 이번 프로젝트를 진행하면서, 프론트엔드도 같이 작성을 해야 하기 때문에 많은 시간이 백엔드 api 개발보다는 프론트엔드 코드 작성하는데에 더 많은 시간을 쏟게 되어 아쉬움이 많았다.

    하지만, 이번에 구현해볼 기능은 jwt 토큰을 이용해서 자동 로그인 기능을 해보려 했고, 프론트엔드 측면보다는 백엔드적인 측면에서 많은 고민을 할 수 있어서 아주 반갑고 재밌었다.

     

    먼저, 이전에 도입했던 accessToken만 사용했을 때의 단점을 몇 가지 이야기해볼 수 있다.

    일단 기본 로직부터 다시 생각해본다면

    Oauth 로그인 -> 인증/인가를 위한 jwt 토큰 (accessToken) 발급 -> 백엔드에서 response 헤더로 AccessToken을 발급하여 전달 -> 전달받은 AccessToken을 프론트에서 관리해서 추후 인증/인가가 필요한 api에 AccessToken을 같이 전달

     

    그렇다면, AccessToken의 유효기간은 어느정도로 하는 것이 적당할까?

    만약 AccessToken의 유효기간이 짧다면? -> 사용자가 로그인을 자주 해야하는 번거로움이 발생할 수 있다.

    만약 AccessToken의 유효기간이 길다면? -> 탈취당했을 경우, 해킹범이 자유롭게 긴 시간동안 이용할 수 있게 된다.

    이러한 점을 고려해서 중간지점을 찾는 것이 중요한데, 만약 사용자가 로그인을 자주 하지 않더라도 AccessToken의 유효기간을 짧게 가져갈 수 있는 방법이 존재한다면 좋을 것 같다는 생각을 해볼 수 있다.

     

    이러한 관점에서 RefreshToken을 도입한다면 어느 정도의 보안적인 측면과 사용자 경험을 모두 가져갈 수 있을 것이라 생각했다.

     

    RefreshToken을 도입하면 뭐가 달라질까?


    기본적인 AccessToken(AT)과 RefreshToken(RT)을 둘 다 적용했을 때의 작동방식은 다음과 같다.

    • 프론트엔드에서 AT와 RT을 모두 백엔드에 보낸다.
    • 백엔드에서는 먼저 AT에 대한 유효성 검사를 진행한다. 만약 AT가 유효하다면 RT를 검증하지 않고 그대로 진행한다.
    • AT가 만료되었을 경우 RT에 대한 유효성 검사를 진행한다.
    • 만약 RT가 만료되지 않았다면, 새로운 AT을 발급하고 과정을 진행한 후 Response로 AT를 내려준다.
    • 만약 RT로 만료되었을 경우, 사용자에게 로그인 요청을 한다.

    여기까지만 보았을 때는, AT 단독으로 사용하는 것과 무슨 차이가 있을 지 알 수가 없다.

    여기서 몇 가지 조건을 추가해보자

    1. AT의 유효시간은 짧게, RT의 유효시간은 길게 가져간다
    2. 사용자가 로그아웃을 한다면 현재 AT와 RT을 무효화한다.

    첫 번째 조건을 적용했을 경우, 예를 들어 AT를 1시간, RT를 1일의 유효기간으로 설정해보자. 만약 AT가 탈취되더라도, 이 AT는 1시간이 지나면 무효화되고, RT를 가지고 있지 않은 이상 더 이상 접근이 불가능해지게 된다.

    두 번째 조건까지 적용하게 된다면, 사용자가 AT와 RT를 탈취당하더라도 사용자가 로그아웃을 하게 된다면 토큰 자체가 무효화되게 된다.

    즉 보안적인 측면을 일부 강화할 수 있게 되는 것이다.

    그런데, AT와 RT을 무효화하고 이를 검증하기 위해서는 결국 서버에서 검증 과정을 거쳐야 한다. 그렇다면 결국 서버가 둘 다 관리하게 된다는 것인데, 이는 세션과 다른점이 없지 않은가..? 하는 의문이 들게 되었다.

    세션을 사용하지 않고 AT을 사용한 이유는, stateless한 속성을 사용하기 위함인데 둘 다 관리를 하는 것은 비효율적이라고 생각했다.

    따라서, RT만 무효화하여 금지토큰으로 따로 관리를 하게 된다면, 유효한 AT을 사용할 때는 여전히 서버에서 무상태성을 유지할 수 있기 때문에 더 옳은 방식일 것이라고 생각했다.

    물론 RT를 탈취당하고 로그아웃까지 하지 않게 된다면 RT 유효기간동안 계속해서 보안적인 위험이 있을 수 있지만, 이는 개발자 단독으로 모든 상황을 대비할 수는 없다고 생각하고 사용자의 측면에서도 주의를 같이 해주어야 한다고 생각한다.

     

    그렇다면 AT와 RT는 프론트에 어떻게 전달하는 것이 좋을까?


    백엔드에서 프론트엔드로 토큰을 전달할 수 있는 방법은 다음과 같다.

    • HTTP Header 이용
    • Cookie 이용

    HTTP Header을 이용할 경우 클라이언트 측에서 쉽게 접근하고 사용할 수 있지만 XSS 공격에 취약하다고 한다.

     

    XSS 공격이란, 임의로 집어넣은 스크립트를 실행해서 보안 정보를 탈취하는 것이다

    Cookie의 경우에는 HttpOnly 플래그를 사용하여 JavaScript에서의 접근을 방지할 수 있고, Secure 플래그로 HTTPS 전송을 강제할 수 있고, SameSite 속성으로 CSRF 공격 또한 방지할 수 있다고 한다. 

     

    CSRF 공격이란, 인증된 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 만드는 공격이다.

     

    XSS공격과 CSRF공격 등에 대해서는 추후 포스팅을 통해 자세히 알아보려 한다.

     

    사실, 보안적인 측면만 따져본다면 AT와 RT 둘 다 Cookie를 사용하는 것이 좋아보인다.

    하지만, HTTP 헤더를 이용하여 AT을 클라이언트 측에서 자유롭게 사용할 수 있고 쿠키의 경우에는 withCredential 옵션을 넣으면 자동으로 모든 쿠키를 전송하게 되는데, 다른 쿠키의 사용목적 때문에 쿠키를 보내게 될 때 굳이 AT와 RT를 같이 보내지 않아도 된다고 생각하여 두 토큰의 전송 방식을 분리하게 되었다.

    만료시간이 짧은 AT의 경우에는 HTTP Header을 이용해서 클라이언트 측에서 관리하고, 만료시간이 긴 RT의 경우 HttpOnly, Secure 플래그를 적용한 Cookie을 이용하였다.

    (추후, 조회수 방지 중복 기능을 위해 viewerId라는 것을 쿠키로 사용하게 되는데, 인증이 필요없는 과정에서 AT와 RT가 둘 다 전송되지 않게 하는 목적도 이룰 수 있게 되었다.)

     

    로그아웃 시에는 어떻게 동작하는 것이 좋을까?


    AT와 RT를 적용하는 이유와, 어떻게 클라이언트측과 전달할 지에 대한 과정을 마쳤다.

    그렇다면, 로그아웃 시에 토큰을 만료시키는 것은 어떤 방식이 좋을까?

     

    먼저, 내가 진행하고 있는 블로그 프로젝트의 경우 실질적으로 사용자를 받을 수 있을 지 없을지, 또한 사용자가 로그인하더라도 댓글을 작성하는 기능 이외에는 현재 사용할 수 있는 기능이 존재하지 않는다. 따라서 간단하게 DB에 저장하더라도 큰 문제가 없을 것이지만, 나는 사용자를 받게 되는 서비스를 운영하게 된다면 어떻게 하는 것이 좋을지에 대해 학습하고 있다는 점을 먼저 말하고 싶다.

     

    위에서 로그아웃을 하더라도 AT는 만료하지 않고 RT는 만료를 시킬 것이라 했다. 이를 위해서는 로그아웃 당시의 RT를 따로 보관하여 검증하는 과정이 필요할 것이다. 여기서 Mysql 등을 이용해서 쿼리 조회를 하는 대신, 메모리 DB인 Redis를 도입하여 빠르게 조회를 하는 것을 목표로 했다. 추가로 로그아웃을 완료한다면 현재 존재하는 쿠키를 만료하는 작업도 같이 진행을 해주어야 한다.

     

    Spring Data 프로젝트에는 Redis를 간편하게 사용할 수 있도록 지원을 하고 있으며, 우리가 일반적으로 사용하는 CRUD Repository처럼 간편하게 사용할 수 있도록 해주고 있다.

    다음과 같이 유니크한 값을 가질 id, 토큰의 값, ttl(유지 시간)을 갖는 엔티티를 생성해주고 

    이러한 식으로 CrudRepository을 상속받게 하면 Spring Data JPA을 사용하는 것처럼 간단하게 사용할 수 있다.

     

    나의 경우에는 RT의 만료기간을 7일로 설정하였는데, 로그아웃을 하고 새로운 RT을 발급받더라도 이전과 같은 토큰은 나오지 않을 것이라고 생각했고 무작정 계속 데이터를 쌓아가기만 한다면 추후 메모리에 부담이 될 수 있다고 생각해 로그아웃을 한 시점부터 7일동안 보관하기로 했다.

     

    또한 token 필드 위에 @Indexed라는 부분은, Repository에서 조회를 할 때에, id뿐만 아니라 @Indexed가 붙은 컬럼으로도 조회를 할 수 있도록 해주는 것이다. 대신, @Indexed을 사용하였을 때, 실제로 forbiddenToken을 저장하게 된다면 한 개의 키가 생기는 것이 아니라 다음과 같이 여러 개의 키가 생기게 된다.

     

    -8672332348559043057 이 이 entity의 id가 되는데, 이 id로 만들어진 키 뿐 아니라 token값으로도 하나의 키가 생성되어 있게 된다. 이를 ttl 명령어를 이용해 찍어본다면

    id를 가진 키는 정상적으로 ttl이 적용되지만, 토큰을 가진 키는 만료되어 제거되지 않는 것이란 걸 확인할 수 있다.

    결국, 메모리의 부담을 주지 않기 위해 ttl을 설정했지만, 키가 누적될수록 메모리에 부담을 줄 수 있게 되는 것이다. 이 문제를 해결하기 위해서 RedisConfig 에서 설정을 추가해 주면 된다.

    기존의 

    @EnableRedisRepositories

     

    에서 옵션을 추가할 수 있는데

    @EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)

    이 옵션을 추가하면, 키의 생명주기를 감지하여 Redis 키가 삭제되거나 만료될 때, 이와 연관된 인덱스도 함께 제거한다고 한다.

     

    한 번 테스트를 해보자,

    • 옵션을 활성화하지 않고 ttl을 10초로 주었을 때

    확인해보면 맨 위의 1) "forbiddenToken:-7407713446285816741" 의 키는 제거되었지만 나머지는 그대로인 것을 확인할 수 있다.

    • 옵션을 활성화하고 ttl을 10초로 주었을 때

    3) 과 같이 phantom이라는 키가 하나 생기고, ttl이 모두 지나고 전부 삭제되는 모습을 확인할 수 있다.

     

    사실, 검색을 해서 알게 된 내용이고 정확히 어떤 원리로 작동하는 지에 대해서는 Redis와 Spring Data Redis을 더 자세히 알아보아야 할 필요성이 있다고 판단했고, 이번 포스팅에서는 이 정도로 넘어가려 한다.

    JwtFilter 변경


    AT만을 이용해서 유저를 판별하는 코드이다. 그럼 이제 RT를 한 번 적용해보자.

    다음과 같이 검증하는 부분이 더 복잡해지고 길어지게 되었다. 차근차근 하나씩 설명해보겠다.

    먼저 try-catch 구문을 사용한 이유는 기본적으로 jjwt 라이브러리를 사용하며 에러가 발생하면 라이브러리 자체에서 에러 문구를 뱉어주게 된다. 하지만, 이는 내가 원한 에러 핸들링이 아니기 때문에 이를 catch 해서 내가 원하는 에러 메시지를 만들기 위해 2개의 try-catch 구문을 사용하게 됐다.

    또한 resolveAccessToken() 에서, 문제가 생겼다는 것은 AT 자체에 문제가 생겨서 아래의 catch문으로 이동하게 되었다는 뜻이므로 유효한 AT가 아니란 것이다.

    첫 단계의 try-catch 구문의 catch에서는 AT에 오류가 있다는 것을 파악하고 refreshToken을 검증하게 된다.

    만약 refreshToken에도 문제가 있다면 e2 에러로 이동해 둘 다 재발급 받을 것을 알려준다.

    AT가 유효하지 않지만 RT가 유효한 경우에는, RT에서 유저의 ID를 가져와 새로운 AT을 만들고 이를 response.setHeader을 이용해서 헤더 등록을 한다.

     

    중간에 코드를 보면 forbiddenTokenService.isExist을 확인할 수 있다. 만약 사용자가 로그아웃을 진행하게 된다면 현재 유효한 RT를 Redis에 저장하고 금지된 토큰인지 파악하는 부분이다.

     

    마지막으로 정상적으로 잘 작동하는지 테스트를 해보자

    AT의 만료시간을 1분으로 두고 테스트를 진행해 보겠다. 테스트는 코드가 아닌 스웨거를 이용해서 실제 응답을 확인해보았다.

    • 로그인 진행 (AT 1분 만료시간 발급)

    • AT가 만료되기 이전(기본적인 응답만 날아옴)

    • AT가 만료된 이후(테스트 로그인으로는 RT을 발급받지 못해 프론트에서 로그인 한 정보를 바탕으로 진행)

    다음과 같이 응답과 함께 새로운 AT가 발급되는 모습을 확인할 수 있다.

    이 AT을 클라이언트 측에서 다시 저장하고 사용하면 될 것이라 생각한다.

     

    • 둘 다 만료가 되었다면?

    정상적으로 로그인 요청을 보내게 된다.

    댓글