본문 바로가기

웹 프로그래밍/스프링

스프링 시큐리티에서 Redis로 jwt 액세스 토큰, 리프레쉬 토큰 구현

액세스 토큰과 리프레쉬 토큰이란?

액세스(Access) 토큰과 리프레쉬(Refresh) 토큰 모두 jwt 토큰이며 사용자 UX와 보안적인 부분을 위해서 고안된 방법이다.

귀찮게 왜 한가지만쓰면 되지 토큰을 분리 했을까? 만약 토큰을 분리하지 않았다면 두가지 경우를 직면하게 된다.

첫째로 토큰의 유효기간이 너무 길어서 토큰이 탈취당했을때 위험성이 높아진다. 탈취된 토큰으로 단지 헤더에 Bearer 토큰을 추가함으로써 오랫동안 사용자 행세를 할수 있기 때문이다.

두번째로 토큰의 유효기간을 짧게 한다면 탈취당해도 재사용 가능성이 줄어들겠지만 사용자가 자주 로그인을 해야함으로 불편함을 겪게된다.

그래서 자주 사용하는 액세스 토큰은 유효기간을 짧게 하고 상대적으로 보안이 약한 localStorage에 저장하고 그 액세스 토큰을 갱신하는 용도인 리프레쉬 토큰은 유효기간을 길게 하고 더 보안이 강한 쿠키에 저장함으로써 위 문제를 해결하는것이다.(쿠키에서 플래그(Secure, HttpOnly, SameSite) 설정을 하면 보안이 더 강화됨)

 

추가로 이번 프로젝트에서는 레디스를 이용해서 쿠키에 직접 jwt 토큰을 담는것이 아니라 UUID를 담아서 레디스에서 키(UUID),밸류(리프레쉬 토큰) 매핑을 통해 레디스에서 리프레쉬 토큰을 찾도록 구현했다.

 

이렇게 함으로써 결과적으로 얻는 이점은 다음과 같다.

 

  1. 토큰이 한곳이 있는것이 아니기 때문에 둘다 탈취하기 까다롭다.
  2. 만약 상대적으로 보안이 약한 localStroage에 있는 액세스 토큰이 탈취 당하더라도 유효기간이 짧기 때문에 재사용 가능성이 줄어든다.
  3. 만약 상대적으로 보안이 더 강한 쿠키가 탈취 당하더라도 쿠키에 담긴 것은 jwt가 아니라 UUID일 뿐이다.

리프레쉬 토큰으로 액세스 토큰을 갱신하는 로직의 도식도는 다음과 같다.

 

기존 플로우 차트에서 리프레쉬 토큰을 확인하는 부분이 생겼고 재발급 루틴으로 다시 돌아가는 부분만 유심히 보면 된다.

따라서 프론트에서도 지금까지는 요청실패를 모두 403(Forbidden) 에러로 처리했지만 이제는 토큰이 만료되었을때를 따로 세분화해서 처리할 필요가 생겼다. 이때의 에러를 401(UnAuthorized) 보내도록 했다.

 

일단 전역으로 리프레쉬 토큰을 사용해서 액세스토큰을 재발급 시켜주는 javascript가 필요하다. 다음의 refreshToken 함수가 바로 그 역할을 수행한다.

 

// 리프레쉬토큰으로 토큰 재발급 코드
function refreshToken(callback) {
    // 쿠키에서 리프레시 토큰 ID 추출 및 서버에 전송하여 새 액세스 토큰 요청
    $.ajax({
        url: "/auth/refresh",
        type: "POST",
        success: function(data) {
            const token = data.token;
            localStorage.setItem('jwtToken', token); // 새 토큰 저장
            callback(); // 사용자 정보 재로딩
        },
        error: function(xhr) { // 리프레시도 만료

            if (xhr.status === 403) {
                console.error("403");
                window.location.href = '/user/login'; // 로그인 페이지로 이동
            } else {
                console.error("오류 발생");
                window.location.href = '/user/login'; // 로그인 페이지로 이동
            }
        }
    });
}

 

도식도에 보면 재발급 성공하고나서 처음부터 다시 시작하는 루틴을하기 위해서 refreshToken 함수는 매개인자로 콜백함수를 받고 재발급 성공시에 그 콜백함수를 실행한다.

 

이제 기존 Ajax 요청 코드의 변화를 살펴보겠다. 단순하게 refreshToken에 자기자신의 콜백함수를 매개인자로 주기위해서 모든 Ajax 요청을 function의 형태로 바꾸면 된다. 아래 예시를 통해서 확인할수 있다. 먼저 기존 코드이다.

 

// 기존 코드 그냥 Ajax 요청임
$(document).ready(function() {
        $.ajax({
            url: "/user",
            type: "GET",
            dataType: "json",
            beforeSend: function(xhr) { // 여기에 beforeSend 추가
                    const jwtToken = localStorage.getItem('jwtToken');
                    if (jwtToken) {
                        xhr.setRequestHeader('Authorization', `Bearer ${jwtToken}`);
                    }
                },
            success: function(user) {
                $('#profile').html(`
                    <img src="${user.profileImg}" alt="프로필 이미지" class="profile-img">
                    <p>로그인 ID: ${user.loginId}</p>
                    <p>이메일: ${user.email}</p>
                    <p>닉네임: ${user.nickname ? user.nickname : ''}</p>
                    <p>성별: ${user.gender ? user.gender : ''}</p>
                    <p>전화번호: ${user.phone ? user.phone : ''}</p>
                    <p>주소: ${user.address ? user.address : ''}</p>
                `);
            },
            error: function(xhr, textStatus, error) {
                if (xhr.status === 403) {
                    alert("권한이 없습니다.");
                    console.log(xhr.status);
                    window.history.back();
                } else {
                    console.error("오류: " + textStatus + ": " + error);
                }
            }
        });

        $('#editProfileBtn').click(function() {
            location.href = '/user/update';
        });

        $('#changePasswordBtn').click(function() {
            location.href = '/user/change-password';
        });

        $('#myOrdersBtn').click(function() {
            location.href = '/myOrders';
        });
    });

 

이제 아래는 재발급 로직을 하기 위해서 함수 형태로 바꾼 형태이다.

 

    $(document).ready(function() {

        loadUserProfile();
        // 기존 Ajax코드를 함수의 형태로 감쌌고 오류코드를 세분화 했다.
        function loadUserProfile() {
            $.ajax({
                url: "/user",
                type: "GET",
                dataType: "json",
                beforeSend: function(xhr) { // 여기에 beforeSend 추가
                    const jwtToken = localStorage.getItem('jwtToken');
                    if (jwtToken) {
                        xhr.setRequestHeader('Authorization', `Bearer ${jwtToken}`);
                    }
                },
                success: function (user) {
                    $('#profile').html(`
                        <img src="${user.profileImg}" alt="프로필 이미지" class="profile-img">
                        <p>로그인 ID: ${user.loginId}</p>
                        <p>이메일: ${user.email}</p>
                        <p>닉네임: ${user.nickname ? user.nickname : ''}</p>
                        <p>성별: ${user.gender ? user.gender : ''}</p>
                        <p>전화번호: ${user.phone ? user.phone : ''}</p>
                        <p>주소: ${user.address ? user.address : ''}</p>
                    `);
                },
                error: function (xhr, textStatus, error) {
                    // 토큰 만료 -> 재발급
                    if (xhr.status === 401) {
                        console.log("refreshToken");
                        refreshToken(loadUserProfile);
                    } else if (xhr.status === 403) {
                        alert("권한이 없습니다.");
                        console.log(xhr.status);
                        location.href = '/';
                    } else {
                        console.error("오류: " + textStatus + ": " + error);
                    }
                }
            });
        }



        $('#editProfileBtn').click(function() {
            location.href = '/user/update';
        });

        $('#changePasswordBtn').click(function() {
            location.href = '/user/change-password';
        });

        $('#myOrdersBtn').click(function() {
            location.href = '/myOrders';
        });
    });

 

error: function 부분을 잘 보면 401 에러가 추가된것을 확인할수 있다.401 에러시 자기자신의 콜백 함수를 refreshToken 함수에게 전달함으로써 액세스 토큰 재발급 성공시에 다시 권한확인을 수행할수 있다.

'웹 프로그래밍 > 스프링' 카테고리의 다른 글

RabbitMQ  (1) 2024.04.26
Docker  (0) 2024.04.12
스프링 시큐리티에서 뷰(View)에 대한 접근 설정  (0) 2024.04.09
OAuth2  (0) 2024.02.14
JWT  (0) 2024.02.13