PPAK

[Spring/JWT] Access Token 과 Refresh Token 을 어디에 저장하고 어떻게 교환해야 할까? 본문

spring

[Spring/JWT] Access Token 과 Refresh Token 을 어디에 저장하고 어떻게 교환해야 할까?

PPakSang 2022. 8. 27. 08:54

제가 서버에 채택한 인증 방식은 JWT 를 활용한 Access Token 교환 방식입니다.

서버 측에서는 사용자가 정상적으로 로그인을 마치면 사용자 인증 정보를 포함하는 Access Token 과 이를 재발급할 수 있는 Refresh Token 을 생성하여 발급합니다.

 

이후에 클라이언트는 발급된 Access Token 을 서버에 제출하기만 하면 정상적인 사용자의 요청으로 간주될 수 있습니다.

 

해당 인증 방식을 구현하는 과정에서 여러가지 보안적인 측면을 고려하지 않을 수 없는데 가장 대표적으로 XSS, CSRF 공격에 대한 방어가 핵심이 될 수 있습니다.

 

본 포스팅 에서는 어떤 방식으로 토큰을 발행하고, 어디에 저장을 하는 것이 옳은가에 대한 고민과 결과를 적어보겠습니다.

 

이전 글에서는 CORS 허용 정책을 세우고 서버에 적용하는 과정을 거쳤습니다.

CORS preflight 체크를 통해서 클라이언트의 요청을 전달할지 말지를 결정할 수 있기 때문에 CORS 정책은 클라이언트 측에서 행할 수 있는 가장 최소한의 보안 정책이라 볼 수 있습니다.

최소한의 보안 정책이라는 말은 여전히 XSS, CSRF 등과 같은 요청을 강제로 발생시키는 공격에 취약하다는 것을 의미합니다.

 

가장 간단한 시나리오를 보겠습니다

 

CSRF

브라우저에 bcd.com 인증 정보(access_token 혹은 session_id) 를 저장하는 쿠키가 존재합니다.

여기서 abc.com 이라는 악성 사이트에서 img 태그 혹은 링크를 통해 bcd.com/delete-userInfo 으로 요청을 강제로 발생시킨다면 브라우저에 포함되어 있는 인증 쿠키와 함께 타겟 서버로 요청이 전송될 것이고 서버는 인증 정보 확인 후 정상적인 요청이라 판단 해 사용자의 정보를 모두 삭제해버립니다.

 

XSS

bcd.com 의 게시판에 브라우저의 쿠키를 참조하여 요청을 강제하는 script 태그가 생성되었습니다. 웹페이지 랜더링 시 강제로 작동하는 해당 script 는 클라이언트 모르게 서버로 요청이 전송됩니다.

 

위 두 가지 경우는 굉장히 단순한 시나리오이며 대부분의 사이트에서는 방어가 되어 있습니다.

 

XSS 의 경우에는 클라이언트 차원에서 스크립트 태그를 입력하지 못하게 방어가 이루어집니다.

CSRF 는 referrer 체크, csrf 토큰 검증, Double submit 쿠키 검증 등등 다양한 방식으로 방어할 수 있고 referrer 체크만으로도 대부분의 CSRF 공격에 대응할 수 있다고 합니다.

 

하지만 서버는 클라이언트의 개인 정보를 소유하고 있기 때문에 굉장히 보수적으로 XSS 와 CSRF 공격에 노출되었다는 가정하에 위조된 요청을 어떻게 방어할지에 대한 고민이 필요한 것 또한 사실입니다.

 

토큰 교환 전략

위조된 요청을 방지하기 위한 요구사항은 아래와 같이 정리할 수 있었습니다.

1. 인증정보는 쿠키로 전달하는 것이 아닌 요청 헤더(Authorizaiton)를 통해 전송한다.

2. 쿠키를 js 로 접근하지 못하게 막는다.

3. 인증정보를 local storage 에 저장하지 않는다.

 

2의 요구사항은 서버 측에서 쿠키를 js 로 참조하지 못하게 httpOnly 쿠키를 발행하는 방식으로 충족할 수 있습니다.

다만 2와 함께 1과 3의 요구사항을 충족시키기 위해서 다음과 같은 의문이 들었습니다.

 

1. Access Token, Refresh Token 을 어디에 저장해야할까?

2. Access Token 을 쿠키가 아니라면 어떻게 발급해야할까?

 

저는 해당 글 을 참조하여 어느정도 해답을 얻을 수 있었습니다.

 

1. Refresh Token 은 쿠키로 발급하고 매 브라우저 랜더링 시 Refresh Token 을 통해 서버로부터 Access Token 을 발급받아 local variable 로 저장한 후 요청을 보내는 방식을 사용합니다.

-> Refresh Token 은 인증에 직접적으로 사용되는 토큰이 아니니 쿠키로 저장하여 사용합니다. 브라우저 랜더링 시에 매번 Access Token 을 발급받는 것이 다소 낭비처럼 보일 수 있으나 보안을 위해 감수해야하는 자원 요청이며 서버 측에서 캐싱을 한다면 거의 비용이 들지 않는 네트워크 요청으로 볼 수 있습니다.

 

2. Access Token 은 HTTP Response 로 전달합니다.

-> HTML Form 에서 발생한 요청은 response 를 읽을 수 없고, fetch 나 ajax 를 통해 요청이 전송되더라도 CORS 정책을 통해 response 가 브라우저로 전달되지 못합니다.

 

결론적으로 제가 계속 방도를 찾지 못했던 이유는 인증 정보(Access Token) 을 브라우저 어딘가에 저장하려고 했기 때문입니다. 물론 인증정보를 반영구적으로 쿠키 혹은 local storage 에 저장한다면 단 한번의 요청으로 Access Token 을 이용할 수 있지만 해당 방식은 보안의 측면에서 취약하다고 볼 수 있습니다. 이 보다는 비용이 크지 않다면 인증 정보가 필요할 때 마다 서버로 부터 인증 정보를 요청하고 탈취될 수 없는 단기 휘발성 변수(local variable) 에 저장하여 이용하는 것이 옳은 것 같습니다.

 

public ApiSuccessResult<TokenDTO> reIssueAccountToken(HttpServletRequest req, HttpServletResponse res) {
//      쿠키로 전달된 refresh 토큰 확인
        String refreshToken = cookieUtil.getCookie(req, "account_refresh_token").getValue();
//      refresh token 이 유효한 상태면 access token 발급
        String accessToken = jwtProvider.reIssueAccountToken(refreshToken);

        String reIssuedRefreshToken = jwtProvider.reIssueAccountRefreshToken(refreshToken);
        if (reIssuedRefreshToken != null) {
            ResponseCookie refreshTokenCookie
                    = cookieUtil.createRefreshTokenCookie(JwtProvider.ACCOUNT_TOKEN_NAME, reIssuedRefreshToken);
            res.addHeader("Set-Cookie", refreshTokenCookie.toString());
        }
        return ApiUtil.success(new TokenDTO(accessToken, reIssuedRefreshToken));
    }

저는 위와 같은 절차로 Access/Refresh Token 재발급 로직을 구성했습니다.

1. 쿠키로 전달된 Refresh Token 을 확인합니다. -> 존재하지 않는다면 예외를 던집니다.

2. Refresh 토큰이 유효한지 체크하고 Access Token 을 Response 로 재발급합니다.

3. Refresh 토큰의 발급 시간이 유효 기간의 1/2 가 지났다면 Refresh Token 을 Cookie 로 재발급합니다.

 

Refresh Token 의 실질적인 유효 기간은 2*REFRESH_TOKEN_VALIDATION_TIME 이며

REFRESH_TOKEN_VALIDATION_TIME 이 지난 후에 재발급이 가능하도록 구현하였습니다.

private JWTVerifier getAccountRefreshTokenReissuableValidator() {
        return JWT.require(getSigningKey(SECRET_KEY))
                .withClaimPresence("email")
                .withClaimPresence("account_id")
                .acceptExpiresAt(REFRESH_TOKEN_VALIDATION_TIME)
                .withIssuer(ISSUER)
                .build();
    }

재발급 가능 여부를 확인할 validator 코드 입니다. 전체 유효 기간의 1/2 을 만료시점으로 두어

토큰 발급 후 REFRESH_TOKEN_VALIDATION_TIME ~ 2*REFRESH_TOKEN_VALIDATION_TIME 시간이 지난 토큰이 전달되면 정상적으로 validation 이 완료됩니다.

public String reIssueAccountRefreshToken(String refreshToken) {
        try {
            validateToken(refreshToken, getAccountRefreshTokenValidator());
        } catch (InvalidClaimException e) {
            throw new IllegalStateException("토큰이 올바르지 않습니다.");
        } catch (TokenExpiredException e) {
            try {
                DecodedJWT decodedJWT = validateToken(refreshToken, getAccountRefreshTokenReissuableValidator());
                String email = decodedJWT.getClaim("email").asString();
                String accountId = decodedJWT.getClaim("account_id").asString();
                return generateAccountRefreshToken(email, accountId);
            } catch (TokenExpiredException e2) {
                throw new IllegalStateException("토큰이 만료되었습니다.");
            }
        }
        return null;
    }

위와 같이 REFRESH_TOKEN_VALIDATION_TIME 의 만료시간을 갖는 RefreshTokenValidator 로 1차적으로 검증하고, 만료되었다면 RefreshTokenReissuableValidator 로 재발급 가능 여부를 체크합니다.

 

위 코드는 위에서 명시한 재발급 로직을 수행하기 위해서 작성한 것이며 더 나은 로직이 있다면 공유해주시면 감사하겠습니다.

Comments